New navigation model (#1)

* Flatten all routing into a single stack

* Replace router with custom implementation

* Add shell header and titles

* Add tab selector

* Add back/forward history menus on longpress

* Fix: don't modify state during render

* Add refresh() to navigation and reroute navigations to the current location to refresh instead of add to history

* Cache screens during navigation to maintain scroll position and improve load-time for renders
This commit is contained in:
Paul Frazee 2022-08-31 14:36:50 -05:00 committed by GitHub
parent d1470bad66
commit 97f52b6a03
57 changed files with 1382 additions and 1159 deletions

View file

@ -1,13 +1,11 @@
import React from 'react'
import {Pressable, View, StyleSheet} from 'react-native'
import {Link} from '@react-navigation/native'
import {useRoute} from '@react-navigation/native'
export const NavItem: React.FC<{label: string; screen: string}> = ({
label,
screen,
}) => {
const route = useRoute()
const Link = <></> // TODO
return (
<View>
<Pressable
@ -18,7 +16,7 @@ export const NavItem: React.FC<{label: string; screen: string}> = ({
<Link
style={[
styles.navItemLink,
route.name === screen && styles.navItemLinkSelected,
false /* TODO route.name === screen*/ && styles.navItemLinkSelected,
]}
to={{screen, params: {}}}>
{label}

View file

@ -1,12 +0,0 @@
import React from 'react'
import {SafeAreaView} from 'react-native'
import {isDesktopWeb} from '../../platform/detection'
import {DesktopWebShell} from './desktop-web/shell'
export const Shell: React.FC = ({children}) => {
return isDesktopWeb ? (
<DesktopWebShell>{children}</DesktopWebShell>
) : (
<SafeAreaView>{children}</SafeAreaView>
)
}

View file

@ -0,0 +1,99 @@
import React from 'react'
import {
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RootSiblings from 'react-native-root-siblings'
import {NavigationTabModel} from '../../../state/models/navigation'
export function createBackMenu(tab: NavigationTabModel): RootSiblings {
const onPressItem = (index: number) => {
sibling.destroy()
tab.goToIndex(index)
}
const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings(
(
<>
<TouchableWithoutFeedback onPress={onOuterPress}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<View style={[styles.menu, styles.back]}>
{tab.backTen.map((item, i) => (
<TouchableOpacity
key={item.index}
style={[styles.menuItem, i !== 0 && styles.menuItemBorder]}
onPress={() => onPressItem(item.index)}>
<Text>{item.title || item.url}</Text>
</TouchableOpacity>
))}
</View>
</>
),
)
return sibling
}
export function createForwardMenu(tab: NavigationTabModel): RootSiblings {
const onPressItem = (index: number) => {
sibling.destroy()
tab.goToIndex(index)
}
const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings(
(
<>
<TouchableWithoutFeedback onPress={onOuterPress}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<View style={[styles.menu, styles.forward]}>
{tab.forwardTen.reverse().map((item, i) => (
<TouchableOpacity
key={item.index}
style={[styles.menuItem, i !== 0 && styles.menuItemBorder]}
onPress={() => onPressItem(item.index)}>
<Text>{item.title || item.url}</Text>
</TouchableOpacity>
))}
</View>
</>
),
)
return sibling
}
const styles = StyleSheet.create({
bg: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: '#000',
opacity: 0.1,
},
menu: {
position: 'absolute',
bottom: 80,
backgroundColor: '#fff',
borderRadius: 8,
opacity: 1,
},
back: {
left: 10,
},
forward: {
left: 60,
},
menuItem: {
paddingVertical: 10,
paddingLeft: 15,
paddingRight: 30,
},
menuItemBorder: {
borderTopWidth: 1,
borderTopColor: '#ddd',
},
})

View file

@ -0,0 +1,235 @@
import React, {useRef} from 'react'
import {observer} from 'mobx-react-lite'
import {
GestureResponderEvent,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import {ScreenContainer, Screen} from 'react-native-screens'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {useStores} from '../../../state'
import {NavigationModel} from '../../../state/models/navigation'
import {match, MatchResult} from '../../routes'
import {TabsSelectorModal} from './tabs-selector'
import {createBackMenu, createForwardMenu} from './history-menu'
const Location = ({icon, title}: {icon: IconProp; title?: string}) => {
return (
<TouchableOpacity style={styles.location}>
{title ? (
<FontAwesomeIcon size={16} style={styles.locationIcon} icon={icon} />
) : (
<FontAwesomeIcon
size={16}
style={styles.locationIconLight}
icon="magnifying-glass"
/>
)}
<Text style={title ? styles.locationText : styles.locationTextLight}>
{title || 'Search'}
</Text>
</TouchableOpacity>
)
}
const Btn = ({
icon,
inactive,
onPress,
onLongPress,
}: {
icon: IconProp
inactive?: boolean
onPress?: (event: GestureResponderEvent) => void
onLongPress?: (event: GestureResponderEvent) => void
}) => {
if (inactive) {
return (
<View style={styles.ctrl}>
<FontAwesomeIcon
size={18}
style={[styles.ctrlIcon, styles.inactive]}
icon={icon}
/>
</View>
)
}
return (
<TouchableOpacity
style={styles.ctrl}
onPress={onPress}
onLongPress={onLongPress}>
<FontAwesomeIcon size={18} style={styles.ctrlIcon} icon={icon} />
</TouchableOpacity>
)
}
export const MobileShell: React.FC = observer(() => {
const stores = useStores()
const tabSelectorRef = useRef<{open: () => void}>()
const screenRenderDesc = constructScreenRenderDesc(stores.nav)
const onPressBack = () => stores.nav.tab.goBack()
const onPressForward = () => stores.nav.tab.goForward()
const onPressHome = () => stores.nav.navigate('/')
const onPressNotifications = () => stores.nav.navigate('/notifications')
const onPressTabs = () => tabSelectorRef.current?.open()
const onLongPressBack = () => createBackMenu(stores.nav.tab)
const onLongPressForward = () => createForwardMenu(stores.nav.tab)
const onNewTab = () => stores.nav.newTab('/')
const onChangeTab = (tabIndex: number) => stores.nav.setActiveTab(tabIndex)
const onCloseTab = (tabIndex: number) => stores.nav.closeTab(tabIndex)
return (
<View style={styles.outerContainer}>
<View style={styles.topBar}>
<Location
icon={screenRenderDesc.icon}
title={stores.nav.tab.current.title}
/>
</View>
<SafeAreaView style={styles.innerContainer}>
<ScreenContainer>
{screenRenderDesc.screens.map(({Com, params, key, activityState}) => (
<Screen
key={key}
style={{backgroundColor: '#fff'}}
activityState={activityState}>
<Com params={params} />
</Screen>
))}
</ScreenContainer>
</SafeAreaView>
<View style={styles.bottomBar}>
<Btn
icon="angle-left"
inactive={!stores.nav.tab.canGoBack}
onPress={onPressBack}
onLongPress={onLongPressBack}
/>
<Btn
icon="angle-right"
inactive={!stores.nav.tab.canGoForward}
onPress={onPressForward}
onLongPress={onLongPressForward}
/>
<Btn icon="house" onPress={onPressHome} />
<Btn icon={['far', 'bell']} onPress={onPressNotifications} />
<Btn icon={['far', 'clone']} onPress={onPressTabs} />
</View>
<TabsSelectorModal
ref={tabSelectorRef}
tabs={stores.nav.tabs}
currentTabIndex={stores.nav.tabIndex}
onNewTab={onNewTab}
onChangeTab={onChangeTab}
onCloseTab={onCloseTab}
/>
</View>
)
})
/**
* This method produces the information needed by the shell to
* render the current screens with screen-caching behaviors.
*/
type ScreenRenderDesc = MatchResult & {key: string; activityState: 0 | 1 | 2}
function constructScreenRenderDesc(nav: NavigationModel): {
icon: IconProp
screens: ScreenRenderDesc[]
} {
let icon: IconProp = 'magnifying-glass'
let screens: ScreenRenderDesc[] = []
for (const tab of nav.tabs) {
const tabScreens = [
...tab.getBackList(5),
Object.assign({}, tab.current, {index: tab.index}),
]
const parsedTabScreens = tabScreens.map(screen => {
const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
const matchRes = match(screen.url)
if (isCurrent) {
icon = matchRes.icon
}
return Object.assign(matchRes, {
key: `t${tab.id}-s${screen.index}`,
activityState: isCurrent ? 2 : 0,
})
})
screens = screens.concat(parsedTabScreens)
}
return {
icon,
screens,
}
}
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
innerContainer: {
flex: 1,
},
topBar: {
flexDirection: 'row',
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#ccc',
paddingLeft: 10,
paddingRight: 10,
paddingTop: 40,
paddingBottom: 5,
},
location: {
flex: 1,
flexDirection: 'row',
borderRadius: 4,
paddingLeft: 10,
paddingRight: 6,
paddingTop: 6,
paddingBottom: 6,
backgroundColor: '#F8F3F3',
},
locationIcon: {
color: '#DB00FF',
marginRight: 8,
},
locationIconLight: {
color: '#909090',
marginRight: 8,
},
locationText: {
color: '#000',
},
locationTextLight: {
color: '#868788',
},
bottomBar: {
flexDirection: 'row',
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#ccc',
paddingLeft: 5,
paddingRight: 15,
paddingBottom: 20,
},
ctrl: {
flex: 1,
paddingTop: 15,
paddingBottom: 15,
},
ctrlIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
inactive: {
color: '#888',
},
})

View file

@ -0,0 +1,158 @@
import React, {forwardRef, useState, useImperativeHandle, useRef} from 'react'
import {StyleSheet, Text, TouchableWithoutFeedback, View} from 'react-native'
import BottomSheet from '@gorhom/bottom-sheet'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {s} from '../../lib/styles'
import {NavigationTabModel} from '../../../state/models/navigation'
import {createCustomBackdrop} from '../../com/util/BottomSheetCustomBackdrop'
import {match} from '../../routes'
const TAB_HEIGHT = 38
const TAB_SPACING = 5
const BOTTOM_MARGIN = 70
export const TabsSelectorModal = forwardRef(function TabsSelectorModal(
{
onNewTab,
onChangeTab,
onCloseTab,
tabs,
currentTabIndex,
}: {
onNewTab: () => void
onChangeTab: (tabIndex: number) => void
onCloseTab: (tabIndex: number) => void
tabs: NavigationTabModel[]
currentTabIndex: number
},
ref,
) {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [snapPoints, setSnapPoints] = useState<number[]>([100])
const bottomSheetRef = useRef<BottomSheet>(null)
useImperativeHandle(ref, () => ({
open() {
setIsOpen(true)
setSnapPoints([
(tabs.length + 1) * (TAB_HEIGHT + TAB_SPACING) + BOTTOM_MARGIN,
])
bottomSheetRef.current?.expand()
},
}))
const onShareBottomSheetChange = (snapPoint: number) => {
if (snapPoint === -1) {
setIsOpen(false)
}
}
const onPressNewTab = () => {
onNewTab()
onClose()
}
const onPressChangeTab = (tabIndex: number) => {
onChangeTab(tabIndex)
onClose()
}
const onClose = () => {
setIsOpen(false)
bottomSheetRef.current?.close()
}
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={isOpen ? createCustomBackdrop(onClose) : undefined}
onChange={onShareBottomSheetChange}>
<View style={s.p10}>
{tabs.map((tab, tabIndex) => {
const {icon} = match(tab.current.url)
const isActive = tabIndex === currentTabIndex
return (
<View
key={tabIndex}
style={[styles.tab, styles.existing, isActive && styles.active]}>
<TouchableWithoutFeedback
onPress={() => onPressChangeTab(tabIndex)}>
<View style={styles.tabIcon}>
<FontAwesomeIcon size={16} icon={icon} />
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback
onPress={() => onPressChangeTab(tabIndex)}>
<Text
style={[styles.tabText, isActive && styles.tabTextActive]}>
{tab.current.title || tab.current.url}
</Text>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={() => onCloseTab(tabIndex)}>
<View style={styles.tabClose}>
<FontAwesomeIcon
size={16}
icon="x"
style={styles.tabCloseIcon}
/>
</View>
</TouchableWithoutFeedback>
</View>
)
})}
<TouchableWithoutFeedback onPress={onPressNewTab}>
<View style={[styles.tab, styles.create]}>
<View style={styles.tabIcon}>
<FontAwesomeIcon size={16} icon="plus" />
</View>
<Text style={styles.tabText}>New tab</Text>
</View>
</TouchableWithoutFeedback>
</View>
</BottomSheet>
)
})
const styles = StyleSheet.create({
tab: {
flexDirection: 'row',
width: '100%',
borderRadius: 4,
height: TAB_HEIGHT,
marginBottom: TAB_SPACING,
},
existing: {
borderColor: '#000',
borderWidth: 1,
},
create: {
backgroundColor: '#F8F3F3',
},
active: {
backgroundColor: '#faf0f0',
borderColor: '#f00',
borderWidth: 1,
},
tabIcon: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 15,
paddingRight: 10,
},
tabText: {
flex: 1,
paddingTop: 10,
paddingBottom: 10,
},
tabTextActive: {
fontWeight: 'bold',
},
tabClose: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 10,
paddingRight: 15,
},
tabCloseIcon: {
color: '#655',
},
})