Skip to content

Commit

Permalink
Support reverse scrolling in iOS Safari
Browse files Browse the repository at this point in the history
  • Loading branch information
inokawa committed Oct 13, 2023
1 parent eef160c commit dfa2f0a
Show file tree
Hide file tree
Showing 8 changed files with 89 additions and 23 deletions.
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.5 kB"
},
{
"name": "VList",
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;
});
17 changes: 17 additions & 0 deletions src/core/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,17 @@ export const createScroller = (
_initRoot(root) {
rootElement = root;

let touching = false;

const syncViewportToScrollPosition = () => {
store._update(ACTION_SCROLL, normalizeOffset(root[scrollToKey]));
};

const onScrollStopped = debounce(() => {
if (touching) {
onScrollStopped();
return;
}
// Check scroll position once just after scrolling stopped
syncViewportToScrollPosition();
store._update(ACTION_SCROLL_END);
Expand All @@ -154,12 +160,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
46 changes: 39 additions & 7 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
updateCacheLength,
computeRange,
} from "./cache";
import { isIOSWebKit } from "./environment";
import type { CacheSnapshot, Writeable } from "./types";
import { abs, clamp, max, min } from "./utils";

Expand Down Expand Up @@ -103,12 +104,14 @@ 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 _prevRange: ItemsRange = [0, initialItemCount];

const shouldDeferJump = isIOSWebKit();
const subscribers = new Set<[number, Subscriber]>();
const getScrollSize = (): number =>
computeTotalSize(cache as Writeable<Cache>);
Expand All @@ -118,6 +121,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 @@ -133,7 +144,7 @@ export const createVirtualStore = (
const [prevStartIndex, prevEndIndex] = _prevRange;
const [start, end] = computeRange(
cache as Writeable<Cache>,
scrollOffset,
scrollOffset + pendingJump,
prevStartIndex,
viewportSize
);
Expand All @@ -155,7 +166,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 @@ -196,6 +208,7 @@ export const createVirtualStore = (
};
},
_update(type, payload): void {
let shouldFlushPendingJump: boolean | undefined;
let shouldSync: boolean | undefined;
let mutated = 0;

Expand Down Expand Up @@ -233,8 +246,7 @@ export const createVirtualStore = (
}

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

Expand Down Expand Up @@ -277,9 +289,8 @@ export const createVirtualStore = (
true
);
const diff = isRemove ? -min(shift, distanceToEnd) : shift;
jump += diff;
applyJump(diff);
scrollOffset = clampScrollOffset(scrollOffset + diff);
jumpCount++;

mutated = UPDATE_SCROLL + UPDATE_JUMP;
} else {
Expand All @@ -296,6 +307,7 @@ export const createVirtualStore = (

if (type === ACTION_SCROLL) {
const delta = payload - scrollOffset;
const distance = abs(delta);
// Scrolling after resizing will be caused by jump compensation
const isJustResized = _resized;
_resized = false;
Expand All @@ -312,11 +324,22 @@ export const createVirtualStore = (
}
}

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.
//
// Update synchronously if scrolled a lot
shouldSync = abs(delta) > viewportSize;
shouldSync = distance > viewportSize;

mutated += UPDATE_SCROLL_WITH_EVENT;
} else {
Expand All @@ -329,6 +352,7 @@ export const createVirtualStore = (
}
case ACTION_SCROLL_END: {
if (updateScrollDirection(SCROLL_IDLE)) {
shouldFlushPendingJump = true;
mutated = UPDATE_SCROLL_DIRECTION;
}
_isManualScrolling = _isManualScrollPremeasuring = false;
Expand All @@ -342,6 +366,14 @@ export const createVirtualStore = (
}

if (mutated) {
if (shouldFlushPendingJump && pendingJump) {
_resized = true;
jump += pendingJump;
pendingJump = 0;
jumpCount++;
mutated += UPDATE_JUMP;
}

const update = () => {
subscribers.forEach(([target, cb]) => {
// Early return to skip React's computation
Expand Down
11 changes: 8 additions & 3 deletions src/react/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
ReactElement,
ReactNode,
} from "react";
import { UPDATE_SIZE, VirtualStore } from "../core/store";
import {
UPDATE_JUMP,
UPDATE_SCROLL,
UPDATE_SIZE,
VirtualStore,
} from "../core/store";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
import { useSelector } from "./useSelector";
import { ListResizer } from "../core/resizer";
Expand Down Expand Up @@ -48,13 +53,13 @@ export const ListItem = memo(
const offset = useSelector(
store,
() => store._getItemOffset(index),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const hide = useSelector(
store,
() => store._isUnmeasuredItem(index),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);

Expand Down
16 changes: 8 additions & 8 deletions src/react/VGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,37 +82,37 @@ const Cell = memo(
const top = useSelector(
vStore,
() => vStore._getItemOffset(rowIndex),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const left = useSelector(
hStore,
() => hStore._getItemOffset(colIndex),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const vHide = useSelector(
vStore,
() => vStore._isUnmeasuredItem(rowIndex),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const hHide = useSelector(
hStore,
() => hStore._isUnmeasuredItem(colIndex),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const height = useSelector(
vStore,
() => vStore._getItemSize(rowIndex),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const width = useSelector(
hStore,
() => hStore._getItemSize(colIndex),
UPDATE_SIZE,
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);

Expand Down Expand Up @@ -304,12 +304,12 @@ export const VGrid = forwardRef<VGridHandle, VGridProps>(
const [startRowIndex, endRowIndex] = useSelector(
vStore,
vStore._getRange,
UPDATE_SCROLL + UPDATE_SIZE
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP
);
const [startColIndex, endColIndex] = useSelector(
hStore,
hStore._getRange,
UPDATE_SCROLL + UPDATE_SIZE
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP
);
const vScrollDirection = useSelector(
vStore,
Expand Down
13 changes: 10 additions & 3 deletions src/react/VList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,21 @@ export const VList = forwardRef<VListHandle, VListProps>(
const [startIndex, endIndex] = useSelector(
store,
store._getRange,
UPDATE_SCROLL + UPDATE_SIZE
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP,
true
);
const scrollDirection = useSelector(
store,
store._getScrollDirection,
UPDATE_SCROLL_DIRECTION
UPDATE_SCROLL_DIRECTION,
true
);
const jumpCount = useSelector(
store,
store._getJumpCount,
UPDATE_JUMP,
true
);
const jumpCount = useSelector(store, store._getJumpCount, UPDATE_JUMP);
const scrollSize = useSelector(
store,
store._getCorrectedScrollSize,
Expand Down
2 changes: 1 addition & 1 deletion src/react/WVList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export const WVList = forwardRef<WVListHandle, WVListProps>(
const [startIndex, endIndex] = useSelector(
store,
store._getRange,
UPDATE_SCROLL + UPDATE_SIZE
UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP
);
const scrollDirection = useSelector(
store,
Expand Down

0 comments on commit dfa2f0a

Please sign in to comment.