diff --git a/apps/mobile/src/components/ui/tabview/TabBar.tsx b/apps/mobile/src/components/ui/tabview/TabBar.tsx new file mode 100644 index 000000000..bf64ec3f2 --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/TabBar.tsx @@ -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 + + TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick> + + onTabItemPress?: (index: number) => void + currentTab?: number + + tabScrollContainerAnimatedX?: AnimatedNative.Value +} + +const springConfig = { + stiffness: 100, + damping: 10, +} + +export const TabBar = forwardRef( + ( + { + tabs, + TabItem = Pressable, + tabbarClassName, + tabbarStyle, + + onTabItemPress, + currentTab: tab, + tabScrollContainerAnimatedX: pagerOffsetX, + }, + ref, + ) => { + const [currentTab, setCurrentTab] = useState(tab || 0) + const [tabWidths, setTabWidths] = useState([]) + const [tabPositions, setTabPositions] = useState([]) + 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(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 ( + { + 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) => ( + { + 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} + > + + + ))} + + + + ) + }, +) + +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 ( + + {tab.name} + + ) +} diff --git a/apps/mobile/src/components/ui/tabview/index.tsx b/apps/mobile/src/components/ui/tabview/index.tsx index 429547858..b2e8dfc9f 100644 --- a/apps/mobile/src/components/ui/tabview/index.tsx +++ b/apps/mobile/src/components/ui/tabview/index.tsx @@ -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 = ({ tabs, Tab = View, @@ -67,80 +57,12 @@ export const TabView: FC = ({ lazyOnce, lazyTab, }) => { - const tabRef = useRef(null) - - const [tabWidths, setTabWidths] = useState([]) - const [tabPositions, setTabPositions] = useState([]) - 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()) const shouldRenderCurrentTab = (index: number) => { @@ -162,47 +84,22 @@ export const TabView: FC = ({ return ( <> - { + 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) => ( - { - // 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} - > - - - ))} - - - + tabs={tabs} + currentTab={currentTab} + tabbarClassName={tabbarClassName} + tabbarStyle={tabbarStyle} + TabItem={TabItem} + tabScrollContainerAnimatedX={pagerOffsetX} + /> = ({ ) } - -const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => { - return ( - - {tab.name} - - ) -} - -const styles = StyleSheet.create({ - tabScroller: { - alignItems: "center", - flexDirection: "row", - paddingHorizontal: 4, - }, - - root: { paddingHorizontal: 6 }, - indicator: { - position: "absolute", - bottom: 0, - height: 2, - borderRadius: 1, - }, -}) diff --git a/apps/mobile/src/components/ui/tabview/types.ts b/apps/mobile/src/components/ui/tabview/types.ts new file mode 100644 index 000000000..2e6dca768 --- /dev/null +++ b/apps/mobile/src/components/ui/tabview/types.ts @@ -0,0 +1,5 @@ +export type Tab = { + name: string + activeColor?: string + value: string +} diff --git a/apps/mobile/src/modules/discover/recommendation-item.tsx b/apps/mobile/src/modules/discover/RecommendationListItem.tsx similarity index 100% rename from apps/mobile/src/modules/discover/recommendation-item.tsx rename to apps/mobile/src/modules/discover/RecommendationListItem.tsx diff --git a/apps/mobile/src/modules/discover/recommendations.tsx b/apps/mobile/src/modules/discover/Recommendations.tsx similarity index 99% rename from apps/mobile/src/modules/discover/recommendations.tsx rename to apps/mobile/src/modules/discover/Recommendations.tsx index e81c6cefd..c22e54fcd 100644 --- a/apps/mobile/src/modules/discover/recommendations.tsx +++ b/apps/mobile/src/modules/discover/Recommendations.tsx @@ -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() diff --git a/apps/mobile/src/modules/discover/SearchTabBar.tsx b/apps/mobile/src/modules/discover/SearchTabBar.tsx new file mode 100644 index 000000000..10ee59369 --- /dev/null +++ b/apps/mobile/src/modules/discover/SearchTabBar.tsx @@ -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 ( + + tab.value === searchType)} + onTabItemPress={(index) => { + setSearchType(Tabs[index].value as SearchType) + }} + /> + + ) +} diff --git a/apps/mobile/src/modules/discover/constants.ts b/apps/mobile/src/modules/discover/constants.ts new file mode 100644 index 000000000..afa88faef --- /dev/null +++ b/apps/mobile/src/modules/discover/constants.ts @@ -0,0 +1,6 @@ +export enum SearchType { + AGGREGATE = "aggregate", + RSS = "rss", + RSSHUB = "rsshub", + USER = "user", +} diff --git a/apps/mobile/src/modules/discover/ctx.tsx b/apps/mobile/src/modules/discover/ctx.tsx index 482bfd747..e4eb6899f 100644 --- a/apps/mobile/src/modules/discover/ctx.tsx +++ b/apps/mobile/src/modules/discover/ctx.tsx @@ -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 searchValueAtom: PrimitiveAtom + + searchTypeAtom: PrimitiveAtom } export const DiscoverPageContext = createContext(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 {children} diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index 2d539d44b..ac0b9fea3 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -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 ( - + + ) } @@ -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 ( - + {focusOrHasValue && ( { 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: { diff --git a/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx b/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx index a7a0ca66d..e67313974 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/discover.tsx @@ -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() {