From dcc9f92d099d0df01517529b1e3fbbefb3cb5d41 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 11 Jun 2026 14:53:37 -0700 Subject: [PATCH] feat(mobile): swipe between For You and Latest feed tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the home feed in a horizontal PagerView so users can swipe left/right to toggle between the For You and Latest feeds, in addition to tapping the existing tab headers. Uses react-native-pager-view (already a dependency — no new native module). Tab state and the pager stay in two-way sync: header taps update state immediately and an effect slides the pager to match; swipes commit the new tab via onPageSelected. The page-selected handler is guarded to skip when the pager already matches state, which swallows the initial onPageSelected PagerView emits on mount (Android) — avoiding a spurious FEED_CHANGE_VIEW analytics event and a clobber of the persisted tab — and a reconciliation effect realigns the pager once the persisted tab resolves from localStorage after mount. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/screens/feed-screen/FeedScreen.tsx | 159 +++++++++++++----- 1 file changed, 119 insertions(+), 40 deletions(-) diff --git a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx index 4703c62d0c1..721db7d512f 100644 --- a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { getFeedQueryKey, @@ -13,6 +13,10 @@ import { FOR_YOU_LOAD_MORE_PAGE_SIZE } from '@audius/common/api' import { Name, FeedTab } from '@audius/common/models' +import { StyleSheet, View } from 'react-native' +import PagerView, { + type PagerViewOnPageSelectedEvent +} from 'react-native-pager-view' import { Screen, ScreenContent } from 'app/components/core' import { EndOfLineupNotice } from 'app/components/lineup/EndOfLineupNotice' @@ -29,6 +33,15 @@ const messages = { endOfFeed: "Looks like you've reached the end of your feed..." } +// Page order must match the tab order rendered by FeedTabs. Swiping left +// advances to a higher index (Latest); swiping right returns to For You. +const tabOrder: FeedTab[] = [FeedTab.FOR_YOU, FeedTab.LATEST] + +const styles = StyleSheet.create({ + pager: { flex: 1 }, + page: { flex: 1 } +}) + export const FeedScreen = () => { const [feedTab, setFeedTab] = useFeedTab() const [feedFilter] = useFeedFilter() @@ -59,7 +72,14 @@ export const FeedScreen = () => { [feedArgs] ) - const handleSelectTab = useCallback( + const pagerRef = useRef(null) + const feedTabIndex = tabOrder.indexOf(feedTab) + // Mirrors the pager's actual on-screen page so we only issue a programmatic + // page change when state and the pager have genuinely diverged (and so a + // swipe doesn't trigger a redundant setPage back to where it already is). + const currentPageRef = useRef(feedTabIndex) + + const commitTab = useCallback( (tab: FeedTab) => { setFeedTab(tab) track(make({ eventName: Name.FEED_CHANGE_VIEW, view: tab })) @@ -67,6 +87,39 @@ export const FeedScreen = () => { [setFeedTab] ) + // Header tap: update state immediately (instant pill highlight); the effect + // below moves the pager to match. + const handleSelectTab = useCallback( + (tab: FeedTab) => { + commitTab(tab) + }, + [commitTab] + ) + + const handlePageSelected = useCallback( + (e: PagerViewOnPageSelectedEvent) => { + const { position } = e.nativeEvent + currentPageRef.current = position + const tab = tabOrder[position] + // Skip when the pager already matches state. This swallows the initial + // onPageSelected that PagerView emits on mount (Android) — which would + // otherwise fire a spurious analytics event and could clobber the + // persisted tab — and avoids double-committing a header tap. + if (tab === feedTab) return + commitTab(tab) + }, + [feedTab, commitTab] + ) + + // Keep the pager aligned with the persisted tab. Covers the async + // localStorage load (the stored tab resolves after mount) and header taps, + // without disturbing swipe gestures, which update currentPageRef first. + useEffect(() => { + if (currentPageRef.current !== feedTabIndex) { + pagerRef.current?.setPageWithoutAnimation(feedTabIndex) + } + }, [feedTabIndex]) + // Memoized so the header isn't a new function reference on every render — // otherwise Screen's setOptions runs each parent re-render and React // Navigation rebuilds the header, remounting AccountPictureHeader and @@ -80,49 +133,75 @@ export const FeedScreen = () => { [isForYou] ) - const lineupProps = isForYou - ? { - trackIds: forYouFeed.trackIds, - lineupItems: forYouFeed.data, - isPending: forYouFeed.isPending, - isFetching: forYouFeed.isFetching, - hasNextPage: forYouFeed.hasNextPage, - loadNextPage: forYouFeed.loadNextPage, - pageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE, - initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE, - refetch: undefined as undefined | (() => void), - querySource: undefined as { queryKey: unknown[] } | undefined - } - : { - trackIds: followFeed.trackIds, - lineupItems: followFeed.data, - isPending: followFeed.isPending, - isFetching: followFeed.isFetching, - hasNextPage: followFeed.hasNextPage, - loadNextPage: followFeed.loadNextPage, - pageSize: FEED_LOAD_MORE_PAGE_SIZE, - initialPageSize: FEED_INITIAL_PAGE_SIZE, - refetch: () => { - followFeed.refetch() - }, - querySource: followQuerySource - } + const forYouLineupProps = { + // For You is intentionally track-focused: render from `trackIds` only and + // omit `lineupItems` so TrackLineup falls back to its pure-track mode, + // dropping playlist/album tiles. The backend `feed/for-you` endpoint has no + // tracks-only param, so this is filtered client-side. Pagination is + // unaffected — useForYouFeed still counts full backend pages (tracks + + // collections) when computing the next offset, so only the rendered set is + // trimmed, not the fetch window. + trackIds: forYouFeed.trackIds, + isPending: forYouFeed.isPending, + isFetching: forYouFeed.isFetching, + hasNextPage: forYouFeed.hasNextPage, + loadNextPage: forYouFeed.loadNextPage, + pageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE, + initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE + } + const followLineupProps = { + trackIds: followFeed.trackIds, + lineupItems: followFeed.data, + isPending: followFeed.isPending, + isFetching: followFeed.isFetching, + hasNextPage: followFeed.hasNextPage, + loadNextPage: followFeed.loadNextPage, + pageSize: FEED_LOAD_MORE_PAGE_SIZE, + initialPageSize: FEED_INITIAL_PAGE_SIZE, + refetch: () => { + followFeed.refetch() + }, + querySource: followQuerySource + } return ( - } - ListFooterComponent={ - - } - {...lineupProps} - /> + {/* Horizontal pager: swipe left/right toggles between For You and + Latest, mirroring the tab headers above. Both lineups stay mounted + so each retains its own scroll position. */} + + + } + ListFooterComponent={ + + } + {...forYouLineupProps} + /> + + + } + ListFooterComponent={ + + } + {...followLineupProps} + /> + + )