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:
parent
d1470bad66
commit
97f52b6a03
57 changed files with 1382 additions and 1159 deletions
251
src/state/models/navigation.ts
Normal file
251
src/state/models/navigation.ts
Normal file
|
@ -0,0 +1,251 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
|
||||
let __tabId = 0
|
||||
function genTabId() {
|
||||
return ++__tabId
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
url: string
|
||||
ts: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
export class NavigationTabModel {
|
||||
id = genTabId()
|
||||
history: HistoryItem[] = [{url: '/', ts: Date.now()}]
|
||||
index = 0
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
// accessors
|
||||
// =
|
||||
|
||||
get current() {
|
||||
return this.history[this.index]
|
||||
}
|
||||
|
||||
get canGoBack() {
|
||||
return this.index > 0
|
||||
}
|
||||
|
||||
get canGoForward() {
|
||||
return this.index < this.history.length - 1
|
||||
}
|
||||
|
||||
getBackList(n: number) {
|
||||
const start = Math.max(this.index - n, 0)
|
||||
const end = Math.min(this.index, n)
|
||||
return this.history.slice(start, end).map((item, i) => ({
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
index: start + i,
|
||||
}))
|
||||
}
|
||||
|
||||
get backTen() {
|
||||
return this.getBackList(10)
|
||||
}
|
||||
|
||||
getForwardList(n: number) {
|
||||
const start = Math.min(this.index + 1, this.history.length)
|
||||
const end = Math.min(this.index + n, this.history.length)
|
||||
return this.history.slice(start, end).map((item, i) => ({
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
index: start + i,
|
||||
}))
|
||||
}
|
||||
|
||||
get forwardTen() {
|
||||
return this.getForwardList(10)
|
||||
}
|
||||
|
||||
// navigation
|
||||
// =
|
||||
|
||||
navigate(url: string, title?: string) {
|
||||
if (this.current?.url === url) {
|
||||
this.refresh()
|
||||
} else {
|
||||
if (this.index < this.history.length - 1) {
|
||||
this.history.length = this.index + 1
|
||||
}
|
||||
this.history.push({url, title, ts: Date.now()})
|
||||
this.index = this.history.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.history = [
|
||||
...this.history.slice(0, this.index),
|
||||
{url: this.current.url, title: this.current.title, ts: Date.now()},
|
||||
...this.history.slice(this.index + 1),
|
||||
]
|
||||
}
|
||||
|
||||
goBack() {
|
||||
if (this.canGoBack) {
|
||||
this.index--
|
||||
}
|
||||
}
|
||||
|
||||
goForward() {
|
||||
if (this.canGoForward) {
|
||||
this.index++
|
||||
}
|
||||
}
|
||||
|
||||
goToIndex(index: number) {
|
||||
if (index >= 0 && index <= this.history.length - 1) {
|
||||
this.index = index
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(title: string) {
|
||||
this.current.title = title
|
||||
}
|
||||
|
||||
// persistence
|
||||
// =
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
history: this.history,
|
||||
index: this.index,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
this.history = []
|
||||
this.index = 0
|
||||
if (isObj(v)) {
|
||||
if (hasProp(v, 'history') && Array.isArray(v.history)) {
|
||||
for (const item of v.history) {
|
||||
if (
|
||||
isObj(item) &&
|
||||
hasProp(item, 'url') &&
|
||||
typeof item.url === 'string'
|
||||
) {
|
||||
let copy: HistoryItem = {
|
||||
url: item.url,
|
||||
ts:
|
||||
hasProp(item, 'ts') && typeof item.ts === 'number'
|
||||
? item.ts
|
||||
: Date.now(),
|
||||
}
|
||||
if (hasProp(item, 'title') && typeof item.title === 'string') {
|
||||
copy.title = item.title
|
||||
}
|
||||
this.history.push(copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasProp(v, 'index') && typeof v.index === 'number') {
|
||||
this.index = v.index
|
||||
}
|
||||
if (this.index >= this.history.length - 1) {
|
||||
this.index = this.history.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NavigationModel {
|
||||
tabs: NavigationTabModel[] = [new NavigationTabModel()]
|
||||
tabIndex = 0
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
serialize: false,
|
||||
hydrate: false,
|
||||
})
|
||||
}
|
||||
|
||||
// accessors
|
||||
// =
|
||||
|
||||
get tab() {
|
||||
return this.tabs[this.tabIndex]
|
||||
}
|
||||
|
||||
isCurrentScreen(tabId: number, index: number) {
|
||||
return this.tab.id === tabId && this.tab.index === index
|
||||
}
|
||||
|
||||
// navigation
|
||||
// =
|
||||
|
||||
navigate(url: string, title?: string) {
|
||||
this.tab.navigate(url, title)
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.tab.refresh()
|
||||
}
|
||||
|
||||
setTitle(title: string) {
|
||||
this.tab.setTitle(title)
|
||||
}
|
||||
|
||||
// tab management
|
||||
// =
|
||||
|
||||
newTab(url: string, title?: string) {
|
||||
const tab = new NavigationTabModel()
|
||||
tab.navigate(url, title)
|
||||
this.tabs.push(tab)
|
||||
this.tabIndex = this.tabs.length - 1
|
||||
}
|
||||
|
||||
setActiveTab(tabIndex: number) {
|
||||
this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
|
||||
}
|
||||
|
||||
closeTab(tabIndex: number) {
|
||||
this.tabs = [
|
||||
...this.tabs.slice(0, tabIndex),
|
||||
...this.tabs.slice(tabIndex + 1),
|
||||
]
|
||||
if (this.tabs.length === 0) {
|
||||
this.newTab('/')
|
||||
} else if (this.tabIndex >= this.tabs.length) {
|
||||
this.tabIndex = this.tabs.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
// persistence
|
||||
// =
|
||||
|
||||
serialize(): unknown {
|
||||
return {
|
||||
tabs: this.tabs.map(t => t.serialize()),
|
||||
tabIndex: this.tabIndex,
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
this.tabs.length = 0
|
||||
this.tabIndex = 0
|
||||
if (isObj(v)) {
|
||||
if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) {
|
||||
for (const tab of v.tabs) {
|
||||
const copy = new NavigationTabModel()
|
||||
copy.hydrate(tab)
|
||||
if (copy.history.length) {
|
||||
this.tabs.push(copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') {
|
||||
this.tabIndex = v.tabIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,12 +7,14 @@ import {adx, AdxClient} from '@adxp/mock-api'
|
|||
import {createContext, useContext} from 'react'
|
||||
import {isObj, hasProp} from '../lib/type-guards'
|
||||
import {SessionModel} from './session'
|
||||
import {NavigationModel} from './navigation'
|
||||
import {MeModel} from './me'
|
||||
import {FeedViewModel} from './feed-view'
|
||||
import {NotificationsViewModel} from './notifications-view'
|
||||
|
||||
export class RootStoreModel {
|
||||
session = new SessionModel()
|
||||
nav = new NavigationModel()
|
||||
me = new MeModel(this)
|
||||
homeFeed = new FeedViewModel(this, {})
|
||||
notesFeed = new NotificationsViewModel(this, {})
|
||||
|
@ -35,6 +37,7 @@ export class RootStoreModel {
|
|||
serialize(): unknown {
|
||||
return {
|
||||
session: this.session.serialize(),
|
||||
nav: this.nav.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,6 +46,9 @@ export class RootStoreModel {
|
|||
if (hasProp(v, 'session')) {
|
||||
this.session.hydrate(v.session)
|
||||
}
|
||||
if (hasProp(v, 'nav')) {
|
||||
this.nav.hydrate(v.nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue