From 59a47413975e2563d511aa085dd7e69d89885ced Mon Sep 17 00:00:00 2001 From: inokawa <48897392+inokawa@users.noreply.github.com> Date: Wed, 30 Aug 2023 22:01:13 +0900 Subject: [PATCH] Support reverse scrolling in iOS Safari --- .size-limit.json | 2 +- README.md | 2 +- src/core/environment.ts | 5 ++++ src/core/scroller.ts | 19 +++++++++++++ src/core/store.ts | 60 ++++++++++++++++++++++++++++++++--------- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/.size-limit.json b/.size-limit.json index 58bd4d38b..c9f492fe9 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -3,7 +3,7 @@ "name": "Total", "path": "lib/index.mjs", "import": "*", - "limit": "5.25 kB" + "limit": "5.3 kB" }, { "name": "VList", diff --git a/README.md b/README.md index 5601c57ab..0a9771968 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ It may be dispatched by ResizeObserver in this lib [as described in spec](https: | Dynamic list size | ✅ | ✅ | 🟠 (needs [AutoSizer](https://github.com/bvaughn/react-virtualized/blob/master/docs/AutoSizer.md)) | 🟠 (needs [AutoSizer](https://github.com/bvaughn/react-virtualized/blob/master/docs/AutoSizer.md)) | ✅ | ✅ | | Dynamic item size | ✅ | ✅ | 🟠 (needs additional codes and has wrong destination when scrolling to item imperatively) | 🟠 (needs [CellMeasurer](https://github.com/bvaughn/react-virtualized/blob/master/docs/CellMeasurer.md) and has wrong destination when scrolling to item imperatively) | 🟠 (has wrong destination when scrolling to item imperatively) | 🟠 (has wrong destination when scrolling to item imperatively) | | Reverse scroll | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Reverse scroll in iOS Safari | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Reverse scroll in iOS Safari | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | Infinite scroll | ✅ | ✅ | 🟠 (needs [react-window-infinite-loader](https://github.com/bvaughn/react-window-infinite-loader)) | 🟠 (needs [InfiniteLoader](https://github.com/bvaughn/react-virtualized/blob/master/docs/InfiniteLoader.md)) | ✅ | ✅ | | Reverse (bi-directional) infinite scroll | ✅ | ✅ | ❌ | ❌ | ❌ | 🟠 (has startItem method but its scroll position can be inaccurate) | | Scroll restoration | ✅ | ✅ (getState) | ❌ | ❌ | ❌ | ❌ | diff --git a/src/core/environment.ts b/src/core/environment.ts index 815379b5a..dab716a36 100644 --- a/src/core/environment.ts +++ b/src/core/environment.ts @@ -24,3 +24,8 @@ export const isRTLDocument = /*#__PURE__*/ once((): boolean => { // TODO support SSR in rtl return isBrowser ? computeStyle(document.body).direction === "rtl" : false; }); + +// Currently, all browsers on iOS/iPadOS are WebKit, including WebView. +export const isIOSWebKit = /*#__PURE__*/ once((): boolean => { + return isBrowser ? /iP(hone|od|ad)/.test(navigator.userAgent) : false; +}); diff --git a/src/core/scroller.ts b/src/core/scroller.ts index 2ee7ad339..463683038 100644 --- a/src/core/scroller.ts +++ b/src/core/scroller.ts @@ -137,11 +137,19 @@ export const createScroller = ( _initRoot(root) { rootElement = root; + let touching = false; + const syncViewportToScrollPosition = () => { store._update(ACTION_SCROLL, normalizeOffset(root[scrollToKey])); }; const onScrollStopped = debounce(() => { + if (touching) { + // Wait while touching + // TODO iOS WebKit fires touch events only once at the beginning of momentum scrolling... + onScrollStopped(); + return; + } // Check scroll position once just after scrolling stopped syncViewportToScrollPosition(); store._update(ACTION_SCROLL_END); @@ -154,12 +162,23 @@ export const createScroller = ( const onWheel = createOnWheel(store, isHorizontal, onScrollStopped); + const onTouchStart = () => { + touching = true; + }; + const onTouchEnd = () => { + touching = false; + }; + root.addEventListener("scroll", onScroll); root.addEventListener("wheel", onWheel, { passive: true }); + root.addEventListener("touchstart", onTouchStart, { passive: true }); + root.addEventListener("touchend", onTouchEnd, { passive: true }); return () => { root.removeEventListener("scroll", onScroll); root.removeEventListener("wheel", onWheel); + root.removeEventListener("touchstart", onTouchStart); + root.removeEventListener("touchend", onTouchEnd); onScrollStopped._cancel(); }; }, diff --git a/src/core/store.ts b/src/core/store.ts index dad073f16..ef3ddd2a9 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -11,10 +11,11 @@ import { updateCacheLength, computeRange, } from "./cache"; +import { isIOSWebKit } from "./environment"; import type { CacheSnapshot, Writeable } from "./types"; import { abs, clamp, max, min } from "./utils"; -export type ScrollJump = Readonly; +export type ScrollJump = number; export type ItemResize = Readonly<[index: number, size: number]>; type ItemsRange = Readonly<[startIndex: number, endIndex: number]>; @@ -100,12 +101,16 @@ export const createVirtualStore = ( let scrollOffset = 0; let jumpCount = 0; let jump: ScrollJump = 0; + let pendingJump: ScrollJump = 0; let _scrollDirection: ScrollDirection = SCROLL_IDLE; let _isManualScrollPremeasuring = false; let _isManualScrolling = false; - let _resized = false; + let _maybeJumped = false; let _prevRange: ItemsRange = [0, initialItemCount]; + // In iOS WebKit browsers, updating scroll position will stop scrolling so it have to be deferred during scrolling. + const shouldDeferJump = isIOSWebKit(); + const subscribers = new Set<[number, Subscriber]>(); const getScrollSize = (): number => computeTotalSize(cache as Writeable); @@ -115,6 +120,14 @@ export const createVirtualStore = ( // Scroll offset may exceed min or max especially in Safari's elastic scrolling. return clamp(value, 0, getScrollOffsetMax()); }; + const applyJump = (j: ScrollJump) => { + if (shouldDeferJump && _scrollDirection !== SCROLL_IDLE) { + pendingJump += j; + } else { + jump += j; + jumpCount++; + } + }; const updateScrollDirection = (dir: ScrollDirection): boolean => { const prev = _scrollDirection; _scrollDirection = dir; @@ -129,7 +142,7 @@ export const createVirtualStore = ( _getRange() { return (_prevRange = computeRange( cache as Writeable, - scrollOffset, + scrollOffset + pendingJump, _prevRange[0], viewportSize )); @@ -147,7 +160,8 @@ export const createVirtualStore = ( return hasUnmeasuredItemsInRange(cache, startIndex, endIndex); }, _getItemOffset(index) { - const offset = computeStartOffset(cache as Writeable, index); + const offset = + computeStartOffset(cache as Writeable, index) - pendingJump; if (isReverse) { return offset + max(0, viewportSize - getScrollSize()); } @@ -188,6 +202,7 @@ export const createVirtualStore = ( }; }, _update(type, payload): void { + let shouldFlushPendingJump: boolean | undefined; let shouldSync: boolean | undefined; let mutated = 0; @@ -224,8 +239,7 @@ export const createVirtualStore = ( } if (diff) { - jump = diff; - jumpCount++; + applyJump(diff); } } @@ -247,7 +261,7 @@ export const createVirtualStore = ( estimateDefaultItemSize(cache as Writeable); } mutated += UPDATE_SIZE_STATE; - _resized = shouldSync = true; + _maybeJumped = shouldSync = true; break; } case ACTION_VIEWPORT_RESIZE: { @@ -268,9 +282,10 @@ export const createVirtualStore = ( true ); const diff = isRemove ? -min(shift, distanceToEnd) : shift; - jump += diff; - scrollOffset = clampScrollOffset(scrollOffset + diff); - jumpCount++; + applyJump(diff); + if (!shouldDeferJump) { + scrollOffset = clampScrollOffset(scrollOffset + diff); + } mutated = UPDATE_SCROLL_STATE; } else { @@ -288,19 +303,30 @@ export const createVirtualStore = ( if (type === ACTION_SCROLL) { const delta = payload - scrollOffset; // Scrolling after resizing will be caused by jump compensation - const isJustResized = _resized; - _resized = false; + const isJustJumped = _maybeJumped; + _maybeJumped = false; // Skip scroll direction detection just after resizing because it may result in the opposite direction. // Scroll events are dispatched enough so it's ok to skip some of them. if ( - (_scrollDirection === SCROLL_IDLE || !isJustResized) && + (_scrollDirection === SCROLL_IDLE || !isJustJumped) && // Ignore until manual scrolling !_isManualScrolling ) { updateScrollDirection(delta < 0 ? SCROLL_UP : SCROLL_DOWN); } + if ( + pendingJump && + ((_scrollDirection === SCROLL_UP && + payload - max(pendingJump, 0) <= 0) || + (_scrollDirection === SCROLL_DOWN && + payload - min(pendingJump, 0) >= getScrollOffsetMax())) + ) { + // Flush if almost reached to start or end + shouldFlushPendingJump = true; + } + // Ignore manual scroll because it may be called in useEffect/useLayoutEffect and cause the warn below. // Warning: flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task. // @@ -318,6 +344,7 @@ export const createVirtualStore = ( } case ACTION_SCROLL_END: { if (updateScrollDirection(SCROLL_IDLE)) { + shouldFlushPendingJump = true; mutated = UPDATE_SCROLL_STATE; } _isManualScrolling = _isManualScrollPremeasuring = false; @@ -331,6 +358,13 @@ export const createVirtualStore = ( } if (mutated) { + if (shouldFlushPendingJump && pendingJump) { + _maybeJumped = true; + jump += pendingJump; + pendingJump = 0; + jumpCount++; + } + subscribers.forEach(([target, cb]) => { // Early return to skip React's computation if (!(mutated & target)) {