diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-03-11 11:20:35 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-03-13 11:24:55 +0100 |
| commit | 183b79329b03dbc35b6a15df8bf26bcd7848e553 (patch) | |
| tree | 1bb6433db7b624151ddb843a2e122898457262d5 /gui/src | |
| parent | 0048a0e03ab8379e00eef55a871234f0aabc31ca (diff) | |
| download | mullvadvpn-183b79329b03dbc35b6a15df8bf26bcd7848e553.tar.xz mullvadvpn-183b79329b03dbc35b6a15df8bf26bcd7848e553.zip | |
Revamp transition container to support transition queues
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/app.tsx | 12 | ||||
| -rw-r--r-- | gui/src/renderer/components/PlatformWindow.tsx | 21 | ||||
| -rw-r--r-- | gui/src/renderer/components/TransitionContainer.tsx | 469 | ||||
| -rw-r--r-- | gui/src/renderer/routes.tsx | 97 |
4 files changed, 400 insertions, 199 deletions
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 4176575d97..a6c92aa672 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -11,7 +11,7 @@ import { Provider } from 'react-redux'; import { bindActionCreators } from 'redux'; import { InvalidAccountError } from '../main/errors'; -import makeRoutes from './routes'; +import AppRoutes from './routes'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; @@ -181,10 +181,12 @@ export default class AppRenderer { return ( <Provider store={this.reduxStore}> <ConnectedRouter history={this.memoryHistory}> - {makeRoutes({ - app: this, - locale: this.locale, - })} + <AppRoutes + sharedProps={{ + app: this, + locale: this.locale, + }} + /> </ConnectedRouter> </Provider> ); diff --git a/gui/src/renderer/components/PlatformWindow.tsx b/gui/src/renderer/components/PlatformWindow.tsx index baf446fd5b..cc10374fec 100644 --- a/gui/src/renderer/components/PlatformWindow.tsx +++ b/gui/src/renderer/components/PlatformWindow.tsx @@ -1,14 +1,18 @@ import * as React from 'react'; -import { Component, Styles, View } from 'reactxp'; +import { Component, Styles, Types, View } from 'reactxp'; interface IProps { arrowPosition?: number; } +const containerStyle = Styles.createViewStyle({ flex: 1 }); + export default class PlatformWindow extends Component<IProps> { public render() { - let style; + return <View style={[containerStyle, this.platformStyle()]}>{this.props.children}</View>; + } + private platformStyle(): Types.ViewStyleRuleSet { if (process.platform === 'darwin') { const arrowPosition = this.props.arrowPosition; let arrowPositionCss = '50%'; @@ -24,10 +28,15 @@ export default class PlatformWindow extends Component<IProps> { `url(../../assets/images/app-header-backdrop.svg) no-repeat`, ]; - // @ts-ignore - style = Styles.createViewStyle({ WebkitMask: webkitMask.join(',') }, false); + return Styles.createViewStyle( + { + // @ts-ignore + WebkitMask: webkitMask.join(','), + }, + false, + ); + } else { + return undefined; } - - return <View style={style}>{this.props.children}</View>; } } diff --git a/gui/src/renderer/components/TransitionContainer.tsx b/gui/src/renderer/components/TransitionContainer.tsx index a2632653d3..8ef9611ac0 100644 --- a/gui/src/renderer/components/TransitionContainer.tsx +++ b/gui/src/renderer/components/TransitionContainer.tsx @@ -1,229 +1,376 @@ import * as React from 'react'; -import { Animated, Component, Styles, Types, UserInterface, View } from 'reactxp'; +import { Animated, Component, Styles, Types, View } from 'reactxp'; import { ITransitionGroupProps } from '../transitions'; +interface ITransitioningViewProps { + viewId: string; +} + +type TransitioningView = React.ReactElement<ITransitioningViewProps>; + +interface ITransitionQueueItem { + view: TransitioningView; + transition: ITransitionGroupProps; +} + interface IProps extends ITransitionGroupProps { - children: React.ReactNode; + children: TransitioningView; } interface IState { - previousChildren?: React.ReactNode; - childrenAnimation?: Types.AnimatedViewStyleRuleSet; - previousChildrenAnimation?: Types.AnimatedViewStyleRuleSet; - dimensions: Types.Dimensions; - isAnimating: boolean; + currentItem?: ITransitionQueueItem; + nextItem?: ITransitionQueueItem; + itemQueue: ITransitionQueueItem[]; + currentItemStyle?: Array<Types.StyleRuleSet<Types.AnimatedViewStyle>>; + nextItemStyle?: Array<Types.StyleRuleSet<Types.AnimatedViewStyle>>; } -const dimensions = UserInterface.measureWindow(); const styles = { - animationDefaultStyle: Styles.createViewStyle({ + animatedContainer: Styles.createViewStyle({ position: 'absolute', - width: dimensions.width, - height: dimensions.height, + left: 0, + right: 0, + top: 0, + bottom: 0, + }), + transitionView: Styles.createViewStyle({ + flex: 1, }), - allowPointerEventsStyle: Styles.createViewStyle({ + userInteractionBlocker: Styles.createViewStyle({ // @ts-ignore - pointerEvents: 'auto', + zIndex: 2, }), - transitionContainerStyle: Styles.createViewStyle({ - width: dimensions.width, - height: dimensions.height, + transitionContainer: Styles.createViewStyle({ + flex: 1, + }), + orderFront: Styles.createViewStyle({ + // @ts-ignore + zIndex: 1, + }), + orderBack: Styles.createViewStyle({ + // @ts-ignore + zIndex: 0, }), }; +export class TransitionView extends Component<ITransitioningViewProps> { + public render() { + return <View style={styles.transitionView}>{this.props.children}</View>; + } +} + export default class TransitionContainer extends Component<IProps, IState> { public state: IState = { - dimensions: UserInterface.measureWindow(), - isAnimating: false, + itemQueue: [], }; + private containerSize = { width: 0, height: 0 }; + + private animation?: Types.Animated.CompositeAnimation; + private isCycling = false; + + private slideValueA = Animated.createValue(0); + private slideAnimationStyleA = Styles.createAnimatedViewStyle({ + transform: [{ translateY: this.slideValueA }], + }); + + private slideValueB = Animated.createValue(0); + private slideAnimationStyleB = Styles.createAnimatedViewStyle({ + transform: [{ translateY: this.slideValueB }], + }); + + private pushValueA = Animated.createValue(0); + private pushStyleA = Styles.createAnimatedViewStyle({ + transform: [{ translateX: this.pushValueA }], + }); + + private pushValueB = Animated.createValue(0); + private pushStyleB = Styles.createAnimatedViewStyle({ + transform: [{ translateX: this.pushValueB }], + }); + + constructor(props: IProps) { + super(props); + + this.state.currentItem = this.makeItem(props); + } + public UNSAFE_componentWillReceiveProps(nextProps: IProps) { - switch (nextProps.name) { - case 'slide-up': - this.slideUpTransition(nextProps); - break; - case 'slide-down': - this.slideDownTransition(nextProps); - break; - case 'push': - this.pushTransition(nextProps); - break; - case 'pop': - this.popTransition(nextProps); - break; - default: - break; + const candidate = nextProps.children; + + if (candidate && this.state.currentItem) { + // synchronize updates to the last added child. + const itemQueueCount = this.state.itemQueue.length; + const lastItemInQueue = + itemQueueCount > 0 ? this.state.itemQueue[itemQueueCount - 1] : undefined; + + if (lastItemInQueue && lastItemInQueue.view.props.viewId === candidate.props.viewId) { + this.setState({ + itemQueue: [...this.state.itemQueue.slice(0, -1), this.makeItem(nextProps)], + }); + } else if ( + itemQueueCount === 0 && + this.state.nextItem && + this.state.nextItem.view.props.viewId === candidate.props.viewId + ) { + this.setState({ + nextItem: this.makeItem(nextProps), + }); + } else if ( + itemQueueCount === 0 && + !this.state.nextItem && + this.state.currentItem.view.props.viewId === candidate.props.viewId + ) { + this.setState({ + currentItem: this.makeItem(nextProps), + }); + } else { + // add new item + this.setState({ + itemQueue: [...this.state.itemQueue, this.makeItem(nextProps)], + }); + } + } else if (candidate && !this.state.currentItem) { + this.setState({ currentItem: this.makeItem(nextProps) }); + } + } + + public componentDidUpdate() { + this.cycle(); + } + + public componentWillUnmount() { + if (this.animation) { + this.animation.stop(); } } - public onFinishedAnimation = (_result: Types.Animated.EndResult) => { - this.setState({ - childrenAnimation: styles.allowPointerEventsStyle, - previousChildren: null, - isAnimating: false, - }); + public render() { + const disableUserInteraction = + this.state.itemQueue.length > 0 || this.state.nextItem ? true : false; + + return ( + <View style={styles.transitionContainer} onLayout={this.onLayout}> + {this.state.currentItem && ( + <Animated.View + key={this.state.currentItem.view.props.viewId} + style={[styles.animatedContainer, this.state.currentItemStyle]}> + {this.state.currentItem.view} + </Animated.View> + )} + + {this.state.nextItem && ( + <Animated.View + key={this.state.nextItem.view.props.viewId} + style={[styles.animatedContainer, this.state.nextItemStyle]}> + {this.state.nextItem.view} + </Animated.View> + )} + + {disableUserInteraction && ( + <View style={[styles.animatedContainer, styles.userInteractionBlocker]} /> + )} + </View> + ); + } + + private onLayout = (event: Types.ViewOnLayoutEvent) => { + this.containerSize = { width: event.width, height: event.height }; }; - public slideUpTransition(nextProps: IProps) { - const currentTranslationValue = Animated.createValue(this.state.dimensions.height); - this.setState( - { - previousChildren: this.props.children, - childrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 1, - transform: [{ translateY: currentTranslationValue }], - }), - previousChildrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 0, - transform: [{ translateY: Animated.createValue(0) }], - }), - isAnimating: true, + private cycle() { + if (!this.isCycling) { + this.isCycling = true; + this.cycleUnguarded(() => { + this.isCycling = false; + }); + } + } + + private cycleUnguarded(onFinish: () => void) { + const itemQueue = this.state.itemQueue; + + const continueCycling = () => { + this.makeNextItemCurrent(() => { + this.cycleUnguarded(onFinish); + }); + }; + + if (itemQueue.length > 0) { + const nextItem = itemQueue[0]; + const transition = nextItem.transition; + + switch (transition.name) { + case 'slide-up': + this.slideUp(transition.duration, continueCycling); + break; + + case 'slide-down': + this.slideDown(transition.duration, continueCycling); + break; + + case 'push': + this.push(transition.duration, continueCycling); + break; + + case 'pop': + this.pop(transition.duration, continueCycling); + break; + + default: + this.replace(() => { + this.cycleUnguarded(onFinish); + }); + break; + } + } else { + this.animation = undefined; + onFinish(); + } + } + + private makeItem(props: IProps): ITransitionQueueItem { + return { + transition: { + name: props.name, + duration: props.duration, }, + view: React.cloneElement(props.children), + }; + } + + private makeNextItemCurrent(completion: () => void) { + this.setState( + (state) => ({ + currentItem: state.nextItem, + nextItem: undefined, + currentItemStyle: [], + nextItemStyle: [], + }), + completion, + ); + } + + private slideUp(duration: number, completion: Types.Animated.EndCallback) { + this.slideValueA.setValue(0); + this.slideValueB.setValue(this.containerSize.height); + + this.setState( + (state) => ({ + nextItem: state.itemQueue[0], + itemQueue: state.itemQueue.slice(1), + currentItemStyle: [this.slideAnimationStyleA, styles.orderBack], + nextItemStyle: [this.slideAnimationStyleB, styles.orderFront], + }), () => { - Animated.timing(currentTranslationValue, { + const animation = Animated.timing(this.slideValueB, { toValue: 0, easing: Animated.Easing.InOut(), - duration: nextProps.duration, - }).start(this.onFinishedAnimation); + duration, + }); + + animation.start(completion); + this.animation = animation; }, ); } - public slideDownTransition(nextProps: IProps) { - const previousTranslationValue = Animated.createValue(0); + private slideDown(duration: number, completion: Types.Animated.EndCallback) { + this.slideValueA.setValue(0); + this.slideValueB.setValue(0); + this.setState( - { - previousChildren: this.props.children, - childrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 0, - transform: [{ translateY: Animated.createValue(0) }], - }), - previousChildrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 1, - transform: [{ translateY: previousTranslationValue }], - }), - isAnimating: true, - }, + (state) => ({ + nextItem: state.itemQueue[0], + itemQueue: state.itemQueue.slice(1), + currentItemStyle: [this.slideAnimationStyleA, styles.orderFront], + nextItemStyle: [this.slideAnimationStyleB, styles.orderBack], + }), () => { - Animated.timing(previousTranslationValue, { - toValue: this.state.dimensions.height, + const animation = Animated.timing(this.slideValueA, { + toValue: this.containerSize.height, easing: Animated.Easing.InOut(), - duration: nextProps.duration, - }).start(this.onFinishedAnimation); + duration, + }); + + animation.start(completion); + this.animation = animation; }, ); } - public pushTransition(nextProps: IProps) { - const currentTranslationValue = Animated.createValue(this.state.dimensions.width); - const previousTranslationValue = Animated.createValue(0); + private push(duration: number, completion: Types.Animated.EndCallback) { + this.pushValueA.setValue(0); + this.pushValueB.setValue(this.containerSize.width); + this.setState( - { - previousChildren: this.props.children, - childrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 1, - transform: [{ translateX: currentTranslationValue }], - }), - previousChildrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 0, - transform: [{ translateX: previousTranslationValue }], - }), - isAnimating: true, - }, + (state) => ({ + nextItem: state.itemQueue[0], + itemQueue: state.itemQueue.slice(1), + currentItemStyle: [this.pushStyleA, styles.orderBack], + nextItemStyle: [this.pushStyleB, styles.orderFront], + }), () => { - const compositeAnimation = Animated.parallel([ - Animated.timing(currentTranslationValue, { - toValue: 0, + const animation = Animated.parallel([ + Animated.timing(this.pushValueA, { + toValue: -this.containerSize.width * 0.5, easing: Animated.Easing.InOut(), - duration: nextProps.duration, + duration, }), - Animated.timing(previousTranslationValue, { - toValue: -this.state.dimensions.width / 2, + Animated.timing(this.pushValueB, { + toValue: 0, easing: Animated.Easing.InOut(), - duration: nextProps.duration, + duration, }), ]); - compositeAnimation.start(this.onFinishedAnimation); + + animation.start(completion); + this.animation = animation; }, ); } - public popTransition(nextProps: IProps) { - const currentTranslationValue = Animated.createValue(-this.state.dimensions.width / 2); - const previousTranslationValue = Animated.createValue(0); + private pop(duration: number, completion: Types.Animated.EndCallback) { + this.pushValueA.setValue(-this.containerSize.width * 0.5); + this.pushValueB.setValue(0); + this.setState( - { - previousChildren: this.props.children, - childrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 0, - transform: [{ translateX: currentTranslationValue }], - }), - previousChildrenAnimation: Styles.createAnimatedViewStyle({ - // @ts-ignore - zIndex: 1, - transform: [{ translateX: previousTranslationValue }], - }), - isAnimating: true, - }, + (state) => ({ + nextItem: state.itemQueue[0], + itemQueue: state.itemQueue.slice(1), + currentItemStyle: [this.pushStyleB, styles.orderFront], + nextItemStyle: [this.pushStyleA, styles.orderBack], + }), () => { - const compositeAnimation = Animated.parallel([ - Animated.timing(currentTranslationValue, { + const animation = Animated.parallel([ + Animated.timing(this.pushValueA, { toValue: 0, easing: Animated.Easing.InOut(), - duration: nextProps.duration, + duration, }), - Animated.timing(previousTranslationValue, { - toValue: this.state.dimensions.width, + Animated.timing(this.pushValueB, { + toValue: this.containerSize.width, easing: Animated.Easing.InOut(), - duration: nextProps.duration, + duration, }), ]); - compositeAnimation.start(this.onFinishedAnimation); + + animation.start(completion); + this.animation = animation; }, ); } - public render() { - const { children } = this.props; - const { - isAnimating, - previousChildren, - childrenAnimation, - previousChildrenAnimation, - } = this.state; - - return ( - <View style={styles.transitionContainerStyle} ignorePointerEvents={isAnimating}> - {previousChildren && ( - <Animated.View - key={getChildKey(previousChildren)} - style={[styles.animationDefaultStyle, previousChildrenAnimation]}> - {previousChildren} - </Animated.View> - )} - - <Animated.View - key={getChildKey(children)} - style={[styles.animationDefaultStyle, childrenAnimation]}> - {children} - </Animated.View> - </View> + private replace(completion: () => void) { + this.setState( + (state) => ({ + currentItem: state.itemQueue[0], + nextItem: undefined, + itemQueue: state.itemQueue.slice(1), + currentItemStyle: [], + nextItemStyle: [], + }), + completion, ); } } - -function getChildKey(child?: React.ReactNode): string | number | undefined { - return child && - typeof child === 'object' && - 'key' in child && - (typeof child.key === 'string' || typeof child.key === 'number') - ? child.key - : undefined; -} diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx index a31dd0de4a..ae694cefbc 100644 --- a/gui/src/renderer/routes.tsx +++ b/gui/src/renderer/routes.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Route, RouteComponentProps, Switch } from 'react-router'; +import { Route, RouteComponentProps, RouteProps, Switch, withRouter } from 'react-router'; import App from './app'; -import TransitionContainer from './components/TransitionContainer'; +import TransitionContainer, { TransitionView } from './components/TransitionContainer'; import AccountPage from './containers/AccountPage'; import AdvancedSettingsPage from './containers/AdvancedSettingsPage'; import ConnectPage from './containers/ConnectPage'; @@ -21,42 +21,85 @@ export interface ISharedRouteProps { type CustomRouteProps = { component: React.ComponentClass<ISharedRouteProps>; -} & Route['props']; +} & RouteProps; -export default function makeRoutes(componentProps: ISharedRouteProps) { - // Renders a route extended with shared props - function CustomRoute({ component: ComponentClass, ...routeProps }: CustomRouteProps) { - const renderOverride = () => <ComponentClass {...componentProps} />; +interface IAppRoutesProps extends RouteComponentProps { + sharedProps: ISharedRouteProps; +} + +interface IAppRoutesState { + previousLocation?: IAppRoutesProps['location']; + currentLocation: IAppRoutesProps['location']; +} + +class AppRoutes extends React.Component<IAppRoutesProps, IAppRoutesState> { + private unobserveHistory?: () => void; + + constructor(props: IAppRoutesProps) { + super(props); + + this.state = { + currentLocation: props.location, + }; + } - return <Route {...routeProps} render={renderOverride} />; + public componentDidMount() { + // React throttles updates, so it's impossible to capture the intermediate navigation without + // listening to the history directly. + this.unobserveHistory = this.props.history.listen((location) => { + this.setState((state) => ({ + previousLocation: state.currentLocation, + currentLocation: location, + })); + }); } - // store previous route - let sourceRoute: string | null = null; + public componentWillUnmount() { + if (this.unobserveHistory) { + this.unobserveHistory(); + } + } + + public render() { + const location = this.state.currentLocation; + const transitionProps = getTransitionProps( + this.state.previousLocation ? this.state.previousLocation.pathname : null, + location.pathname, + ); + + // Renders a route extended with shared props + const CustomRoute = ({ component: ComponentClass, ...routeProps }: CustomRouteProps) => { + const renderOverride = () => <ComponentClass {...this.props.sharedProps} />; - function renderRoute({ location }: RouteComponentProps) { - const destinationRoute = location.pathname; - const transitionProps = getTransitionProps(sourceRoute, destinationRoute); - sourceRoute = destinationRoute; + return <Route {...routeProps} render={renderOverride} />; + }; return ( <PlatformWindowContainer> <TransitionContainer {...transitionProps}> - <Switch key={location.key} location={location}> - <CustomRoute exact={true} path="/" component={LaunchPage} /> - <CustomRoute exact={true} path="/login" component={LoginPage} /> - <CustomRoute exact={true} path="/connect" component={ConnectPage} /> - <CustomRoute exact={true} path="/settings" component={SettingsPage} /> - <CustomRoute exact={true} path="/settings/account" component={AccountPage} /> - <CustomRoute exact={true} path="/settings/preferences" component={PreferencesPage} /> - <CustomRoute exact={true} path="/settings/advanced" component={AdvancedSettingsPage} /> - <CustomRoute exact={true} path="/settings/support" component={SupportPage} /> - <CustomRoute exact={true} path="/select-location" component={SelectLocationPage} /> - </Switch> + <TransitionView viewId={location.key || ''}> + <Switch key={location.key} location={location}> + <CustomRoute exact={true} path="/" component={LaunchPage} /> + <CustomRoute exact={true} path="/login" component={LoginPage} /> + <CustomRoute exact={true} path="/connect" component={ConnectPage} /> + <CustomRoute exact={true} path="/settings" component={SettingsPage} /> + <CustomRoute exact={true} path="/settings/account" component={AccountPage} /> + <CustomRoute exact={true} path="/settings/preferences" component={PreferencesPage} /> + <CustomRoute + exact={true} + path="/settings/advanced" + component={AdvancedSettingsPage} + /> + <CustomRoute exact={true} path="/settings/support" component={SupportPage} /> + <CustomRoute exact={true} path="/select-location" component={SelectLocationPage} /> + </Switch> + </TransitionView> </TransitionContainer> </PlatformWindowContainer> ); } - - return <Route render={renderRoute} />; } + +const AppRoutesWithRouter = withRouter(AppRoutes); + +export default AppRoutesWithRouter; |
