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 ? ( <>