diff --git a/src/screens/Notifications/components/PagerView/Content.tsx b/src/screens/Notifications/components/PagerView/Content.tsx index 40d9598926..6826077b33 100644 --- a/src/screens/Notifications/components/PagerView/Content.tsx +++ b/src/screens/Notifications/components/PagerView/Content.tsx @@ -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 testID?: string }) { - const {initialPage, selectedPage, onPageSelected, onPageScrollStateChanged} = - usePagerContext() + const { + initialPage, + selectedPage, + dragProgress, + dragState, + onPageSelected, + onPageScrollStateChanged, + } = usePagerContext() const pagerRef = useRef(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( + 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 = ( - { - 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 => ( {child} ))} - + ) return manageDrawerGesture ? ( @@ -83,6 +150,13 @@ export function Content({ ) } +type PagerEventData = + | PagerViewOnPageScrollEventData + | PagerViewOnPageSelectedEventData + | PageScrollStateChangedNativeEventData + +type PagerNativeEvent = NativeSyntheticEvent + function DrawerGestureRequireFail({children}: {children: React.ReactNode}) { const drawerGesture = useContext(DrawerGestureContext) const pagerGesture = useMemo(() => { diff --git a/src/screens/Notifications/components/PagerView/Content.web.tsx b/src/screens/Notifications/components/PagerView/Content.web.tsx index 424d373be6..ec57b2875c 100644 --- a/src/screens/Notifications/components/PagerView/Content.web.tsx +++ b/src/screens/Notifications/components/PagerView/Content.web.tsx @@ -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 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 ( diff --git a/src/screens/Notifications/components/PagerView/context.tsx b/src/screens/Notifications/components/PagerView/context.tsx index 0b2b246ec8..e8ae05be9c 100644 --- a/src/screens/Notifications/components/PagerView/context.tsx +++ b/src/screens/Notifications/components/PagerView/context.tsx @@ -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 + dragState: SharedValue } 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('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() { diff --git a/src/screens/Notifications/components/TabPills.tsx b/src/screens/Notifications/components/TabPills.tsx index a1f76331b4..eeddfcc6bb 100644 --- a/src/screens/Notifications/components/TabPills.tsx +++ b/src/screens/Notifications/components/TabPills.tsx @@ -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 onSelectTab: (tab: string) => void contentContainerStyle?: StyleProp gutterWidth?: number }) { const t = useTheme() const {t: l} = useLingui() - const listRef = useRef(null) + const {width: windowWidth} = useWindowDimensions() + const listRef = useRef(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([]) + const contentRef = useRef(null) + const tabRefs = useRef>([]) + 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 ( - + 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) => ( - - ))} + onScroll={ + IS_WEB + ? event => setScrollX(event.nativeEvent.contentOffset.x) + : undefined + } + scrollEventThrottle={IS_WEB ? 16 : undefined}> + + {tabs.map((tab, index) => ( + { + tabRefs.current[index] = element + }} + tab={tab} + index={index} + active={tab.key === selectedTab} + onSelectTab={handleSelectTab} + /> + ))} + {tabOffsets.map((layout, index) => ( + + ))} + {tabOffsets.length === tabs.length && ( + + )} + {tabOffsets.map((layout, index) => ( + + + {tabs[index].label} + + + ))} + @@ -234,6 +323,7 @@ export function TabPills({ a.h_full, a.aspect_square, a.rounded_full, + a.curve_continuous, ]}> @@ -269,6 +359,7 @@ export function TabPills({ a.h_full, a.aspect_square, a.rounded_full, + a.curve_continuous, ]}> @@ -279,56 +370,177 @@ export function TabPills({ } function TabPill({ + elementRef, tab, active, index, onSelectTab, - onLayout, }: { + elementRef: React.Ref 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 ( - - onLayout( - index, - event.nativeEvent.layout.x, - event.nativeEvent.layout.width, - ) - }> - + ) } + +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 + 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 ( + + + + + + ) +} diff --git a/src/screens/Notifications/index.tsx b/src/screens/Notifications/index.tsx index 9831110ef6..1d317b91f1 100644 --- a/src/screens/Notifications/index.tsx +++ b/src/screens/Notifications/index.tsx @@ -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() { }}> - {({selectedPage, selectPage}) => ( + {({selectedPage, selectPage, dragProgress}) => ( 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 && ( + + )} {