animated pill

This commit is contained in:
Samuel Newman
2026-08-26 12:45:22 +01:00
parent c3807ad37d
commit 75cbc450ba
5 changed files with 425 additions and 104 deletions
@@ -1,5 +1,6 @@
import {
Children,
memo,
useCallback,
useContext,
useEffect,
@@ -7,16 +8,31 @@ import {
useRef,
useState,
} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type NativeSyntheticEvent,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import NativePagerView from 'react-native-pager-view'
import {
type PagerViewOnPageScrollEventData,
type PagerViewOnPageSelectedEventData,
type PageScrollStateChangedNativeEventData,
} from 'react-native-pager-view'
import Animated, {useEvent, useSharedValue} from 'react-native-reanimated'
import {scheduleOnRN} from 'react-native-worklets'
import {useFocusEffect} from '@react-navigation/native'
import {useSetDrawerSwipeDisabled} from '#/state/shell'
import {atoms as a} from '#/alf'
import {usePagerContext} from './context'
const AnimatedPagerView = Animated.createAnimatedComponent(NativePagerView)
const MemoizedAnimatedPagerView = memo(AnimatedPagerView)
export function Content({
children,
manageDrawerGesture = false,
@@ -28,8 +44,14 @@ export function Content({
style?: StyleProp<ViewStyle>
testID?: string
}) {
const {initialPage, selectedPage, onPageSelected, onPageScrollStateChanged} =
usePagerContext()
const {
initialPage,
selectedPage,
dragProgress,
dragState,
onPageSelected,
onPageScrollStateChanged,
} = usePagerContext()
const pagerRef = useRef<NativePagerView>(null)
const currentPage = useRef(initialPage)
const [isIdle, setIsIdle] = useState(true)
@@ -52,28 +74,73 @@ export function Content({
}
}, [selectedPage])
const handlePageSelected = useCallback(
(page: number) => {
currentPage.current = page
onPageSelected(page)
},
[onPageSelected],
)
const handlePageScrollStateChanged = useCallback(
(state: 'idle' | 'dragging' | 'settling') => {
setIsIdle(state === 'idle')
onPageScrollStateChanged(state)
},
[onPageScrollStateChanged],
)
const didInit = useSharedValue(false)
const handlePageScroll = useEvent<PagerNativeEvent>(
event => {
'worklet'
if (event.eventName.endsWith('onPageScroll') && 'offset' in event) {
if (didInit.get() === false) {
// iOS emits a spurious zero-position event before confirming the
// supplied initial page.
return
}
dragProgress.set(event.offset + event.position)
} else if (
event.eventName.endsWith('onPageScrollStateChanged') &&
'pageScrollState' in event
) {
scheduleOnRN(handlePageScrollStateChanged, event.pageScrollState)
if (
dragState.get() === 'idle' &&
event.pageScrollState === 'settling'
) {
// Android reports programmatic paging as a settling gesture. Keep
// this idle so tab bars can distinguish taps from direct swipes.
return
}
dragState.set(event.pageScrollState)
} else if (
event.eventName.endsWith('onPageSelected') &&
'position' in event
) {
didInit.set(true)
dragProgress.set(event.position)
scheduleOnRN(handlePageSelected, event.position)
}
},
['onPageScroll', 'onPageScrollStateChanged', 'onPageSelected'],
true,
)
const content = (
<NativePagerView
<MemoizedAnimatedPagerView
ref={pagerRef}
testID={testID}
style={[a.flex_1, style]}
initialPage={initialPage}
onPageSelected={event => {
const page = event.nativeEvent.position
currentPage.current = page
onPageSelected(page)
}}
onPageScrollStateChanged={event => {
const state = event.nativeEvent.pageScrollState
setIsIdle(state === 'idle')
onPageScrollStateChanged(state)
}}>
onPageScroll={handlePageScroll}>
{Children.map(children, child => (
<View collapsable={false} style={a.flex_1}>
{child}
</View>
))}
</NativePagerView>
</MemoizedAnimatedPagerView>
)
return manageDrawerGesture ? (
@@ -83,6 +150,13 @@ export function Content({
)
}
type PagerEventData =
| PagerViewOnPageScrollEventData
| PagerViewOnPageSelectedEventData
| PageScrollStateChangedNativeEventData
type PagerNativeEvent = NativeSyntheticEvent<PagerEventData>
function DrawerGestureRequireFail({children}: {children: React.ReactNode}) {
const drawerGesture = useContext(DrawerGestureContext)
const pagerGesture = useMemo(() => {
@@ -1,5 +1,6 @@
import {Activity, Children, useEffect, useId, useRef, useState} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {ReduceMotion, withTiming} from 'react-native-reanimated'
import {atoms as a} from '#/alf'
import {usePagerContext} from './context'
@@ -15,7 +16,8 @@ export function Content({
style?: StyleProp<ViewStyle>
testID?: string
}) {
const {selectedPage, onPageSelected} = usePagerContext()
const {selectedPage, dragProgress, dragState, onPageSelected} =
usePagerContext()
const pages = Children.toArray(children)
const activityName = useId()
const previousPage = useRef(selectedPage)
@@ -26,6 +28,17 @@ export function Content({
useEffect(() => {
if (selectedPage !== previousPage.current) {
previousPage.current = selectedPage
dragState.set('settling')
dragProgress.set(
withTiming(
selectedPage,
{duration: 200, reduceMotion: ReduceMotion.System},
finished => {
'worklet'
if (finished) dragState.set('idle')
},
),
)
onPageSelected(selectedPage)
}
@@ -33,7 +46,7 @@ export function Content({
if (current.has(selectedPage)) return current
return new Set([...current, selectedPage])
})
}, [selectedPage, onPageSelected])
}, [selectedPage, dragProgress, dragState, onPageSelected])
return (
<View testID={testID} style={[a.flex_1, style]}>
@@ -8,6 +8,7 @@ import {
useState,
} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type SharedValue, useSharedValue} from 'react-native-reanimated'
import {atoms as a} from '#/alf'
@@ -16,6 +17,8 @@ export type PagerScrollState = 'idle' | 'dragging' | 'settling'
export type PagerRenderProps = {
selectedPage: number
selectPage: (page: number) => void
dragProgress: SharedValue<number>
dragState: SharedValue<PagerScrollState>
}
type PagerContextValue = PagerRenderProps & {
@@ -45,6 +48,8 @@ export function Root({
}) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const selectedPageRef = useRef(initialPage)
const dragProgress = useSharedValue(initialPage)
const dragState = useSharedValue<PagerScrollState>('idle')
const handlePageSelected = useCallback(
(page: number) => {
@@ -73,6 +78,8 @@ export function Root({
initialPage,
selectedPage,
selectPage,
dragProgress,
dragState,
onPageSelected: handlePageSelected,
onPageScrollStateChanged: (state: PagerScrollState) =>
onPageScrollStateChanged?.(state),
@@ -81,6 +88,8 @@ export function Root({
initialPage,
selectedPage,
selectPage,
dragProgress,
dragState,
handlePageSelected,
onPageScrollStateChanged,
],
@@ -100,13 +109,12 @@ export function TabBar({
}: {
children: (props: PagerRenderProps) => ReactNode
}) {
const {selectedPage, selectPage} = usePager()
return children({selectedPage, selectPage})
return children(usePager())
}
export function usePager(): PagerRenderProps {
const {selectedPage, selectPage} = usePagerContext()
return {selectedPage, selectPage}
const {selectedPage, selectPage, dragProgress, dragState} = usePagerContext()
return {selectedPage, selectPage, dragProgress, dragState}
}
export function usePagerContext() {
+292 -80
View File
@@ -1,13 +1,21 @@
import {useEffect, useRef, useState} from 'react'
import {useEffect, useLayoutEffect, useRef, useState} from 'react'
import {
Pressable,
type ReactNativeElement,
type ScrollView,
type StyleProp,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import Animated, {
interpolate,
type SharedValue,
useAnimatedStyle,
useDerivedValue,
} from 'react-native-reanimated'
import {useLingui} from '@lingui/react/macro'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, tokens, useTheme, utils, web} from '#/alf'
@@ -27,60 +35,75 @@ export type TabPillItem = {
export function TabPills({
tabs,
selectedTab,
dragProgress,
onSelectTab,
contentContainerStyle,
gutterWidth = tokens.space.lg,
}: {
tabs: TabPillItem[]
selectedTab: string
dragProgress: SharedValue<number>
onSelectTab: (tab: string) => void
contentContainerStyle?: StyleProp<ViewStyle>
gutterWidth?: number
}) {
const t = useTheme()
const {t: l} = useLingui()
const listRef = useRef<ScrollView>(null)
const {width: windowWidth} = useWindowDimensions()
const listRef = useRef<ScrollView & ReactNativeElement>(null)
const [totalWidth, setTotalWidth] = useState(0)
const [scrollX, setScrollX] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<PillLayout[]>([])
const contentRef = useRef<View>(null)
const tabRefs = useRef<Array<View | null>>([])
const didMeasure = useRef(false)
const tabLayoutKey = tabs.map(tab => `${tab.key}:${tab.label}`).join('|')
const tabCount = tabs.length
const onInitialLayout = useNonReactiveCallback(() => {
scrollIntoViewIfNeeded(tabs.findIndex(tab => tab.key === selectedTab))
})
useLayoutEffect(() => {
const viewportRect = listRef.current?.getBoundingClientRect()
const contentRect = contentRef.current?.getBoundingClientRect()
if (!viewportRect || !contentRect) return
useEffect(() => {
if (tabOffsets) {
onInitialLayout()
}
}, [tabOffsets, onInitialLayout])
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
const layouts = Array.from({length: tabCount}, (_, index) => {
const rect = tabRefs.current[index]?.getBoundingClientRect()
if (!rect) return null
return {
x: rect.left - contentRect.left,
y: rect.top - contentRect.top,
width: rect.width,
height: rect.height,
}
})
}
if (layouts.some(layout => layout === null)) return
const nextLayouts = layouts as PillLayout[]
setTabOffsets(current =>
areLayoutsEqual(current, nextLayouts) ? current : nextLayouts,
)
if (IS_WEB) {
setTotalWidth(viewportRect.width)
setContentWidth(contentRect.width)
}
const selectedIndex = tabs.findIndex(tab => tab.key === selectedTab)
const selectedLayout = nextLayouts[selectedIndex]
if (selectedLayout) {
const centeredOffset =
selectedLayout.x - (viewportRect.width / 2 - selectedLayout.width / 2)
const maxOffset = Math.max(0, contentRect.width - viewportRect.width)
listRef.current?.scrollTo({
x: Math.min(maxOffset, Math.max(0, centeredOffset)),
animated: didMeasure.current,
})
}
didMeasure.current = true
}, [selectedTab, tabLayoutKey, tabCount, tabs, windowWidth])
function handleSelectTab(index: number) {
const tab = tabs[index]
onSelectTab(tab.key)
scrollIntoViewIfNeeded(index)
}
function handleTabLayout(index: number, x: number, width: number) {
if (!tabOffsets.length) {
pendingTabOffsets.current[index] = {x, width}
if (
pendingTabOffsets.current.filter(offset => !!offset).length ===
tabs.length
) {
setTabOffsets(pendingTabOffsets.current)
}
}
}
const canScrollLeft = scrollX > 0
@@ -170,15 +193,10 @@ export function TabPills({
}, [])
return (
<View style={[a.relative, a.flex_row]}>
<View style={[a.relative, a.flex_row]} accessibilityRole="tablist">
<BlockDrawerGesture>
<DraggableScrollView
ref={listRef}
contentContainerStyle={[
a.gap_sm,
{paddingHorizontal: gutterWidth},
contentContainerStyle,
]}
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToOffsets={
@@ -186,22 +204,93 @@ export function TabPills({
? tabOffsets.map(offset => offset.x - tokens.space.xl)
: undefined
}
onLayout={event => setTotalWidth(event.nativeEvent.layout.width)}
onContentSizeChange={width => setContentWidth(width)}
onScroll={event => {
setScrollX(event.nativeEvent.contentOffset.x)
}}
scrollEventThrottle={16}>
{tabs.map((tab, index) => (
<TabPill
key={tab.key}
tab={tab}
index={index}
active={tab.key === selectedTab}
onSelectTab={handleSelectTab}
onLayout={handleTabLayout}
/>
))}
onScroll={
IS_WEB
? event => setScrollX(event.nativeEvent.contentOffset.x)
: undefined
}
scrollEventThrottle={IS_WEB ? 16 : undefined}>
<View
ref={contentRef}
style={[
a.flex_row,
a.gap_sm,
{paddingHorizontal: gutterWidth},
contentContainerStyle,
]}>
{tabs.map((tab, index) => (
<TabPill
key={tab.key}
elementRef={element => {
tabRefs.current[index] = element
}}
tab={tab}
index={index}
active={tab.key === selectedTab}
onSelectTab={handleSelectTab}
/>
))}
{tabOffsets.map((layout, index) => (
<View
key={`border-${tabs[index].key}`}
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.rounded_full,
a.curve_continuous,
t.atoms.bg,
t.atoms.border_contrast_low,
{
zIndex: 1,
borderWidth: 1,
left: layout.x,
top: layout.y,
width: layout.width,
height: layout.height,
},
]}></View>
))}
{tabOffsets.length === tabs.length && (
<PillIndicator
layouts={tabOffsets}
dragProgress={dragProgress}
backgroundColor={t.atoms.bg_contrast_50.backgroundColor}
borderColor={t.palette.contrast_50}
/>
)}
{tabOffsets.map((layout, index) => (
<View
key={`label-${tabs[index].key}`}
aria-hidden
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[
a.absolute,
a.align_center,
a.justify_center,
{
zIndex: 3,
left: layout.x,
top: layout.y,
width: layout.width,
height: layout.height,
},
]}>
<Text
style={[
a.font_medium,
tabs[index].key === selectedTab
? t.atoms.text
: t.atoms.text_contrast_high,
]}>
{tabs[index].label}
</Text>
</View>
))}
</View>
</DraggableScrollView>
</BlockDrawerGesture>
@@ -234,6 +323,7 @@ export function TabPills({
a.h_full,
a.aspect_square,
a.rounded_full,
a.curve_continuous,
]}>
<ButtonIcon icon={ArrowLeft} />
</Button>
@@ -269,6 +359,7 @@ export function TabPills({
a.h_full,
a.aspect_square,
a.rounded_full,
a.curve_continuous,
]}>
<ButtonIcon icon={ArrowRight} />
</Button>
@@ -279,56 +370,177 @@ export function TabPills({
}
function TabPill({
elementRef,
tab,
active,
index,
onSelectTab,
onLayout,
}: {
elementRef: React.Ref<View>
tab: TabPillItem
active: boolean
index: number
onSelectTab: (index: number) => void
onLayout: (index: number, x: number, width: number) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<View
onLayout={event =>
onLayout(
index,
event.nativeEvent.layout.x,
event.nativeEvent.layout.width,
)
}>
<Button
label={
<View ref={elementRef}>
<Pressable
accessibilityLabel={
active ? l`${tab.label} tab, selected` : l`Select ${tab.label} tab`
}
accessibilityHint={l`Shows ${tab.label} notifications`}
accessibilityRole="tab"
accessibilityState={{selected: active}}
onPress={() => onSelectTab(index)}>
onPress={() => onSelectTab(index)}
style={[a.rounded_full, a.curve_continuous]}>
<View
style={[
a.rounded_full,
a.curve_continuous,
a.px_lg,
a.py_sm,
{borderWidth: 1},
active
? [t.atoms.bg_contrast_50, {borderColor: t.palette.contrast_50}]
: [a.bg_transparent, t.atoms.border_contrast_low],
a.bg_transparent,
{borderWidth: 1, borderColor: 'transparent'},
]}>
<Text
style={[
a.font_medium,
active ? t.atoms.text : t.atoms.text_contrast_high,
]}>
<Text accessible={false} style={[a.font_medium, {opacity: 0}]}>
{tab.label}
</Text>
</View>
</Button>
</Pressable>
</View>
)
}
type PillLayout = {
x: number
y: number
width: number
height: number
}
function areLayoutsEqual(a: PillLayout[], b: PillLayout[]) {
return (
a.length === b.length &&
a.every(
(layout, index) =>
layout.x === b[index].x &&
layout.y === b[index].y &&
layout.width === b[index].width &&
layout.height === b[index].height,
)
)
}
function PillIndicator({
layouts,
dragProgress,
backgroundColor,
borderColor,
}: {
layouts: PillLayout[]
dragProgress: SharedValue<number>
backgroundColor: string
borderColor: string
}) {
const height = layouts[0].height
const radius = height / 2
const inputRange = layouts.map((_, index) => index)
const xOutputRange = layouts.map(layout => layout.x)
const widthOutputRange = layouts.map(layout => layout.width)
const geometry = useDerivedValue(() => {
const progress = dragProgress.get()
return {
x: interpolate(progress, inputRange, xOutputRange, 'clamp'),
width: interpolate(progress, inputRange, widthOutputRange, 'clamp'),
}
})
const containerStyle = useAnimatedStyle(() => ({
transform: [{translateX: geometry.get().x}],
}))
const middleStyle = useAnimatedStyle(() => ({
transform: [{scaleX: Math.max(0.01, geometry.get().width - height)}],
}))
const rightCapStyle = useAnimatedStyle(() => ({
transform: [{translateX: geometry.get().width - radius - 1}],
}))
return (
<Animated.View
accessible={false}
pointerEvents="none"
style={[
a.absolute,
a.curve_continuous,
{
zIndex: 1,
top: layouts[0].y,
left: 0,
width: radius + 1,
height,
},
containerStyle,
]}>
<View
style={[
a.curve_continuous,
a.absolute,
a.top_0,
a.left_0,
{
width: radius + 1,
height,
backgroundColor,
borderColor,
borderTopWidth: 1,
borderBottomWidth: 1,
borderLeftWidth: 1,
borderTopLeftRadius: radius,
borderBottomLeftRadius: radius,
},
]}
/>
<Animated.View
style={[
a.curve_continuous,
a.absolute,
a.top_0,
{
left: radius,
width: 1,
height,
transformOrigin: 'left center',
backgroundColor,
borderColor,
borderTopWidth: 1,
borderBottomWidth: 1,
},
middleStyle,
]}
/>
<Animated.View
style={[
a.curve_continuous,
a.absolute,
a.top_0,
a.left_0,
{
width: radius + 1,
height,
backgroundColor,
borderColor,
borderTopWidth: 1,
borderRightWidth: 1,
borderBottomWidth: 1,
borderTopRightRadius: radius,
borderBottomRightRadius: radius,
},
rightCapStyle,
]}
/>
</Animated.View>
)
}
+17 -3
View File
@@ -24,11 +24,11 @@ import {NotificationsScreen as LegacyNotificationsScreen} from '#/view/screens/N
import {PageList} from '#/screens/Notifications/components/PageList'
import * as Pager from '#/screens/Notifications/components/PagerView'
import {TabPills} from '#/screens/Notifications/components/TabPills'
import {atoms as a, useTheme, utils} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, utils} from '#/alf'
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
import * as Layout from '#/components/Layout'
import {useAnalytics} from '#/analytics'
import {IS_LIQUID_GLASS} from '#/env'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams,
@@ -99,10 +99,11 @@ function NewNotificationsScreenInner() {
}}>
<NotificationsHeader onHeightChange={setHeaderOffset}>
<Pager.TabBar>
{({selectedPage, selectPage}) => (
{({selectedPage, selectPage, dragProgress}) => (
<TabPills
tabs={tabs}
selectedTab={tabs[selectedPage].key}
dragProgress={dragProgress}
onSelectTab={tab =>
selectPage(tabs.findIndex(candidate => candidate.key === tab))
}
@@ -134,6 +135,7 @@ function NotificationsHeader({
const t = useTheme()
const headerMode = useHomeHeaderMode()
const {headerHeight} = useShellLayout()
const {gtMobile} = useBreakpoints()
const insets = useSafeAreaInsets()
const headerPinnedHeight = IS_LIQUID_GLASS ? insets.top : 0
@@ -183,6 +185,18 @@ function NotificationsHeader({
style={[a.absolute, a.inset_0, t.atoms.bg]}
/>
)}
{IS_WEB && gtMobile && (
<Layout.Center
pointerEvents="none"
style={[
a.absolute,
a.inset_0,
a.border_x,
t.atoms.border_contrast_low,
{maxWidth: Layout.CENTER_COLUMN_WIDTH + 2},
]}
/>
)}
<Animated.View
style={[IS_LIQUID_GLASS && {paddingTop: insets.top}, titleStyle]}
onLayout={event => {