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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9
This commit is contained in:
@@ -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 (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.left_0,
|
||||
a.right_0,
|
||||
{top: -GRADIENT_HEIGHT, height: GRADIENT_HEIGHT},
|
||||
fadeStyle,
|
||||
]}>
|
||||
<LinearGradient
|
||||
key={t.name} // android does not update when you change the colors. sigh.
|
||||
start={[0.5, 0]}
|
||||
end={[0.5, 1]}
|
||||
colors={[
|
||||
utils.alpha(t.atoms.bg.backgroundColor, 0),
|
||||
t.atoms.bg.backgroundColor,
|
||||
]}
|
||||
locations={[0.15, 0.55]}
|
||||
style={[a.flex_1]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
]}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, fadeStyle]}>
|
||||
<LinearGradient
|
||||
key={t.name} // android does not update when you change the colors. sigh.
|
||||
start={[0.5, 0]}
|
||||
end={[0.5, 1]}
|
||||
colors={[
|
||||
utils.alpha(t.atoms.bg.backgroundColor, 0),
|
||||
utils.alpha(t.atoms.bg.backgroundColor, 0.75),
|
||||
]}
|
||||
locations={[0, 0.65]}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View style={pillStyle}>
|
||||
<PressableScale
|
||||
testID="composePromptPill"
|
||||
@@ -175,15 +138,13 @@ export function ComposePromptPill() {
|
||||
onLongPress={ios(() => {
|
||||
onPress()
|
||||
playHaptic('Heavy')
|
||||
})}
|
||||
onLayout={e => pillHeight.set(e.nativeEvent.layout.height)}>
|
||||
})}>
|
||||
<GlassView
|
||||
isInteractive
|
||||
glassEffectStyle={{
|
||||
style: glassVisible ? 'regular' : 'none',
|
||||
style: glassVisible ? 'clear' : 'none',
|
||||
animate: true,
|
||||
}}
|
||||
tintColor={t.palette.contrast_50}
|
||||
style={[a.rounded_full]}
|
||||
fallbackStyle={[
|
||||
a.border,
|
||||
|
||||
@@ -49,10 +49,15 @@ export type ComposePromptConfig = {
|
||||
type Entry = {
|
||||
id: number
|
||||
/**
|
||||
* How present the registering screen is, 0..1. Owned by the registering
|
||||
* hook so that it can be sprung to 0 on unregister.
|
||||
* The registering screen's own presence, 0..1, read directly so that two
|
||||
* screens mid-transition are always sampled on the same frame.
|
||||
*/
|
||||
presence: SharedValue<number>
|
||||
/**
|
||||
* 1 while registered, sprung to 0 on unregister so a screen that leaves
|
||||
* without a transition still fades the pill out.
|
||||
*/
|
||||
weight: SharedValue<number>
|
||||
config: ComposePromptConfig
|
||||
}
|
||||
|
||||
@@ -72,6 +77,7 @@ type StateContext = {
|
||||
type ActionsContext = {
|
||||
register: (
|
||||
presence: SharedValue<number>,
|
||||
weight: SharedValue<number>,
|
||||
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<ActionsContext>(
|
||||
() => ({
|
||||
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<number | null>(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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && <ComposePromptGradient />}
|
||||
{hasSession ? (
|
||||
<>
|
||||
<Btn
|
||||
|
||||
@@ -45,7 +45,10 @@ import {
|
||||
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 {styles} from './BottomBarStyles'
|
||||
|
||||
type NavItemValue = 'home' | 'search' | 'chat' | 'notifications' | 'profile'
|
||||
@@ -98,6 +101,7 @@ export function BottomBarWeb() {
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
{hasSession && <ComposePromptGradient />}
|
||||
{hasSession ? (
|
||||
<>
|
||||
<NavItem routeName="Home" href="/" navItem="home">
|
||||
|
||||
Reference in New Issue
Block a user