From 64ae8d201ae16664b3f1f68f23e57ab62059117f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 21:54:57 +0000 Subject: [PATCH] Tune the compose pill: bar-attached gradient, clear glass, no scale - Draw the fade behind the pill as part of the bottom bar, from transparent to the bar's background, so it meets the bar seamlessly and slides and fades with it instead of travelling with the pill. - Sit the pill almost on the bar, as the thread prompt did. - Use the clear glass style without a tint. - Drop the scale and the drop-while-fading motion; the pill now only translates down into the bar's space, keeping its equal edge insets there. - Sample each screen's presence directly in the registry instead of via a mirrored shared value per screen. The mirrors updated a frame apart, so the summed visibility dipped mid-transition between two screens that both show the pill, which read as a jitter. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9 --- .../composePrompt/ComposePromptGradient.tsx | 53 ++++++++++++++ .../composePrompt/ComposePromptPill.tsx | 73 +++++-------------- src/features/composePrompt/context.tsx | 38 +++++----- src/features/composePrompt/index.tsx | 1 + src/view/shell/bottom-bar/BottomBar.tsx | 6 +- src/view/shell/bottom-bar/BottomBarWeb.tsx | 6 +- 6 files changed, 98 insertions(+), 79 deletions(-) create mode 100644 src/features/composePrompt/ComposePromptGradient.tsx diff --git a/src/features/composePrompt/ComposePromptGradient.tsx b/src/features/composePrompt/ComposePromptGradient.tsx new file mode 100644 index 0000000000..83b4e33ead --- /dev/null +++ b/src/features/composePrompt/ComposePromptGradient.tsx @@ -0,0 +1,53 @@ +import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import {LinearGradient} from 'expo-linear-gradient' + +import {atoms as a, useTheme, utils} from '#/alf' +import {useComposePromptState} from './context' + +/** + * Tall enough to cover the pill sitting just above the bar, plus a lead-in + * over the content above it. + */ +const GRADIENT_HEIGHT = 64 + +/** + * Fade from the content into the bottom bar's background, drawn above the + * bar so it moves and fades with it. Rendered inside the bar; only shown + * while a compose pill is. + */ +export function ComposePromptGradient() { + const {visibility, config} = useComposePromptState() + const t = useTheme() + + const fadeStyle = useAnimatedStyle(() => ({ + opacity: visibility.get(), + })) + + if (!config) { + return null + } + + return ( + + + + ) +} diff --git a/src/features/composePrompt/ComposePromptPill.tsx b/src/features/composePrompt/ComposePromptPill.tsx index 2af12022e7..e9766e761e 100644 --- a/src/features/composePrompt/ComposePromptPill.tsx +++ b/src/features/composePrompt/ComposePromptPill.tsx @@ -1,14 +1,11 @@ import {useState} from 'react' -import {useWindowDimensions} from 'react-native' import Animated, { interpolate, useAnimatedReaction, useAnimatedStyle, - useSharedValue, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {scheduleOnRN} from 'react-native-worklets' -import {LinearGradient} from 'expo-linear-gradient' import {useLingui} from '@lingui/react/macro' import {PressableScale} from '#/lib/custom-animations/PressableScale' @@ -18,7 +15,7 @@ import {useSession} from '#/state/session' import {useMinimalShellMode} from '#/state/shell/minimal-mode' import {useShellLayout} from '#/state/shell/shell-layout' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, ios, tokens, useTheme, utils} from '#/alf' +import {atoms as a, ios, tokens, useTheme} from '#/alf' import {Button} from '#/components/Button' import {GlassView, IS_GLASS_AVAILABLE} from '#/components/GlassView' import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera' @@ -33,19 +30,11 @@ const AVATAR_SIZE = 32 /** * Horizontal inset of the pill while the bottom bar is showing. */ -const SIDE_MARGIN_UP = tokens.space.md +const SIDE_MARGIN_UP = tokens.space.sm /** * Gap between the pill and the top of the bottom bar. */ -const GAP_ABOVE_BAR = tokens.space.sm -/** - * Extra room above the pill for the gradient to fade in over. - */ -const GRADIENT_LEAD = tokens.space._4xl -/** - * How much the pill shrinks once it has dropped into the bar's space. - */ -const DOWN_SCALE = 0.92 +const GAP_ABOVE_BAR = tokens.space._2xs /** * The compose pill that lives in the bottom bar. Screens opt in with @@ -57,7 +46,6 @@ export function ComposePromptPill() { const {footerMode} = useMinimalShellMode() const {footerHeight} = useShellLayout() const insets = useSafeAreaInsets() - const {width: windowWidth} = useWindowDimensions() const t = useTheme() const {t: l} = useLingui() const ax = useAnalytics() @@ -65,7 +53,6 @@ export function ComposePromptPill() { const {currentAccount} = useSession() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {onPressGallery, onPressCamera} = useComposePromptMedia(config?.open) - const pillHeight = useSharedValue(AVATAR_SIZE + tokens.space.sm * 2) /* * Liquid Glass materializes and dissolves through the system's own @@ -83,30 +70,24 @@ export function ComposePromptPill() { ) /* - * In the down state the pill is inset from the left, right and bottom - * edges by the same amount, so that its corners are concentric with the - * device bevels. The inner pill is scaled about its centre, so the outer - * padding and offset are solved for the visual margins after scaling. + * The pill sits just above the bar and follows it down as it hides. In the + * down state it is inset from the left, right and bottom edges by the same + * amount, so that its corners are concentric with the device bevels. */ const downMargin = Math.max(insets.bottom, tokens.space.lg) const wrapperStyle = useAnimatedStyle(() => { const mode = footerMode.get() - const scale = interpolate(mode, [0, 1], [1, DOWN_SCALE]) - const sideMargin = interpolate(mode, [0, 1], [SIDE_MARGIN_UP, downMargin]) - const paddingHorizontal = - (sideMargin - ((1 - scale) * windowWidth) / 2) / scale const upBottom = footerHeight.get() + GAP_ABOVE_BAR - const bottomMargin = interpolate(mode, [0, 1], [upBottom, downMargin]) - const translateY = - upBottom - - bottomMargin + - ((1 - scale) * pillHeight.get()) / 2 + - // a small drop while fading, like a sheet dismissing - (1 - visibility.get()) * tokens.space.md return { bottom: footerHeight.get(), - paddingHorizontal, - transform: [{translateY}], + paddingHorizontal: interpolate( + mode, + [0, 1], + [SIDE_MARGIN_UP, downMargin], + ), + transform: [ + {translateY: interpolate(mode, [0, 1], [0, upBottom - downMargin])}, + ], } }) @@ -116,9 +97,6 @@ export function ComposePromptPill() { pointerEvents: shown > 0.5 ? 'auto' : 'none', // a fully transparent ancestor stops Liquid Glass rendering at all opacity: IS_GLASS_AVAILABLE ? 1 : shown, - transform: [ - {scale: interpolate(footerMode.get(), [0, 1], [1, DOWN_SCALE])}, - ], } }) @@ -143,24 +121,9 @@ export function ComposePromptPill() { a.left_0, a.right_0, a.z_10, - {paddingTop: GRADIENT_LEAD, paddingBottom: GAP_ABOVE_BAR}, + {paddingBottom: GAP_ABOVE_BAR}, wrapperStyle, ]}> - - - { onPress() playHaptic('Heavy') - })} - onLayout={e => pillHeight.set(e.nativeEvent.layout.height)}> + })}> + /** + * 1 while registered, sprung to 0 on unregister so a screen that leaves + * without a transition still fades the pill out. + */ + weight: SharedValue config: ComposePromptConfig } @@ -72,6 +77,7 @@ type StateContext = { type ActionsContext = { register: ( presence: SharedValue, + weight: SharedValue, config: ComposePromptConfig, ) => number update: (id: number, config: ComposePromptConfig) => void @@ -92,7 +98,7 @@ export function Provider({children}: {children: React.ReactNode}) { const visibility = useDerivedValue(() => { let sum = 0 for (const entry of entries) { - sum += entry.presence.get() + sum += entry.presence.get() * entry.weight.get() } return clamp(sum, 0, 1) }, [entries]) @@ -106,7 +112,7 @@ export function Provider({children}: {children: React.ReactNode}) { let best: number | null = null let bestPresence = 0 for (const entry of entries) { - const presence = entry.presence.get() + const presence = entry.presence.get() * entry.weight.get() if (presence > bestPresence) { best = entry.id bestPresence = presence @@ -127,9 +133,9 @@ export function Provider({children}: {children: React.ReactNode}) { const actions = useMemo( () => ({ - register(presence, config) { + register(presence, weight, config) { const id = nextId++ - setEntries(prev => [...prev, {id, presence, config}]) + setEntries(prev => [...prev, {id, presence, weight, config}]) return id }, update(id, config) { @@ -192,30 +198,20 @@ function useComposePromptActions() { export function useComposePromptForScreen(config: ComposePromptConfig | null) { const {register, update, unregister} = useComposePromptActions() const {presence} = useScreenPresence() - const contribution = useSharedValue(0) + const weight = useSharedValue(0) const idRef = useRef(null) const enabled = config !== null // the bar's top border would cut across the pill's gradient useHideBottomBarBorderForScreen({enabled}) - useAnimatedReaction( - () => presence.get(), - (current, previous) => { - if (enabled && current !== previous) { - contribution.set(current) - } - }, - [enabled], - ) - const getConfig = useEffectEvent(() => config) useEffect(() => { const initial = getConfig() if (!initial) return - contribution.set(presence.get()) - const id = register(contribution, initial) + weight.set(1) + const id = register(presence, weight, initial) idRef.current = id return () => { idRef.current = null @@ -223,7 +219,7 @@ export function useComposePromptForScreen(config: ComposePromptConfig | null) { * Fade out before removing so that a screen removed without a * transition (or a config switched off) does not snap the pill away. */ - contribution.set( + weight.set( withSpring(0, SHELL_SPRING_CONFIG, finished => { if (finished) { scheduleOnRN(unregister, id) @@ -231,7 +227,7 @@ export function useComposePromptForScreen(config: ComposePromptConfig | null) { }), ) } - }, [enabled, register, unregister, contribution, presence]) + }, [enabled, register, unregister, weight, presence]) const label = config?.label const accessibilityLabel = config?.accessibilityLabel diff --git a/src/features/composePrompt/index.tsx b/src/features/composePrompt/index.tsx index f54079e879..1f7ddae2fc 100644 --- a/src/features/composePrompt/index.tsx +++ b/src/features/composePrompt/index.tsx @@ -4,6 +4,7 @@ import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {type ComposerLogContext} from '#/state/shell/composer' import {type ComposePromptConfig, useComposePromptForScreen} from './context' +export {ComposePromptGradient} from './ComposePromptGradient' export {ComposePromptPill} from './ComposePromptPill' export { type ComposePromptConfig, diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index c592abdb09..97bd1db55b 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -56,7 +56,10 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' import {useAnalytics} from '#/analytics' -import {ComposePromptPill} from '#/features/composePrompt' +import { + ComposePromptGradient, + ComposePromptPill, +} from '#/features/composePrompt' import {useActorStatus} from '#/features/liveNow' import {useDemoMode} from '#/storage/hooks/demo-mode' import {styles} from './BottomBarStyles' @@ -174,6 +177,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { onLayout={e => { footerHeight.set(e.nativeEvent.layout.height) }}> + {hasSession && } {hasSession ? ( <> footerHeight.set(event.nativeEvent.layout.height)}> + {hasSession && } {hasSession ? ( <>