Refactor the scroll-to-top UX
parent
9673225f78
commit
4e1876fe85
|
@ -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>) {
|
||||
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 || 0)
|
||||
setLastY(y)
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (!isMinimal && y > 10 && dy > 10) {
|
||||
if (!store.shell.minimalShellMode && y > 10 && dy > 10) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
isMinimal = true
|
||||
} else if (isMinimal && (y <= 10 || dy < -10)) {
|
||||
} else if (store.shell.minimalShellMode && (y <= 10 || dy < -10)) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
isMinimal = 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)
|
||||
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
|
||||
}, [store, setIsScrolledDown]),
|
||||
]
|
||||
}
|
||||
|
|
|
@ -154,6 +154,7 @@ export const Feed = observer(function Feed({
|
|||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={100}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -47,7 +47,7 @@ const styles = StyleSheet.create({
|
|||
outer: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
right: 28,
|
||||
right: 24,
|
||||
bottom: 94,
|
||||
width: 60,
|
||||
height: 60,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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}>
|
||||
{currentFeed ? (
|
||||
<TextLink
|
||||
type="md-medium"
|
||||
style={pal.textLight}
|
||||
href={`/profile/${name}/feed/${rkey}/liked-by`}
|
||||
text={`Liked by ${currentFeed?.data.likeCount} ${pluralize(
|
||||
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>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,9 +98,11 @@ 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>
|
||||
)
|
||||
|
|
|
@ -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>(
|
||||
|
|
Loading…
Reference in New Issue