diff --git a/CHANGELOG.md b/CHANGELOG.md index 49848a383a..2462f0ac47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ The following is a curated list of changes in the Enact project, newest changes ## [unreleased] +### Changed + +- `ui/Scroller` scroll animation method for `scrollMode: native` ### Fixed - `spotlight` navigation from an element to a different container on page load. diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index fbfab4ac67..2c2791b901 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -2,6 +2,11 @@ The following is a curated list of changes in the Enact ui module, newest changes on the top. +## [unreleased] + +### Changed + +- `ui/Scroller` scroll animation method for `scrollMode: native` ## [5.3.1] - 2025-10-14 ### Fixed diff --git a/packages/ui/Scroller/ScrollerBasic.js b/packages/ui/Scroller/ScrollerBasic.js index 5091edc81a..58475ed266 100644 --- a/packages/ui/Scroller/ScrollerBasic.js +++ b/packages/ui/Scroller/ScrollerBasic.js @@ -79,6 +79,8 @@ class ScrollerBasic extends Component { } } + scrollAnimationId = null; + scrollBounds = { clientWidth: 0, clientHeight: 0, @@ -117,8 +119,49 @@ class ScrollerBasic extends Component { } // scrollMode 'native' - scrollToPosition (left, top, behavior) { - this.props.scrollContentRef.current.scrollTo({left: this.getRtlPositionX(left), top, behavior}); + scrollToPosition (left, top, behavior, repeat) { + const node = this.props.scrollContentRef.current; + const smoothBehavior = behavior === 'smooth'; + + if (platform.chrome && smoothBehavior && repeat) { + this.animateScroll(this.getRtlPositionX(left), top, node); + } else { + node.scrollTo({left: this.getRtlPositionX(left), top, behavior}); + } + } + + /** + * Programmatically animates the native scroll position of `node` toward the target `left`/`top` + * offsets using `requestAnimationFrame`. It computes the scroll direction on each axis, then repeatedly + * calls `scrollBy` in small 18px steps (instant behavior) until the target is reached or a scroll bound + * is hit. Used as a Chrome fallback when repeating a smooth scroll. + */ + animateScroll (left, top, node) { + const directionX = Math.sign(left - node.scrollLeft); + const directionY = Math.sign(top - node.scrollTop); + + const animateScroll = () => { + const scrollLeft = directionX > 0 ? node.scrollLeft < left : node.scrollLeft > left; + const scrollTop = directionY > 0 ? node.scrollTop < top : node.scrollTop > top; + + // Check if we reached the scroll bounds and cancel the animation + if ( + top > this.scrollBounds.maxTop && node.scrollTop === this.scrollBounds.maxTop || + left > this.scrollBounds.maxLeft && node.scrollLeft === this.scrollBounds.maxLeft || + top < 0 && node.scrollTop === 0 || + left < 0 && node.scrollLeft === 0 + ) { + window.cancelAnimationFrame(this.scrollAnimationId); + return; + } + + if (scrollTop || scrollLeft) { + node.scrollBy({top: directionY * 8, left: directionX * 8, behavior: 'instant'}); + this.scrollAnimationId = window.requestAnimationFrame(animateScroll); + } + }; + + 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..f850df3463 --- /dev/null +++ b/packages/ui/Scroller/tests/ScrollerBasic-specs.js @@ -0,0 +1,61 @@ +import '@testing-library/jest-dom'; + +import {ScrollerBasic} from '../Scroller'; + +describe('ScrollBasic', () => { + let scrollContentRef; + + beforeEach(() => { + jest.createMockFromModule('@enact/core/platform'); + + scrollContentRef = { + current: { + scrollLeft: 0, + scrollTop: 0, + scrollBy: jest.fn(), + scrollTo: jest.fn() + } + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test( + 'should call scrollBy on scrollToPosition', + () => { + const instance = new ScrollerBasic({scrollContentRef, direction: 'both'}); + + instance.scrollToPosition(100, 200, 'smooth'); + expect(scrollContentRef.current.scrollTo).toHaveBeenCalledWith({left: 100, top: 200, behavior: 'smooth'}); + + instance.scrollToPosition(100, 200, 'instant'); + expect(scrollContentRef.current.scrollTo).toHaveBeenCalledWith({left: 100, top: 200, behavior: 'instant'}); + } + ); + + test( + 'should call scrollBy with animated values during animateScroll', + () => { + let rafCallback; + const instance = new ScrollerBasic({scrollContentRef, direction: 'both'}); + instance.scrollBounds.maxTop = 500; + instance.scrollBounds.maxLeft = 500; + + window.requestAnimationFrame = jest.fn((cb) => { + rafCallback = cb; + }); + window.cancelAnimationFrame = jest.fn(); + + instance.animateScroll(100, 200, scrollContentRef.current); + rafCallback(); + expect(scrollContentRef.current.scrollBy).toHaveBeenCalled(); + + scrollContentRef.current.scrollTop = 500; + instance.animateScroll(550, 550, scrollContentRef.current); + rafCallback(); + expect(window.cancelAnimationFrame).toHaveBeenCalled(); + } + ); +}); diff --git a/packages/ui/useScroll/useScroll.js b/packages/ui/useScroll/useScroll.js index 5325aa9196..49aa6bda43 100644 --- a/packages/ui/useScroll/useScroll.js +++ b/packages/ui/useScroll/useScroll.js @@ -838,6 +838,8 @@ const useScrollBase = (props) => { } } else { props.preventScroll?.(ev); + mutableRef.current.repeat = ev.repeat; + if (ev.repeat && mutableRef.current.lastInputType === 'arrowKey') return; forward('onKeyDown', ev, props); } } @@ -1174,7 +1176,7 @@ const useScrollBase = (props) => { let {roundedTargetX, roundedTargetY} = roundTarget(scrollContentHandle.current, targetX, targetY); if (animate) { - scrollContentHandle.current.scrollToPosition(roundedTargetX, roundedTargetY, 'smooth'); + scrollContentHandle.current.scrollToPosition(roundedTargetX, roundedTargetY, 'smooth', mutableRef.current.repeat); } else { scrollContentHandle.current.scrollToPosition(roundedTargetX, roundedTargetY, 'instant'); }