animated pill
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user