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
This commit is contained in:
Whitewater 2025-07-24 04:43:47 +08:00 committed by GitHub
parent ad7c5ddb9b
commit ab7efc34a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 411 additions and 96 deletions

View File

@ -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<ScreenItemContextType>(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
}

View File

@ -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 (
<Animated.View
className="bottom-0 flex w-full flex-row items-center justify-center gap-2 pt-16"
style={animatedStyle}
>
<View
className={cn(
"flex flex-row items-center gap-2 transition-all duration-200",
active ? "opacity-50" : "opacity-80",
)}
>
<View
className={cn(
"rotate-90 transition-all duration-200",
active ? "opacity-0" : "opacity-100",
)}
>
<ArrowLeftCuteReIcon width={16} height={16} color={iconColor} />
</View>
<Text style={{ color: textColor }}>
{active ? t("entry.release_to_next_entry") : t("entry.pull_up_to_next_entry")}
</Text>
</View>
</Animated.View>
)
}

View File

@ -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 (
<View
className={cn(
"absolute bottom-0 flex w-full translate-y-full flex-row items-center justify-center gap-2 pt-4 transition-all duration-200",
hide ? "opacity-0" : "opacity-100",
)}
style={{ paddingBottom: insets.bottom + 20 }}
>
<View
className={cn(
"flex flex-row items-center gap-2 transition-all duration-200",
active ? "opacity-50" : "opacity-80",
)}
>
<View
className={cn(
"rotate-90 transition-all duration-200",
active ? "opacity-0" : "opacity-100",
)}
>
<ArrowLeftCuteReIcon width={16} height={16} color={iconColor} />
</View>
<Text style={{ color: textColor }}>
{active ? t("entry.release_to_next_entry") : t("entry.pull_up_to_next_entry")}
</Text>
</View>
</View>
)
}

View File

@ -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<number>
}
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<NativeScrollEvent>) => void
onScrollEndDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
}
EntryPullUpToNext: FC<EntryPullUpToNextProps>
gestureWrapperProps: GestureWrapperProps
GestureWrapper: FC<PropsWithChildren<GestureWrapperProps>>
}

View File

@ -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 (
<GestureDetector gesture={gesture}>
<View className="flex flex-1">{children}</View>
</GestureDetector>
)
}
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,
},
}
}

View File

@ -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 (
<View
className={cn(
"absolute bottom-0 flex w-full translate-y-full flex-row items-center justify-center gap-2 pt-4 transition-all duration-200",
hide ? "opacity-0" : "opacity-100",
)}
style={{
paddingBottom: insets.bottom + 20,
}}
>
<View
className={cn(
"flex flex-row items-center gap-2 transition-all duration-200",
active ? "opacity-50" : "opacity-80",
)}
>
<View
className={cn(
"rotate-90 transition-all duration-200",
active ? "opacity-0" : "opacity-100",
)}
>
<ArrowLeftCuteReIcon width={16} height={16} color={iconColor} />
</View>
<Text
style={{
color: textColor,
}}
>
{active ? t("entry.release_to_next_entry") : t("entry.pull_up_to_next_entry")}
</Text>
</View>
</View>
)
}
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,
},
}
}

View File

@ -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<{
<EntryContentContext value={ctxValue}>
<PortalProvider>
<BottomTabBarHeightContext value={insets.bottom}>
<SafeNavigationScrollView
Header={<EntryNavigationHeader entryId={entryId} />}
ScrollViewBottom={<EntryPullUpToNext {...pullUpViewProps} />}
automaticallyAdjustContentInsets={false}
contentContainerClassName="flex min-h-full pb-16"
{...scrollViewEventHandlers}
>
<ItemPressable
itemStyle={ItemPressableStyle.UnStyled}
onPress={() => entry?.url && openLink(entry.url)}
className="rounded-xl py-4"
<GestureWrapper {...gestureWrapperProps}>
<SafeNavigationScrollView
Header={<EntryNavigationHeader entryId={entryId} />}
ScrollViewBottom={<EntryPullUpToNext {...pullUpViewProps} />}
automaticallyAdjustContentInsets={false}
contentContainerClassName="flex min-h-full pb-16"
{...scrollViewEventHandlers}
>
{viewType === FeedViewType.SocialMedia ? (
<EntrySocialTitle entryId={entryId} />
) : (
<>
<EntryTitle title={entry?.title || ""} entryId={entryId} />
<EntryInfo entryId={entryId} />
</>
<ItemPressable
itemStyle={ItemPressableStyle.UnStyled}
onPress={() => entry?.url && openLink(entry.url)}
className="rounded-xl py-4"
>
{viewType === FeedViewType.SocialMedia ? (
<EntrySocialTitle entryId={entryId} />
) : (
<>
<EntryTitle title={entry?.title || ""} entryId={entryId} />
<EntryInfo entryId={entryId} />
</>
)}
</ItemPressable>
<EntryAISummary entryId={entryId} />
{entry && (
<View className="mt-3">
<EntryContentWebViewWithContext entryId={entryId} />
</View>
)}
</ItemPressable>
<EntryAISummary entryId={entryId} />
{entry && (
<View className="mt-3">
<EntryContentWebViewWithContext entryId={entryId} />
</View>
)}
{viewType === FeedViewType.SocialMedia && (
<View className="mt-2">
<EntryInfoSocial entryId={entryId} />
</View>
)}
</SafeNavigationScrollView>
{viewType === FeedViewType.SocialMedia && (
<View className="mt-2">
<EntryInfoSocial entryId={entryId} />
</View>
)}
</SafeNavigationScrollView>
</GestureWrapper>
</BottomTabBarHeightContext>
</PortalProvider>
</EntryContentContext>