Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support reverse scrolling in iOS Safari #165

Merged
merged 1 commit into from
Oct 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Total",
"path": "lib/index.mjs",
"import": "*",
"limit": "5.25 kB"
"limit": "5.3 kB"
},
{
"name": "VList",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ❌ | ❌ | ❌ | ❌ |
Expand Down
5 changes: 5 additions & 0 deletions src/core/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
19 changes: 19 additions & 0 deletions src/core/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
};
},
Expand Down
60 changes: 47 additions & 13 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
export type ScrollJump = number;
export type ItemResize = Readonly<[index: number, size: number]>;
type ItemsRange = Readonly<[startIndex: number, endIndex: number]>;

Expand Down Expand Up @@ -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<Cache>);
Expand All @@ -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;
Expand All @@ -129,7 +142,7 @@ export const createVirtualStore = (
_getRange() {
return (_prevRange = computeRange(
cache as Writeable<Cache>,
scrollOffset,
scrollOffset + pendingJump,
_prevRange[0],
viewportSize
));
Expand All @@ -147,7 +160,8 @@ export const createVirtualStore = (
return hasUnmeasuredItemsInRange(cache, startIndex, endIndex);
},
_getItemOffset(index) {
const offset = computeStartOffset(cache as Writeable<Cache>, index);
const offset =
computeStartOffset(cache as Writeable<Cache>, index) - pendingJump;
if (isReverse) {
return offset + max(0, viewportSize - getScrollSize());
}
Expand Down Expand Up @@ -188,6 +202,7 @@ export const createVirtualStore = (
};
},
_update(type, payload): void {
let shouldFlushPendingJump: boolean | undefined;
let shouldSync: boolean | undefined;
let mutated = 0;

Expand Down Expand Up @@ -224,8 +239,7 @@ export const createVirtualStore = (
}

if (diff) {
jump = diff;
jumpCount++;
applyJump(diff);
}
}

Expand All @@ -247,7 +261,7 @@ export const createVirtualStore = (
estimateDefaultItemSize(cache as Writeable<Cache>);
}
mutated += UPDATE_SIZE_STATE;
_resized = shouldSync = true;
_maybeJumped = shouldSync = true;
break;
}
case ACTION_VIEWPORT_RESIZE: {
Expand All @@ -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 {
Expand All @@ -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.
//
Expand All @@ -318,6 +344,7 @@ export const createVirtualStore = (
}
case ACTION_SCROLL_END: {
if (updateScrollDirection(SCROLL_IDLE)) {
shouldFlushPendingJump = true;
mutated = UPDATE_SCROLL_STATE;
}
_isManualScrolling = _isManualScrollPremeasuring = false;
Expand All @@ -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)) {
Expand Down
Loading