Hide footer on scroll down (minimal shell mode)

zio/stable
Paul Frazee 2022-12-12 16:04:14 -06:00
parent 470f444eed
commit 1aec0ee156
17 changed files with 101 additions and 7 deletions

View File

@ -74,6 +74,7 @@ export interface ComposerOpts {
} }
export class ShellUiModel { export class ShellUiModel {
minimalShellMode = false
isMainMenuOpen = false isMainMenuOpen = false
isModalActive = false isModalActive = false
activeModal: activeModal:
@ -91,6 +92,10 @@ export class ShellUiModel {
makeAutoObservable(this) makeAutoObservable(this)
} }
setMinimalShellMode(v: boolean) {
this.minimalShellMode = v
}
setMainMenuOpen(v: boolean) { setMainMenuOpen(v: boolean) {
this.isMainMenuOpen = v this.isMainMenuOpen = v
} }

View File

@ -6,15 +6,18 @@ import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/ErrorMessage' import {ErrorMessage} from '../util/ErrorMessage'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {OnScrollCb} from '../../lib/useOnMainScroll'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({
view, view,
onPressTryAgain, onPressTryAgain,
onScroll,
}: { }: {
view: NotificationsViewModel view: NotificationsViewModel
onPressTryAgain?: () => void onPressTryAgain?: () => void
onScroll?: OnScrollCb
}) { }) {
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your // VirtualizedList: You have a large list that is slow to update - make sure your
@ -65,6 +68,7 @@ export const Feed = observer(function Feed({
refreshing={view.isRefreshing} refreshing={view.isRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onScroll={onScroll}
/> />
)} )}
</View> </View>

View File

@ -13,6 +13,7 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {FeedModel} from '../../../state/models/feed-view' import {FeedModel} from '../../../state/models/feed-view'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {ComposePrompt} from '../composer/Prompt' import {ComposePrompt} from '../composer/Prompt'
import {OnScrollCb} from '../../lib/useOnMainScroll'
const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'} const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
@ -23,12 +24,14 @@ export const Feed = observer(function Feed({
scrollElRef, scrollElRef,
onPressCompose, onPressCompose,
onPressTryAgain, onPressTryAgain,
onScroll,
}: { }: {
feed: FeedModel feed: FeedModel
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCompose: () => void onPressCompose: () => void
onPressTryAgain?: () => void onPressTryAgain?: () => void
onScroll?: OnScrollCb
}) { }) {
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your // VirtualizedList: You have a large list that is slow to update - make sure your
@ -92,6 +95,7 @@ export const Feed = observer(function Feed({
ListFooterComponent={FeedFooter} ListFooterComponent={FeedFooter}
refreshing={feed.isRefreshing} refreshing={feed.isRefreshing}
contentContainerStyle={{paddingBottom: 100}} contentContainerStyle={{paddingBottom: 100}}
onScroll={onScroll}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
/> />

View File

@ -1,8 +1,14 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {FlatList, View} from 'react-native' import {
FlatList,
NativeSyntheticEvent,
NativeScrollEvent,
View,
} from 'react-native'
import {Selector} from './Selector' import {Selector} from './Selector'
import {HorzSwipe} from './gestures/HorzSwipe' import {HorzSwipe} from './gestures/HorzSwipe'
import {useAnimatedValue} from '../../lib/useAnimatedValue' import {useAnimatedValue} from '../../lib/useAnimatedValue'
import {OnScrollCb} from '../../lib/useOnMainScroll'
const HEADER_ITEM = {_reactKey: '__header__'} const HEADER_ITEM = {_reactKey: '__header__'}
const SELECTOR_ITEM = {_reactKey: '__selector__'} const SELECTOR_ITEM = {_reactKey: '__selector__'}
@ -17,6 +23,7 @@ export function ViewSelector({
renderItem, renderItem,
ListFooterComponent, ListFooterComponent,
onSelectView, onSelectView,
onScroll,
onRefresh, onRefresh,
onEndReached, onEndReached,
}: { }: {
@ -32,6 +39,7 @@ export function ViewSelector({
| null | null
| undefined | undefined
onSelectView?: (viewIndex: number) => void onSelectView?: (viewIndex: number) => void
onScroll?: OnScrollCb
onRefresh?: () => void onRefresh?: () => void
onEndReached?: (info: {distanceFromEnd: number}) => void onEndReached?: (info: {distanceFromEnd: number}) => void
}) { }) {
@ -90,6 +98,7 @@ export function ViewSelector({
ListFooterComponent={ListFooterComponent} ListFooterComponent={ListFooterComponent}
stickyHeaderIndices={STICKY_HEADER_INDICES} stickyHeaderIndices={STICKY_HEADER_INDICES}
refreshing={refreshing} refreshing={refreshing}
onScroll={onScroll}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
/> />

View File

@ -0,0 +1,25 @@
import {useState} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from '../../state'
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => 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)
if (!isMinimal && y > 10 && dy > 10) {
store.shell.setMinimalShellMode(true)
isMinimal = true
} else if (isMinimal && (y <= 10 || dy < -10)) {
store.shell.setMinimalShellMode(false)
isMinimal = false
}
}
}

View File

@ -9,6 +9,7 @@ import {useStores} from '../../state'
import {FeedModel} from '../../state/models/feed-view' import {FeedModel} from '../../state/models/feed-view'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {s, colors} from '../lib/styles' import {s, colors} from '../lib/styles'
import {useOnMainScroll} from '../lib/useOnMainScroll'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
@ -18,6 +19,7 @@ export const Home = observer(function Home({
scrollElRef, scrollElRef,
}: ScreenParams) { }: ScreenParams) {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false) const [hasSetup, setHasSetup] = useState<boolean>(false)
const {appState} = useAppState({ const {appState} = useAppState({
onForeground: () => doPoll(true), onForeground: () => doPoll(true),
@ -95,6 +97,7 @@ export const Home = observer(function Home({
style={{flex: 1}} style={{flex: 1}}
onPressCompose={onPressCompose} onPressCompose={onPressCompose}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
/> />
{defaultFeedView.hasNewLatest ? ( {defaultFeedView.hasNewLatest ? (
<TouchableOpacity <TouchableOpacity

View File

@ -5,9 +5,11 @@ import {Feed} from '../com/notifications/Feed'
import {useStores} from '../../state' import {useStores} from '../../state'
import {NotificationsViewModel} from '../../state/models/notifications-view' import {NotificationsViewModel} from '../../state/models/notifications-view'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useOnMainScroll} from '../lib/useOnMainScroll'
export const Notifications = ({navIdx, visible}: ScreenParams) => { export const Notifications = ({navIdx, visible}: ScreenParams) => {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store)
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
@ -33,7 +35,11 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
return ( return (
<View style={{flex: 1}}> <View style={{flex: 1}}>
<ViewHeader title="Notifications" /> <ViewHeader title="Notifications" />
<Feed view={store.me.notifications} onPressTryAgain={onPressTryAgain} /> <Feed
view={store.me.notifications}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
/>
</View> </View>
) )
} }

View File

@ -14,6 +14,7 @@ export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, 'Downvoted by') store.nav.setTitle(navIdx, 'Downvoted by')
store.shell.setMinimalShellMode(false)
} }
}, [store, visible]) }, [store, visible])

View File

@ -14,6 +14,7 @@ export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, 'Reposted by') store.nav.setTitle(navIdx, 'Reposted by')
store.shell.setMinimalShellMode(false)
} }
}, [store, visible]) }, [store, visible])

View File

@ -29,6 +29,7 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
return return
} }
setTitle() setTitle()
store.shell.setMinimalShellMode(false)
if (!view.hasLoaded && !view.isLoading) { if (!view.hasLoaded && !view.isLoading) {
console.log('Fetching post thread', uri) console.log('Fetching post thread', uri)
view.setup().then( view.setup().then(

View File

@ -18,6 +18,7 @@ import {EmptyState} from '../com/util/EmptyState'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import * as Toast from '../com/util/Toast' import * as Toast from '../com/util/Toast'
import {s, colors} from '../lib/styles' import {s, colors} from '../lib/styles'
import {useOnMainScroll} from '../lib/useOnMainScroll'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const END_ITEM = {_reactKey: '__end__'} const END_ITEM = {_reactKey: '__end__'}
@ -25,6 +26,7 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false) const [hasSetup, setHasSetup] = useState<boolean>(false)
const uiState = useMemo( const uiState = useMemo(
() => new ProfileUiModel(store, {user: params.name}), () => new ProfileUiModel(store, {user: params.name}),
@ -252,6 +254,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
ListFooterComponent={Footer} ListFooterComponent={Footer}
refreshing={uiState.isRefreshing || false} refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView} onSelectView={onSelectView}
onScroll={onMainScroll}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
/> />

View File

@ -12,6 +12,7 @@ export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, `Followers of ${name}`) store.nav.setTitle(navIdx, `Followers of ${name}`)
store.shell.setMinimalShellMode(false)
} }
}, [store, visible, name]) }, [store, visible, name])

View File

@ -12,6 +12,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, `Followed by ${name}`) store.nav.setTitle(navIdx, `Followed by ${name}`)
store.shell.setMinimalShellMode(false)
} }
}, [store, visible, name]) }, [store, visible, name])

View File

@ -12,6 +12,7 @@ export const ProfileMembers = ({navIdx, visible, params}: ScreenParams) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, `Members of ${name}`) store.nav.setTitle(navIdx, `Members of ${name}`)
store.shell.setMinimalShellMode(false)
} }
}, [store, visible, name]) }, [store, visible, name])

View File

@ -29,6 +29,7 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.shell.setMinimalShellMode(false)
autocompleteView.setup() autocompleteView.setup()
textInput.current?.focus() textInput.current?.focus()
store.nav.setTitle(navIdx, `Search`) store.nav.setTitle(navIdx, `Search`)

View File

@ -18,6 +18,7 @@ export const Settings = observer(function Settings({
if (!visible) { if (!visible) {
return return
} }
store.shell.setMinimalShellMode(false)
store.nav.setTitle(navIdx, 'Settings') store.nav.setTitle(navIdx, 'Settings')
}, [visible, store]) }, [visible, store])

View File

@ -116,6 +116,7 @@ export const MobileShell: React.FC = observer(() => {
const winDim = useWindowDimensions() const winDim = useWindowDimensions()
const [menuSwipingDirection, setMenuSwipingDirection] = useState(0) const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
const swipeGestureInterp = useAnimatedValue(0) const swipeGestureInterp = useAnimatedValue(0)
const minimalShellInterp = useAnimatedValue(0)
const tabMenuInterp = useAnimatedValue(0) const tabMenuInterp = useAnimatedValue(0)
const newTabInterp = useAnimatedValue(0) const newTabInterp = useAnimatedValue(0)
const [isRunningNewTabAnim, setIsRunningNewTabAnim] = useState(false) const [isRunningNewTabAnim, setIsRunningNewTabAnim] = useState(false)
@ -156,6 +157,27 @@ export const MobileShell: React.FC = observer(() => {
const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive) const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive)
const doNewTab = (url: string) => () => store.nav.newTab(url) const doNewTab = (url: string) => () => store.nav.newTab(url)
// minimal shell animation
// =
useEffect(() => {
if (store.shell.minimalShellMode) {
Animated.timing(minimalShellInterp, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start()
} else {
Animated.timing(minimalShellInterp, {
toValue: 0,
duration: 100,
useNativeDriver: true,
}).start()
}
}, [minimalShellInterp, store.shell.minimalShellMode])
const footerMinimalShellTransform = {
transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
}
// tab selector animation // tab selector animation
// = // =
const toggleTabsMenu = (active: boolean) => { const toggleTabsMenu = (active: boolean) => {
@ -182,7 +204,7 @@ export const MobileShell: React.FC = observer(() => {
useNativeDriver: false, useNativeDriver: false,
}).start() }).start()
} }
}, [isTabsSelectorActive]) }, [tabMenuInterp, isTabsSelectorActive])
// new tab animation // new tab animation
// = // =
@ -190,7 +212,7 @@ export const MobileShell: React.FC = observer(() => {
if (screenRenderDesc.hasNewTab && !isRunningNewTabAnim) { if (screenRenderDesc.hasNewTab && !isRunningNewTabAnim) {
setIsRunningNewTabAnim(true) setIsRunningNewTabAnim(true)
} }
}, [screenRenderDesc.hasNewTab]) }, [isRunningNewTabAnim, screenRenderDesc.hasNewTab])
useEffect(() => { useEffect(() => {
if (isRunningNewTabAnim) { if (isRunningNewTabAnim) {
const reset = () => { const reset = () => {
@ -208,7 +230,7 @@ export const MobileShell: React.FC = observer(() => {
} else { } else {
newTabInterp.setValue(0) newTabInterp.setValue(0)
} }
}, [isRunningNewTabAnim]) }, [newTabInterp, store.nav.tab, isRunningNewTabAnim])
// navigation swipes // navigation swipes
// = // =
@ -396,10 +418,11 @@ export const MobileShell: React.FC = observer(() => {
tabMenuInterp={tabMenuInterp} tabMenuInterp={tabMenuInterp}
onClose={() => toggleTabsMenu(false)} onClose={() => toggleTabsMenu(false)}
/> />
<View <Animated.View
style={[ style={[
styles.bottomBar, styles.bottomBar,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 40)}, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 40)},
footerMinimalShellTransform,
]}> ]}>
<Btn <Btn
icon={isAtHome ? 'home-solid' : 'home'} icon={isAtHome ? 'home-solid' : 'home'}
@ -419,7 +442,7 @@ export const MobileShell: React.FC = observer(() => {
onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined} onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined}
notificationCount={store.me.notificationCount} notificationCount={store.me.notificationCount}
/> />
</View> </Animated.View>
<Modal /> <Modal />
<Lightbox /> <Lightbox />
<Composer <Composer
@ -565,6 +588,10 @@ const styles = StyleSheet.create({
paddingHorizontal: 6, paddingHorizontal: 6,
}, },
bottomBar: { bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flexDirection: 'row', flexDirection: 'row',
backgroundColor: colors.white, backgroundColor: colors.white,
borderTopWidth: 1, borderTopWidth: 1,