Settle the compose pill: border follows the pill, less width change, light tint

- Blend the bar's top border away from the pill's own visibility rather than
  a per-screen border contribution. The latter was faded on a spring when a
  screen was popped, so the border flashed back mid-pop between two screens
  that both show the pill.
- Give the current label a small head start when picking the most present
  screen, so the two cannot trade places repeatedly as they cross.
- Grow the up-state side inset to 16pt so the pill changes width less on its
  way down to the safe-area inset.
- Add a half-strength tint to the clear glass.

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 22:09:59 +00:00
parent 5453044368
commit 6b69667567
5 changed files with 30 additions and 22 deletions
@@ -15,7 +15,7 @@ import {useSession} from '#/state/session'
import {useMinimalShellMode} from '#/state/shell/minimal-mode' import {useMinimalShellMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, ios, tokens, useTheme} from '#/alf' import {atoms as a, ios, tokens, useTheme, utils} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {GlassView, IS_GLASS_AVAILABLE} from '#/components/GlassView' import {GlassView, IS_GLASS_AVAILABLE} from '#/components/GlassView'
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera' import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
@@ -28,9 +28,11 @@ import {useComposePromptMedia} from './useComposePromptMedia'
const AVATAR_SIZE = 32 const AVATAR_SIZE = 32
/** /**
* Horizontal inset of the pill while the bottom bar is showing. * Horizontal inset of the pill while the bottom bar is showing. Once the bar
* hides, the inset grows to the bottom safe area for equal spacing, so the
* closer this is to that, the less the pill changes width on the way down.
*/ */
const SIDE_MARGIN_UP = tokens.space.sm const SIDE_MARGIN_UP = tokens.space.lg
/** /**
* Gap between the pill and the top of the bottom bar. * Gap between the pill and the top of the bottom bar.
*/ */
@@ -145,6 +147,7 @@ export function ComposePromptPill() {
style: glassVisible ? 'clear' : 'none', style: glassVisible ? 'clear' : 'none',
animate: true, animate: true,
}} }}
tintColor={utils.alpha(t.palette.contrast_50, 0.5)}
style={[a.rounded_full]} style={[a.rounded_full]}
fallbackStyle={[ fallbackStyle={[
a.border, a.border,
+9 -6
View File
@@ -19,7 +19,6 @@ import {
import {scheduleOnRN} from 'react-native-worklets' import {scheduleOnRN} from 'react-native-worklets'
import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import { import {
useScreenCoverage, useScreenCoverage,
useScreenPresence, useScreenPresence,
@@ -101,10 +100,12 @@ const actionsContext = createContext<ActionsContext | null>(null)
actionsContext.displayName = 'ComposePromptActionsContext' actionsContext.displayName = 'ComposePromptActionsContext'
let nextId = 0 let nextId = 0
const LABEL_SWITCH_MARGIN = 0.1
export function Provider({children}: {children: React.ReactNode}) { export function Provider({children}: {children: React.ReactNode}) {
const [entries, setEntries] = useState<Entry[]>([]) const [entries, setEntries] = useState<Entry[]>([])
const [activeId, setActiveId] = useState<number | null>(null) const [activeId, setActiveId] = useState<number | null>(null)
const activeIdOnUI = useSharedValue<number | null>(null)
const coverage = useScreenCoverage() const coverage = useScreenCoverage()
const visibility = useDerivedValue(() => { const visibility = useDerivedValue(() => {
@@ -118,13 +119,17 @@ export function Provider({children}: {children: React.ReactNode}) {
/* /*
* The pill shows the label of whichever screen is the most present, so * The pill shows the label of whichever screen is the most present, so
* that during a push or swipe-back the text switches at the midpoint of * that during a push or swipe-back the text switches at the midpoint of
* the transition rather than when the outgoing screen unmounts. * the transition rather than when the outgoing screen unmounts. The current
* label gets a small head start so the two cannot trade places repeatedly
* as they cross.
*/ */
const mostPresentId = useDerivedValue(() => { const mostPresentId = useDerivedValue(() => {
let best: number | null = null let best: number | null = null
let bestPresence = 0 let bestPresence = 0
for (const entry of entries) { for (const entry of entries) {
const presence = entryPresence(entry, coverage.get()) const presence =
entryPresence(entry, coverage.get()) +
(entry.id === activeIdOnUI.get() ? LABEL_SWITCH_MARGIN : 0)
if (presence > bestPresence) { if (presence > bestPresence) {
best = entry.id best = entry.id
bestPresence = presence bestPresence = presence
@@ -138,6 +143,7 @@ export function Provider({children}: {children: React.ReactNode}) {
(current, previous) => { (current, previous) => {
// keep the last label while everything fades out // keep the last label while everything fades out
if (current !== previous && current !== null) { if (current !== previous && current !== null) {
activeIdOnUI.set(current)
scheduleOnRN(setActiveId, current) scheduleOnRN(setActiveId, current)
} }
}, },
@@ -223,9 +229,6 @@ export function useComposePromptForScreen(config: ComposePromptConfig | null) {
const idRef = useRef<number | null>(null) const idRef = useRef<number | null>(null)
const enabled = config !== null const enabled = config !== null
// the bar's top border would cut across the pill's gradient
useHideBottomBarBorderForScreen({enabled})
const getConfig = useEffectEvent(() => config) const getConfig = useEffectEvent(() => config)
useEffect(() => { useEffect(() => {
+7 -11
View File
@@ -36,7 +36,7 @@ function useHideBottomBarBorderSetter() {
* Hides the bottom bar's top border while the surrounding screen is present, * Hides the bottom bar's top border while the surrounding screen is present,
* fading it with the screen transition. * fading it with the screen transition.
*/ */
export function useHideBottomBarBorderForScreen({enabled} = {enabled: true}) { export function useHideBottomBarBorderForScreen() {
const register = useHideBottomBarBorderSetter() const register = useHideBottomBarBorderSetter()
const {presence} = useScreenPresence() const {presence} = useScreenPresence()
const contribution = useSharedValue(0) const contribution = useSharedValue(0)
@@ -44,18 +44,13 @@ export function useHideBottomBarBorderForScreen({enabled} = {enabled: true}) {
useAnimatedReaction( useAnimatedReaction(
() => presence.get(), () => presence.get(),
(current, previous) => { (current, previous) => {
if (enabled && current !== previous) { if (current !== previous) {
contribution.set(current) contribution.set(current)
} }
}, },
[enabled],
) )
useEffect(() => { useEffect(() => register(contribution), [register, contribution])
if (!enabled) return
contribution.set(presence.get())
return register(contribution)
}, [enabled, register, contribution, presence])
} }
/** /**
@@ -73,16 +68,17 @@ export function useHideBottomBarBorder() {
/** /**
* Animated border color for the bottom bar, blending the border into the * Animated border color for the bottom bar, blending the border into the
* background as screens that hide it come and go. * background as screens that hide it come and go. `alsoHidden` is a further
* 0..1 hidden amount to combine in, for shell UI that sits on the bar.
*/ */
export function useBottomBarBorderStyle() { export function useBottomBarBorderStyle(alsoHidden?: SharedValue<number>) {
const t = useTheme() const t = useTheme()
const hideBorder = useHideBottomBarBorder() const hideBorder = useHideBottomBarBorder()
const visibleColor = t.atoms.border_contrast_low.borderColor const visibleColor = t.atoms.border_contrast_low.borderColor
const hiddenColor = t.atoms.bg.backgroundColor const hiddenColor = t.atoms.bg.backgroundColor
return useAnimatedStyle(() => ({ return useAnimatedStyle(() => ({
borderColor: interpolateColor( borderColor: interpolateColor(
hideBorder.get(), Math.max(hideBorder.get(), alsoHidden?.get() ?? 0),
[0, 1], [0, 1],
[visibleColor, hiddenColor], [visibleColor, hiddenColor],
), ),
+4 -1
View File
@@ -59,6 +59,7 @@ import {useAnalytics} from '#/analytics'
import { import {
ComposePromptGradient, ComposePromptGradient,
ComposePromptPill, ComposePromptPill,
useComposePromptState,
} from '#/features/composePrompt' } from '#/features/composePrompt'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDemoMode} from '#/storage/hooks/demo-mode'
@@ -84,7 +85,9 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const accountSwitchControl = useDialogControl() const accountSwitchControl = useDialogControl()
const messagesMenuControl = Menu.useMenuControl() const messagesMenuControl = Menu.useMenuControl()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const borderStyle = useBottomBarBorderStyle() const {visibility: composePromptVisibility} = useComposePromptState()
// the border would cut across the compose pill's gradient
const borderStyle = useBottomBarBorderStyle(composePromptVisibility)
const iconWidth = 28 const iconWidth = 28
const showSignIn = useCallback(() => { const showSignIn = useCallback(() => {
+4 -1
View File
@@ -48,6 +48,7 @@ import {useAnalytics} from '#/analytics'
import { import {
ComposePromptGradient, ComposePromptGradient,
ComposePromptPill, ComposePromptPill,
useComposePromptState,
} from '#/features/composePrompt' } from '#/features/composePrompt'
import {styles} from './BottomBarStyles' import {styles} from './BottomBarStyles'
@@ -61,7 +62,9 @@ export function BottomBarWeb() {
const {requestSwitchToAccount} = useLoggedOutViewControls() const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const {footerHeight} = useShellLayout() const {footerHeight} = useShellLayout()
const borderStyle = useBottomBarBorderStyle() const {visibility: composePromptVisibility} = useComposePromptState()
// the border would cut across the compose pill's gradient
const borderStyle = useBottomBarBorderStyle(composePromptVisibility)
const accountSwitchControl = useDialogControl() const accountSwitchControl = useDialogControl()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const iconWidth = 26 const iconWidth = 26