refactor(rn): extract tab bar as a component

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-01-08 23:56:49 +08:00
parent 834c93fc00
commit 9c3400a5d5
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
10 changed files with 280 additions and 155 deletions

View File

@ -0,0 +1,206 @@
import { cn } from "@follow/utils"
import type { FC } from "react"
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"
import type {
Animated as AnimatedNative,
StyleProp,
TouchableOpacityProps,
ViewStyle,
} from "react-native"
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
import { accentColor } from "@/src/theme/colors"
import type { Tab } from "./types"
interface TabBarProps {
tabs: Tab[]
tabbarClassName?: string
tabbarStyle?: StyleProp<ViewStyle>
TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick<TouchableOpacityProps, "onLayout">>
onTabItemPress?: (index: number) => void
currentTab?: number
tabScrollContainerAnimatedX?: AnimatedNative.Value
}
const springConfig = {
stiffness: 100,
damping: 10,
}
export const TabBar = forwardRef<ScrollView, TabBarProps>(
(
{
tabs,
TabItem = Pressable,
tabbarClassName,
tabbarStyle,
onTabItemPress,
currentTab: tab,
tabScrollContainerAnimatedX: pagerOffsetX,
},
ref,
) => {
const [currentTab, setCurrentTab] = useState(tab || 0)
const [tabWidths, setTabWidths] = useState<number[]>([])
const [tabPositions, setTabPositions] = useState<number[]>([])
const indicatorPosition = useSharedValue(0)
useEffect(() => {
if (typeof tab === "number") {
setCurrentTab(tab)
}
}, [tab])
const sharedPagerOffsetX = useSharedValue(0)
const [tabBarWidth, setTabBarWidth] = useState(0)
useEffect(() => {
if (pagerOffsetX) {
return
}
sharedPagerOffsetX.value = withSpring(currentTab * tabBarWidth, springConfig)
}, [currentTab, pagerOffsetX, sharedPagerOffsetX, tabBarWidth])
useEffect(() => {
if (!pagerOffsetX) return
const id = pagerOffsetX.addListener(({ value }) => {
sharedPagerOffsetX.value = value
})
return () => {
pagerOffsetX.removeListener(id)
}
}, [pagerOffsetX, sharedPagerOffsetX])
const tabRef = useRef<ScrollView>(null)
useEffect(() => {
if (!pagerOffsetX) return
const listener = pagerOffsetX.addListener(({ value }) => {
// Calculate which tab should be active based on scroll position
const tabIndex = Math.round(value / tabBarWidth)
if (tabIndex !== currentTab) {
setCurrentTab(tabIndex)
onTabItemPress?.(tabIndex)
}
})
return () => pagerOffsetX.removeListener(listener)
}, [currentTab, onTabItemPress, pagerOffsetX, tabBarWidth])
useImperativeHandle(ref, () => tabRef.current!)
useEffect(() => {
if (tabWidths.length > 0) {
indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig)
if (tabRef.current) {
const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0
const isCurrentTabVisible =
sharedPagerOffsetX.value < tabPositions[currentTab] &&
sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab]
if (!isCurrentTabVisible) {
tabRef.current.scrollTo({ x, y: 0, animated: true })
}
}
}
}, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths])
const indicatorStyle = useAnimatedStyle(() => {
const scrollProgress = sharedPagerOffsetX.value / tabBarWidth
const currentIndex = Math.floor(scrollProgress)
const nextIndex = Math.min(currentIndex + 1, tabs.length - 1)
const progress = scrollProgress - currentIndex
// Interpolate between current and next tab positions
const xPosition =
tabPositions[currentIndex] +
(tabPositions[nextIndex] - tabPositions[currentIndex]) * progress
// Interpolate between current and next tab widths
const width =
tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress
return {
transform: [{ translateX: xPosition }],
width,
backgroundColor: tabs[currentTab].activeColor || accentColor,
}
})
return (
<ScrollView
onLayout={(event) => {
setTabBarWidth(event.nativeEvent.layout.width)
}}
showsHorizontalScrollIndicator={false}
className={cn(
"border-tertiary-system-background relative shrink-0 grow-0",
tabbarClassName,
)}
horizontal
ref={tabRef}
contentContainerStyle={styles.tabScroller}
style={[styles.root, tabbarStyle]}
>
{tabs.map((tab, index) => (
<TabItem
onPress={() => {
setCurrentTab(index)
onTabItemPress?.(index)
}}
key={tab.value}
isSelected={index === currentTab}
onLayout={(event) => {
const { width, x } = event.nativeEvent.layout
setTabWidths((prev) => {
const newWidths = [...prev]
newWidths[index] = width
return newWidths
})
setTabPositions((prev) => {
const newPositions = [...prev]
newPositions[index] = x
return newPositions
})
}}
tab={tab}
>
<TabItemInner tab={tab} isSelected={index === currentTab} />
</TabItem>
))}
<Animated.View style={[styles.indicator, indicatorStyle]} />
</ScrollView>
)
},
)
const styles = StyleSheet.create({
tabScroller: {
alignItems: "center",
flexDirection: "row",
paddingHorizontal: 4,
},
root: { paddingHorizontal: 6 },
indicator: {
position: "absolute",
bottom: 0,
height: 2,
borderRadius: 1,
},
})
const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => {
return (
<View className="p-2">
<Text style={{ color: isSelected ? accentColor : "gray" }}>{tab.name}</Text>
</View>
)
}

View File

@ -1,23 +1,18 @@
import { cn } from "@follow/utils"
import type { FC } from "react"
import { useEffect, useRef, useState } from "react"
import type { StyleProp, TouchableOpacityProps, ViewStyle } from "react-native"
import { useCallback, useEffect, useRef, useState } from "react"
import type { ScrollView, StyleProp, TouchableOpacityProps, ViewStyle } from "react-native"
import {
Animated as RnAnimated,
Pressable,
ScrollView,
StyleSheet,
Text,
useAnimatedValue,
useWindowDimensions,
View,
} from "react-native"
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils"
import { accentColor } from "@/src/theme/colors"
import { AnimatedScrollView } from "../../common/AnimatedComponents"
import { TabBar } from "./TabBar"
type Tab = {
name: string
@ -45,11 +40,6 @@ interface TabViewProps {
lazyOnce?: boolean
}
const springConfig = {
stiffness: 100,
damping: 10,
}
export const TabView: FC<TabViewProps> = ({
tabs,
Tab = View,
@ -67,80 +57,12 @@ export const TabView: FC<TabViewProps> = ({
lazyOnce,
lazyTab,
}) => {
const tabRef = useRef<ScrollView>(null)
const [tabWidths, setTabWidths] = useState<number[]>([])
const [tabPositions, setTabPositions] = useState<number[]>([])
const [currentTab, setCurrentTab] = useState(initialTab ?? 0)
const pagerOffsetX = useAnimatedValue(0)
const sharedPagerOffsetX = useSharedValue(0)
useEffect(() => {
const id = pagerOffsetX.addListener(({ value }) => {
sharedPagerOffsetX.value = value
})
return () => {
pagerOffsetX.removeListener(id)
}
}, [pagerOffsetX, sharedPagerOffsetX])
const indicatorPosition = useSharedValue(0)
const { width: windowWidth } = useWindowDimensions()
useEffect(() => {
if (tabWidths.length > 0) {
indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig)
if (tabRef.current) {
const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0
const isCurrentTabVisible =
sharedPagerOffsetX.value < tabPositions[currentTab] &&
sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab]
if (!isCurrentTabVisible) {
tabRef.current.scrollTo({ x, y: 0, animated: true })
}
}
}
}, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths])
const indicatorStyle = useAnimatedStyle(() => {
const scrollProgress = sharedPagerOffsetX.value / windowWidth
const currentIndex = Math.floor(scrollProgress)
const nextIndex = Math.min(currentIndex + 1, tabs.length - 1)
const progress = scrollProgress - currentIndex
// Interpolate between current and next tab positions
const xPosition =
tabPositions[currentIndex] + (tabPositions[nextIndex] - tabPositions[currentIndex]) * progress
// Interpolate between current and next tab widths
const width =
tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress
return {
transform: [{ translateX: xPosition }],
width,
backgroundColor: tabs[currentTab].activeColor || accentColor,
}
})
useEffect(() => {
const listener = pagerOffsetX.addListener(({ value }) => {
// Calculate which tab should be active based on scroll position
const tabIndex = Math.round(value / windowWidth)
if (tabIndex !== currentTab) {
setCurrentTab(tabIndex)
onTabChange?.(tabIndex)
}
})
return () => pagerOffsetX.removeListener(listener)
}, [tabWidths, tabPositions, currentTab, pagerOffsetX, windowWidth, onTabChange])
const [lazyTabSet, setLazyTabSet] = useState(() => new Set<number>())
const shouldRenderCurrentTab = (index: number) => {
@ -162,47 +84,22 @@ export const TabView: FC<TabViewProps> = ({
return (
<>
<ScrollView
showsHorizontalScrollIndicator={false}
className={cn(
"border-tertiary-system-background relative shrink-0 grow-0",
tabbarClassName,
<TabBar
onTabItemPress={useCallback(
(index: number) => {
contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true })
setCurrentTab(index)
onTabChange?.(index)
},
[onTabChange, windowWidth],
)}
horizontal
ref={tabRef}
contentContainerStyle={styles.tabScroller}
style={[styles.root, tabbarStyle]}
>
{tabs.map((tab, index) => (
<TabItem
onPress={() => {
// setCurrentTab(index)
contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true })
onTabChange?.(index)
}}
key={tab.value}
isSelected={index === currentTab}
onLayout={(event) => {
const { width, x } = event.nativeEvent.layout
setTabWidths((prev) => {
const newWidths = [...prev]
newWidths[index] = width
return newWidths
})
setTabPositions((prev) => {
const newPositions = [...prev]
newPositions[index] = x
return newPositions
})
}}
tab={tab}
>
<TabItemInner tab={tab} isSelected={index === currentTab} />
</TabItem>
))}
<Animated.View style={[styles.indicator, indicatorStyle]} />
</ScrollView>
tabs={tabs}
currentTab={currentTab}
tabbarClassName={tabbarClassName}
tabbarStyle={tabbarStyle}
TabItem={TabItem}
tabScrollContainerAnimatedX={pagerOffsetX}
/>
<AnimatedScrollView
onScroll={RnAnimated.event([{ nativeEvent: { contentOffset: { x: pagerOffsetX } } }], {
@ -227,27 +124,3 @@ export const TabView: FC<TabViewProps> = ({
</>
)
}
const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => {
return (
<View className="p-2">
<Text style={{ color: isSelected ? accentColor : "gray" }}>{tab.name}</Text>
</View>
)
}
const styles = StyleSheet.create({
tabScroller: {
alignItems: "center",
flexDirection: "row",
paddingHorizontal: 4,
},
root: { paddingHorizontal: 6 },
indicator: {
position: "absolute",
bottom: 0,
height: 2,
borderRadius: 1,
},
})

View File

@ -0,0 +1,5 @@
export type Tab = {
name: string
activeColor?: string
value: string
}

View File

@ -16,7 +16,7 @@ import { TabView } from "@/src/components/ui/tabview"
import { apiClient } from "@/src/lib/api-fetch"
import { RSSHubCategoryCopyMap } from "./copy"
import { RecommendationListItem } from "./recommendation-item"
import { RecommendationListItem } from "./RecommendationListItem"
export const Recommendations = () => {
const headerHeight = useHeaderHeight()

View File

@ -0,0 +1,31 @@
import { useAtom } from "jotai"
import { View } from "react-native"
import { TabBar } from "@/src/components/ui/tabview/TabBar"
import type { Tab } from "@/src/components/ui/tabview/types"
import { SearchType } from "./constants"
import { useDiscoverPageContext } from "./ctx"
const Tabs: Tab[] = [
{ name: "All", value: SearchType.AGGREGATE },
{ name: "RSS", value: SearchType.RSS },
{ name: "RSSHub", value: SearchType.RSSHUB },
{ name: "User", value: SearchType.USER },
]
export const SearchTabBar = () => {
const { searchTypeAtom } = useDiscoverPageContext()
const [searchType, setSearchType] = useAtom(searchTypeAtom)
return (
<View>
<TabBar
tabs={Tabs}
currentTab={Tabs.findIndex((tab) => tab.value === searchType)}
onTabItemPress={(index) => {
setSearchType(Tabs[index].value as SearchType)
}}
/>
</View>
)
}

View File

@ -0,0 +1,6 @@
export enum SearchType {
AGGREGATE = "aggregate",
RSS = "rss",
RSSHUB = "rsshub",
USER = "user",
}

View File

@ -2,9 +2,13 @@ import type { PrimitiveAtom } from "jotai"
import { atom } from "jotai"
import { createContext, useContext, useState } from "react"
import { SearchType } from "./constants"
interface DiscoverPageContextType {
searchFocusedAtom: PrimitiveAtom<boolean>
searchValueAtom: PrimitiveAtom<string>
searchTypeAtom: PrimitiveAtom<SearchType>
}
export const DiscoverPageContext = createContext<DiscoverPageContextType>(null!)
@ -12,10 +16,11 @@ export const DiscoverPageProvider = ({ children }: { children: React.ReactNode }
const [atomRefs] = useState((): DiscoverPageContextType => {
const searchFocusedAtom = atom(true)
const searchValueAtom = atom("")
const searchTypeAtom = atom(SearchType.AGGREGATE)
return {
searchFocusedAtom,
searchValueAtom,
searchTypeAtom,
}
})
return <DiscoverPageContext.Provider value={atomRefs}>{children}</DiscoverPageContext.Provider>

View File

@ -1,5 +1,4 @@
import { getDefaultHeaderHeight } from "@react-navigation/elements"
import { useTheme } from "@react-navigation/native"
import { router } from "expo-router"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import { useEffect, useRef } from "react"
@ -21,6 +20,7 @@ import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
import { accentColor, useColor } from "@/src/theme/colors"
import { useDiscoverPageContext } from "./ctx"
import { SearchTabBar } from "./SearchTabBar"
export const SearchHeader = () => {
const frame = useSafeAreaFrame()
@ -28,11 +28,12 @@ export const SearchHeader = () => {
const headerHeight = getDefaultHeaderHeight(frame, false, insets.top)
return (
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
<View style={{ minHeight: headerHeight, paddingTop: insets.top }} className="relative">
<BlurEffect />
<View style={styles.header}>
<ComposeSearchBar />
</View>
<SearchTabBar />
</View>
)
}
@ -105,7 +106,6 @@ const ComposeSearchBar = () => {
}
const SearchInput = () => {
const { colors } = useTheme()
const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext()
const [isFocused, setIsFocused] = useAtom(searchFocusedAtom)
const placeholderTextColor = useColor("placeholderText")
@ -173,7 +173,7 @@ const SearchInput = () => {
}, [isFocused])
return (
<View style={{ backgroundColor: colors.card, ...styles.searchbar }}>
<View style={styles.searchbar} className="dark:bg-gray-6 bg-gray-5">
{focusOrHasValue && (
<Animated.View
style={{
@ -221,7 +221,6 @@ const SearchInput = () => {
const styles = StyleSheet.create({
header: {
flex: 1,
alignItems: "center",
marginTop: -3,
flexDirection: "row",
@ -229,15 +228,15 @@ const styles = StyleSheet.create({
marginHorizontal: 16,
position: "relative",
},
searchbar: {
flex: 1,
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
borderRadius: 50,
height: "100%",
height: 32,
position: "relative",
},
searchInput: {

View File

@ -1,6 +1,6 @@
import { Stack } from "expo-router"
import { Recommendations } from "@/src/modules/discover/recommendations"
import { Recommendations } from "@/src/modules/discover/Recommendations"
import { DiscoverHeader } from "@/src/modules/discover/search"
export default function Discover() {