feat: screen presentation and dismiss animations for mobile (#2073)

This commit is contained in:
Mo
2022-11-30 14:37:36 -06:00
committed by GitHub
parent 0e95b451d6
commit 7f2074a6ec
79 changed files with 1338 additions and 878 deletions

View File

@@ -0,0 +1,13 @@
import { ElementIds } from '@/Constants/ElementIDs'
export enum AppPaneId {
Navigation = 'NavigationColumn',
Items = 'ItemsColumn',
Editor = 'EditorColumn',
}
export const AppPaneIdToDivId = {
[AppPaneId.Navigation]: ElementIds.NavigationColumn,
[AppPaneId.Items]: ElementIds.ItemsColumn,
[AppPaneId.Editor]: ElementIds.EditorColumn,
}

View File

@@ -0,0 +1,60 @@
import { log, LoggingDomain } from '@/Logging'
const ENTRANCE_DURATION = 200
const EXIT_DURATION = 200
export async function animatePaneEntranceTransitionFromOffscreenToTheRight(elementId: string): Promise<void> {
log(LoggingDomain.Panes, 'Animating pane entrance transition from offscreen to the right', elementId)
const element = document.getElementById(elementId)
if (!element) {
return
}
const animation = element.animate(
[
{
transform: 'translateX(100%)',
},
{
transform: 'translateX(0)',
},
],
{
duration: ENTRANCE_DURATION,
easing: 'ease-in-out',
fill: 'forwards',
},
)
await animation.finished
animation.finish()
}
export async function animatePaneExitTransitionOffscreenToTheRight(elementId: string): Promise<void> {
log(LoggingDomain.Panes, 'Animating pane exit transition offscreen to the right', elementId)
const element = document.getElementById(elementId)
if (!element) {
return
}
const animation = element.animate(
[
{
transform: 'translateX(0)',
},
{
transform: 'translateX(100%)',
},
],
{
duration: EXIT_DURATION,
easing: 'ease-in-out',
fill: 'forwards',
},
)
await animation.finished
animation.finish()
}

View File

@@ -0,0 +1,342 @@
import { PANEL_NAME_NAVIGATION, PANEL_NAME_NOTES } from '@/Constants/Constants'
import { ElementIds } from '@/Constants/ElementIDs'
import useIsTabletOrMobileScreen from '@/Hooks/useIsTabletOrMobileScreen'
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
import { ApplicationEvent, classNames, PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useState } from 'react'
import { usePrevious } from '../ContentListView/Calendar/usePrevious'
import ContentListView from '../ContentListView/ContentListView'
import NoteGroupView from '../NoteGroupView/NoteGroupView'
import PanelResizer, { PanelResizeType, PanelSide, ResizeFinishCallback } from '../PanelResizer/PanelResizer'
import { AppPaneId, AppPaneIdToDivId } from './AppPaneMetadata'
import { useResponsiveAppPane } from './ResponsivePaneProvider'
import Navigation from '../Tags/Navigation'
import { useApplication } from '../ApplicationProvider'
import {
animatePaneEntranceTransitionFromOffscreenToTheRight,
animatePaneExitTransitionOffscreenToTheRight,
} from '@/Components/Panes/PaneAnimator'
import { isPanesChangeLeafDismiss, isPanesChangePush } from '@/Controllers/PaneController/panesForLayout'
import { log, LoggingDomain } from '@/Logging'
const NAVIGATION_PANEL_MIN_WIDTH = 48
const ITEMS_PANEL_MIN_WIDTH = 200
const PLACEHOLDER_NAVIGATION_PANEL_WIDTH = 220
const PLACEHOLDER_NOTES_PANEL_WIDTH = 400
const PanesSystemComponent = () => {
const application = useApplication()
const isTabletOrMobileScreenWrapped = useIsTabletOrMobileScreen()
const { isTabletOrMobile, isTablet, isMobile } = isTabletOrMobileScreenWrapped
const previousIsTabletOrMobileWrapped = usePrevious(isTabletOrMobileScreenWrapped)
const paneController = useResponsiveAppPane()
const previousPaneController = usePrevious(paneController)
const [renderPanes, setRenderPanes] = useState<AppPaneId[]>([])
const [panesPendingEntrance, setPanesPendingEntrance] = useState<AppPaneId[]>([])
const [panesPendingExit, setPanesPendingExit] = useState<AppPaneId[]>([])
const viewControllerManager = application.getViewControllerManager()
const [navigationPanelWidth, setNavigationPanelWidth] = useState<number>(
application.getPreference(PrefKey.TagsPanelWidth, PLACEHOLDER_NAVIGATION_PANEL_WIDTH),
)
const [navigationRef, setNavigationRef] = useState<HTMLDivElement | null>(null)
const [itemsPanelWidth, setItemsPanelWidth] = useState<number>(
application.getPreference(PrefKey.NotesPanelWidth, PLACEHOLDER_NOTES_PANEL_WIDTH),
)
const [listRef, setListRef] = useState<HTMLDivElement | null>(null)
const showPanelResizers = !isTabletOrMobile
const [_editorRef, setEditorRef] = useState<HTMLDivElement | null>(null)
const animationsSupported = isMobile
useEffect(() => {
if (!animationsSupported) {
return
}
const panes = paneController.panes
const previousPanes = previousPaneController?.panes
if (!previousPanes) {
setPanesPendingEntrance([])
return
}
const isPush = isPanesChangePush(previousPanes, panes)
if (isPush) {
setPanesPendingEntrance([panes[panes.length - 1]])
}
}, [paneController.panes, previousPaneController?.panes, animationsSupported])
useEffect(() => {
if (!animationsSupported) {
return
}
const panes = paneController.panes
const previousPanes = previousPaneController?.panes
if (!previousPanes) {
setPanesPendingExit([])
return
}
const isExit = isPanesChangeLeafDismiss(previousPanes, panes)
if (isExit) {
setPanesPendingExit([previousPanes[previousPanes.length - 1]])
}
}, [paneController.panes, previousPaneController?.panes, animationsSupported])
useEffect(() => {
setRenderPanes(paneController.panes)
}, [paneController.panes])
useEffect(() => {
if (!panesPendingEntrance || panesPendingEntrance?.length === 0) {
return
}
if (panesPendingEntrance.length > 1) {
console.warn('More than one pane pending entrance. This is not supported.')
return
}
void animatePaneEntranceTransitionFromOffscreenToTheRight(AppPaneIdToDivId[panesPendingEntrance[0]]).then(() => {
setPanesPendingEntrance([])
})
}, [panesPendingEntrance])
useEffect(() => {
if (!panesPendingExit || panesPendingExit?.length === 0) {
return
}
if (panesPendingExit.length > 1) {
console.warn('More than one pane pending exit. This is not supported.')
return
}
void animatePaneExitTransitionOffscreenToTheRight(AppPaneIdToDivId[panesPendingExit[0]]).then(() => {
setPanesPendingExit([])
})
}, [panesPendingExit])
useEffect(() => {
const removeObserver = application.addEventObserver(async () => {
const width = application.getPreference(PrefKey.TagsPanelWidth, PLACEHOLDER_NAVIGATION_PANEL_WIDTH)
setNavigationPanelWidth(width)
}, ApplicationEvent.PreferencesChanged)
return () => {
removeObserver()
}
}, [application])
const navigationPanelResizeWidthChangeCallback = useCallback((width: number) => {
setNavigationPanelWidth(width)
}, [])
const itemsPanelResizeWidthChangeCallback = useCallback((width: number) => {
setItemsPanelWidth(width)
}, [])
const handleInitialItemsListPanelWidthLoad = useCallback((width: number) => {
setItemsPanelWidth(width)
}, [])
const navigationPanelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.publishPanelDidResizeEvent(PANEL_NAME_NAVIGATION, width, isCollapsed)
},
[application],
)
const itemsPanelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, width, isCollapsed)
},
[application],
)
useEffect(() => {
if (isTablet && !previousIsTabletOrMobileWrapped?.isTablet) {
if (paneController.selectedPane !== AppPaneId.Navigation) {
paneController.removePane(AppPaneId.Navigation)
}
} else if (
!isTablet &&
previousIsTabletOrMobileWrapped?.isTablet &&
!paneController.panes.includes(AppPaneId.Navigation)
) {
paneController.insertPaneAtIndex(AppPaneId.Navigation, 0)
}
}, [isTablet, paneController, previousIsTabletOrMobileWrapped])
const computeStylesForContainer = (): React.CSSProperties => {
const panes = paneController.panes
const numPanes = panes.length
if (isMobile) {
return {}
}
switch (numPanes) {
case 1: {
return {
gridTemplateColumns: 'auto',
}
}
case 2: {
if (paneController.focusModeEnabled) {
return {
gridTemplateColumns: '0 1fr',
}
}
if (isTablet) {
return {
gridTemplateColumns: '1fr 2fr',
}
} else {
if (panes[0] === AppPaneId.Navigation) {
return {
gridTemplateColumns: `${navigationPanelWidth}px auto`,
}
} else {
return {
gridTemplateColumns: `${itemsPanelWidth}px auto`,
}
}
}
}
case 3: {
if (paneController.focusModeEnabled) {
return {
gridTemplateColumns: '0 0 1fr',
}
}
return {
gridTemplateColumns: `${navigationPanelWidth}px ${itemsPanelWidth}px 2fr`,
}
}
default:
return {}
}
}
const computeClassesForPane = (_paneId: AppPaneId, isPendingEntrance: boolean, index: number): string => {
const common = `app-pane app-pane-${index + 1} h-full content`
if (isMobile) {
return `absolute top-0 left-0 w-full flex flex-col ${common} ${
isPendingEntrance ? 'translate-x-[100%]' : 'translate-x-0 '
}`
} else {
return `flex flex-col relative overflow-hidden ${common}`
}
}
const computeClassesForContainer = (): string => {
if (isMobile) {
return 'w-full'
}
return 'grid'
}
const renderPanesWithPendingExit = [...renderPanes, ...panesPendingExit]
log(LoggingDomain.Panes, 'Rendering panes', renderPanesWithPendingExit)
return (
<div id="app" className={`app ${computeClassesForContainer()}`} style={{ ...computeStylesForContainer() }}>
{renderPanesWithPendingExit.map((pane, index) => {
const isPendingEntrance = panesPendingEntrance?.includes(pane)
const className = computeClassesForPane(pane, isPendingEntrance ?? false, index)
if (pane === AppPaneId.Navigation) {
return (
<Navigation
id={ElementIds.NavigationColumn}
ref={setNavigationRef}
className={classNames(className, isTabletOrMobile ? 'w-full' : '')}
key="navigation-pane"
application={application}
>
{showPanelResizers && navigationRef && (
<PanelResizer
collapsable={true}
defaultWidth={navigationPanelWidth}
hoverable={true}
left={0}
minWidth={NAVIGATION_PANEL_MIN_WIDTH}
modifyElementWidth={false}
panel={navigationRef}
resizeFinishCallback={navigationPanelResizeFinishCallback}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
width={navigationPanelWidth}
widthEventCallback={navigationPanelResizeWidthChangeCallback}
/>
)}
</Navigation>
)
} else if (pane === AppPaneId.Items) {
return (
<ContentListView
id={ElementIds.ItemsColumn}
className={className}
ref={setListRef}
key={'content-list-view'}
application={application}
onPanelWidthLoad={handleInitialItemsListPanelWidthLoad}
accountMenuController={viewControllerManager.accountMenuController}
filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController}
navigationController={viewControllerManager.navigationController}
noAccountWarningController={viewControllerManager.noAccountWarningController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
linkingController={viewControllerManager.linkingController}
>
{showPanelResizers && listRef && (
<PanelResizer
collapsable={true}
defaultWidth={itemsPanelWidth}
hoverable={true}
left={0}
minWidth={ITEMS_PANEL_MIN_WIDTH}
modifyElementWidth={false}
panel={listRef}
resizeFinishCallback={itemsPanelResizeFinishCallback}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
width={itemsPanelWidth}
widthEventCallback={itemsPanelResizeWidthChangeCallback}
/>
)}
</ContentListView>
)
} else if (pane === AppPaneId.Editor) {
return (
<ErrorBoundary key="editor-pane">
<NoteGroupView
id={ElementIds.EditorColumn}
innerRef={(ref) => setEditorRef(ref)}
className={className}
application={application}
/>
</ErrorBoundary>
)
}
})}
</div>
)
}
export default observer(PanesSystemComponent)

View File

@@ -0,0 +1,144 @@
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import {
useEffect,
ReactNode,
useMemo,
createContext,
useCallback,
useContext,
memo,
useRef,
useLayoutEffect,
MutableRefObject,
} from 'react'
import { AppPaneId } from './AppPaneMetadata'
import { PaneController } from '../../Controllers/PaneController/PaneController'
import { observer } from 'mobx-react-lite'
type ResponsivePaneData = {
selectedPane: AppPaneId
toggleListPane: () => void
toggleNavigationPane: () => void
isListPaneCollapsed: boolean
isNavigationPaneCollapsed: boolean
panes: PaneController['panes']
toggleAppPane: (paneId: AppPaneId) => void
presentPane: PaneController['presentPane']
popToPane: PaneController['popToPane']
dismissLastPane: PaneController['dismissLastPane']
replacePanes: PaneController['replacePanes']
removePane: PaneController['removePane']
insertPaneAtIndex: PaneController['insertPaneAtIndex']
setPaneLayout: PaneController['setPaneLayout']
focusModeEnabled: PaneController['focusModeEnabled']
}
const ResponsivePaneContext = createContext<ResponsivePaneData | undefined>(undefined)
export const useResponsiveAppPane = () => {
const value = useContext(ResponsivePaneContext)
if (!value) {
throw new Error('Component must be a child of <ResponsivePaneProvider />')
}
return value
}
type ChildrenProps = {
children: ReactNode
}
type ProviderProps = {
paneController: PaneController
} & ChildrenProps
function useStateRef<State>(state: State): MutableRefObject<State> {
const ref = useRef<State>(state)
useLayoutEffect(() => {
ref.current = state
}, [state])
return ref
}
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
const ResponsivePaneProvider = ({ paneController, children }: ProviderProps) => {
const currentSelectedPane = paneController.currentPane
const currentSelectedPaneRef = useStateRef<AppPaneId>(currentSelectedPane)
const toggleAppPane = useCallback(
(paneId: AppPaneId) => {
paneController.presentPane(paneId)
},
[paneController],
)
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
if (
currentSelectedPaneRef.current === AppPaneId.Editor ||
currentSelectedPaneRef.current === AppPaneId.Navigation
) {
toggleAppPane(AppPaneId.Items)
return true
} else {
return false
}
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, currentSelectedPaneRef, toggleAppPane])
const contextValue = useMemo(
(): ResponsivePaneData => ({
selectedPane: currentSelectedPane,
toggleAppPane,
presentPane: paneController.presentPane,
isListPaneCollapsed: paneController.isListPaneCollapsed,
isNavigationPaneCollapsed: paneController.isNavigationPaneCollapsed,
toggleListPane: paneController.toggleListPane,
toggleNavigationPane: paneController.toggleNavigationPane,
panes: paneController.panes,
popToPane: paneController.popToPane,
dismissLastPane: paneController.dismissLastPane,
replacePanes: paneController.replacePanes,
removePane: paneController.removePane,
insertPaneAtIndex: paneController.insertPaneAtIndex,
setPaneLayout: paneController.setPaneLayout,
focusModeEnabled: paneController.focusModeEnabled,
}),
[
currentSelectedPane,
toggleAppPane,
paneController.panes,
paneController.isListPaneCollapsed,
paneController.isNavigationPaneCollapsed,
paneController.toggleListPane,
paneController.toggleNavigationPane,
paneController.presentPane,
paneController.popToPane,
paneController.dismissLastPane,
paneController.replacePanes,
paneController.removePane,
paneController.insertPaneAtIndex,
paneController.setPaneLayout,
paneController.focusModeEnabled,
],
)
return (
<ResponsivePaneContext.Provider value={contextValue}>
<MemoizedChildren children={children} />
</ResponsivePaneContext.Provider>
)
}
export default observer(ResponsivePaneProvider)