Skip to content

Commit b6a4bca

Browse files
committed
Scroll to top of feed on tap retap
Changelog: enhancement
1 parent dbef2b8 commit b6a4bca

4 files changed

Lines changed: 78 additions & 20 deletions

File tree

apps/polycentric/src/common/components/List.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
} from '@shopify/flash-list';
1010
import React, {
1111
cloneElement,
12+
forwardRef,
1213
isValidElement,
1314
useEffect,
15+
useImperativeHandle,
1416
useRef,
1517
useState,
1618
} from 'react';
@@ -43,22 +45,39 @@ export type ListProps<T> = FlashListProps<T> & {
4345
| undefined;
4446
};
4547

46-
export function List<T extends PostData = PostData>(props: ListProps<T>) {
48+
/** Imperative handle exposed by `List` (and `FeedList`). */
49+
export type ListRef = { scrollToTop: () => void };
50+
51+
export const List = forwardRef(function List<T extends PostData = PostData>(
52+
props: ListProps<T>,
53+
ref: React.Ref<ListRef>,
54+
) {
4755
if (isWeb) {
48-
return <WebFeedViewer<T> {...props} />;
56+
return <WebFeedViewer<T> {...props} listRef={ref} />;
4957
}
5058

51-
return <NativeList<T> {...props} />;
52-
}
59+
return <NativeList<T> {...props} listRef={ref} />;
60+
}) as <T extends PostData = PostData>(
61+
props: ListProps<T> & { ref?: React.Ref<ListRef> },
62+
) => React.ReactElement;
5363

5464
function NativeList<T>({
5565
HeaderComponent,
5666
contentContainerStyle,
5767
refreshControl,
5868
onScroll: _ignoredOnScroll,
69+
listRef,
5970
...rest
60-
}: ListProps<T>) {
71+
}: ListProps<T> & { listRef?: React.Ref<ListRef> }) {
6172
const ref = useRef<FlashListRef<T>>(null);
73+
useImperativeHandle(
74+
listRef,
75+
() => ({
76+
scrollToTop: () =>
77+
ref.current?.scrollToOffset({ offset: 0, animated: true }),
78+
}),
79+
[],
80+
);
6281
const lastScrollY = useSharedValue(0);
6382
const headerTranslate = useSharedValue(0);
6483
const headerHeightShared = useSharedValue(0);
@@ -139,6 +158,7 @@ function NativeList<T>({
139158
) : null}
140159

141160
<AnimatedFlashList
161+
ref={ref as React.Ref<FlashListRef<unknown>>}
142162
{...(rest as FlashListProps<unknown>)}
143163
refreshControl={adjustedRefreshControl}
144164
onScroll={onScroll}
@@ -166,8 +186,20 @@ function WebFeedViewer<T>({
166186
onEndReached,
167187
contentContainerStyle,
168188
stickyHeaderIndices,
169-
}: ListProps<T>) {
189+
listRef,
190+
}: ListProps<T> & { listRef?: React.Ref<ListRef> }) {
170191
const sentinelRef = useRef<View>(null);
192+
useImperativeHandle(
193+
listRef,
194+
() => ({
195+
scrollToTop: () => {
196+
if (typeof window !== 'undefined') {
197+
window.scrollTo({ top: 0, behavior: 'smooth' });
198+
}
199+
},
200+
}),
201+
[],
202+
);
171203
const items = (data as readonly T[] | null | undefined) ?? [];
172204
const [visibleCount, setVisibleCount] = useState(WEB_INITIAL_VISIBLE);
173205

apps/polycentric/src/features/feed/ExploreScreen.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {
1313
} from '@/src/common/theme';
1414
import { isIOS, isWeb } from '@/src/common/util/platform';
1515
import { useFocusEffect } from 'expo-router';
16-
import { useState } from 'react';
16+
import { useCallback, useRef, useState } from 'react';
1717
import { Alert, Pressable, View } from 'react-native';
18+
import type { ListRef } from '@/src/common/components/List';
1819
import { ComposerInput } from '../composer';
1920
import FeedList from './FeedList';
2021
import { useExploreFeed } from './hooks/useExploreFeed';
@@ -72,12 +73,20 @@ export default function ExploreScreen() {
7273

7374
const [enabled, setEnabled] = useState<boolean>(false);
7475
const feed = useExploreFeed({ enabled });
76+
const listRef = useRef<ListRef>(null);
7577

7678
useFocusEffect(() => {
7779
setEnabled(true);
7880
});
7981

80-
useFocusedRefresh(feed.refresh);
82+
// Re-tapping the active tab scrolls to the top and refreshes.
83+
const { refresh } = feed;
84+
useFocusedRefresh(
85+
useCallback(() => {
86+
listRef.current?.scrollToTop();
87+
refresh();
88+
}, [refresh]),
89+
);
8190

8291
if (feed.error) {
8392
return (
@@ -101,7 +110,7 @@ export default function ExploreScreen() {
101110
return (
102111
<Screen>
103112
<Screen.PrimaryColumn>
104-
<FeedList feed={feed} HeaderComponent={ListHeader} />
113+
<FeedList ref={listRef} feed={feed} HeaderComponent={ListHeader} />
105114
{showComposeFab ? (
106115
<Fab
107116
onPress={openCompose}

apps/polycentric/src/features/feed/FeedList.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { forwardRef } from 'react';
12
import { ActivityIndicator, RefreshControl, View } from 'react-native';
23
import {
34
List,
45
type ListProps,
6+
type ListRef,
57
type ListRenderItem,
68
} from '../../common/components/List';
79
import type { FeedHookResult } from './hooks/types';
@@ -25,19 +27,23 @@ const defaultRenderItem: ListRenderItem<PostData> = ({ item }) => (
2527
<Post post={item} />
2628
);
2729

28-
export default function FeedList({
29-
feed,
30-
emptyMessage = 'No posts yet',
31-
renderItem = defaultRenderItem,
32-
keyExtractor = defaultKeyExtractor,
33-
...rest
34-
}: FeedListProps) {
30+
const FeedList = forwardRef<ListRef, FeedListProps>(function FeedList(
31+
{
32+
feed,
33+
emptyMessage = 'No posts yet',
34+
renderItem = defaultRenderItem,
35+
keyExtractor = defaultKeyExtractor,
36+
...rest
37+
},
38+
ref,
39+
) {
3540
const { theme } = useTheme();
3641

3742
const insets = useSafeAreaInsets();
3843

3944
return (
4045
<List<PostData>
46+
ref={ref}
4147
renderItem={renderItem}
4248
keyExtractor={keyExtractor}
4349
data={feed.items}
@@ -84,4 +90,6 @@ export default function FeedList({
8490
{...rest}
8591
/>
8692
);
87-
}
93+
});
94+
95+
export default FeedList;

apps/polycentric/src/features/feed/FeedScreen.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { Atoms } from '@/src/common/theme';
99
import { isIOS, isWeb } from '@/src/common/util/platform';
1010
import { ComposerInput } from '@/src/features/composer';
1111
import { useFocusEffect } from 'expo-router';
12-
import { useCallback, useState } from 'react';
12+
import { useCallback, useRef, useState } from 'react';
1313
import { View } from 'react-native';
14+
import type { ListRef } from '@/src/common/components/List';
1415
import FeedList from './FeedList';
1516
import { useFollowingFeed } from './hooks/useFollowingFeed';
1617

@@ -29,14 +30,22 @@ export default function FeedScreen() {
2930

3031
const [enabled, setEnabled] = useState<boolean>(false);
3132
const feed = useFollowingFeed({ enabled });
33+
const listRef = useRef<ListRef>(null);
3234

3335
useFocusEffect(
3436
useCallback(() => {
3537
setEnabled(true);
3638
}, [setEnabled]),
3739
);
3840

39-
useFocusedRefresh(feed.refresh);
41+
// Re-tapping the active tab scrolls to the top and refreshes.
42+
const { refresh } = feed;
43+
useFocusedRefresh(
44+
useCallback(() => {
45+
listRef.current?.scrollToTop();
46+
refresh();
47+
}, [refresh]),
48+
);
4049

4150
if (feed.error) {
4251
return (
@@ -60,7 +69,7 @@ export default function FeedScreen() {
6069
return (
6170
<Screen>
6271
<Screen.PrimaryColumn>
63-
<FeedList feed={feed} HeaderComponent={ListHeader} />
72+
<FeedList ref={listRef} feed={feed} HeaderComponent={ListHeader} />
6473
{showComposeFab ? (
6574
<Fab
6675
onPress={openCompose}

0 commit comments

Comments
 (0)