summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-09-06 15:21:53 +0200
committerAndrej Mihajlov <and@mullvad.net>2019-09-09 18:53:35 +0200
commit5c59821ecf95bbaea1431e1b74fe730d67bb3054 (patch)
tree2f7e69b9019673c549906a952ce165719d5a8362
parentb81fdd74657e37375688ccf022e242a5167d607e (diff)
downloadmullvadvpn-5c59821ecf95bbaea1431e1b74fe730d67bb3054.tar.xz
mullvadvpn-5c59821ecf95bbaea1431e1b74fe730d67bb3054.zip
Use ReactXP abstractions in Switch
-rw-r--r--gui/assets/css/style.css1
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx2
-rw-r--r--gui/src/renderer/components/Preferences.tsx119
-rw-r--r--gui/src/renderer/components/Switch.css44
-rw-r--r--gui/src/renderer/components/Switch.tsx269
5 files changed, 220 insertions, 215 deletions
diff --git a/gui/assets/css/style.css b/gui/assets/css/style.css
index 5074c42090..7cebbdfdf4 100644
--- a/gui/assets/css/style.css
+++ b/gui/assets/css/style.css
@@ -5,4 +5,3 @@
/* app */
@import '../../src/renderer/components/CustomScrollbars.css';
-@import '../../src/renderer/components/Switch.css';
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;
}
}