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:
parent
69851eb443
commit
524ad05002
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in New Issue