From 524ad0500260f13f4181ea8108e109f7b7cf05a5 Mon Sep 17 00:00:00 2001 From: Whitewater Date: Thu, 19 Jun 2025 15:17:27 +0800 Subject: [PATCH] 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 --- .../native/example/src/components/Pager.tsx | 4 +- .../PagerView/EnhancePagerController.swift | 12 +- .../PagerView/EnhancePagerViewModule.swift | 4 +- .../src/components/native/PagerView/index.tsx | 4 +- .../src/components/native/PagerView/specs.ts | 8 +- .../src/modules/screen/PagerList.ios.tsx | 31 +++- apps/mobile/src/modules/screen/PagerList.tsx | 121 +++++++++------- .../modules/screen/TimelineViewSelector.tsx | 134 ++++++++++++------ apps/mobile/src/modules/screen/atoms.ts | 41 +++++- apps/mobile/src/providers/index.tsx | 9 +- 10 files changed, 253 insertions(+), 115 deletions(-) diff --git a/apps/mobile/native/example/src/components/Pager.tsx b/apps/mobile/native/example/src/components/Pager.tsx index 382694a42..c9d5a04b9 100644 --- a/apps/mobile/native/example/src/components/Pager.tsx +++ b/apps/mobile/native/example/src/components/Pager.tsx @@ -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 diff --git a/apps/mobile/native/ios/Modules/PagerView/EnhancePagerController.swift b/apps/mobile/native/ios/Modules/PagerView/EnhancePagerController.swift index e5d3b8a23..5bea420e6 100644 --- a/apps/mobile/native/ios/Modules/PagerView/EnhancePagerController.swift +++ b/apps/mobile/native/ios/Modules/PagerView/EnhancePagerController.swift @@ -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 diff --git a/apps/mobile/native/ios/Modules/PagerView/EnhancePagerViewModule.swift b/apps/mobile/native/ios/Modules/PagerView/EnhancePagerViewModule.swift index fb7d38de6..5bd6e95ac 100644 --- a/apps/mobile/native/ios/Modules/PagerView/EnhancePagerViewModule.swift +++ b/apps/mobile/native/ios/Modules/PagerView/EnhancePagerViewModule.swift @@ -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]) diff --git a/apps/mobile/src/components/native/PagerView/index.tsx b/apps/mobile/src/components/native/PagerView/index.tsx index 0a20c339b..564f0de47 100644 --- a/apps/mobile/src/components/native/PagerView/index.tsx +++ b/apps/mobile/src/components/native/PagerView/index.tsx @@ -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 = ({ 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?.() diff --git a/apps/mobile/src/components/native/PagerView/specs.ts b/apps/mobile/src/components/native/PagerView/specs.ts index 251a91a7a..187cc411c 100644 --- a/apps/mobile/src/components/native/PagerView/specs.ts +++ b/apps/mobile/src/components/native/PagerView/specs.ts @@ -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 diff --git a/apps/mobile/src/modules/screen/PagerList.ios.tsx b/apps/mobile/src/modules/screen/PagerList.ios.tsx index 67d10ef9f..cad0ba03a 100644 --- a/apps/mobile/src/modules/screen/PagerList.ios.tsx +++ b/apps/mobile/src/modules/screen/PagerList.ios.tsx @@ -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(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 ( { + 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} diff --git a/apps/mobile/src/modules/screen/PagerList.tsx b/apps/mobile/src/modules/screen/PagerList.tsx index cc7016acc..4007b14f1 100644 --- a/apps/mobile/src/modules/screen/PagerList.tsx +++ b/apps/mobile/src/modules/screen/PagerList.tsx @@ -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(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(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, diff --git a/apps/mobile/src/modules/screen/TimelineViewSelector.tsx b/apps/mobile/src/modules/screen/TimelineViewSelector.tsx index c1528ab06..2e02c1a9d 100644 --- a/apps/mobile/src/modules/screen/TimelineViewSelector.tsx +++ b/apps/mobile/src/modules/screen/TimelineViewSelector.tsx @@ -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 ( void style?: Exclude, 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 ( ({ - 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 isActive: boolean }) { @@ -126,6 +143,7 @@ function ViewItem({ const { t } = useTranslation("common") const itemRef = React.useRef(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({ selectTimeline({ type: "view", viewId: view.view })} - style={isActive ? { backgroundColor: view.activeColor } : undefined} > - - {isActive ? ( - <> - - {t(view.name)} - - - - ) : ( - !!unreadCount && - !isActive && ( - - ) - )} + + ({ + opacity: interpolate(dragProgress.get(), [index - 1, index, index + 1], [0, 1, 0]), + }))} + > + + + ({ + opacity: interpolate(dragProgress.get(), [index - 1, index, index + 1], [1, 0, 1]), + }))} + > + + + + + ({ + width: interpolate( + dragProgress.get(), + [index - 1, index, index + 1], + [0, ACTIVE_TEXT_WIDTH, 0], + "clamp", + ), + }))} + > + + {t(view.name)} + + + + + {/* Unread indicator for inactive items */} + ({ + backgroundColor: textColor, + borderColor, + display: unreadCount ? "flex" : "none", + opacity: interpolate( + dragProgress.get(), + [index - 1, index, index + 1], + [1, 0, 1], + "clamp", + ), + }))} + /> diff --git a/apps/mobile/src/modules/screen/atoms.ts b/apps/mobile/src/modules/screen/atoms.ts index 08b6df932..bf576e804 100644 --- a/apps/mobile/src/modules/screen/atoms.ts +++ b/apps/mobile/src/modules/screen/atoms.ts @@ -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 | 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 +} diff --git a/apps/mobile/src/providers/index.tsx b/apps/mobile/src/providers/index.tsx index 8897a46ec..76fd8b46a 100644 --- a/apps/mobile/src/providers/index.tsx +++ b/apps/mobile/src/providers/index.tsx @@ -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 }) => { - - {children} - + + + {children} + +