Refactor the scroll-to-top UX

zio/stable
Paul Frazee 2023-05-24 18:46:27 -05:00
parent 9673225f78
commit 4e1876fe85
9 changed files with 102 additions and 100 deletions

View File

@ -1,28 +1,50 @@
import {useState} from 'react'
import {useState, useCallback, useRef} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from 'state/index'
import {s} from 'lib/styles'
export type onMomentumScrollEndCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type ResetCb = () => void
export function useOnMainScroll(store: RootStoreModel) {
let [lastY, setLastY] = useState(0)
let isMinimal = store.shell.minimalShellMode
return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY || 0)
setLastY(y)
export function useOnMainScroll(
store: RootStoreModel,
): [OnScrollCb, boolean, ResetCb] {
let lastY = useRef(0)
let [isScrolledDown, setIsScrolledDown] = useState(false)
return [
useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY.current || 0)
lastY.current = y
if (!isMinimal && y > 10 && dy > 10) {
store.shell.setMinimalShellMode(true)
isMinimal = true
} else if (isMinimal && (y <= 10 || dy < -10)) {
if (!store.shell.minimalShellMode && y > 10 && dy > 10) {
store.shell.setMinimalShellMode(true)
} else if (store.shell.minimalShellMode && (y <= 10 || dy < -10)) {
store.shell.setMinimalShellMode(false)
}
if (
!isScrolledDown &&
event.nativeEvent.contentOffset.y > s.window.height
) {
setIsScrolledDown(true)
} else if (
isScrolledDown &&
event.nativeEvent.contentOffset.y < s.window.height
) {
setIsScrolledDown(false)
}
},
[store, isScrolledDown],
),
isScrolledDown,
useCallback(() => {
setIsScrolledDown(false)
store.shell.setMinimalShellMode(false)
isMinimal = false
}
}
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
}, [store, setIsScrolledDown]),
]
}

View File

@ -154,6 +154,7 @@ export const Feed = observer(function Feed({
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
onScroll={onScroll}
scrollEventThrottle={100}
contentContainerStyle={s.contentContainer}
/>
) : null}

View File

@ -14,7 +14,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
@ -47,7 +47,6 @@ export const Feed = observer(function Feed({
onPressTryAgain?: () => void
onScroll?: OnScrollCb
scrollEventThrottle?: number
onMomentumScrollEnd?: onMomentumScrollEndCb
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number

View File

@ -47,7 +47,7 @@ const styles = StyleSheet.create({
outer: {
position: 'absolute',
zIndex: 1,
right: 28,
right: 24,
bottom: 94,
width: 60,
height: 60,

View File

@ -1,23 +1,25 @@
import React from 'react'
import {StyleSheet, TouchableOpacity} from 'react-native'
import {observer} from 'mobx-react-lite'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Text} from '../text/Text'
import {colors, gradients} from 'lib/styles'
import {clamp} from 'lodash'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const LoadLatestBtn = observer(
({onPress, label}: {onPress: () => void; label: string}) => {
const store = useStores()
const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets()
return (
<TouchableOpacity
style={[
styles.loadLatest,
pal.borderDark,
pal.view,
!store.shell.minimalShellMode && {
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
},
@ -26,16 +28,8 @@ export const LoadLatestBtn = observer(
hitSlop={HITSLOP}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={label}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.loadLatestInner}>
<Text type="md-bold" style={styles.loadLatestText}>
{label}
</Text>
</LinearGradient>
accessibilityHint="">
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
</TouchableOpacity>
)
},
@ -44,19 +38,14 @@ export const LoadLatestBtn = observer(
const styles = StyleSheet.create({
loadLatest: {
position: 'absolute',
left: 20,
left: 18,
bottom: 35,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowOffset: {width: 0, height: 1},
},
loadLatestInner: {
borderWidth: 1,
width: 52,
height: 52,
borderRadius: 26,
flexDirection: 'row',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 30,
},
loadLatestText: {
color: colors.white,
alignItems: 'center',
justifyContent: 'center',
},
})

View File

@ -20,13 +20,13 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {isDesktopWeb, isWeb} from 'platform/detection'
import {isDesktopWeb} from 'platform/detection'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired(
@ -48,7 +48,8 @@ export const CustomFeedScreen = withAuthRequired(
return feed
}, [store, uri])
const isPinned = store.me.savedFeeds.isPinned(uri)
const [allowScrollToTop, setAllowScrollToTop] = useState(false)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
useSetTitle(currentFeed?.displayName)
const onToggleSaved = React.useCallback(async () => {
@ -66,6 +67,7 @@ export const CustomFeedScreen = withAuthRequired(
store.log.error('Failed up update feeds', {err})
}
}, [store, currentFeed])
const onToggleLiked = React.useCallback(async () => {
Haptics.default()
try {
@ -81,6 +83,7 @@ export const CustomFeedScreen = withAuthRequired(
store.log.error('Failed up toggle like', {err})
}
}, [store, currentFeed])
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => {
@ -88,11 +91,17 @@ export const CustomFeedScreen = withAuthRequired(
store.log.error('Failed to toggle pinned feed', {e})
})
}, [store, currentFeed])
const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
shareUrl(url)
}, [name, rkey])
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const renderHeaderBtns = React.useCallback(() => {
return (
<View style={styles.headerBtns}>
@ -220,15 +229,17 @@ export const CustomFeedScreen = withAuthRequired(
</Text>
) : null}
<View style={styles.headerDetailsFooter}>
<TextLink
type="md-medium"
style={pal.textLight}
href={`/profile/${name}/feed/${rkey}/liked-by`}
text={`Liked by ${currentFeed?.data.likeCount} ${pluralize(
currentFeed?.data.likeCount || 0,
'user',
)}`}
/>
{currentFeed ? (
<TextLink
type="md-medium"
style={pal.textLight}
href={`/profile/${name}/feed/${rkey}/liked-by`}
text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
currentFeed?.data.likeCount || 0,
'user',
)}`}
/>
) : null}
<Button
type={'default'}
accessibilityLabel={
@ -267,46 +278,19 @@ export const CustomFeedScreen = withAuthRequired(
onTogglePinned,
])
const onMomentumScrollEnd: onMomentumScrollEndCb = React.useCallback(
event => {
console.log('onMomentumScrollEnd')
if (event.nativeEvent.contentOffset.y > s.window.height * 3) {
setAllowScrollToTop(true)
} else {
setAllowScrollToTop(false)
}
},
[],
)
const onScroll: OnScrollCb = React.useCallback(event => {
// since onMomentumScrollEnd is not supported in react-native-web, we have to use onScroll which fires more often so is not desirable on mobile
if (isWeb) {
if (event.nativeEvent.contentOffset.y > s.window.height * 2) {
setAllowScrollToTop(true)
} else {
setAllowScrollToTop(false)
}
}
}, [])
return (
<View style={s.hContentRegion}>
<ViewHeader title="" renderButton={renderHeaderBtns} />
<Feed
scrollElRef={scrollElRef}
feed={algoFeed}
onMomentumScrollEnd={onMomentumScrollEnd}
onScroll={onScroll} // same logic as onMomentumScrollEnd but for web
onScroll={onMainScroll}
scrollEventThrottle={100}
ListHeaderComponent={renderListHeaderComponent}
extraData={[uri, isPinned]}
/>
{allowScrollToTop ? (
<LoadLatestBtn
onPress={() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
}}
label="Scroll to top"
/>
{isScrolledDown ? (
<LoadLatestBtn onPress={onScrollToTop} label="Scroll to top" />
) : null}
</View>
)

View File

@ -150,7 +150,8 @@ const FeedPage = observer(
renderEmptyState?: () => JSX.Element
}) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const {screen, track} = useAnalytics()
const scrollElRef = React.useRef<FlatList>(null)
const {appState} = useAppState({
@ -178,12 +179,13 @@ const FeedPage = observer(
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET})
}, [scrollElRef])
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onSoftReset = React.useCallback(() => {
if (isPageFocused) {
feed.refresh()
scrollToTop()
feed.refresh()
}
}, [isPageFocused, scrollToTop, feed])
@ -254,10 +256,11 @@ const FeedPage = observer(
showPostFollowBtn
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollEventThrottle={100}
renderEmptyState={renderEmptyState}
headerOffset={HEADER_OFFSET}
/>
{feed.hasNewLatest && !feed.isRefreshing && (
{isScrolledDown && (
<LoadLatestBtn onPress={onPressLoadLatest} label="Load new posts" />
)}
<FAB

View File

@ -25,7 +25,8 @@ type Props = NativeStackScreenProps<
export const NotificationsScreen = withAuthRequired(
observer(({}: Props) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
@ -37,7 +38,8 @@ export const NotificationsScreen = withAuthRequired(
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
}, [scrollElRef])
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
@ -96,10 +98,12 @@ export const NotificationsScreen = withAuthRequired(
onScroll={onMainScroll}
scrollElRef={scrollElRef}
/>
{store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing && (
<LoadLatestBtn onPress={onPressLoadLatest} label="Load new notifications" />
)}
{isScrolledDown && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new notifications"
/>
)}
</View>
)
}),

View File

@ -35,7 +35,7 @@ export const SearchScreen = withAuthRequired(
const store = useStores()
const scrollViewRef = React.useRef<ScrollView>(null)
const flatListRef = React.useRef<FlatList>(null)
const onMainScroll = useOnMainScroll(store)
const [onMainScroll] = useOnMainScroll(store)
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteModel>(