/** * ScrollView.tsx * * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT license. * * Web-specific implementation of the cross-platform ScrollView abstraction. */ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import * as RX from '../common/Interfaces'; import Timers from '../common/utils/Timers'; import CustomScrollbar from './CustomScrollbar'; import * as _ from './utils/lodashMini'; import ScrollViewConfig from './ScrollViewConfig'; import Styles from './Styles'; import ViewBase from './ViewBase'; const _styles = { defaultStyle: { position: 'relative', overflow: 'hidden', alignSelf: 'stretch', flexGrow: 1, flexShrink: 1, // This forces some browsers (like Chrome) to create a new render context, // which can significantly speed up scrolling. transform: 'translateZ(0)' }, verticalStyle: { flexDirection: 'column', overflowY: 'auto', overflowX: 'hidden' }, horizontalStyle: { flexDirection: 'row', overflowY: 'hidden', overflowX: 'auto' } }; let _initializedCustomStyles = false; const _customStyles = { defaultStyle: { overflow: 'hidden', msOverflowStyle: 'auto', flexDirection: 'column', // This forces some browsers (like Chrome) to create a new render context, // which can significantly speed up scrolling. transform: 'translateZ(0)' }, verticalStyle: {}, horizontalStyle: {}, customScrollContainer: { position: 'relative', overflow: 'hidden', boxSizing: 'border-box', alignSelf: 'stretch' }, customScrollVertical: { // Set flex only for vertical scroll view. // Don't set flex for horizontal scroll view, otherwise it disappears. display: 'flex', flex: '1 1 0px' } }; // Default to once per frame. const _defaultScrollThrottleValue = 1000 / 60; export class ScrollView extends ViewBase<RX.Types.ScrollViewProps, RX.Types.Stateless, RX.ScrollView> implements RX.ScrollView { private _mountedComponent: HTMLElement | null = null; constructor(props: RX.Types.ScrollViewProps) { super(props); // Set final styles upon initialization of the first ScrollView. This was previously done in the head // of this file, but it broke the pattern of not doing external work (such as accessing the document // object) on Types initialization. if (!_initializedCustomStyles) { _initializedCustomStyles = true; const nativeScrollbarWidth = CustomScrollbar.getNativeScrollbarWidth(); _customStyles.verticalStyle = { overflowY: 'scroll', paddingRight: 30 - nativeScrollbarWidth, marginRight: -30, // Fixes a bug for Chrome beta where the parent flexbox (customScrollContainer) doesn't // recognize that its child got populated with items. Smallest default width gives an // indication that content will exist here. minHeight: 0 }; _customStyles.horizontalStyle = { // The display needs to be set to flex, otherwise the scrollview incorrectly shows up vertically. display: 'flex', overflowX: 'scroll', paddingBottom: 30 - nativeScrollbarWidth, marginBottom: -30, // Fixes a bug for Chrome beta where the parent flexbox (customScrollContainer) doesn't // recognize that its child got populated with items. Smallest default width gives an // indication that content will exist here. minWidth: 0 }; } } private _mounted = false; private _customScrollbar: CustomScrollbar | undefined; private _customScrollbarEnabled = true; private _dragging = false; componentDidUpdate() { super.componentDidUpdate(); if (!this.props.onContentSizeChange) { return; } _.defer(() => { if (this.props.onContentSizeChange) { const container = this._getContainer(); if (!container) { return; } this.props.onContentSizeChange(container.scrollWidth, container.scrollHeight); } }); } render() { return this._customScrollbarEnabled ? this._renderWithCustomScrollbar() : this._renderNormal(); } UNSAFE_componentWillMount() { this._onPropsChange(this.props); } componentDidMount() { super.componentDidMount(); this._mounted = true; this._createCustomScrollbarsIfNeeded(this.props); } UNSAFE_componentWillReceiveProps(newProps: RX.Types.ScrollViewProps) { super.UNSAFE_componentWillReceiveProps(newProps); this._onPropsChange(newProps); } componentWillUnmount() { super.componentWillUnmount(); this._mounted = false; if (this._customScrollbar) { this._customScrollbar.dispose(); this._customScrollbar = undefined; } } protected _getContainer(): HTMLElement | null { return this._mountedComponent; } // Throttled scroll handler private _onScroll = _.throttle((e: React.SyntheticEvent<any>) => { if (!this._mounted) { return; } if (this._customScrollbarEnabled && this._customScrollbar) { this._customScrollbar.update(); } // Check if this should be also fire an onLayout event // The browser sends a scroll event when resizing const onLayoutPromise = this._checkAndReportLayout(); // Recent versions of Chrome have started to defer all timers until // after scrolling has completed. Because of this, our deferred layout // reporting sometimes doesn't get handled for up to seconds at a time. // Force the list of deferred changes to be reported now. ViewBase._reportDeferredLayoutChanges(); if (this.props.onScroll || this.props.scrollXAnimatedValue || this.props.scrollYAnimatedValue) { onLayoutPromise.then(() => { const container = this._getContainer(); if (!container) { return; } if (this.props.onScroll) { this.props.onScroll(container.scrollTop, container.scrollLeft); } if (this.props.scrollXAnimatedValue) { this.props.scrollXAnimatedValue.setValue(container.scrollLeft); } if (this.props.scrollYAnimatedValue) { this.props.scrollYAnimatedValue.setValue(container.scrollTop); } }).catch(e => { console.warn('ScrollView onLayout exception: ' + JSON.stringify(e)); }); } }, (this.props.scrollEventThrottle || _defaultScrollThrottleValue), { leading: true, trailing: true }); private _onPropsChange(props: RX.Types.ScrollViewProps) { this._customScrollbarEnabled = ScrollViewConfig.useCustomScrollbars(); // If we're turning on custom scrollbars or toggling vertical and/or horizontal, we need to re-create // the scrollbar. this._createCustomScrollbarsIfNeeded(props); } private _createCustomScrollbarsIfNeeded(props: RX.Types.ScrollViewProps) { if (this._mounted && this._customScrollbarEnabled) { if (this._customScrollbar) { if (this.props.horizontal === props.horizontal && this.props.showsHorizontalScrollIndicator === props.showsHorizontalScrollIndicator && this.props.showsVerticalScrollIndicator === props.showsVerticalScrollIndicator) { // No need to re-create the scrollbar. return; } this._customScrollbar.dispose(); this._customScrollbar = undefined; } const element = ReactDOM.findDOMNode(this) as HTMLElement | null; if (element) { this._customScrollbar = new CustomScrollbar(element); const horizontalHidden = (props.horizontal && props.showsHorizontalScrollIndicator === false); const verticalHidden = (!props.horizontal && props.showsVerticalScrollIndicator === false); this._customScrollbar.init({ horizontal: props.horizontal && !horizontalHidden, vertical: !props.horizontal && !verticalHidden, hiddenScrollbar: horizontalHidden || verticalHidden }); } } } private _getContainerStyle(): RX.Types.ScrollViewStyleRuleSet { const { scrollEnabled = true } = this.props; const styles: any = [{ display: 'block' }]; const sourceStyles = this._customScrollbarEnabled ? _customStyles : _styles; styles.push(sourceStyles.defaultStyle); if (scrollEnabled && this.props.horizontal) { styles.push(sourceStyles.horizontalStyle); } else if (scrollEnabled) { styles.push(sourceStyles.verticalStyle); } return Styles.combine([styles, this.props.style]); } private _renderNormal() { return ( <div ref={ this._onMount } role={ 'none' } onScroll={ this._onScroll } onTouchStart={ this._onTouchStart } onTouchEnd={ this._onTouchEnd } style={ this._getContainerStyle() as any } onKeyDown={ this.props.onKeyPress } onFocus={ this.props.onFocus } onBlur={ this.props.onBlur } data-test-id={ this.props.testId } > { this.props.children } </div> ); } private _renderWithCustomScrollbar() { let containerStyles: any = _customStyles.customScrollContainer; const scrollComponentClassNames = ['scrollViewport']; if (this.props.horizontal) { scrollComponentClassNames.push('scrollViewportH'); } else { scrollComponentClassNames.push('scrollViewportV'); containerStyles = _.extend({}, _customStyles.customScrollVertical, containerStyles); } return ( <div role={ 'none' } className='rxCustomScroll' style={ containerStyles } data-test-id={ this.props.testId } > <div ref={ this._onMount } role={ 'none' } className={ scrollComponentClassNames.join(' ') } onScroll={ this._onScroll } style={ this._getContainerStyle() as any } onKeyDown={ this.props.onKeyPress } onFocus={ this.props.onFocus } onBlur={ this.props.onBlur } onTouchStart={ this._onTouchStart } onTouchEnd={ this._onTouchEnd } > { this.props.children } </div> </div> ); } protected _onMount = (component: HTMLElement | null) => { this._mountedComponent = component; } setScrollTop(scrollTop: number, animate = false): void { const container = this._getContainer(); if (!container) { return; } this._onScroll.cancel(); if (animate) { const start = container.scrollTop; const change = scrollTop - start; const increment = 20; const duration = 200; const animateScroll = (elapsedTime: number) => { elapsedTime += increment; const position = this._easeInOut(elapsedTime, start, change, duration); container.scrollTop = position; if (elapsedTime < duration) { Timers.setTimeout(function() { animateScroll(elapsedTime); }, increment); } }; animateScroll(0); } else { container.scrollTop = scrollTop; } } setScrollLeft(scrollLeft: number, animate = false): void { const container = this._getContainer(); if (!container) { return; } this._onScroll.cancel(); if (animate) { const start = container.scrollLeft; const change = scrollLeft - start; const increment = 20; const duration = 200; const animateScroll = (elapsedTime: number) => { elapsedTime += increment; const position = this._easeInOut(elapsedTime, start, change, duration); container.scrollLeft = position; if (elapsedTime < duration) { Timers.setTimeout(function() { animateScroll(elapsedTime); }, increment); } }; animateScroll(0); } else { container.scrollLeft = scrollLeft; } } private _easeInOut(currentTime: number, start: number, change: number, duration: number) { currentTime /= duration / 2; if (currentTime < 1) { return change / 2 * currentTime * currentTime + start; } currentTime -= 1; return -change / 2 * (currentTime * (currentTime - 2) - 1) + start; } private _onTouchStart = () => { if (!this._dragging) { this._dragging = true; if (this.props.onScrollBeginDrag) { this.props.onScrollBeginDrag(); } } } private _onTouchEnd = () => { this._dragging = false; if (this.props.onScrollEndDrag) { this.props.onScrollEndDrag(); } } } export default ScrollView;