feat: enhance scroll interactions in TimelineSelector (#3952)

* feat: enhance PagerView onScroll event to include position parameter for iOS

* feat: add TimelineSelectorDragProgress context and provider for managing drag state

* feat: enhance TimelineViewSelector with drag progress and animated item transitions

* feat: integrate drag progress into PagerList and TimelineViewSelector for enhanced user interaction

* feat: add animated icon transitions in ViewItem based on drag progress

* fix: add scrollViewDidEndDecelerating to trigger onScrollEnd callback

* fix: enhance PagerList to synchronize drag progress with timeline selection
This commit is contained in:
Whitewater 2025-06-19 15:17:27 +08:00 committed by GitHub
parent 69851eb443
commit 524ad05002
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 253 additions and 115 deletions

View File

@ -8,7 +8,7 @@ const EnhancePageView = requireNativeView("EnhancePageView")
interface PagerProps {
onPageChange?: (e: NativeSyntheticEvent<{ index: number }>) => void
onScroll?: (e: NativeSyntheticEvent<{ percent: number; direction: "left" | "right" }>) => void
onScroll?: (e: NativeSyntheticEvent<{ percent: number; direction: "left" | "right" | "none"; position: number }>) => void
onScrollBegin?: () => void
onScrollEnd?: () => void
onPageWillAppear?: (e: NativeSyntheticEvent<{ index: number }>) => void
@ -30,7 +30,7 @@ type PagerViewProps = {
transitionStyle?: "scroll" | "pageCurl"
page?: number
onPageChange?: (index: number) => void
onScroll?: (percent: number, direction: "left" | "right") => void
onScroll?: (percent: number, direction: "left" | "right" | "none") => void
onScrollBegin?: () => void
onScrollEnd?: () => void
onPageWillAppear?: (index: number) => void

View File

@ -63,7 +63,7 @@ class EnhancePagerController: UIPageViewController, UIScrollViewDelegate {
// Events
var onPageIndexChange: ((Int) -> Void)?
var onScroll: ((CGFloat, PagerDirection) -> Void)?
var onScroll: ((CGFloat, PagerDirection, Int) -> Void)?
var onScrollEnd: ((Int) -> Void)?
var onScrollStart: ((Int) -> Void)?
var onPageWillAppear: ((Int) -> Void)?
@ -130,9 +130,10 @@ class EnhancePagerController: UIPageViewController, UIScrollViewDelegate {
let positionFromStartOfCurrentPage = abs(startOffset - scrollView.contentOffset.x)
let percent = positionFromStartOfCurrentPage / view.frame.width
let position = currentPageIndex
debugPrint(percent, direction)
onScroll?(percent, direction)
debugPrint(percent, direction, position)
onScroll?(percent, direction, position)
}
public func scrollViewDidEndDragging(
@ -143,6 +144,11 @@ class EnhancePagerController: UIPageViewController, UIScrollViewDelegate {
onScrollEnd?(currentPageIndex)
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
isDragging = false
onScrollEnd?(currentPageIndex)
}
}
// Add this extension to implement UIPageViewControllerDataSource

View File

@ -127,8 +127,8 @@ private class EnhancePagerView: ExpoView, UIGestureRecognizerDelegate {
pageController.onScrollStart = { [weak self] index in
self?.onScrollBegin(["index": index])
}
pageController.onScroll = { [weak self] percent, direction in
self?.onScroll(["percent": percent, "direction": direction.rawValue])
pageController.onScroll = { [weak self] percent, direction, position in
self?.onScroll(["percent": percent, "direction": direction.rawValue, "position": position])
}
pageController.onScrollEnd = { [weak self] index in
self?.onScrollEnd(["index": index])

View File

@ -20,7 +20,7 @@ interface PagerViewProps {
transitionStyle?: "scroll" | "pageCurl"
page?: number
onPageChange?: (index: number) => void
onScroll?: (percent: number, direction: "left" | "right") => void
onScroll?: (percent: number, direction: "left" | "right" | "none", position: number) => void
onScrollBegin?: () => void
onScrollEnd?: (index: number) => void
onPageWillAppear?: (index: number) => void
@ -76,7 +76,7 @@ export const PagerView: FC<PagerViewProps> = ({
onPageChange?.(e.nativeEvent.index)
}}
onScroll={(e) => {
onScroll?.(e.nativeEvent.percent, e.nativeEvent.direction)
onScroll?.(e.nativeEvent.percent, e.nativeEvent.direction, e.nativeEvent.position)
}}
onScrollBegin={() => {
onScrollBegin?.()

View File

@ -11,7 +11,13 @@ export const EnhancePageView = requireNativeView("EnhancePageView")
export interface PagerProps {
onPageChange?: (e: NativeSyntheticEvent<{ index: number }>) => void
onScroll?: (e: NativeSyntheticEvent<{ percent: number; direction: "left" | "right" }>) => void
onScroll?: (
e: NativeSyntheticEvent<{
percent: number
direction: "left" | "right" | "none"
position: number
}>,
) => void
onScrollBegin?: () => void
onScrollEnd?: (e: NativeSyntheticEvent<{ index: number }>) => void
onPageWillAppear?: (e: NativeSyntheticEvent<{ index: number }>) => void

View File

@ -3,13 +3,14 @@ import { useTypeScriptHappyCallback } from "@follow/hooks"
import { useViewWithSubscription } from "@follow/store/subscription/hooks"
import { EventBus } from "@follow/utils/event-bus"
import * as Haptics from "expo-haptics"
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { clamp, withTiming } from "react-native-reanimated"
import { PagerView } from "@/src/components/native/PagerView"
import type { PagerRef } from "@/src/components/native/PagerView/specs"
import { selectTimeline, useSelectedFeed } from "./atoms"
import { selectTimeline, useSelectedFeed, useTimelineSelectorDragProgress } from "./atoms"
import { PagerListVisibleContext, PagerListWillVisibleContext } from "./PagerListContext"
export function PagerList({
@ -30,21 +31,39 @@ export function PagerList({
const [initialPageIndex] = useState(activeViewIndex)
const pagerRef = useRef<PagerRef>(null)
const rid = useId()
useEffect(() => {
const dragProgress = useTimelineSelectorDragProgress()
useLayoutEffect(() => {
return EventBus.subscribe("SELECT_TIMELINE", (data) => {
if (data.target !== rid) {
pagerRef.current?.setPage(activeViews.indexOf(data.view.viewId))
const targetIndex = activeViews.indexOf(data.view.viewId)
pagerRef.current?.setPage(targetIndex)
dragProgress.set(withTiming(targetIndex))
}
})
}, [activeViews, pagerRef, rid])
}, [activeViews, dragProgress, pagerRef, rid])
const [dragging, setDragging] = useState(false)
return (
<PagerView
ref={pagerRef}
initialPageIndex={initialPageIndex}
onScroll={(percent, direction, position) => {
const progress = clamp(
percent * (direction === "left" ? -1 : direction === "right" ? 1 : 0) + position,
0,
activeViews.length - 1,
)
dragProgress.set(progress)
}}
onScrollBegin={useCallback(() => setDragging(true), [])}
onScrollEnd={useCallback(() => setDragging(false), [])}
onScrollEnd={useCallback(
(index: number) => {
setDragging(false)
dragProgress.set(withTiming(index))
},
[dragProgress],
)}
pageContainerClassName="flex-1"
containerClassName="flex-1 absolute inset-0"
containerStyle={style}

View File

@ -2,17 +2,21 @@ import type { FeedViewType } from "@follow/constants"
import { useViewWithSubscription } from "@follow/store/subscription/hooks"
import { EventBus } from "@follow/utils/event-bus"
import * as Haptics from "expo-haptics"
import { useCallback, useEffect, useId, useMemo, useRef } from "react"
import { useEffect, useId, useMemo, useRef } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { Animated, StyleSheet, View } from "react-native"
import { StyleSheet, View } from "react-native"
import type {
PagerViewOnPageScrollEventData,
PagerViewOnPageSelectedEventData,
PageScrollStateChangedNativeEventData,
} from "react-native-pager-view"
import PagerView from "react-native-pager-view"
import { useSharedValue } from "react-native-reanimated"
import { selectTimeline, useSelectedFeed } from "@/src/modules/screen/atoms"
import Animated, { runOnJS, useEvent, useHandler } from "react-native-reanimated"
import { selectTimeline, useSelectedFeed, useTimelineSelectorDragProgress } from "./atoms"
import { PagerListVisibleContext, PagerListWillVisibleContext } from "./PagerListContext"
const AnimatedPagerView = Animated.createAnimatedComponent<typeof PagerView>(PagerView)
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
export function PagerList({
renderItem,
@ -30,6 +34,7 @@ export function PagerList({
() => activeViews.indexOf(viewId as FeedViewType),
[activeViews, viewId],
)
const dragProgress = useTimelineSelectorDragProgress()
const pagerRef = useRef<PagerView>(null)
@ -41,42 +46,33 @@ export function PagerList({
}
})
}, [activeViews, pagerRef, rid])
const userInitiatedDragRef = useSharedValue(false)
// const [dragging, setDragging] = useState(false)
const pageScrollHandler = useCallback(
(e: {
nativeEvent: {
position: number
offset: number
}
}) => {
"worklet"
const handlePageScroll = usePagerHandlers(
{
onPageScroll(e: PagerViewOnPageScrollEventData) {
"worklet"
const { position, offset } = e
dragProgress.set(offset + position)
},
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
"worklet"
const { pageScrollState } = e
if (pageScrollState === "dragging") {
// setDragging(true)
} else if (pageScrollState === "idle") {
// setDragging(false)
}
const { position, offset } = e.nativeEvent
if (!userInitiatedDragRef.value) {
return
}
let targetIndex: number
if (offset > 0.6 && position < activeViews.length - 1) {
targetIndex = position + 1
} else if (offset < 0.4 && position === activeViewIndex - 1) {
targetIndex = position
} else if (offset === 0 && position === activeViewIndex) {
targetIndex = activeViewIndex
} else {
targetIndex = activeViewIndex
}
if (targetIndex !== activeViewIndex) {
selectTimeline({ type: "view", viewId: activeViews[targetIndex]! }, rid)
userInitiatedDragRef.value = false
}
if (pageScrollState === "settling") {
runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Light)
}
},
onPageSelected(e: PagerViewOnPageSelectedEventData) {
"worklet"
runOnJS(selectTimeline)({ type: "view", viewId: activeViews[e.position]! }, rid)
},
},
[activeViewIndex, activeViews, rid, userInitiatedDragRef],
[],
)
return (
@ -88,21 +84,7 @@ export function PagerList({
layoutDirection="ltr"
offscreenPageLimit={1}
overdrag
onPageScroll={pageScrollHandler}
onPageScrollStateChanged={(e) => {
const { pageScrollState } = e.nativeEvent
if (pageScrollState === "dragging") {
// setDragging(true)
userInitiatedDragRef.value = true
} else if (pageScrollState === "idle") {
// setDragging(false)
}
if (pageScrollState === "settling") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
}}
pageMargin={100}
onPageScroll={handlePageScroll}
orientation="horizontal"
>
{useMemo(
@ -127,6 +109,37 @@ export function PagerList({
)
}
/**
* Ported from bluesky-social/social-app
* https://github.com/bluesky-social/social-app/blob/bf95345b333c56876cabf4c5b8516c431cc8ce9b/src/view/com/pager/Pager.tsx#L159-L190
*/
function usePagerHandlers(
handlers: {
onPageScroll: (e: PagerViewOnPageScrollEventData) => void
onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void
onPageSelected?: (e: PagerViewOnPageSelectedEventData) => void
},
dependencies: unknown[],
) {
const { doDependenciesDiffer } = useHandler(handlers as any, dependencies)
const subscribeForEvents = ["onPageScroll", "onPageScrollStateChanged", "onPageSelected"]
return useEvent(
(event) => {
"worklet"
const { onPageScroll, onPageScrollStateChanged, onPageSelected } = handlers
if (event.eventName.endsWith("onPageScroll")) {
onPageScroll(event as any as PagerViewOnPageScrollEventData)
} else if (event.eventName.endsWith("onPageScrollStateChanged")) {
onPageScrollStateChanged(event as any as PageScrollStateChangedNativeEventData)
} else if (event.eventName.endsWith("onPageSelected")) {
onPageSelected?.(event as any as PagerViewOnPageSelectedEventData)
}
},
subscribeForEvents,
doDependenciesDiffer,
)
}
const styles = StyleSheet.create({
container: {
flex: 1,

View File

@ -5,14 +5,22 @@ import { useEffect } from "react"
import { useTranslation } from "react-i18next"
import type { StyleProp, ViewStyle } from "react-native"
import { ScrollView, Text, useWindowDimensions, View } from "react-native"
import { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
import Animated, {
interpolate,
interpolateColor,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated"
import { ReAnimatedPressable } from "@/src/components/common/AnimatedComponents"
import { gentleSpringPreset } from "@/src/constants/spring"
import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui"
import type { ViewDefinition } from "@/src/constants/views"
import { views } from "@/src/constants/views"
import { selectTimeline, useSelectedFeed } from "@/src/modules/screen/atoms"
import {
selectTimeline,
useSelectedFeed,
useTimelineSelectorDragProgress,
} from "@/src/modules/screen/atoms"
import { useColor } from "@/src/theme/colors"
import { UnreadCount } from "../subscription/items/UnreadCount"
@ -20,6 +28,7 @@ import { TimelineViewSelectorContextMenu } from "./TimelineViewSelectorContextMe
const ACTIVE_WIDTH = 180
const INACTIVE_WIDTH = 48
const ACTIVE_TEXT_WIDTH = 85
export function TimelineViewSelector() {
const activeViews = useViewWithSubscription()
@ -38,12 +47,13 @@ export function TimelineViewSelector() {
contentContainerClassName="flex-row gap-3 items-center px-3"
showsHorizontalScrollIndicator={false}
>
{activeViews.map((v) => {
{activeViews.map((v, index) => {
const view = views.find((view) => view.view === v)
if (!view) return null
return (
<ViewItem
key={view.name}
index={index}
view={view}
scrollViewRef={scrollViewRef}
isActive={selectedFeed?.type === "view" && selectedFeed.viewId === view.view}
@ -56,18 +66,23 @@ export function TimelineViewSelector() {
}
function ItemWrapper({
index,
activeColor,
children,
isActive,
onPress,
style,
}: {
children: React.ReactNode
index: number
isActive: boolean
activeColor: string
onPress: () => void
style?: Exclude<StyleProp<ViewStyle>, number>
}) {
const { width: windowWidth } = useWindowDimensions()
const activeViews = useViewWithSubscription()
const dragProgress = useTimelineSelectorDragProgress()
const activeWidth = Math.max(
windowWidth - (INACTIVE_WIDTH + 12) * (activeViews.length - 1) - 8 * 2,
@ -75,25 +90,24 @@ function ItemWrapper({
)
const textWidth = useSharedValue(0)
const width = useSharedValue(
isActive ? Math.max(activeWidth, textWidth.value + INACTIVE_WIDTH) : INACTIVE_WIDTH,
)
const bgColor = useColor("gray5")
useEffect(() => {
width.value = withSpring(
isActive ? Math.max(activeWidth, textWidth.value + INACTIVE_WIDTH) : INACTIVE_WIDTH,
gentleSpringPreset,
)
}, [isActive, width, textWidth, activeWidth])
return (
<ReAnimatedPressable
className="relative flex h-12 flex-row items-center justify-center gap-2 overflow-hidden rounded-[1.2rem]"
className="relative flex h-12 flex-row items-center justify-center gap-2 overflow-hidden rounded-[1.2rem] pl-2"
onPress={onPress}
style={useAnimatedStyle(() => ({
backgroundColor: bgColor,
width: width.value,
backgroundColor: interpolateColor(
dragProgress.get(),
[index - 1, index, index + 1],
[bgColor, activeColor, bgColor],
),
width: interpolate(
dragProgress.get(),
[index - 1, index, index + 1],
[INACTIVE_WIDTH, Math.max(activeWidth, textWidth.value + INACTIVE_WIDTH), INACTIVE_WIDTH],
"clamp",
),
...style,
}))}
>
@ -101,7 +115,7 @@ function ItemWrapper({
className="flex-row items-center gap-2"
onLayout={({ nativeEvent }) => {
if (isActive) {
textWidth.value = nativeEvent.layout.width
textWidth.set(nativeEvent.layout.width)
}
}}
>
@ -113,10 +127,13 @@ function ItemWrapper({
function ViewItem({
view,
index,
scrollViewRef,
isActive,
}: {
view: ViewDefinition
// The notification or audio view will be hidden in some cases, so we need to pass the index
index: number
scrollViewRef: React.RefObject<ScrollView | null>
isActive: boolean
}) {
@ -126,6 +143,7 @@ function ViewItem({
const { t } = useTranslation("common")
const itemRef = React.useRef<View>(null)
const { width: windowWidth } = useWindowDimensions()
const dragProgress = useTimelineSelectorDragProgress()
// Scroll to center the active item when it becomes active
useEffect(() => {
@ -151,31 +169,65 @@ function ViewItem({
<View ref={itemRef}>
<ItemWrapper
isActive={isActive}
index={index}
activeColor={view.activeColor}
onPress={() => selectTimeline({ type: "view", viewId: view.view })}
style={isActive ? { backgroundColor: view.activeColor } : undefined}
>
<view.icon color={isActive ? "#fff" : textColor} height={21} width={21} />
{isActive ? (
<>
<Text key={view.name} className="text-sm font-semibold text-white" numberOfLines={1}>
{t(view.name)}
</Text>
<UnreadCount
max={99}
unread={unreadCount}
dotClassName="size-1.5 rounded-full bg-white"
textClassName="text-white font-bold"
/>
</>
) : (
!!unreadCount &&
!isActive && (
<View
className="absolute -right-0.5 -top-0.5 size-2 rounded-full border"
style={{ backgroundColor: textColor, borderColor }}
/>
)
)}
<View className="relative">
<Animated.View
style={useAnimatedStyle(() => ({
opacity: interpolate(dragProgress.get(), [index - 1, index, index + 1], [0, 1, 0]),
}))}
>
<view.icon color="#fff" height={21} width={21} />
</Animated.View>
<Animated.View
className="absolute"
style={useAnimatedStyle(() => ({
opacity: interpolate(dragProgress.get(), [index - 1, index, index + 1], [1, 0, 1]),
}))}
>
<view.icon color={textColor} height={21} width={21} />
</Animated.View>
</View>
<Animated.View
className="flex flex-row items-center gap-2 overflow-hidden"
style={useAnimatedStyle(() => ({
width: interpolate(
dragProgress.get(),
[index - 1, index, index + 1],
[0, ACTIVE_TEXT_WIDTH, 0],
"clamp",
),
}))}
>
<Text key={view.name} className="text-sm font-semibold text-white" numberOfLines={1}>
{t(view.name)}
</Text>
<UnreadCount
max={99}
unread={unreadCount}
dotClassName="size-1.5 rounded-full bg-white"
textClassName="text-white font-bold"
/>
</Animated.View>
{/* Unread indicator for inactive items */}
<Animated.View
className="absolute -top-0.5 left-5 size-2 rounded-full border"
style={useAnimatedStyle(() => ({
backgroundColor: textColor,
borderColor,
display: unreadCount ? "flex" : "none",
opacity: interpolate(
dragProgress.get(),
[index - 1, index, index + 1],
[1, 0, 1],
"clamp",
),
}))}
/>
</ItemWrapper>
</View>
</TimelineViewSelectorContextMenu>

View File

@ -20,13 +20,17 @@ import { useFeedById } from "@follow/store/feed/hooks"
import { useInboxById } from "@follow/store/inbox/hooks"
import { useListById } from "@follow/store/list/hooks"
import { getSubscriptionByCategory } from "@follow/store/subscription/getter"
import { useViewWithSubscription } from "@follow/store/subscription/hooks"
import { jotaiStore } from "@follow/utils"
import { EventBus } from "@follow/utils/event-bus"
import { debounce } from "es-toolkit"
import { atom, useAtomValue } from "jotai"
import { selectAtom } from "jotai/utils"
import { createContext, use, useCallback, useEffect, useMemo, useState } from "react"
import type { ReactNode } from "react"
import { createContext, createElement, use, useCallback, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import type { SharedValue } from "react-native-reanimated"
import { makeMutable, useSharedValue } from "react-native-reanimated"
import { useFetchEntriesSettings } from "@/src/atoms/settings/general"
import { views } from "@/src/constants/views"
@ -383,3 +387,38 @@ export const useViewDefinition = (view?: FeedViewType) => {
const viewDef = useMemo(() => views.find((v) => v.view === view), [view])
return viewDef
}
const TimelineSelectorDragProgressContext = createContext<SharedValue<number> | null>(null)
export const TimelineSelectorDragProgressProvider = ({ children }: { children: ReactNode }) => {
const selectedFeed = useSelectedFeed()
const viewId = selectedFeed?.type === "view" ? selectedFeed.viewId : undefined
const activeViews = useViewWithSubscription()
const activeViewIndex = useMemo(
() => activeViews.indexOf(viewId as FeedViewType),
[activeViews, viewId],
)
const initialPage = activeViewIndex
const dragProgress = useSharedValue(initialPage)
return createElement(
TimelineSelectorDragProgressContext,
{
value: dragProgress,
},
children,
)
}
export const useTimelineSelectorDragProgress = () => {
const dragProgress = use(TimelineSelectorDragProgressContext)
if (!dragProgress) {
console.error(
"useTimelineSelectorDragProgress must be used within TimelineSelectorDragProgressProvider",
)
return makeMutable(0)
}
return dragProgress
}

View File

@ -17,6 +17,7 @@ import { ErrorBoundary } from "../components/common/ErrorBoundary"
import { GlobalErrorScreen } from "../components/errors/GlobalErrorScreen"
import { LightboxStateProvider } from "../components/lightbox/lightboxState"
import { queryClient } from "../lib/query-client"
import { TimelineSelectorDragProgressProvider } from "../modules/screen/atoms"
import { MigrationProvider } from "./migration"
import { ServerConfigsProvider } from "./ServerConfigsProvider"
@ -36,9 +37,11 @@ export const RootProviders = ({ children }: { children: ReactNode }) => {
<SheetProvider>
<ActionSheetProvider>
<LightboxStateProvider>
<PortalProvider>
<SafeAreaProvider>{children}</SafeAreaProvider>
</PortalProvider>
<TimelineSelectorDragProgressProvider>
<PortalProvider>
<SafeAreaProvider>{children}</SafeAreaProvider>
</PortalProvider>
</TimelineSelectorDragProgressProvider>
</LightboxStateProvider>
</ActionSheetProvider>
<ServerConfigsProvider />