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 {
minimalShellMode = false
isMainMenuOpen = false
isModalActive = false
activeModal:
@ -91,6 +92,10 @@ export class ShellUiModel {
makeAutoObservable(this)
}
setMinimalShellMode(v: boolean) {
this.minimalShellMode = v
}
setMainMenuOpen(v: boolean) {
this.isMainMenuOpen = v
}

View File

@ -6,15 +6,18 @@ import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/ErrorMessage'
import {EmptyState} from '../util/EmptyState'
import {OnScrollCb} from '../../lib/useOnMainScroll'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = observer(function Feed({
view,
onPressTryAgain,
onScroll,
}: {
view: NotificationsViewModel
onPressTryAgain?: () => void
onScroll?: OnScrollCb
}) {
// 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
@ -65,6 +68,7 @@ export const Feed = observer(function Feed({
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
onScroll={onScroll}
/>
)}
</View>

View File

@ -13,6 +13,7 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {FeedModel} from '../../../state/models/feed-view'
import {FeedItem} from './FeedItem'
import {ComposePrompt} from '../composer/Prompt'
import {OnScrollCb} from '../../lib/useOnMainScroll'
const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
@ -23,12 +24,14 @@ export const Feed = observer(function Feed({
scrollElRef,
onPressCompose,
onPressTryAgain,
onScroll,
}: {
feed: FeedModel
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCompose: () => void
onPressTryAgain?: () => void
onScroll?: OnScrollCb
}) {
// 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
@ -92,6 +95,7 @@ export const Feed = observer(function Feed({
ListFooterComponent={FeedFooter}
refreshing={feed.isRefreshing}
contentContainerStyle={{paddingBottom: 100}}
onScroll={onScroll}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>

View File

@ -1,8 +1,14 @@
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 {HorzSwipe} from './gestures/HorzSwipe'
import {useAnimatedValue} from '../../lib/useAnimatedValue'
import {OnScrollCb} from '../../lib/useOnMainScroll'
const HEADER_ITEM = {_reactKey: '__header__'}
const SELECTOR_ITEM = {_reactKey: '__selector__'}
@ -17,6 +23,7 @@ export function ViewSelector({
renderItem,
ListFooterComponent,
onSelectView,
onScroll,
onRefresh,
onEndReached,
}: {
@ -32,6 +39,7 @@ export function ViewSelector({
| null
| undefined
onSelectView?: (viewIndex: number) => void
onScroll?: OnScrollCb
onRefresh?: () => void
onEndReached?: (info: {distanceFromEnd: number}) => void
}) {
@ -90,6 +98,7 @@ export function ViewSelector({
ListFooterComponent={ListFooterComponent}
stickyHeaderIndices={STICKY_HEADER_INDICES}
refreshing={refreshing}
onScroll={onScroll}
onRefresh={onRefresh}
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 {ScreenParams} from '../routes'
import {s, colors} from '../lib/styles'
import {useOnMainScroll} from '../lib/useOnMainScroll'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
@ -18,6 +19,7 @@ export const Home = observer(function Home({
scrollElRef,
}: ScreenParams) {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false)
const {appState} = useAppState({
onForeground: () => doPoll(true),
@ -95,6 +97,7 @@ export const Home = observer(function Home({
style={{flex: 1}}
onPressCompose={onPressCompose}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
/>
{defaultFeedView.hasNewLatest ? (
<TouchableOpacity

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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