Skip to content

Commit

Permalink
Merge pull request #224 from inokawa/smooth-lag
Browse files Browse the repository at this point in the history
Improve start response of smooth scrolling
  • Loading branch information
inokawa authored Oct 25, 2023
2 parents f4fd676 + d8b44bd commit 4a42715
Show file tree
Hide file tree
Showing 5 changed files with 71 additions and 117 deletions.
6 changes: 4 additions & 2 deletions e2e/VList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ test.describe("check if scrollToIndex works", () => {

// Check if this is smooth scrolling
await expect(called).toBeGreaterThanOrEqual(
browserName === "webkit" ? 4 : 10
// TODO find better way to check in webkit
browserName === "webkit" ? 2 : 10
);

// Check if scrolled precisely
Expand Down Expand Up @@ -690,7 +691,8 @@ test.describe("check if scrollToIndex works", () => {

// Check if this is smooth scrolling
await expect(called).toBeGreaterThanOrEqual(
browserName === "webkit" ? 4 : 10
// TODO find better way to check in webkit
browserName === "webkit" ? 2 : 10
);

// Check if scrolled precisely
Expand Down
39 changes: 0 additions & 39 deletions src/core/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
computeOffset as computeStartOffset,
findIndex,
Cache,
// hasUnmeasuredItemsInRange,
updateCacheLength,
initCache,
computeRange,
Expand Down Expand Up @@ -571,44 +570,6 @@ describe(findIndex.name, () => {
});
});

// describe(hasUnmeasuredItemsInRange.name, () => {
// it("should return false if all measured", () => {
// const sizes = [10, 10, 10, 10];
// const cache: Cache = {
// _length: sizes.length,
// _sizes: sizes,
// _computedOffsetIndex: 0,
// _offsets: [0],
// _defaultItemSize: 30,
// };
// expect(hasUnmeasuredItemsInRange(cache, 0, sizes.length - 1)).toBe(false);
// });

// it("should return true if start is not measured", () => {
// const sizes = [10, -1, 10, 10];
// const cache: Cache = {
// _length: sizes.length,
// _sizes: sizes,
// _computedOffsetIndex: 0,
// _offsets: [0],
// _defaultItemSize: 30,
// };
// expect(hasUnmeasuredItemsInRange(cache, 1, 2)).toBe(true);
// });

// it("should return true if end is not measured", () => {
// const sizes = [10, 10, -1, 10];
// const cache: Cache = {
// _length: sizes.length,
// _sizes: sizes,
// _computedOffsetIndex: 0,
// _offsets: [0],
// _defaultItemSize: 30,
// };
// expect(hasUnmeasuredItemsInRange(cache, 1, 2)).toBe(true);
// });
// });

describe(initCache.name, () => {
it("should create cache", () => {
expect(initCache(10, 23)).toMatchInlineSnapshot(`
Expand Down
10 changes: 0 additions & 10 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,6 @@ export const computeRange = (
return [start, findIndex(cache, scrollOffset + viewportSize, start)];
};

// export const hasUnmeasuredItemsInRange = (
// cache: Cache,
// startIndex: number,
// endIndex: number
// ): boolean => {
// return cache._sizes
// .slice(max(0, startIndex - 1), min(cache._length - 1, endIndex + 1) + 1)
// .includes(UNCACHED);
// };

export const estimateDefaultItemSize = (cache: Writeable<Cache>) => {
const measuredSizes = cache._sizes.filter((s) => s !== UNCACHED);
// This function will be called after measurement so measured size array must be longer than 0
Expand Down
98 changes: 45 additions & 53 deletions src/core/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,70 +95,62 @@ export const createScroller = (
return clamp(getOffset(), 0, store._getScrollOffsetMax());
};

if (smooth && isSmoothScrollSupported()) {
let queue: [() => void, () => void] | undefined;
const waitForMeasurement = (): [Promise<void>, () => void] => {
// Wait for the scroll destination items to be measured.
// The measurement will be done asynchronously and the timing is not predictable so we use promise.
// For example, ResizeObserver may not fire when window is not visible.
let queue: (() => void) | undefined;
return [
new Promise<void>((resolve, reject) => {
queue = resolve;
// Reject when items around scroll destination completely measured
timeout((cancelScroll = reject), 150);
}),
store._subscribe(UPDATE_SIZE_STATE, () => {
queue && queue();
}),
];
};

const measure = () => {
if (smooth && isSmoothScrollSupported()) {
while (true) {
store._update(ACTION_BEFORE_MANUAL_SMOOTH_SCROLL, getTargetOffset());
};
measure();

const detectStop = debounce(() => {
queue && queue[0]();
detectStop._cancel();
}, 100);
const unsubscribe = store._subscribe(UPDATE_SIZE_STATE, () => {
measure();
detectStop();
});

try {
await new Promise<void>((resolve, reject) => {
queue = [resolve, (cancelScroll = reject)];
// maybe already measured
detectStop();
});
} catch (e) {
// canceled
return;
} finally {
unsubscribe();
detectStop._cancel();
if (!store._hasUnmeasuredItemsInSmoothScrollRange()) {
break;
}

const [promise, unsubscribe] = waitForMeasurement();

try {
await promise;
} catch (e) {
// canceled
return;
} finally {
unsubscribe();
}
}

rootElement.scrollTo({
[isHorizontal ? "left" : "top"]: normalizeOffset(getTargetOffset()),
behavior: "smooth",
});
} else {
while (true) {
const [promise, unsubscribe] = waitForMeasurement();

return;
}

// TODO simplify
while (true) {
let queue: [() => void, () => void] | undefined;
const unsubscribe = store._subscribe(UPDATE_SIZE_STATE, () => {
queue && queue[0]();
});

try {
rootElement[scrollToKey] = normalizeOffset(getTargetOffset());
store._update(ACTION_MANUAL_SCROLL);
try {
rootElement[scrollToKey] = normalizeOffset(getTargetOffset());
store._update(ACTION_MANUAL_SCROLL);

// Wait for the scroll destination items to be measured.
// The measurement will be done asynchronously and the timing is not predictable so we use promise.
// For example, ResizeObserver may not fire when window is not visible.
await new Promise<void>((resolve, reject) => {
queue = [resolve, (cancelScroll = reject)];

// Reject when items around scroll destination completely measured
timeout(reject, 150);
});
} catch (e) {
// canceled or finished
return;
} finally {
unsubscribe();
await promise;
} catch (e) {
// canceled or finished
return;
} finally {
unsubscribe();
}
}
}
};
Expand Down
35 changes: 22 additions & 13 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "./cache";
import { isIOSWebKit } from "./environment";
import type { CacheSnapshot, Writeable } from "./types";
import { abs, clamp, exists, max, min } from "./utils";
import { abs, clamp, max, min } from "./utils";

export type ScrollJump = number;
type ViewportResize = [size: number, paddingStart: number, paddingEnd: number];
Expand Down Expand Up @@ -74,6 +74,7 @@ export type VirtualStore = {
_getCache(): CacheSnapshot;
_getRange(): ItemsRange;
_isUnmeasuredItem(index: number): boolean;
_hasUnmeasuredItemsInSmoothScrollRange(): boolean;
_getItemOffset(index: number): number;
_getItemSize(index: number): number;
_getItemsLength(): number;
Expand Down Expand Up @@ -106,7 +107,7 @@ export const createVirtualStore = (
let pendingJump: ScrollJump = 0;
let _scrollDirection: ScrollDirection = SCROLL_IDLE;
let _isManualScrolling = false;
let _smoothScrollTarget: number | null = null;
let _smoothScrollRange: ItemsRange | null = null;
let _maybeJumped = false;
let _prevRange: ItemsRange = [0, initialItemCount];

Expand Down Expand Up @@ -140,16 +141,10 @@ export const createVirtualStore = (
return JSON.parse(JSON.stringify(cache)) as unknown as CacheSnapshot;
},
_getRange() {
if (exists(_smoothScrollTarget)) {
const [targetStartIndex, targetEndIndex] = computeRange(
cache as Writeable<Cache>,
_smoothScrollTarget,
_prevRange[0],
viewportSize
);
if (_smoothScrollRange) {
return [
min(_prevRange[0], targetStartIndex),
max(_prevRange[1], targetEndIndex),
min(_prevRange[0], _smoothScrollRange[0]),
max(_prevRange[1], _smoothScrollRange[1]),
];
}
return (_prevRange = computeRange(
Expand All @@ -162,6 +157,15 @@ export const createVirtualStore = (
_isUnmeasuredItem(index) {
return cache._sizes[index] === UNCACHED;
},
_hasUnmeasuredItemsInSmoothScrollRange() {
if (!_smoothScrollRange) return false;
return cache._sizes
.slice(
max(0, _smoothScrollRange[0] - 1),
min(cache._length - 1, _smoothScrollRange[1] + 1) + 1
)
.includes(UNCACHED);
},
_getItemOffset(index) {
const offset =
computeStartOffset(cache as Writeable<Cache>, index) - pendingJump;
Expand Down Expand Up @@ -348,15 +352,20 @@ export const createVirtualStore = (
mutated = UPDATE_SCROLL_STATE;
}
_isManualScrolling = false;
_smoothScrollTarget = null;
_smoothScrollRange = null;
break;
}
case ACTION_MANUAL_SCROLL: {
_isManualScrolling = true;
break;
}
case ACTION_BEFORE_MANUAL_SMOOTH_SCROLL: {
_smoothScrollTarget = payload;
_smoothScrollRange = computeRange(
cache as Writeable<Cache>,
payload,
_prevRange[0],
viewportSize
);
mutated = UPDATE_SCROLL_STATE;
break;
}
Expand Down

0 comments on commit 4a42715

Please sign in to comment.