Let a swipe count while the viewport is still moving
A viewport change opens a window in which the feed holds the reader's video in place, because the scroll-snap container re-anchors on positions measured against the height from before it and must not be allowed to flip the video. The window answered every scroll it saw with a realign. On a phone that is the worst moment to do it: the chrome sliding out of the way is caused by the swipe, so the window stood open at exactly the moment the reader was swiping. The reel moved under the finger, far enough to show the top of the next video at the bottom of the screen, and was then pulled back to where it started -- a second later, once the settle window ran out. A re-anchor has nothing behind it and a swipe has a finger or a wheel, so record the input and let a scroll that follows one close the window instead of being reverted into it. Closing it that way skips the realign, or the close would undo the swipe that caused it. Only input that means to scroll counts -- a finger that has moved rather than one that has merely landed, since a tap must not sign for the re-anchor after it. Telling a turn apart from the chrome moving was reading the orientation flip alone, which a few pixels of height cross whenever the viewport is near enough to square. That pinned the slides and turned snapping off for a second over a height change, which is the one thing it is there to tell a turn apart from; a turn swaps the long side, and swapping it moves the width too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -44,10 +44,21 @@ App.feed = App.feed || {};
|
||||
// scroll events with positions that no longer map to the active slide;
|
||||
// onResize sets this for the brief realign window so those events don't
|
||||
// flip the active video -- rotating the device must never change which
|
||||
// slide is playing. Normal swipes (no resize in flight) are unaffected.
|
||||
// slide is playing.
|
||||
//
|
||||
// What it must not ignore is the reader. A re-snap has nothing behind it;
|
||||
// a swipe has a finger or a wheel. From the scroll event alone the two are
|
||||
// identical, so the input is recorded and a scroll that follows one closes
|
||||
// the window instead of being reverted into it. Without that, a swipe on a
|
||||
// phone was routinely thrown away: the chrome sliding out of the way *is*
|
||||
// the scroll, so the window was open at exactly the moment it did most
|
||||
// harm -- the reel moved under the finger, showing the next video coming
|
||||
// in, and was then pulled back to where it started.
|
||||
let suppressScroll = false;
|
||||
let resizeSettleRaf = null;
|
||||
let resizeSettleTimer = null;
|
||||
const INPUT_GRACE_MS = 700;
|
||||
let lastInputAt = 0;
|
||||
// A rotation is not one event. The viewport, the browser's own chrome and
|
||||
// the scroll-snap container settle over many frames and often several
|
||||
// `resize` events, so the realign window is held open until the slide
|
||||
@@ -1319,8 +1330,14 @@ App.feed = App.feed || {};
|
||||
// in the event, is what keeps that position from being painted --
|
||||
// waiting for the next frame means showing it first.
|
||||
if (suppressScroll) {
|
||||
realignToActive();
|
||||
return;
|
||||
// Unless it is the reader scrolling. A swipe closes the window
|
||||
// instead of being reverted into it -- and closes it without a
|
||||
// realign, or the close would undo the swipe that caused it.
|
||||
if (Date.now() - lastInputAt >= INPUT_GRACE_MS) {
|
||||
realignToActive();
|
||||
return;
|
||||
}
|
||||
closeSettle(false);
|
||||
}
|
||||
if (scrollRaf) return;
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
@@ -1358,6 +1375,12 @@ App.feed = App.feed || {};
|
||||
const isTurn = function(before, now) {
|
||||
if (Date.now() - turnSignalAt < TURN_SIGNAL_MS) return true;
|
||||
if (!before) return false;
|
||||
// Swapping the long side moves both of them. Reading the flip on its
|
||||
// own calls a few pixels of height a rotation whenever the viewport is
|
||||
// near enough to square for them to cross it -- and then pins the
|
||||
// slides and turns snapping off for a second over the chrome sliding
|
||||
// away, which is the one thing this is here to tell a turn apart from.
|
||||
if (before.w === now.w) return false;
|
||||
return (before.w > before.h) !== (now.w > now.h);
|
||||
};
|
||||
|
||||
@@ -1420,8 +1443,11 @@ App.feed = App.feed || {};
|
||||
}
|
||||
};
|
||||
|
||||
// Ends the realign window: one last realign, then real swipes count again.
|
||||
const finishSettle = function() {
|
||||
// Ends the realign window, and `realign` says whether to re-anchor on the
|
||||
// way out. A window that closes on its own does -- holding the reader's
|
||||
// video in place is what it was opened for. One cut short by a swipe must
|
||||
// not, or closing it would undo the very swipe that closed it.
|
||||
const closeSettle = function(realign) {
|
||||
if (resizeSettleRaf) {
|
||||
cancelAnimationFrame(resizeSettleRaf);
|
||||
resizeSettleRaf = null;
|
||||
@@ -1431,7 +1457,7 @@ App.feed = App.feed || {};
|
||||
resizeSettleTimer = null;
|
||||
}
|
||||
releaseTurn();
|
||||
realignToActive();
|
||||
if (realign) realignToActive();
|
||||
suppressScroll = false;
|
||||
lastShape = viewportShape();
|
||||
const after = settleDone;
|
||||
@@ -1439,6 +1465,11 @@ App.feed = App.feed || {};
|
||||
if (after) after();
|
||||
};
|
||||
|
||||
// Ends the realign window: one last realign, then real swipes count again.
|
||||
const finishSettle = function() {
|
||||
closeSettle(true);
|
||||
};
|
||||
|
||||
// Watches the viewport settle, frame by frame, holding the active video in
|
||||
// place the whole way.
|
||||
const settleStep = function() {
|
||||
@@ -1940,6 +1971,19 @@ App.feed = App.feed || {};
|
||||
|
||||
if (!scrollBound) {
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||
// How the realign window tells a swipe from the container
|
||||
// re-anchoring itself (see suppressScroll): a scroll with one of
|
||||
// these behind it is the reader's, and outranks the window.
|
||||
// Only input that means to scroll counts -- a finger that has
|
||||
// moved, not one that has merely landed, since a tap must not
|
||||
// sign for the re-anchor that follows it.
|
||||
const noteInput = () => { lastInputAt = Date.now(); };
|
||||
['wheel', 'touchmove'].forEach((type) => {
|
||||
scroller.addEventListener(type, noteInput, { passive: true });
|
||||
});
|
||||
// Arrow keys and Page Up/Down scroll the container too, and the
|
||||
// keystroke lands on whatever has focus rather than the scroller.
|
||||
window.addEventListener('keydown', noteInput, { passive: true });
|
||||
window.addEventListener('resize', onResize);
|
||||
// `orientationchange` fires before the new metrics are readable and
|
||||
// the visual viewport moves on its own as the browser's chrome
|
||||
|
||||
Reference in New Issue
Block a user