diff --git a/packages/ui/Scroller/ScrollerBasic.js b/packages/ui/Scroller/ScrollerBasic.js index 5091edc81a..03e2ea5c89 100644 --- a/packages/ui/Scroller/ScrollerBasic.js +++ b/packages/ui/Scroller/ScrollerBasic.js @@ -1,5 +1,6 @@ import EnactPropTypes from '@enact/core/internal/prop-types'; import {platform} from '@enact/core/platform'; +import {perfNow} from '@enact/core/util'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import {Component} from 'react'; @@ -79,6 +80,8 @@ class ScrollerBasic extends Component { } } + scrollAnimationId = null; + scrollBounds = { clientWidth: 0, clientHeight: 0, @@ -118,7 +121,44 @@ class ScrollerBasic extends Component { // scrollMode 'native' scrollToPosition (left, top, behavior) { - this.props.scrollContentRef.current.scrollTo({left: this.getRtlPositionX(left), top, behavior}); + const node = this.props.scrollContentRef.current; + + if (platform.chrome && behavior === 'smooth') { + this.animateScroll(this.getRtlPositionX(left), top, node); + } else { + node.scrollTo({left: this.getRtlPositionX(left), top, behavior}); + } + } + + // scrollMode 'native' + animateScroll (left, top, node) { + const {scrollLeft, scrollTop} = node; + + if (this.scrollAnimationId) { + window.cancelAnimationFrame(this.scrollAnimationId); + this.scrollAnimationId = null; + } + + const animationDuration = 1000; + const startTime = perfNow(); + const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4); + + const animateScroll = () => { + const elapsed = Math.max(15, perfNow() - startTime); + const e = easeOutQuart(elapsed / animationDuration); + + const currX = Math.round(scrollLeft + (left - scrollLeft) * e); + const currY = Math.round(scrollTop + (top - scrollTop) * e); + + if (elapsed < animationDuration) { + node.scrollTo({top: currY, left: currX}); + this.scrollAnimationId = window.requestAnimationFrame(animateScroll); + } else { + window.cancelAnimationFrame(this.scrollAnimationId); + } + }; + + this.scrollAnimationId = window.requestAnimationFrame(animateScroll); } // scrollMode 'native' diff --git a/packages/ui/Scroller/tests/ScrollerBasic-specs.js b/packages/ui/Scroller/tests/ScrollerBasic-specs.js new file mode 100644 index 0000000000..c61559d80a --- /dev/null +++ b/packages/ui/Scroller/tests/ScrollerBasic-specs.js @@ -0,0 +1,74 @@ +import '@testing-library/jest-dom'; + +import {ScrollerBasic} from '../Scroller'; + +describe('ScrollBasic', () => { + let scrollContentRef; + + beforeEach(() => { + scrollContentRef = { + current: { + scrollLeft: 0, + scrollTop: 0, + scrollTo: jest.fn() + } + }; + }); + + test( + 'should call scrollTo on scrollToPosition', + () => { + const instance = new ScrollerBasic({scrollContentRef, direction: 'both'}); + + instance.scrollToPosition(30, 40, 'smooth'); + expect(scrollContentRef.current.scrollTo).toHaveBeenCalledWith({left: 30, top: 40, behavior: 'smooth'}); + + instance.scrollToPosition(30, 40, 'instant'); + expect(scrollContentRef.current.scrollTo).toHaveBeenCalledWith({left: 30, top: 40, behavior: 'instant'}); + } + ); + + test( + 'should call scrollTo with animated values during animateScroll', + () => { + let now = 0; + let rafCallback; + const instance = new ScrollerBasic({scrollContentRef, direction: 'both'}); + + jest.useFakeTimers(); + jest.spyOn(require('@enact/core/util'), 'perfNow').mockImplementation(() => now); + window.requestAnimationFrame = jest.fn((cb) => { + rafCallback = cb; + return 1; + }); + window.cancelAnimationFrame = jest.fn(); + + instance.animateScroll(100, 200, scrollContentRef.current); + + now = 500; + rafCallback(); + expect(scrollContentRef.current.scrollTo).toHaveBeenCalled(); + + now = 1001; + rafCallback(); + expect(window.cancelAnimationFrame).toHaveBeenCalled(); + + jest.useRealTimers(); + } + ); + + test( + 'should cancel previous animation frame if one exists', + () => { + const instance = new ScrollerBasic({scrollContentRef, direction: 'both'}); + instance.scrollAnimationId = 1; + window.cancelAnimationFrame = jest.fn(); + window.requestAnimationFrame = jest.fn(() => 2); + + instance.animateScroll(100, 200, scrollContentRef.current); + + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(instance.scrollAnimationId).toBe(2); + } + ); +});