diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-09-06 15:21:53 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-09-09 18:53:35 +0200 |
| commit | 5c59821ecf95bbaea1431e1b74fe730d67bb3054 (patch) | |
| tree | 2f7e69b9019673c549906a952ce165719d5a8362 /gui/src | |
| parent | b81fdd74657e37375688ccf022e242a5167d607e (diff) | |
| download | mullvadvpn-5c59821ecf95bbaea1431e1b74fe730d67bb3054.tar.xz mullvadvpn-5c59821ecf95bbaea1431e1b74fe730d67bb3054.zip | |
Use ReactXP abstractions in Switch
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettings.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/Preferences.tsx | 119 | ||||
| -rw-r--r-- | gui/src/renderer/components/Switch.css | 44 | ||||
| -rw-r--r-- | gui/src/renderer/components/Switch.tsx | 269 |
4 files changed, 220 insertions, 214 deletions
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 94776fab41..6abf4c4958 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -140,7 +140,7 @@ export default class AdvancedSettings extends Component<IProps, IState> { }; } - public componentDidUpdate(_oldProps: IProps, _oldState: IState) { + public componentDidUpdate(_prevProps: IProps, _prevState: IState) { if (this.props.mssfix !== this.state.persistedMssfix) { this.setState((state, props) => ({ ...state, diff --git a/gui/src/renderer/components/Preferences.tsx b/gui/src/renderer/components/Preferences.tsx index b690a506b3..2c6df58a99 100644 --- a/gui/src/renderer/components/Preferences.tsx +++ b/gui/src/renderer/components/Preferences.tsx @@ -13,7 +13,7 @@ import { import styles from './PreferencesStyles'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; -export interface IPreferencesProps { +export interface IProps { autoStart: boolean; autoConnect: boolean; allowLan: boolean; @@ -31,7 +31,7 @@ export interface IPreferencesProps { onClose: () => void; } -export default class Preferences extends Component<IPreferencesProps> { +export default class Preferences extends Component<IProps> { public render() { return ( <Layout> @@ -62,7 +62,7 @@ export default class Preferences extends Component<IPreferencesProps> { <Cell.Label> {messages.pgettext('preferences-view', 'Launch app on start-up')} </Cell.Label> - <Cell.Switch isOn={this.props.autoStart} onChange={this.onChangeAutoStart} /> + <Cell.Switch isOn={this.props.autoStart} onChange={this.props.setAutoStart} /> </Cell.Container> <View style={styles.preferences__separator} /> @@ -111,17 +111,49 @@ export default class Preferences extends Component<IPreferencesProps> { )} </Cell.Footer> - <MonochromaticIconToggle - enable={this.props.enableMonochromaticIconToggle} - monochromaticIcon={this.props.monochromaticIcon} - onChange={this.props.setMonochromaticIcon} - /> + {this.props.enableMonochromaticIconToggle ? ( + <React.Fragment> + <Cell.Container> + <Cell.Label> + {messages.pgettext('preferences-view', 'Monochromatic tray icon')} + </Cell.Label> + <Cell.Switch + isOn={this.props.monochromaticIcon} + onChange={this.props.setMonochromaticIcon} + /> + </Cell.Container> + <Cell.Footer> + {messages.pgettext( + 'preferences-view', + 'Use a monochromatic tray icon instead of a colored one.', + )} + </Cell.Footer> + </React.Fragment> + ) : ( + undefined + )} - <StartMinimizedToggle - enable={this.props.enableStartMinimizedToggle} - startMinimized={this.props.startMinimized} - onChange={this.props.setStartMinimized} - /> + {this.props.enableStartMinimizedToggle ? ( + <React.Fragment> + <Cell.Container> + <Cell.Label> + {messages.pgettext('preferences-view', 'Start minimized')} + </Cell.Label> + <Cell.Switch + isOn={this.props.startMinimized} + onChange={this.props.setStartMinimized} + /> + </Cell.Container> + <Cell.Footer> + {messages.pgettext( + 'preferences-view', + 'Show only the tray icon when the app starts.', + )} + </Cell.Footer> + </React.Fragment> + ) : ( + undefined + )} </View> </NavigationScrollbars> </View> @@ -131,65 +163,4 @@ export default class Preferences extends Component<IPreferencesProps> { </Layout> ); } - - private onChangeAutoStart = (autoStart: boolean) => { - this.props.setAutoStart(autoStart); - }; -} - -interface IMonochromaticIconProps { - enable: boolean; - monochromaticIcon: boolean; - onChange: (value: boolean) => void; -} - -class MonochromaticIconToggle extends Component<IMonochromaticIconProps> { - public render() { - if (this.props.enable) { - return ( - <View> - <Cell.Container> - <Cell.Label> - {messages.pgettext('preferences-view', 'Monochromatic tray icon')} - </Cell.Label> - <Cell.Switch isOn={this.props.monochromaticIcon} onChange={this.props.onChange} /> - </Cell.Container> - <Cell.Footer> - {messages.pgettext( - 'preferences-view', - 'Use a monochromatic tray icon instead of a colored one.', - )} - </Cell.Footer> - </View> - ); - } else { - return null; - } - } -} - -interface IStartMinimizedProps { - enable: boolean; - startMinimized: boolean; - onChange: (value: boolean) => void; -} - -class StartMinimizedToggle extends Component<IStartMinimizedProps> { - public render() { - if (this.props.enable) { - return ( - <View> - <Cell.Container> - <Cell.Label>{messages.pgettext('preferences-view', 'Start minimized')}</Cell.Label> - <Cell.Switch isOn={this.props.startMinimized} onChange={this.props.onChange} /> - </Cell.Container> - <Cell.Footer> - {messages.pgettext('preferences-view', 'Show only the tray icon when the app starts.')} - </Cell.Footer> - </View> - ); - } else { - return null; - } - } } diff --git a/gui/src/renderer/components/Switch.css b/gui/src/renderer/components/Switch.css deleted file mode 100644 index 22cc3360c8..0000000000 --- a/gui/src/renderer/components/Switch.css +++ /dev/null @@ -1,44 +0,0 @@ -.switch { - display: block; - position: relative; - -webkit-appearance: none; - border-radius: 16px; - width: 52px; - height: 32px; - border: 2px solid white; - background-color: transparent; - transition: 300ms ease-in-out all; -} - -.switch:checked { - text-align: right; -} - -.switch::after { - position: absolute; - left: 2px; - top: 2px; - display: block; - content: ''; - width: 24px; - height: 24px; - border-radius: 24px; - background-color: #d0021b; - transition: 200ms ease-in-out all; - transform: translate3d(0, 0, 0); -} - -.switch:active::after { - width: 28px; -} - -.switch:active:checked::after { - transform: translate3d(0, 0, 0); - left: 18px; -} - -.switch:checked::after { - background-color: #44ad4d; - transform: translate3d(0, 0, 0); - left: 22px; -} diff --git a/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx index eef588f44b..3e693706f9 100644 --- a/gui/src/renderer/components/Switch.tsx +++ b/gui/src/renderer/components/Switch.tsx @@ -1,146 +1,225 @@ import * as React from 'react'; - -const CLICK_TIMEOUT = 1000; -const MOVE_THRESHOLD = 10; +import { Animated, Component, GestureView, Styles, Types, View } from 'reactxp'; +import { colors } from '../../config.json'; interface IProps { - className?: string; isOn: boolean; onChange?: (isOn: boolean) => void; } interface IState { - ignoreChange: boolean; - initialPos: { x: number; y: number }; - startTime?: number; + isOn: boolean; + isPressed: boolean; +} + +const styles = { + holder: Styles.createViewStyle({ + width: 52, + height: 32, + borderColor: colors.white, + borderWidth: 2, + borderStyle: 'solid', + borderRadius: 16, + padding: 2, + }), + knob: Styles.createViewStyle({ + height: 24, + borderRadius: 24, + }), +}; + +interface IPosition { + x: number; + y: number; } -export default class Switch extends React.Component<IProps, IState> { +const SWITCH_DEFAULT_WIDTH = 24; +const SWITCH_PRESSED_WIDTH = 28; + +export default class Switch extends Component<IProps, IState> { public static defaultProps: Partial<IProps> = { isOn: false, onChange: undefined, }; public state: IState = { - ignoreChange: false, - initialPos: { x: 0, y: 0 }, - startTime: undefined, + isOn: false, + isPressed: false, }; - public isCapturingMouseEvents = false; - public ref = React.createRef<HTMLInputElement>(); + private isPanning = false; + private startPos = { x: 0, y: 0 }; + private startValue = false; + + private translationValue = Animated.createValue(0); + private widthValue = Animated.createValue(SWITCH_DEFAULT_WIDTH); + private colorValue = Animated.createValue(0); + private interpolatedColorValue = Animated.interpolate( + this.colorValue, + [0, 1], + [colors.red, colors.green], + ); + private animatedStyle = Styles.createAnimatedViewStyle({ + width: this.widthValue, + backgroundColor: this.interpolatedColorValue, + transform: [ + { + translateX: this.translationValue, + }, + ], + }); + private animation?: Types.Animated.CompositeAnimation; + + constructor(props: IProps) { + super(props); + + this.state.isOn = props.isOn; + + if (props.isOn) { + this.translationValue.setValue(this.computeTranslation(props.isOn, false)); + this.colorValue.setValue(1); + } + } public componentWillUnmount() { - // guard from abrupt programmatic unmount - if (this.isCapturingMouseEvents) { - this.stopCapturingMouseEvents(); + if (this.animation) { + this.animation.stop(); } } - public render() { - const { isOn, onChange, ...otherProps } = this.props; - const className = ('switch ' + (otherProps.className || '')).trim(); + public shouldComponentUpdate(nextProps: IProps, nextState: IState) { return ( - <input - {...otherProps} - type="checkbox" - ref={this.ref} - className={className} - checked={isOn} - onMouseDown={this.handleMouseDown} - onChange={this.handleChange} - /> + nextState.isOn !== this.state.isOn || + nextState.isPressed !== this.state.isPressed || + nextProps.isOn !== this.props.isOn ); } - private handleMouseDown = (e: React.MouseEvent<HTMLInputElement>) => { - const { clientX: x, clientY: y } = e; - this.startCapturingMouseEvents(); - this.setState({ - initialPos: { x, y }, - startTime: e.timeStamp, - }); - }; + public componentDidUpdate(prevProps: IProps, prevState: IState) { + if ( + this.props.isOn !== prevProps.isOn && + this.props.isOn !== this.state.isOn && + !this.isPanning + ) { + this.setState({ isOn: this.props.isOn }); + } else if (prevState.isOn !== this.state.isOn || prevState.isPressed !== this.state.isPressed) { + this.animate(); + } + } - private handleMouseMove = (e: MouseEvent) => { - const inputElement = this.ref.current; - const { x: x0 } = this.state.initialPos; - const { clientX: x, clientY: y } = e; - const dx = Math.abs(x0 - x); + public render() { + return ( + <GestureView + preferredPan={Types.PreferredPanGesture.Horizontal} + onPanHorizontal={this.onPanHorizontal} + onTap={this.onTap}> + <View style={styles.holder}> + <Animated.View style={[styles.knob, this.animatedStyle]} /> + </View> + </GestureView> + ); + } - if (dx < MOVE_THRESHOLD) { - return; - } + private onTap = (_gesture: Types.TapGestureState) => { + this.setState( + (state) => ({ isOn: !state.isOn, isPressed: false }), + () => { + this.notify(); + }, + ); + }; - const isOn = !!this.props.isOn; - let nextOn = isOn; + private onPanHorizontal = (gesture: Types.PanGestureState) => { + if (this.isPanning) { + if (gesture.isComplete) { + this.isPanning = false; - if (x < x0 && isOn) { - nextOn = false; - } else if (x > x0 && !isOn) { - nextOn = true; - } + this.setState({ isPressed: false }, () => { + if (this.startValue !== this.state.isOn) { + this.notify(); + } + }); + } else { + const currentPos = { x: gesture.clientX, y: gesture.clientY }; + const nextOn = this.computeNextState(this.startPos, currentPos); - if (isOn !== nextOn) { - this.setState({ - initialPos: { x, y }, - ignoreChange: true, - }); + if (this.state.isOn !== nextOn) { + this.startPos = currentPos; - if (inputElement) { - inputElement.checked = nextOn; + this.setState({ isOn: nextOn }); + } + } + } else { + if (gesture.isComplete) { + return; } - this.notify(nextOn); + this.isPanning = true; + this.startPos = { x: gesture.clientX, y: gesture.clientY }; + this.startValue = this.state.isOn; + this.setState({ isPressed: true }); } }; - private handleMouseUp = () => { - this.stopCapturingMouseEvents(); - }; - - private handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const startTime = this.state.startTime; - const eventTarget = e.target; - - if (typeof startTime !== 'number') { - throw new Error('startTime must be a number.'); + private computeNextState(initialPos: IPosition, currentPos: IPosition): boolean { + if (currentPos.x < initialPos.x && this.state.isOn) { + return false; + } else if (currentPos.x > initialPos.x && !this.state.isOn) { + return true; + } else { + return this.state.isOn; } + } - const dt = e.timeStamp - startTime; + private computeKnobWidth(isPressed: boolean) { + return isPressed ? SWITCH_PRESSED_WIDTH : SWITCH_DEFAULT_WIDTH; + } - if (this.state.ignoreChange) { - this.setState({ ignoreChange: false }); - e.preventDefault(); - } else if (dt > CLICK_TIMEOUT) { - e.preventDefault(); + private computeTranslation(isOn: boolean, isPressed: boolean) { + if (isOn) { + return isPressed ? 16 : 20; } else { - this.notify(eventTarget.checked); - } - }; - - private notify(isOn: boolean) { - const onChange = this.props.onChange; - if (onChange) { - onChange(isOn); + return 0; } } - private startCapturingMouseEvents() { - if (this.isCapturingMouseEvents) { - throw new Error('startCapturingMouseEvents() is called out of order.'); + private animate(onFinish?: (done: boolean) => void) { + const duration = 200; + const animation = Animated.parallel([ + Animated.timing(this.translationValue, { + toValue: this.computeTranslation(this.state.isOn, this.state.isPressed), + duration, + }), + Animated.timing(this.widthValue, { + toValue: this.computeKnobWidth(this.state.isPressed), + duration, + }), + Animated.timing(this.colorValue, { + toValue: this.state.isOn ? 1 : 0, + duration, + }), + ]); + + if (this.animation) { + this.animation.stop(); } - document.addEventListener('mousemove', this.handleMouseMove); - document.addEventListener('mouseup', this.handleMouseUp); - this.isCapturingMouseEvents = true; + + animation.start((options) => { + if (options.finished) { + this.animation = undefined; + } + + if (onFinish) { + onFinish(options.finished); + } + }); + + this.animation = animation; } - private stopCapturingMouseEvents() { - if (!this.isCapturingMouseEvents) { - throw new Error('stopCapturingMouseEvents() is called out of order.'); + private notify() { + if (this.props.onChange) { + this.props.onChange(this.state.isOn); } - document.removeEventListener('mousemove', this.handleMouseMove); - document.removeEventListener('mouseup', this.handleMouseUp); - this.isCapturingMouseEvents = false; } } |
