Skip to content

Commit

Permalink
feat(mobile): implement scroll-to-top functionality (#2831)
Browse files Browse the repository at this point in the history
* refactor(mobile): wrap entry list components to forward refs

* feat(mobile): implement scroll-to-top functionality

* fix: cast ref types in TimelineSelectorMasonryList

* chore: ensure proper cleanup in useScrollToTopRef hook
  • Loading branch information
lawvs authored Feb 20, 2025
1 parent 9c720a7 commit 9b3439f
Show file tree
Hide file tree
Showing 8 changed files with 92 additions and 27 deletions.
29 changes: 29 additions & 0 deletions apps/mobile/src/atoms/scroll-to-top.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { jotaiStore } from "@follow/utils"
import type { FlashList } from "@shopify/flash-list"
import { atom } from "jotai"
import { useEffect, useRef } from "react"

const defaultScrollToTop = { scrollToTop: () => {} }
const scrollToTopAtom = atom<{ scrollToTop: () => void }>(defaultScrollToTop)

export const scrollToTop = () => {
const { scrollToTop } = jotaiStore.get(scrollToTopAtom)
scrollToTop()
}

export const useScrollToTopRef = <T extends FlashList<unknown>>(enabled = true) => {
const ref = useRef<T>(null)

useEffect(() => {
if (!enabled) return
const scrollToTop = () => {
ref.current?.scrollToOffset({ animated: true, offset: 0 })
}
jotaiStore.set(scrollToTopAtom, { scrollToTop })
return () => {
if (jotaiStore.get(scrollToTopAtom).scrollToTop !== scrollToTop) return
jotaiStore.set(scrollToTopAtom, defaultScrollToTop)
}
}, [enabled, ref])
return ref
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescri
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"

import { scrollToTop } from "@/src/atoms/scroll-to-top"
import {
AttachNavigationScrollViewContext,
SetAttachNavigationScrollViewContext,
Expand Down Expand Up @@ -174,7 +175,13 @@ export const NavigationBlurEffectHeader = ({
<View pointerEvents="box-none" style={[StyleSheet.absoluteFill]}>
{options.headerBackground?.()}
</View>
<Header title={options.title ?? ""} {...options} headerBackground={() => null} />
<TouchableOpacity onPress={scrollToTop}>
<Header
title={options.title ?? ""}
{...options}
headerBackground={() => null}
/>
</TouchableOpacity>
{hideableBottom}
</Animated.View>
)
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ListRenderItemInfo } from "@shopify/flash-list"
import { useCallback, useMemo } from "react"
import type { ElementRef } from "react"
import { forwardRef, useCallback, useMemo } from "react"
import { View } from "react-native"

import { usePlayingUrl } from "@/src/lib/player"
Expand All @@ -10,7 +11,10 @@ import { useOnViewableItemsChanged } from "./hooks"
import { ItemSeparator } from "./ItemSeparator"
import { EntryNormalItem } from "./templates/EntryNormalItem"

export function EntryListContentArticle({ entryIds }: { entryIds: string[] }) {
export const EntryListContentArticle = forwardRef<
ElementRef<typeof TimelineSelectorList>,
{ entryIds: string[] }
>(({ entryIds }, ref) => {
const playingAudioUrl = usePlayingUrl()

const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls()
Expand All @@ -31,6 +35,7 @@ export function EntryListContentArticle({ entryIds }: { entryIds: string[] }) {

return (
<TimelineSelectorList
ref={ref}
onRefresh={refetch}
isRefetching={isRefetching}
data={entryIds}
Expand All @@ -44,7 +49,7 @@ export function EntryListContentArticle({ entryIds }: { entryIds: string[] }) {
ListFooterComponent={ListFooterComponent}
/>
)
}
})

export function EntryItemSkeleton() {
return (
Expand Down
18 changes: 10 additions & 8 deletions apps/mobile/src/modules/entry-list/EntryListContentGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import type { MasonryFlashListProps } from "@shopify/flash-list"
import { useCallback } from "react"
import type { ElementRef } from "react"
import { forwardRef, useCallback } from "react"
import { ActivityIndicator, View } from "react-native"

import { useFetchEntriesControls } from "@/src/modules/screen/atoms"
Expand All @@ -11,12 +12,12 @@ import { useOnViewableItemsChanged } from "./hooks"
import type { MasonryItem } from "./templates/EntryGridItem"
import { EntryGridItem } from "./templates/EntryGridItem"

export function EntryListContentGrid({
entryIds,
...rest
}: {
entryIds: string[]
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">) {
export const EntryListContentGrid = forwardRef<
ElementRef<typeof TimelineSelectorMasonryList>,
{
entryIds: string[]
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">
>(({ entryIds, ...rest }, ref) => {
const { fetchNextPage, refetch, isRefetching, hasNextPage } = useFetchEntriesControls()
const onViewableItemsChanged = useOnViewableItemsChanged(
(item) => (item.key as any).split("-")[0],
Expand Down Expand Up @@ -67,6 +68,7 @@ export function EntryListContentGrid({

return (
<TimelineSelectorMasonryList
ref={ref}
isRefetching={isRefetching}
data={data}
renderItem={useTypeScriptHappyCallback(({ item }: { item: MasonryItem }) => {
Expand All @@ -90,7 +92,7 @@ export function EntryListContentGrid({
onRefresh={refetch}
/>
)
}
})

const defaultKeyExtractor = (item: MasonryItem & { index: number }) => {
const key = `${item.id}-${item.index}`
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ListRenderItemInfo } from "@shopify/flash-list"
import { useCallback, useMemo } from "react"
import type { ElementRef } from "react"
import { forwardRef, useCallback, useMemo } from "react"
import { View } from "react-native"

import { useFetchEntriesControls } from "../screen/atoms"
Expand All @@ -8,7 +9,10 @@ import { useOnViewableItemsChanged } from "./hooks"
import { ItemSeparatorFullWidth } from "./ItemSeparator"
import { EntrySocialItem } from "./templates/EntrySocialItem"

export function EntryListContentSocial({ entryIds }: { entryIds: string[] }) {
export const EntryListContentSocial = forwardRef<
ElementRef<typeof TimelineSelectorList>,
{ entryIds: string[] }
>(({ entryIds }, ref) => {
const { fetchNextPage, isFetching, refetch, isRefetching } = useFetchEntriesControls()

const renderItem = useCallback(
Expand All @@ -25,6 +29,7 @@ export function EntryListContentSocial({ entryIds }: { entryIds: string[] }) {

return (
<TimelineSelectorList
ref={ref}
onRefresh={() => {
refetch()
}}
Expand All @@ -41,7 +46,7 @@ export function EntryListContentSocial({ entryIds }: { entryIds: string[] }) {
ListFooterComponent={ListFooterComponent}
/>
)
}
})

export function EntryItemSkeleton() {
return (
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/modules/entry-list/EntryListSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FeedViewType } from "@follow/constants"

import { useScrollToTopRef } from "@/src/atoms/scroll-to-top"
import { EntryListContentGrid } from "@/src/modules/entry-list/EntryListContentGrid"

import { EntryListContentArticle } from "./EntryListContentArticle"
Expand All @@ -8,11 +9,16 @@ import { EntryListContentSocial } from "./EntryListContentSocial"
export function EntryListSelector({
entryIds,
viewId,
active = true,
}: {
entryIds: string[]
viewId: FeedViewType
active?: boolean
}) {
let ContentComponent = EntryListContentArticle
const ref = useScrollToTopRef(active)

let ContentComponent: typeof EntryListContentSocial | typeof EntryListContentGrid =
EntryListContentArticle
switch (viewId) {
case FeedViewType.SocialMedia: {
ContentComponent = EntryListContentSocial
Expand All @@ -29,5 +35,5 @@ export function EntryListSelector({
}
}

return <ContentComponent entryIds={entryIds} />
return <ContentComponent ref={ref} entryIds={entryIds} />
}
12 changes: 9 additions & 3 deletions apps/mobile/src/modules/entry-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,20 @@ function ViewPagerList({ viewId }: { viewId: FeedViewType }) {
pageMargin={10}
orientation="horizontal"
>
{useMemo(() => views.map((view) => <ViewEntryList viewId={view.view} key={view.view} />), [])}
{useMemo(
() =>
views.map((view) => (
<ViewEntryList key={view.view} viewId={view.view} active={page === view.view} />
)),
[page],
)}
</AnimatedPagerView>
)
}

function ViewEntryList({ viewId }: { viewId: FeedViewType }) {
function ViewEntryList({ viewId, active }: { viewId: FeedViewType; active: boolean }) {
const entryIds = useEntryIdsByView(viewId)
return <EntryListSelector entryIds={entryIds} viewId={viewId} />
return <EntryListSelector entryIds={entryIds} viewId={viewId} active={active} />
}

function FeedEntryList({ feedId }: { feedId: string }) {
Expand Down
19 changes: 12 additions & 7 deletions apps/mobile/src/modules/screen/TimelineSelectorList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { FlashListProps, MasonryFlashListProps } from "@shopify/flash-list"
import type {
FlashListProps,
MasonryFlashListProps,
MasonryFlashListRef,
} from "@shopify/flash-list"
import { FlashList, MasonryFlashList } from "@shopify/flash-list"
import type { ElementRef, RefObject } from "react"
import { forwardRef, useCallback, useContext, useImperativeHandle, useRef } from "react"
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"
import { RefreshControl } from "react-native"
Expand Down Expand Up @@ -75,11 +80,10 @@ export const TimelineSelectorList = forwardRef<
)
})

export const TimelineSelectorMasonryList = ({
onRefresh,
isRefetching,
...props
}: Props & Omit<MasonryFlashListProps<any>, "onRefresh">) => {
export const TimelineSelectorMasonryList = forwardRef<
ElementRef<typeof MasonryFlashList>,
Props & Omit<MasonryFlashListProps<any>, "onRefresh">
>(({ onRefresh, isRefetching, ...props }, ref) => {
const { refetch: unreadRefetch } = usePrefetchUnread()
const { refetch: subscriptionRefetch } = usePrefetchSubscription()

Expand All @@ -102,6 +106,7 @@ export const TimelineSelectorMasonryList = ({

return (
<MasonryFlashList
ref={ref as RefObject<MasonryFlashListRef<any>>}
refreshControl={
<RefreshControl
progressViewOffset={headerHeight}
Expand All @@ -127,4 +132,4 @@ export const TimelineSelectorMasonryList = ({
onScroll={onScroll}
/>
)
}
})

0 comments on commit 9b3439f

Please sign in to comment.