From ab7efc34a7326c593cbc05c7a0e037a9cda910ea Mon Sep 17 00:00:00 2001 From: Whitewater Date: Thu, 24 Jul 2025 04:43:47 +0800 Subject: [PATCH] feat: implement pull-up navigation on Android (#4228) * feat: implement pull-up navigation on Android * refactor: update folder structure * feat: integrate scroll view progress into pull-up navigation * refactor: extract PullUpIndicatorIos component * fix: compatible custom text size * chore: clean code --- .../src/lib/navigation/ScreenItemContext.ts | 31 +++- .../PullUpIndicatorAndroid.tsx | 55 ++++++ .../pull-up-navigation/PullUpIndicatorIos.tsx | 52 ++++++ .../entry-content/pull-up-navigation/types.ts | 34 ++++ .../use-pull-up-navigation.android.tsx | 175 ++++++++++++++++++ .../use-pull-up-navigation.tsx} | 86 +++------ .../entries/[entryId]/EntryDetailScreen.tsx | 74 ++++---- 7 files changed, 411 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorAndroid.tsx create mode 100644 apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorIos.tsx create mode 100644 apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts create mode 100644 apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.android.tsx rename apps/mobile/src/modules/entry-content/{use-pull-up-to-next.tsx => pull-up-navigation/use-pull-up-navigation.tsx} (58%) diff --git a/apps/mobile/src/lib/navigation/ScreenItemContext.ts b/apps/mobile/src/lib/navigation/ScreenItemContext.ts index c535d97cc..67472f8a1 100644 --- a/apps/mobile/src/lib/navigation/ScreenItemContext.ts +++ b/apps/mobile/src/lib/navigation/ScreenItemContext.ts @@ -1,7 +1,8 @@ import type { PrimitiveAtom } from "jotai" import type { ReactNode } from "react" -import { createContext } from "react" +import { createContext, use } from "react" import type { SharedValue } from "react-native-reanimated" +import { useDerivedValue } from "react-native-reanimated" export interface ScreenItemContextType { screenId: string @@ -20,3 +21,31 @@ export interface ScreenItemContextType { }> } export const ScreenItemContext = createContext(null!) + +export const useScrollViewProgress = () => { + const { reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight } = use(ScreenItemContext)! + + // Use useDerivedValue to create a reactive SharedValue that updates + // whenever any of the input SharedValues change + const progress = useDerivedValue(() => { + // Calculate how far we've scrolled as a proportion of scrollable content + // Scrollable content = total content height - visible height + // Progress is clamped between 0 and 1 + const MAGIC_SHIFT = 95 // Adjust this value based on your layout needs + const scrollableHeight = Math.max( + 0, + scrollViewContentHeight.value - scrollViewHeight.value - MAGIC_SHIFT, + ) + + if (scrollableHeight <= 0) { + // If there's no scrollable content, we're at 100% progress + return 1 + } + + // Calculate progress as a value between 0 and 1 + const progress = Math.min(1, Math.max(0, reAnimatedScrollY.value / scrollableHeight)) + return progress + }, [reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight]) + + return progress +} diff --git a/apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorAndroid.tsx b/apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorAndroid.tsx new file mode 100644 index 000000000..22c441a99 --- /dev/null +++ b/apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorAndroid.tsx @@ -0,0 +1,55 @@ +import { cn } from "@follow/utils" +import { useTranslation } from "react-i18next" +import { View } from "react-native" +import Animated, { useAnimatedStyle } from "react-native-reanimated" +import { useColor } from "react-native-uikit-colors" + +import { Text } from "@/src/components/ui/typography/Text" +import { ArrowLeftCuteReIcon } from "@/src/icons/arrow_left_cute_re" + +import type { UsePullUpToNextReturn } from "./types" + +/** + * Component that handles pulling up to navigate to the next unread entry for Android + */ +export const PullUpIndicatorAndroid: UsePullUpToNextReturn["EntryPullUpToNext"] = ({ + active, + hide = false, + translateY, +}) => { + const { t } = useTranslation() + const textColor = useColor("secondaryLabel") + const iconColor = useColor("label") + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: -translateY.value }], + // paddingBottom: insets.bottom, + opacity: hide ? 0 : 1, + })) + + return ( + + + + + + + {active ? t("entry.release_to_next_entry") : t("entry.pull_up_to_next_entry")} + + + + ) +} diff --git a/apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorIos.tsx b/apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorIos.tsx new file mode 100644 index 000000000..b4a39b4f8 --- /dev/null +++ b/apps/mobile/src/modules/entry-content/pull-up-navigation/PullUpIndicatorIos.tsx @@ -0,0 +1,52 @@ +import { cn } from "@follow/utils" +import { useTranslation } from "react-i18next" +import { View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useColor } from "react-native-uikit-colors" + +import { Text } from "@/src/components/ui/typography/Text" +import { ArrowLeftCuteReIcon } from "@/src/icons/arrow_left_cute_re" + +import type { UsePullUpToNextReturn } from "./types" + +/** + * Component that handles pulling up to navigate to the next unread entry + */ +export const PullUpIndicatorIos: UsePullUpToNextReturn["EntryPullUpToNext"] = ({ + active, + hide = false, +}) => { + const { t } = useTranslation() + const insets = useSafeAreaInsets() + const textColor = useColor("secondaryLabel") + const iconColor = useColor("label") + + return ( + + + + + + + {active ? t("entry.release_to_next_entry") : t("entry.pull_up_to_next_entry")} + + + + ) +} diff --git a/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts b/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts new file mode 100644 index 000000000..9f34709a3 --- /dev/null +++ b/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts @@ -0,0 +1,34 @@ +import type { FC, PropsWithChildren } from "react" +import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" +import type { ComposedGesture, GestureType } from "react-native-gesture-handler" +import type { SharedValue } from "react-native-reanimated" +import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes" + +interface EntryPullUpToNextProps { + active: boolean + hide?: boolean + translateY: SharedValue +} + +export interface UsePullUpToNextProps { + enabled?: boolean + onRefresh?: (() => void) | undefined + progressViewOffset?: number +} + +interface GestureWrapperProps { + enabled?: boolean + gesture?: ComposedGesture | GestureType +} + +export interface UsePullUpToNextReturn { + pullUpViewProps: EntryPullUpToNextProps + scrollViewEventHandlers: { + onScroll?: (e: ReanimatedScrollEvent) => void + onScrollBeginDrag?: (e: NativeSyntheticEvent) => void + onScrollEndDrag?: (event: NativeSyntheticEvent) => void + } + EntryPullUpToNext: FC + gestureWrapperProps: GestureWrapperProps + GestureWrapper: FC> +} diff --git a/apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.android.tsx b/apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.android.tsx new file mode 100644 index 000000000..204523557 --- /dev/null +++ b/apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.android.tsx @@ -0,0 +1,175 @@ +import * as Haptics from "expo-haptics" +import { useCallback, useRef, useState } from "react" +import { View } from "react-native" +import { Gesture, GestureDetector } from "react-native-gesture-handler" +import { runOnJS, useSharedValue, withSpring } from "react-native-reanimated" +import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes" + +import { useScrollViewProgress } from "@/src/lib/navigation/ScreenItemContext" + +import { PullUpIndicatorAndroid } from "./PullUpIndicatorAndroid" +import type { UsePullUpToNextProps, UsePullUpToNextReturn } from "./types" + +const THRESHOLD = 70 // The threshold in pixels to trigger the next entry +const FEEDBACK_THRESHOLD = 0.5 // When to give haptic feedback (50% of the way to the threshold) + +const GestureWrapper: UsePullUpToNextReturn["GestureWrapper"] = ({ + gesture, + enabled, + children, +}) => { + if (!enabled || !gesture) { + return <>{children} + } + + return ( + + {children} + + ) +} + +export const usePullUpToNext = ({ + enabled = true, + onRefresh, +}: UsePullUpToNextProps): UsePullUpToNextReturn => { + const scrollViewProgress = useScrollViewProgress() + const isAtEnd = useSharedValue(false) + const [refreshing, setRefreshing] = useState(false) + const [dragState, setDragState] = useState(false) + const feedbackGiven = useRef(false) + const translateY = useSharedValue(0) + + const initialTouchLocation = useSharedValue<{ x: number; y: number } | null>(null) + const panGesture = Gesture.Pan() + .enabled(enabled) + .manualActivation(true) + .maxPointers(1) + .onBegin((event) => { + initialTouchLocation.value = { x: event.x, y: event.y } + }) + .onTouchesMove((evt, state) => { + const isShortContent = scrollViewProgress.value === 1 + // Make sure we only process gestures when at end of content + if (!isAtEnd.value && !isShortContent) { + state.fail() + return + } + const changedTouch = evt.changedTouches.at(0) + if (!initialTouchLocation.value || !changedTouch) { + state.fail() + return + } + + const yDiff = changedTouch.y - initialTouchLocation.value.y + const isPullUpPanning = yDiff < -1 + + if (isPullUpPanning) { + runOnJS(setDragState)(true) + state.activate() + } else { + state.fail() + } + }) + .onUpdate((event) => { + // Only process upward gestures when at the end of the content + if (event.translationY >= 0) { + return + } + // Apply a damping effect to make the pull feel more natural + const pullDistance = Math.min(Math.abs(event.translationY) * 0.7, THRESHOLD * 1.5) / 2 + translateY.value = pullDistance + + // Ratio used to determine when to deactivate the pulling threshold + const thresholdRatio = 0.95 + // Provide haptic feedback when crossing the threshold + if (pullDistance > THRESHOLD * FEEDBACK_THRESHOLD && !feedbackGiven.current) { + runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Heavy) + feedbackGiven.current = true + runOnJS(setRefreshing)(true) + } else if ( + pullDistance < THRESHOLD * FEEDBACK_THRESHOLD * thresholdRatio && + feedbackGiven.current + ) { + feedbackGiven.current = false + runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Soft) + runOnJS(setRefreshing)(false) + } + }) + .onEnd(() => { + feedbackGiven.current = false + runOnJS(setDragState)(false) + + if (refreshing) { + if (onRefresh) { + runOnJS(onRefresh)() + } + } else { + // Not enough pull or not at the end, reset with a nice spring animation + translateY.value = withSpring(0, { + damping: 16, + mass: 1, + stiffness: 200, + }) + + if (refreshing) { + runOnJS(setRefreshing)(false) + } + } + }) + + // Track whether the scroll view is at the end + const lastScrollY = useRef(0) + const handleScroll = useCallback( + (event: ReanimatedScrollEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event + if (Math.abs(contentOffset.y - lastScrollY.current) < 5) { + return + } + lastScrollY.current = contentOffset.y + // Check if we're near the bottom of the scroll view with a slightly larger buffer + const isEnd = + contentOffset.y >= contentSize.height - layoutMeasurement.height - 20 && + contentSize.height > layoutMeasurement.height + if (isEnd !== isAtEnd.value) { + isAtEnd.value = isEnd + } + }, + [isAtEnd], + ) + + if (!enabled) { + // Return empty implementation for non-Android platforms + return { + scrollViewEventHandlers: {}, + pullUpViewProps: { + active: false, + hide: true, + translateY, + } satisfies UsePullUpToNextReturn["pullUpViewProps"], + EntryPullUpToNext: () => null, + GestureWrapper, + gestureWrapperProps: { + enabled: false, + }, + } + } + + return { + scrollViewEventHandlers: { + onScroll: handleScroll, + }, + pullUpViewProps: { + active: refreshing, + hide: !dragState, + translateY, + }, + + EntryPullUpToNext: PullUpIndicatorAndroid, + GestureWrapper, + gestureWrapperProps: { + gesture: panGesture, + enabled, + }, + } +} diff --git a/apps/mobile/src/modules/entry-content/use-pull-up-to-next.tsx b/apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.tsx similarity index 58% rename from apps/mobile/src/modules/entry-content/use-pull-up-to-next.tsx rename to apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.tsx index 4ebccc8ee..4f08259f1 100644 --- a/apps/mobile/src/modules/entry-content/use-pull-up-to-next.tsx +++ b/apps/mobile/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation.tsx @@ -1,73 +1,24 @@ -import { cn } from "@follow/utils" import * as Haptics from "expo-haptics" import { useCallback, useRef, useState } from "react" -import { useTranslation } from "react-i18next" import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" -import { View } from "react-native" +import { useSharedValue } from "react-native-reanimated" import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes" -import { useSafeAreaInsets } from "react-native-safe-area-context" -import { useColor } from "react-native-uikit-colors" -import { Text } from "@/src/components/ui/typography/Text" -import { ArrowLeftCuteReIcon } from "@/src/icons/arrow_left_cute_re" +import { PullUpIndicatorIos } from "./PullUpIndicatorIos" +import type { UsePullUpToNextProps, UsePullUpToNextReturn } from "./types" -interface EntryPullUpToNextProps { - active: boolean - hide?: boolean -} +// eslint-disable-next-line react-refresh/only-export-components +const EmptyGestureWrapper: UsePullUpToNextReturn["GestureWrapper"] = ({ + children, +}: { + children?: React.ReactNode +}) => children -/** - * Component that handles pulling up to navigate to the next unread entry - */ -const EntryPullUpToNext = ({ active, hide = false }: EntryPullUpToNextProps) => { - const { t } = useTranslation() - const insets = useSafeAreaInsets() - const textColor = useColor("secondaryLabel") - const iconColor = useColor("label") - return ( - - - - - - - {active ? t("entry.release_to_next_entry") : t("entry.pull_up_to_next_entry")} - - - - ) -} export const usePullUpToNext = ({ enabled = true, onRefresh, progressViewOffset = 70, -}: { - enabled?: boolean - onRefresh?: (() => void) | undefined - progressViewOffset?: number -} = {}) => { +}: UsePullUpToNextProps = {}): UsePullUpToNextReturn => { const dragging = useRef(false) const isOverThreshold = useRef(false) const [dragState, setDragState] = useState(false) @@ -124,14 +75,20 @@ export const usePullUpToNext = ({ }, [dragging, onRefresh], ) + const translateY = useSharedValue(0) if (!enabled) { return { scrollViewEventHandlers: {}, pullUpViewProps: { active: false, hide: dragState, - } satisfies EntryPullUpToNextProps, + translateY, + } satisfies UsePullUpToNextReturn["pullUpViewProps"], EntryPullUpToNext: () => null, + GestureWrapper: EmptyGestureWrapper, + gestureWrapperProps: { + enabled: false, + }, } } return { @@ -143,7 +100,12 @@ export const usePullUpToNext = ({ pullUpViewProps: { active: refreshing, hide: !dragState, - } satisfies EntryPullUpToNextProps, - EntryPullUpToNext, + translateY, + } satisfies UsePullUpToNextReturn["pullUpViewProps"], + EntryPullUpToNext: PullUpIndicatorIos, + GestureWrapper: EmptyGestureWrapper, + gestureWrapperProps: { + enabled: false, + }, } } diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx index aa1e0af9e..0aa6d6e68 100644 --- a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx @@ -29,7 +29,7 @@ import { checkLanguage } from "@/src/lib/translation" import { EntryContentContext, useEntryContentContext } from "@/src/modules/entry-content/ctx" import { EntryAISummary } from "@/src/modules/entry-content/EntryAISummary" import { EntryNavigationHeader } from "@/src/modules/entry-content/EntryNavigationHeader" -import { usePullUpToNext } from "@/src/modules/entry-content/use-pull-up-to-next" +import { usePullUpToNext } from "@/src/modules/entry-content/pull-up-navigation/use-pull-up-navigation" import { EntrySocialTitle, EntryTitle } from "../../../../modules/entry-content/EntryTitle" @@ -62,7 +62,13 @@ export const EntryDetailScreen: NavigationControllerView<{ const currentEntryIdx = entryIds.indexOf(entryId) return entryIds[currentEntryIdx + 1] }, [entryId, entryIds]) - const { EntryPullUpToNext, scrollViewEventHandlers, pullUpViewProps } = usePullUpToNext({ + const { + EntryPullUpToNext, + scrollViewEventHandlers, + pullUpViewProps, + GestureWrapper, + gestureWrapperProps, + } = usePullUpToNext({ enabled: !!nextEntryId, onRefresh: useCallback(() => { if (!nextEntryId) return @@ -85,39 +91,41 @@ export const EntryDetailScreen: NavigationControllerView<{ - } - ScrollViewBottom={} - automaticallyAdjustContentInsets={false} - contentContainerClassName="flex min-h-full pb-16" - {...scrollViewEventHandlers} - > - entry?.url && openLink(entry.url)} - className="rounded-xl py-4" + + } + ScrollViewBottom={} + automaticallyAdjustContentInsets={false} + contentContainerClassName="flex min-h-full pb-16" + {...scrollViewEventHandlers} > - {viewType === FeedViewType.SocialMedia ? ( - - ) : ( - <> - - - + entry?.url && openLink(entry.url)} + className="rounded-xl py-4" + > + {viewType === FeedViewType.SocialMedia ? ( + + ) : ( + <> + + + + )} + + + {entry && ( + + + )} - - - {entry && ( - - - - )} - {viewType === FeedViewType.SocialMedia && ( - - - - )} - + {viewType === FeedViewType.SocialMedia && ( + + + + )} + +