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:
Claude
2026-09-07 21:54:57 +00:00
parent 6d455c9098
commit 64ae8d201a
6 changed files with 98 additions and 79 deletions
@@ -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,
+17 -21
View File
@@ -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
+1
View File
@@ -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,
+5 -1
View File
@@ -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
+5 -1
View File
@@ -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">