animated pill
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -7,16 +8,31 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} 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 {DrawerGestureContext} from 'react-native-drawer-layout'
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||||
import NativePagerView from 'react-native-pager-view'
|
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 {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useSetDrawerSwipeDisabled} from '#/state/shell'
|
import {useSetDrawerSwipeDisabled} from '#/state/shell'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {usePagerContext} from './context'
|
import {usePagerContext} from './context'
|
||||||
|
|
||||||
|
const AnimatedPagerView = Animated.createAnimatedComponent(NativePagerView)
|
||||||
|
const MemoizedAnimatedPagerView = memo(AnimatedPagerView)
|
||||||
|
|
||||||
export function Content({
|
export function Content({
|
||||||
children,
|
children,
|
||||||
manageDrawerGesture = false,
|
manageDrawerGesture = false,
|
||||||
@@ -28,8 +44,14 @@ export function Content({
|
|||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
testID?: string
|
testID?: string
|
||||||
}) {
|
}) {
|
||||||
const {initialPage, selectedPage, onPageSelected, onPageScrollStateChanged} =
|
const {
|
||||||
usePagerContext()
|
initialPage,
|
||||||
|
selectedPage,
|
||||||
|
dragProgress,
|
||||||
|
dragState,
|
||||||
|
onPageSelected,
|
||||||
|
onPageScrollStateChanged,
|
||||||
|
} = usePagerContext()
|
||||||
const pagerRef = useRef<NativePagerView>(null)
|
const pagerRef = useRef<NativePagerView>(null)
|
||||||
const currentPage = useRef(initialPage)
|
const currentPage = useRef(initialPage)
|
||||||
const [isIdle, setIsIdle] = useState(true)
|
const [isIdle, setIsIdle] = useState(true)
|
||||||
@@ -52,28 +74,73 @@ export function Content({
|
|||||||
}
|
}
|
||||||
}, [selectedPage])
|
}, [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 = (
|
const content = (
|
||||||
<NativePagerView
|
<MemoizedAnimatedPagerView
|
||||||
ref={pagerRef}
|
ref={pagerRef}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={[a.flex_1, style]}
|
style={[a.flex_1, style]}
|
||||||
initialPage={initialPage}
|
initialPage={initialPage}
|
||||||
onPageSelected={event => {
|
onPageScroll={handlePageScroll}>
|
||||||
const page = event.nativeEvent.position
|
|
||||||
currentPage.current = page
|
|
||||||
onPageSelected(page)
|
|
||||||
}}
|
|
||||||
onPageScrollStateChanged={event => {
|
|
||||||
const state = event.nativeEvent.pageScrollState
|
|
||||||
setIsIdle(state === 'idle')
|
|
||||||
onPageScrollStateChanged(state)
|
|
||||||
}}>
|
|
||||||
{Children.map(children, child => (
|
{Children.map(children, child => (
|
||||||
<View collapsable={false} style={a.flex_1}>
|
<View collapsable={false} style={a.flex_1}>
|
||||||
{child}
|
{child}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</NativePagerView>
|
</MemoizedAnimatedPagerView>
|
||||||
)
|
)
|
||||||
|
|
||||||
return manageDrawerGesture ? (
|
return manageDrawerGesture ? (
|
||||||
@@ -83,6 +150,13 @@ export function Content({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PagerEventData =
|
||||||
|
| PagerViewOnPageScrollEventData
|
||||||
|
| PagerViewOnPageSelectedEventData
|
||||||
|
| PageScrollStateChangedNativeEventData
|
||||||
|
|
||||||
|
type PagerNativeEvent = NativeSyntheticEvent<PagerEventData>
|
||||||
|
|
||||||
function DrawerGestureRequireFail({children}: {children: React.ReactNode}) {
|
function DrawerGestureRequireFail({children}: {children: React.ReactNode}) {
|
||||||
const drawerGesture = useContext(DrawerGestureContext)
|
const drawerGesture = useContext(DrawerGestureContext)
|
||||||
const pagerGesture = useMemo(() => {
|
const pagerGesture = useMemo(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Activity, Children, useEffect, useId, useRef, useState} from 'react'
|
import {Activity, Children, useEffect, useId, useRef, useState} from 'react'
|
||||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import {ReduceMotion, withTiming} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {usePagerContext} from './context'
|
import {usePagerContext} from './context'
|
||||||
@@ -15,7 +16,8 @@ export function Content({
|
|||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
testID?: string
|
testID?: string
|
||||||
}) {
|
}) {
|
||||||
const {selectedPage, onPageSelected} = usePagerContext()
|
const {selectedPage, dragProgress, dragState, onPageSelected} =
|
||||||
|
usePagerContext()
|
||||||
const pages = Children.toArray(children)
|
const pages = Children.toArray(children)
|
||||||
const activityName = useId()
|
const activityName = useId()
|
||||||
const previousPage = useRef(selectedPage)
|
const previousPage = useRef(selectedPage)
|
||||||
@@ -26,6 +28,17 @@ export function Content({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPage !== previousPage.current) {
|
if (selectedPage !== previousPage.current) {
|
||||||
previousPage.current = selectedPage
|
previousPage.current = selectedPage
|
||||||
|
dragState.set('settling')
|
||||||
|
dragProgress.set(
|
||||||
|
withTiming(
|
||||||
|
selectedPage,
|
||||||
|
{duration: 200, reduceMotion: ReduceMotion.System},
|
||||||
|
finished => {
|
||||||
|
'worklet'
|
||||||
|
if (finished) dragState.set('idle')
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
onPageSelected(selectedPage)
|
onPageSelected(selectedPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +46,7 @@ export function Content({
|
|||||||
if (current.has(selectedPage)) return current
|
if (current.has(selectedPage)) return current
|
||||||
return new Set([...current, selectedPage])
|
return new Set([...current, selectedPage])
|
||||||
})
|
})
|
||||||
}, [selectedPage, onPageSelected])
|
}, [selectedPage, dragProgress, dragState, onPageSelected])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={[a.flex_1, style]}>
|
<View testID={testID} style={[a.flex_1, style]}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import {type SharedValue, useSharedValue} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ export type PagerScrollState = 'idle' | 'dragging' | 'settling'
|
|||||||
export type PagerRenderProps = {
|
export type PagerRenderProps = {
|
||||||
selectedPage: number
|
selectedPage: number
|
||||||
selectPage: (page: number) => void
|
selectPage: (page: number) => void
|
||||||
|
dragProgress: SharedValue<number>
|
||||||
|
dragState: SharedValue<PagerScrollState>
|
||||||
}
|
}
|
||||||
|
|
||||||
type PagerContextValue = PagerRenderProps & {
|
type PagerContextValue = PagerRenderProps & {
|
||||||
@@ -45,6 +48,8 @@ export function Root({
|
|||||||
}) {
|
}) {
|
||||||
const [selectedPage, setSelectedPage] = useState(initialPage)
|
const [selectedPage, setSelectedPage] = useState(initialPage)
|
||||||
const selectedPageRef = useRef(initialPage)
|
const selectedPageRef = useRef(initialPage)
|
||||||
|
const dragProgress = useSharedValue(initialPage)
|
||||||
|
const dragState = useSharedValue<PagerScrollState>('idle')
|
||||||
|
|
||||||
const handlePageSelected = useCallback(
|
const handlePageSelected = useCallback(
|
||||||
(page: number) => {
|
(page: number) => {
|
||||||
@@ -73,6 +78,8 @@ export function Root({
|
|||||||
initialPage,
|
initialPage,
|
||||||
selectedPage,
|
selectedPage,
|
||||||
selectPage,
|
selectPage,
|
||||||
|
dragProgress,
|
||||||
|
dragState,
|
||||||
onPageSelected: handlePageSelected,
|
onPageSelected: handlePageSelected,
|
||||||
onPageScrollStateChanged: (state: PagerScrollState) =>
|
onPageScrollStateChanged: (state: PagerScrollState) =>
|
||||||
onPageScrollStateChanged?.(state),
|
onPageScrollStateChanged?.(state),
|
||||||
@@ -81,6 +88,8 @@ export function Root({
|
|||||||
initialPage,
|
initialPage,
|
||||||
selectedPage,
|
selectedPage,
|
||||||
selectPage,
|
selectPage,
|
||||||
|
dragProgress,
|
||||||
|
dragState,
|
||||||
handlePageSelected,
|
handlePageSelected,
|
||||||
onPageScrollStateChanged,
|
onPageScrollStateChanged,
|
||||||
],
|
],
|
||||||
@@ -100,13 +109,12 @@ export function TabBar({
|
|||||||
}: {
|
}: {
|
||||||
children: (props: PagerRenderProps) => ReactNode
|
children: (props: PagerRenderProps) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {selectedPage, selectPage} = usePager()
|
return children(usePager())
|
||||||
return children({selectedPage, selectPage})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePager(): PagerRenderProps {
|
export function usePager(): PagerRenderProps {
|
||||||
const {selectedPage, selectPage} = usePagerContext()
|
const {selectedPage, selectPage, dragProgress, dragState} = usePagerContext()
|
||||||
return {selectedPage, selectPage}
|
return {selectedPage, selectPage, dragProgress, dragState}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePagerContext() {
|
export function usePagerContext() {
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import {useEffect, useRef, useState} from 'react'
|
import {useEffect, useLayoutEffect, useRef, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
|
Pressable,
|
||||||
|
type ReactNativeElement,
|
||||||
type ScrollView,
|
type ScrollView,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
type SharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
||||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
import {atoms as a, tokens, useTheme, utils, web} from '#/alf'
|
import {atoms as a, tokens, useTheme, utils, web} from '#/alf'
|
||||||
@@ -27,60 +35,75 @@ export type TabPillItem = {
|
|||||||
export function TabPills({
|
export function TabPills({
|
||||||
tabs,
|
tabs,
|
||||||
selectedTab,
|
selectedTab,
|
||||||
|
dragProgress,
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
gutterWidth = tokens.space.lg,
|
gutterWidth = tokens.space.lg,
|
||||||
}: {
|
}: {
|
||||||
tabs: TabPillItem[]
|
tabs: TabPillItem[]
|
||||||
selectedTab: string
|
selectedTab: string
|
||||||
|
dragProgress: SharedValue<number>
|
||||||
onSelectTab: (tab: string) => void
|
onSelectTab: (tab: string) => void
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
gutterWidth?: number
|
gutterWidth?: number
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
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 [totalWidth, setTotalWidth] = useState(0)
|
||||||
const [scrollX, setScrollX] = useState(0)
|
const [scrollX, setScrollX] = useState(0)
|
||||||
const [contentWidth, setContentWidth] = useState(0)
|
const [contentWidth, setContentWidth] = useState(0)
|
||||||
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
const [tabOffsets, setTabOffsets] = useState<PillLayout[]>([])
|
||||||
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
scrollIntoViewIfNeeded(tabs.findIndex(tab => tab.key === selectedTab))
|
const viewportRect = listRef.current?.getBoundingClientRect()
|
||||||
})
|
const contentRect = contentRef.current?.getBoundingClientRect()
|
||||||
|
if (!viewportRect || !contentRect) return
|
||||||
|
|
||||||
useEffect(() => {
|
const layouts = Array.from({length: tabCount}, (_, index) => {
|
||||||
if (tabOffsets) {
|
const rect = tabRefs.current[index]?.getBoundingClientRect()
|
||||||
onInitialLayout()
|
if (!rect) return null
|
||||||
}
|
return {
|
||||||
}, [tabOffsets, onInitialLayout])
|
x: rect.left - contentRect.left,
|
||||||
|
y: rect.top - contentRect.top,
|
||||||
function scrollIntoViewIfNeeded(index: number) {
|
width: rect.width,
|
||||||
const btnLayout = tabOffsets[index]
|
height: rect.height,
|
||||||
if (!btnLayout) return
|
}
|
||||||
listRef.current?.scrollTo({
|
|
||||||
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
|
|
||||||
animated: true,
|
|
||||||
})
|
})
|
||||||
}
|
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) {
|
function handleSelectTab(index: number) {
|
||||||
const tab = tabs[index]
|
const tab = tabs[index]
|
||||||
onSelectTab(tab.key)
|
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
|
const canScrollLeft = scrollX > 0
|
||||||
@@ -170,15 +193,10 @@ export function TabPills({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.relative, a.flex_row]}>
|
<View style={[a.relative, a.flex_row]} accessibilityRole="tablist">
|
||||||
<BlockDrawerGesture>
|
<BlockDrawerGesture>
|
||||||
<DraggableScrollView
|
<DraggableScrollView
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
contentContainerStyle={[
|
|
||||||
a.gap_sm,
|
|
||||||
{paddingHorizontal: gutterWidth},
|
|
||||||
contentContainerStyle,
|
|
||||||
]}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
decelerationRate="fast"
|
decelerationRate="fast"
|
||||||
snapToOffsets={
|
snapToOffsets={
|
||||||
@@ -186,22 +204,93 @@ export function TabPills({
|
|||||||
? tabOffsets.map(offset => offset.x - tokens.space.xl)
|
? tabOffsets.map(offset => offset.x - tokens.space.xl)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onLayout={event => setTotalWidth(event.nativeEvent.layout.width)}
|
onScroll={
|
||||||
onContentSizeChange={width => setContentWidth(width)}
|
IS_WEB
|
||||||
onScroll={event => {
|
? event => setScrollX(event.nativeEvent.contentOffset.x)
|
||||||
setScrollX(event.nativeEvent.contentOffset.x)
|
: undefined
|
||||||
}}
|
}
|
||||||
scrollEventThrottle={16}>
|
scrollEventThrottle={IS_WEB ? 16 : undefined}>
|
||||||
{tabs.map((tab, index) => (
|
<View
|
||||||
<TabPill
|
ref={contentRef}
|
||||||
key={tab.key}
|
style={[
|
||||||
tab={tab}
|
a.flex_row,
|
||||||
index={index}
|
a.gap_sm,
|
||||||
active={tab.key === selectedTab}
|
{paddingHorizontal: gutterWidth},
|
||||||
onSelectTab={handleSelectTab}
|
contentContainerStyle,
|
||||||
onLayout={handleTabLayout}
|
]}>
|
||||||
/>
|
{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>
|
</DraggableScrollView>
|
||||||
</BlockDrawerGesture>
|
</BlockDrawerGesture>
|
||||||
|
|
||||||
@@ -234,6 +323,7 @@ export function TabPills({
|
|||||||
a.h_full,
|
a.h_full,
|
||||||
a.aspect_square,
|
a.aspect_square,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
|
a.curve_continuous,
|
||||||
]}>
|
]}>
|
||||||
<ButtonIcon icon={ArrowLeft} />
|
<ButtonIcon icon={ArrowLeft} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -269,6 +359,7 @@ export function TabPills({
|
|||||||
a.h_full,
|
a.h_full,
|
||||||
a.aspect_square,
|
a.aspect_square,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
|
a.curve_continuous,
|
||||||
]}>
|
]}>
|
||||||
<ButtonIcon icon={ArrowRight} />
|
<ButtonIcon icon={ArrowRight} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,56 +370,177 @@ export function TabPills({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TabPill({
|
function TabPill({
|
||||||
|
elementRef,
|
||||||
tab,
|
tab,
|
||||||
active,
|
active,
|
||||||
index,
|
index,
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
onLayout,
|
|
||||||
}: {
|
}: {
|
||||||
|
elementRef: React.Ref<View>
|
||||||
tab: TabPillItem
|
tab: TabPillItem
|
||||||
active: boolean
|
active: boolean
|
||||||
index: number
|
index: number
|
||||||
onSelectTab: (index: number) => void
|
onSelectTab: (index: number) => void
|
||||||
onLayout: (index: number, x: number, width: number) => void
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View ref={elementRef}>
|
||||||
onLayout={event =>
|
<Pressable
|
||||||
onLayout(
|
accessibilityLabel={
|
||||||
index,
|
|
||||||
event.nativeEvent.layout.x,
|
|
||||||
event.nativeEvent.layout.width,
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<Button
|
|
||||||
label={
|
|
||||||
active ? l`${tab.label} tab, selected` : l`Select ${tab.label} tab`
|
active ? l`${tab.label} tab, selected` : l`Select ${tab.label} tab`
|
||||||
}
|
}
|
||||||
|
accessibilityHint={l`Shows ${tab.label} notifications`}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityState={{selected: active}}
|
accessibilityState={{selected: active}}
|
||||||
onPress={() => onSelectTab(index)}>
|
onPress={() => onSelectTab(index)}
|
||||||
|
style={[a.rounded_full, a.curve_continuous]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
|
a.curve_continuous,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
{borderWidth: 1},
|
a.bg_transparent,
|
||||||
active
|
{borderWidth: 1, borderColor: 'transparent'},
|
||||||
? [t.atoms.bg_contrast_50, {borderColor: t.palette.contrast_50}]
|
|
||||||
: [a.bg_transparent, t.atoms.border_contrast_low],
|
|
||||||
]}>
|
]}>
|
||||||
<Text
|
<Text accessible={false} style={[a.font_medium, {opacity: 0}]}>
|
||||||
style={[
|
|
||||||
a.font_medium,
|
|
||||||
active ? t.atoms.text : t.atoms.text_contrast_high,
|
|
||||||
]}>
|
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Button>
|
</Pressable>
|
||||||
</View>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import {NotificationsScreen as LegacyNotificationsScreen} from '#/view/screens/N
|
|||||||
import {PageList} from '#/screens/Notifications/components/PageList'
|
import {PageList} from '#/screens/Notifications/components/PageList'
|
||||||
import * as Pager from '#/screens/Notifications/components/PagerView'
|
import * as Pager from '#/screens/Notifications/components/PagerView'
|
||||||
import {TabPills} from '#/screens/Notifications/components/TabPills'
|
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 {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_LIQUID_GLASS} from '#/env'
|
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
NotificationsTabNavigatorParams,
|
NotificationsTabNavigatorParams,
|
||||||
@@ -99,10 +99,11 @@ function NewNotificationsScreenInner() {
|
|||||||
}}>
|
}}>
|
||||||
<NotificationsHeader onHeightChange={setHeaderOffset}>
|
<NotificationsHeader onHeightChange={setHeaderOffset}>
|
||||||
<Pager.TabBar>
|
<Pager.TabBar>
|
||||||
{({selectedPage, selectPage}) => (
|
{({selectedPage, selectPage, dragProgress}) => (
|
||||||
<TabPills
|
<TabPills
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
selectedTab={tabs[selectedPage].key}
|
selectedTab={tabs[selectedPage].key}
|
||||||
|
dragProgress={dragProgress}
|
||||||
onSelectTab={tab =>
|
onSelectTab={tab =>
|
||||||
selectPage(tabs.findIndex(candidate => candidate.key === tab))
|
selectPage(tabs.findIndex(candidate => candidate.key === tab))
|
||||||
}
|
}
|
||||||
@@ -134,6 +135,7 @@ function NotificationsHeader({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const headerMode = useHomeHeaderMode()
|
const headerMode = useHomeHeaderMode()
|
||||||
const {headerHeight} = useShellLayout()
|
const {headerHeight} = useShellLayout()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const headerPinnedHeight = IS_LIQUID_GLASS ? insets.top : 0
|
const headerPinnedHeight = IS_LIQUID_GLASS ? insets.top : 0
|
||||||
|
|
||||||
@@ -183,6 +185,18 @@ function NotificationsHeader({
|
|||||||
style={[a.absolute, a.inset_0, t.atoms.bg]}
|
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
|
<Animated.View
|
||||||
style={[IS_LIQUID_GLASS && {paddingTop: insets.top}, titleStyle]}
|
style={[IS_LIQUID_GLASS && {paddingTop: insets.top}, titleStyle]}
|
||||||
onLayout={event => {
|
onLayout={event => {
|
||||||
|
|||||||
Reference in New Issue
Block a user