diff options
6 files changed, 182 insertions, 53 deletions
diff --git a/desktop/packages/mullvad-vpn/assets/css/global.css b/desktop/packages/mullvad-vpn/assets/css/global.css index dfa159e020..e8d4d1619c 100644 --- a/desktop/packages/mullvad-vpn/assets/css/global.css +++ b/desktop/packages/mullvad-vpn/assets/css/global.css @@ -40,3 +40,14 @@ body { transition-duration: 0ms !important; } } + +::view-transition-image-pair(root) { + isolation: auto; +} + +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; + display: block; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts new file mode 100644 index 0000000000..33ea694d04 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts @@ -0,0 +1 @@ +export * from './reduce-motion'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts new file mode 100644 index 0000000000..6b000bf7b7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts @@ -0,0 +1,3 @@ +export function getReduceMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx index 741c298da6..afea9582ab 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx @@ -5,54 +5,27 @@ import { IHistoryObject, LocationState } from '../../shared/ipc-types'; import { GeneratedRoutePath } from './routeHelpers'; import { RoutePath } from './routes'; -export interface ITransitionSpecification { - name: string; - duration: number; +export enum TransitionType { + show, + dismiss, + push, + pop, + none, } -interface ITransitionMap { - [name: string]: ITransitionSpecification; -} - -/** - * Transition descriptors - */ -export const transitions: ITransitionMap = { - show: { - name: 'slide-up', - duration: 450, - }, - dismiss: { - name: 'slide-down', - duration: 450, - }, - push: { - name: 'push', - duration: 450, - }, - pop: { - name: 'pop', - duration: 450, - }, - none: { - name: '', - duration: 0, - }, -}; - -const transitionOpposites: Record<string, string> = { - 'slide-up': 'slide-down', - 'slide-down': 'slide-up', - push: 'pop', - pop: 'push', - '': '', -}; - -function oppositeTransition(transition: ITransitionSpecification): ITransitionSpecification { - return { - ...transition, - name: transitionOpposites[transition.name], - }; +function oppositeTransition(transition: TransitionType): TransitionType { + switch (transition) { + case TransitionType.show: + return TransitionType.dismiss; + case TransitionType.dismiss: + return TransitionType.none; + case TransitionType.push: + return TransitionType.pop; + case TransitionType.pop: + return TransitionType.none; + case TransitionType.none: + return TransitionType.none; + } } type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObject<LocationState>; @@ -60,7 +33,7 @@ type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObj type LocationListener = ( location: Location<LocationState>, action: Action, - transition: ITransitionSpecification, + transition: TransitionType, ) => void; export default class History { @@ -95,7 +68,7 @@ export default class History { } public push = (nextLocation: LocationDescriptor, nextState?: Partial<LocationState>) => { - const state = { transition: transitions.push, ...nextState }; + const state = { transition: TransitionType.push, ...nextState }; this.pushImpl(nextLocation, state); this.notify(state.transition); }; @@ -113,7 +86,7 @@ export default class History { this.index = 0; this.entries = [location]; - this.notify(nextState?.transition ?? transitions.none); + this.notify(nextState?.transition ?? TransitionType.none); }; public replaceRoot = ( @@ -125,7 +98,7 @@ export default class History { this.entries.splice(0, 1, location); if (this.index === 0) { - this.notify(replacementState?.transition ?? transitions.none); + this.notify(replacementState?.transition ?? TransitionType.none); } }; @@ -188,7 +161,7 @@ export default class History { this.entries.splice(this.index, this.entries.length - this.index, location); } - private popImpl(n = 1): ITransitionSpecification | undefined { + private popImpl(n = 1): TransitionType | undefined { if (this.canGo(-n)) { const transition = this.getPopTransition(n); @@ -202,7 +175,7 @@ export default class History { } } - private notify(transition: ITransitionSpecification) { + private notify(transition: TransitionType) { this.listeners.forEach((listener) => listener(this.location, this.action, transition)); } @@ -242,7 +215,7 @@ export default class History { return { scrollPosition: state?.scrollPosition ?? [0, 0], expandedSections: state?.expandedSections ?? {}, - transition: state?.transition ?? transitions.none, + transition: state?.transition ?? TransitionType.none, }; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts new file mode 100644 index 0000000000..ee4a8f7eae --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts @@ -0,0 +1,130 @@ +import { Location } from 'history'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; + +import { ViewTransition } from '../../../types/global'; +import { LocationState } from '../../shared/ipc-types'; +import { useAppContext } from '../context'; +import { TransitionType, useHistory } from '../lib/history'; +import { getReduceMotion } from './functions'; +import { useEffectEvent } from './utility-hooks'; + +type QueueItem = { location: Location<LocationState>; transition: TransitionType }; + +const TRANSITION_DURATION = 450; + +const viewTransitionRef: { current?: ViewTransition } = {}; + +export function useAfterTransition() { + const runAfterTransition = useCallback((fn: () => void) => { + if (viewTransitionRef.current) { + void viewTransitionRef.current.finished.then(() => runAfterTransition(fn)); + } else { + fn(); + } + }, []); + + return runAfterTransition; +} + +export function useViewTransitions(onTransition?: () => void): Location<LocationState> { + const history = useHistory(); + const [currentLocation, setCurrentLocation] = useState(history.location); + const queuedLocationRef = useRef<QueueItem | undefined>(); + const { setNavigationHistory } = useAppContext(); + + const reduceMotion = getReduceMotion(); + + const updateView = useEffectEvent((location: Location<LocationState>) => { + setNavigationHistory(history.asObject); + setCurrentLocation(location); + }); + + const transitionToView = useEffectEvent( + (location: Location<LocationState>, transition: TransitionType) => { + if (reduceMotion) { + updateView(location); + return; + } + + flushSync(() => { + viewTransitionRef.current = document.startViewTransition(() => { + updateView(location); + }); + + void viewTransitionRef.current.ready.then(() => animateNavigation(transition)); + void viewTransitionRef.current.finished.then(() => { + const queueLocation = queuedLocationRef.current; + + delete viewTransitionRef.current; + delete queuedLocationRef.current; + + if (queueLocation) { + transitionToView(queueLocation.location, queueLocation.transition); + } else { + onTransition?.(); + } + }); + }); + }, + ); + + useEffect(() => { + // React throttles updates, so it's impossible to capture the intermediate navigation without + // listening to the history directly. + const unobserveHistory = history.listen((location, _, transition) => { + if (viewTransitionRef.current === undefined) { + transitionToView(location, transition); + } else { + queuedLocationRef.current = { location, transition }; + } + }); + + return () => { + unobserveHistory?.(); + }; + }, [history]); + + return currentLocation; +} + +function animateNavigation(transition: TransitionType) { + const oldInFront = transition === TransitionType.dismiss || transition === TransitionType.pop; + const oldZIndex = oldInFront ? 2 : 0; + + document.documentElement.animate( + [ + { transform: 'translate(0%, 0%)', zIndex: oldZIndex }, + { transform: oldToTransform[transition], zIndex: oldZIndex }, + ], + { + duration: TRANSITION_DURATION, + easing: 'ease-in-out', + pseudoElement: '::view-transition-old(root)', + }, + ); + document.documentElement.animate( + [{ transform: newFromTransform[transition] }, { transform: 'translate(0%, 0%)' }], + { + duration: TRANSITION_DURATION, + easing: 'ease-in-out', + pseudoElement: '::view-transition-new(root)', + }, + ); +} + +const oldToTransform = { + [TransitionType.show]: 'translateY(0%)', + [TransitionType.dismiss]: 'translateY(100%)', + [TransitionType.push]: 'translateX(-33%)', + [TransitionType.pop]: 'translateX(100%)', + [TransitionType.none]: '', +}; + +const newFromTransform = { + [TransitionType.show]: 'translateY(100%)', + [TransitionType.dismiss]: 'translateY(0%)', + [TransitionType.push]: 'translateX(100%)', + [TransitionType.pop]: 'translateX(-33%)', + [TransitionType.none]: '', +}; diff --git a/desktop/packages/mullvad-vpn/types/global/index.d.ts b/desktop/packages/mullvad-vpn/types/global/index.d.ts index fafa8e3b8c..31570bd59c 100644 --- a/desktop/packages/mullvad-vpn/types/global/index.d.ts +++ b/desktop/packages/mullvad-vpn/types/global/index.d.ts @@ -1,9 +1,20 @@ import { IpcRendererEventChannel } from '../../src/renderer/lib/ipc-event-channel'; +// The ViewTransition types can be removed from here whenever TS adds support for them. +interface ViewTransition { + readonly ready: Promise<void>; + readonly finished: Promise<void>; +} + declare global { interface Window { ipc: typeof IpcRendererEventChannel; env: { platform: NodeJS.Platform; development: boolean; e2e: boolean }; e2e: { location: string }; } + + // The ViewTransition types can be removed from here whenever TS adds support for them. + interface Document { + startViewTransition(callback: () => void): ViewTransition; + } } |
