diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-09-09 11:50:46 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-09-09 11:50:46 +0200 |
| commit | d8a2cffae6005837a2974e60ae78b7d3830123bd (patch) | |
| tree | 01ea25ce1d38e5b668bd650be50c94f64abae26d /gui/src | |
| parent | 998a616eff0c90019028f84bcbe90b90d7e57235 (diff) | |
| parent | 5581a723950e2afd90086086e1dc4f50f9d71375 (diff) | |
| download | mullvadvpn-d8a2cffae6005837a2974e60ae78b7d3830123bd.tar.xz mullvadvpn-d8a2cffae6005837a2974e60ae78b7d3830123bd.zip | |
Merge branch 'sticky-navigation'
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/NavigationBar.tsx | 483 |
1 files changed, 397 insertions, 86 deletions
diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx index dc5b6c91ea..9e81cb6b92 100644 --- a/gui/src/renderer/components/NavigationBar.tsx +++ b/gui/src/renderer/components/NavigationBar.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp'; import { colors } from '../../config.json'; import CustomScrollbars, { IScrollEvent } from './CustomScrollbars'; @@ -16,9 +17,12 @@ const styles = { flexDirection: 'row', }), separator: Styles.createViewStyle({ - borderStyle: 'solid', - borderBottomWidth: 1, - borderColor: 'rgba(0, 0, 0, 0.2)', + backgroundColor: 'rgba(0, 0, 0, 0.2)', + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 1, }), darwin: Styles.createViewStyle({ paddingTop: 24, @@ -50,21 +54,6 @@ const styles = { opacity: 0, }), }, - buttonBarItem: { - default: Styles.createButtonStyle({ - cursor: 'default', - }), - content: Styles.createViewStyle({ - flexDirection: 'row', - alignItems: 'center', - }), - label: Styles.createTextStyle({ - fontFamily: 'Open Sans', - fontSize: 13, - fontWeight: '600', - color: colors.white60, - }), - }, closeBarItem: { default: Styles.createViewStyle({ cursor: 'default', @@ -96,39 +85,118 @@ const styles = { marginRight: 8, }), }, + scopeBar: { + container: Styles.createViewStyle({ + flexDirection: 'row', + backgroundColor: colors.blue40, + borderRadius: 13, + }), + item: { + base: Styles.createButtonStyle({ + cursor: 'default', + flex: 1, + paddingHorizontal: 8, + paddingVertical: 4, + }), + selected: Styles.createButtonStyle({ + backgroundColor: colors.blue, + }), + hover: Styles.createButtonStyle({ + backgroundColor: colors.blue40, + }), + label: Styles.createTextStyle({ + fontFamily: 'Open Sans', + fontSize: 13, + color: colors.white, + textAlign: 'center', + }), + }, + }, + stickyContentHolder: Styles.createViewStyle({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.darkBlue, + }), }; interface INavigationScrollContextValue { - scrollTop: number; - onScroll: (event: IScrollEvent) => void; + navigationContainer?: NavigationContainer; + showsBarTitle: boolean; + showsBarSeparator: boolean; } const NavigationScrollContext = React.createContext<INavigationScrollContextValue>({ - scrollTop: 0, - onScroll: (_event: IScrollEvent) => { - // no-op - }, + showsBarTitle: false, + showsBarSeparator: false, }); export class NavigationContainer extends Component { public state = { - scrollTop: 0, + navigationContainer: this, + showsBarTitle: false, + showsBarSeparator: false, }; + private scrollEventListeners: Array<(event: IScrollEvent) => void> = []; + + public componentDidMount() { + this.updateBarAppearance({ scrollLeft: 0, scrollTop: 0 }); + } + public render() { return ( - <NavigationScrollContext.Provider - value={{ scrollTop: this.state.scrollTop, onScroll: this.onScroll }}> + <NavigationScrollContext.Provider value={this.state}> {this.props.children} </NavigationScrollContext.Provider> ); } - private onScroll = (event: IScrollEvent) => { - this.setState({ - scrollTop: event.scrollTop, - }); + public onScroll = (event: IScrollEvent) => { + this.notifyScrollEventListeners(event); + this.updateBarAppearance(event); }; + + public addScrollEventListener(fn: (event: IScrollEvent) => void) { + const index = this.scrollEventListeners.indexOf(fn); + if (index === -1) { + this.scrollEventListeners.push(fn); + } + } + + public removeScrollEventListener(fn: (event: IScrollEvent) => void) { + const index = this.scrollEventListeners.indexOf(fn); + if (index !== -1) { + this.scrollEventListeners.splice(index, 1); + } + } + + private notifyScrollEventListeners(event: IScrollEvent) { + this.scrollEventListeners.forEach((listener) => listener(event)); + } + + private updateBarAppearance(event: IScrollEvent) { + // detect if any of child elements provide a sticky context + // in that case the navigation bar does not draw the separator line + // since the sticky content is expected to include it. + const hasSticky = React.Children.toArray(this.props.children).some((child) => { + return React.isValidElement(child) && child.type === StickyContentContainer; + }); + + // that's where SettingsHeader.HeaderTitle intersects the navigation bar + const showsBarSeparator = event.scrollTop > 11 && !hasSticky; + + // that's when SettingsHeader.HeaderTitle goes behind the navigation bar + const showsBarTitle = event.scrollTop > 39; + + if ( + this.state.showsBarSeparator !== showsBarSeparator || + this.state.showsBarTitle !== showsBarTitle + ) { + this.setState({ showsBarSeparator, showsBarTitle }); + } + } } interface INavigationScrollbarsProps { @@ -136,32 +204,48 @@ interface INavigationScrollbarsProps { style?: React.CSSProperties; children?: React.ReactNode; } + export const NavigationScrollbars = React.forwardRef(function NavigationScrollbarsT( props: INavigationScrollbarsProps, ref?: React.Ref<CustomScrollbars>, ) { return ( - <NavigationScrollContext.Consumer> - {(context) => { - const { style, children, ...otherProps } = props; - const wrappedOnScroll = (scroll: IScrollEvent) => { - context.onScroll(scroll); - - if (otherProps.onScroll) { - otherProps.onScroll(scroll); - } - }; - - return ( - <CustomScrollbars ref={ref} style={style} onScroll={wrappedOnScroll}> - {children} - </CustomScrollbars> - ); - }} - </NavigationScrollContext.Consumer> + <PrivateNavigationScrollbars forwardedRef={ref} {...props}> + {props.children} + </PrivateNavigationScrollbars> ); }); +interface IPrivateNavigationScrollbars extends INavigationScrollbarsProps { + forwardedRef?: React.Ref<CustomScrollbars>; +} + +class PrivateNavigationScrollbars extends Component<IPrivateNavigationScrollbars> { + public static contextType = NavigationScrollContext; + public context!: React.ContextType<typeof NavigationScrollContext>; + + public render() { + return ( + <CustomScrollbars + ref={this.props.forwardedRef} + style={this.props.style} + onScroll={this.onScroll}> + {this.props.children} + </CustomScrollbars> + ); + } + + private onScroll = (scroll: IScrollEvent) => { + if (this.context.navigationContainer) { + this.context.navigationContainer.onScroll(scroll); + } + + if (this.props.onScroll) { + this.props.onScroll(scroll); + } + }; +} + interface IPrivateTitleBarItemProps { visible: boolean; titleAdjustment: number; @@ -259,6 +343,258 @@ class PrivateBarItemAnimationContainer extends Component<IPrivateBarItemAnimatio } } +interface IStickyContentContext { + container: HTMLDivElement | null; + holder: React.Ref<View>; + isSticky: boolean; +} + +const StickyContentContext = React.createContext<IStickyContentContext>({ + container: null, + holder: React.createRef<View>(), + isSticky: false, +}); + +export class StickyContentContainer extends Component<{ + style: Types.StyleRuleSet<Types.ViewStyle>; +}> { + public static contextType = NavigationScrollContext; + public context!: React.ContextType<typeof NavigationScrollContext>; + + public state = { + container: null, + holder: React.createRef<View>(), + isSticky: false, + }; + + public componentDidMount() { + if (this.context.navigationContainer) { + this.context.navigationContainer.addScrollEventListener(this.onScroll); + } + } + + public componentWillUnmount() { + if (this.context.navigationContainer) { + this.context.navigationContainer.removeScrollEventListener(this.onScroll); + } + } + + public render() { + return ( + <div + ref={this.onRef} + style={{ + position: 'relative', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }}> + <View style={this.props.style}> + <StickyContentContext.Provider value={this.state}> + {this.props.children} + </StickyContentContext.Provider> + </View> + </div> + ); + } + + private onScroll = async (_scrollEvent: IScrollEvent) => { + const holder = this.state.holder.current; + + if (holder) { + let layout: Types.LayoutInfo; + + try { + layout = await UserInterface.measureLayoutRelativeToAncestor(holder, this); + } catch { + // TODO: handle error + return; + } + + const isSticky = layout.y <= 0; + + if (this.state.isSticky !== isSticky) { + this.setState({ isSticky }); + } + } + }; + + private onRef = (ref: HTMLDivElement | null) => { + this.setState({ container: ref }); + }; +} + +interface IScopeBarProps { + defaultSelectedIndex: number; + onChange?: (selectedIndex: number) => void; + style?: Types.StyleRuleSet<Types.ViewStyle>; + children: React.ReactNode; +} + +interface IScopeBarState { + selectedIndex: number; +} + +export class ScopeBar extends Component<IScopeBarProps, IScopeBarState> { + public static defaultProps: Partial<IScopeBarProps> = { + defaultSelectedIndex: 0, + }; + + public state = { + selectedIndex: 0, + }; + + constructor(props: IScopeBarProps) { + super(props); + + this.state = { + selectedIndex: props.defaultSelectedIndex, + }; + } + + public render() { + return ( + <View style={[styles.scopeBar.container, this.props.style]}> + {React.Children.map(this.props.children, (child, index) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + ...(child.props || {}), + selected: index === this.state.selectedIndex, + onPress: this.makePressHandler(index), + }); + } else { + return undefined; + } + })} + </View> + ); + } + + public shouldComponentUpdate(nextProps: IScopeBarProps, nextState: IScopeBarState) { + return ( + this.props.onChange !== nextProps.onChange || + this.props.style !== nextProps.style || + this.props.children !== nextProps.children || + this.state.selectedIndex !== nextState.selectedIndex + ); + } + + private makePressHandler(index: number) { + return () => { + if (this.state.selectedIndex !== index) { + this.setState({ selectedIndex: index }, () => { + if (this.props.onChange) { + this.props.onChange(index); + } + }); + } + }; + } +} + +interface IStickyContentHolderProps { + style?: Types.ViewStyleRuleSet; +} + +interface IStickyContentHolderState { + contentHeight: number; +} + +export class StickyContentHolder extends Component< + IStickyContentHolderProps, + IStickyContentHolderState +> { + public state = { + contentHeight: 0, + }; + + public render() { + return ( + <StickyContentContext.Consumer> + {(stickyContext) => { + const contentStyle = stickyContext.isSticky ? styles.stickyContentHolder : undefined; + const contentPlaceholderStyle = stickyContext.isSticky + ? Styles.createViewStyle( + { + height: this.state.contentHeight, + }, + false, + ) + : undefined; + + const children = ( + <View style={contentStyle} onLayout={this.onLayout}> + {this.props.children} + {stickyContext.isSticky ? <NavigationBarSeparator /> : undefined} + </View> + ); + + return ( + <View style={this.props.style} ref={stickyContext.holder}> + {stickyContext.isSticky && stickyContext.container ? ( + <React.Fragment> + <View style={contentPlaceholderStyle} /> + {ReactDOM.createPortal(children, stickyContext.container)} + </React.Fragment> + ) : ( + children + )} + </View> + ); + }} + </StickyContentContext.Consumer> + ); + } + + private onLayout = async (layout: Types.LayoutInfo) => { + if (this.state.contentHeight !== layout.height) { + this.setState({ contentHeight: layout.height }); + } + }; +} + +interface IScopeBarItemProps { + children?: React.ReactText; + selected?: boolean; + onPress?: () => void; +} + +export class ScopeBarItem extends Component<IScopeBarItemProps> { + public state = { + isHovered: false, + }; + + public render() { + const hoverStyle = this.props.selected + ? styles.scopeBar.item.selected + : this.state.isHovered + ? styles.scopeBar.item.hover + : undefined; + + return ( + <Button + style={[styles.scopeBar.item.base, hoverStyle]} + onHoverStart={this.onHoverStart} + onHoverEnd={this.onHoverEnd} + onPress={this.props.onPress}> + <Text style={styles.scopeBar.item.label}>{this.props.children}</Text> + </Button> + ); + } + + private onHoverStart = () => { + this.setState({ isHovered: true }); + }; + + private onHoverEnd = () => { + this.setState({ isHovered: false }); + }; +} + +function NavigationBarSeparator() { + return <View style={styles.navigationBar.separator} />; +} + interface INavigationBarProps { children?: React.ReactNode; } @@ -270,7 +606,10 @@ export const NavigationBar = React.forwardRef(function NavigationBarT( return ( <NavigationScrollContext.Consumer> {(context) => ( - <PrivateNavigationBar ref={ref} scrollTop={context.scrollTop}> + <PrivateNavigationBar + ref={ref} + showsBarTitle={context.showsBarTitle} + showsBarSeparator={context.showsBarSeparator}> {props.children} </PrivateNavigationBar> )} @@ -279,14 +618,13 @@ export const NavigationBar = React.forwardRef(function NavigationBarT( }); interface IPrivateNavigationBarProps { - scrollTop: number; + showsBarSeparator: boolean; + showsBarTitle: boolean; children?: React.ReactNode; } interface IPrivateNavigationBarState { titleAdjustment: number; - showsBarSeparator: boolean; - showsBarTitle: boolean; } const PrivateTitleBarItemContext = React.createContext({ @@ -300,31 +638,8 @@ class PrivateNavigationBar extends Component< IPrivateNavigationBarProps, IPrivateNavigationBarState > { - public static defaultProps: Partial<IPrivateNavigationBarProps> = { - scrollTop: 0, - }; - - public static getDerivedStateFromProps( - props: IPrivateNavigationBarProps, - state: IPrivateNavigationBarState, - ) { - // that's where SettingsHeader.HeaderTitle intersects the navigation bar - const showsBarSeparator = props.scrollTop > 11; - - // that's when SettingsHeader.HeaderTitle goes behind the navigation bar - const showsBarTitle = props.scrollTop > 30; - - return { - ...state, - showsBarSeparator, - showsBarTitle, - }; - } - public state: IPrivateNavigationBarState = { titleAdjustment: 0, - showsBarSeparator: false, - showsBarTitle: false, }; private titleViewRef = React.createRef<PrivateTitleBarItem>(); @@ -336,31 +651,27 @@ class PrivateNavigationBar extends Component< ) { return ( this.props.children !== nextProps.children || - this.state.titleAdjustment !== nextState.titleAdjustment || - this.state.showsBarSeparator !== nextState.showsBarSeparator || - this.state.showsBarTitle !== nextState.showsBarTitle + this.props.showsBarSeparator !== nextProps.showsBarSeparator || + this.props.showsBarTitle !== nextProps.showsBarTitle || + this.state.titleAdjustment !== nextState.titleAdjustment ); } public render() { return ( - <View - style={[ - styles.navigationBar.default, - this.state.showsBarSeparator ? styles.navigationBar.separator : undefined, - this.getPlatformStyle(), - ]}> + <View style={[styles.navigationBar.default, this.getPlatformStyle()]}> <View style={styles.navigationBar.content} onLayout={this.onLayout}> <PrivateTitleBarItemContext.Provider value={{ titleAdjustment: this.state.titleAdjustment, - visible: this.state.showsBarTitle, + visible: this.props.showsBarTitle, titleRef: this.titleViewRef, measuringTextRef: this.measuringTextRef, }}> {this.props.children} </PrivateTitleBarItemContext.Provider> </View> + {this.props.showsBarSeparator && <NavigationBarSeparator />} </View> ); } |
