diff --git a/vue/src/components/RssFeeds.vue b/vue/src/components/RssFeeds.vue index db275b1..7b6d46e 100644 --- a/vue/src/components/RssFeeds.vue +++ b/vue/src/components/RssFeeds.vue @@ -34,14 +34,27 @@ function scrollToNextArticle() { } } -// Swipe-to-navigate in article view. Transient per-gesture state, not shared -// app state, so it's kept as plain component-local variables rather than in -// useFeeds() — nothing outside this component needs it. +// Swipe-to-navigate in article view. The article visually tracks the finger +// while dragging (dragOffsetX/isDragging below), then either completes the +// page-turn or springs back to center on release. This is transient +// per-gesture state, not shared app state, so it's kept local to this +// component rather than in useFeeds() — nothing outside this component needs +// it. +const dragOffsetX = ref(0) // live translateX (px) applied to the current article while dragging or springing back +const isDragging = ref(false) // true only while actively tracking touchmove — disables the CSS transition so the article follows the finger with zero lag; the spring-back and the eventual page-turn both happen after this flips back to false, so they use the (slower, already-tuned) CSS transitions instead + let touchStartX = null let touchStartY = null +let touchStartTime = null +let gestureAxis = null // 'horizontal' | 'vertical' | null (undecided) — locked in after DIRECTION_LOCK_THRESHOLD of movement, so a single gesture can't both page the article and scroll the page +let articleWidth = 0 // captured on touchstart; the live drag and the commit-distance check below are relative to it +let dragRafPending = false +let pendingDragOffset = 0 -const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe -const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe +const DIRECTION_LOCK_THRESHOLD = 10 // px of movement before an ambiguous drag commits to horizontal (swipe) or vertical (scroll) +const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn +const COMMIT_VELOCITY = 0.5 // px/ms — a flick at least this fast completes the page-turn even short of the distance ratio +const BOUNDARY_DAMPING = 0.35 // drag multiplier applied when dragging past the first/last article, so it still moves a little (feedback) but resists // Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the // article view uses — set right before the index actually changes, by both @@ -65,22 +78,74 @@ function onArticleTouchStart(event) { // — those should scroll the block, not page the article. if (event.touches.length !== 1 || event.target.closest('pre')) { touchStartX = null - touchStartY = null return } - touchStartX = event.touches[0].clientX - touchStartY = event.touches[0].clientY + const touch = event.touches[0] + touchStartX = touch.clientX + touchStartY = touch.clientY + touchStartTime = Date.now() + gestureAxis = null + const article = event.currentTarget.querySelector('.article-feature') + articleWidth = article ? article.offsetWidth : window.innerWidth +} + +function onArticleTouchMove(event) { + if (touchStartX === null || event.touches.length !== 1) return + const touch = event.touches[0] + const deltaX = touch.clientX - touchStartX + const deltaY = touch.clientY - touchStartY + + if (gestureAxis === null) { + if (Math.abs(deltaX) < DIRECTION_LOCK_THRESHOLD && Math.abs(deltaY) < DIRECTION_LOCK_THRESHOLD) return + gestureAxis = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical' + if (gestureAxis === 'horizontal') isDragging.value = true + } + if (gestureAxis !== 'horizontal') return // let the page scroll normally instead + + // Stop the page from also scrolling vertically (and the browser's own + // horizontal-overscroll gesture) while we're driving the drag ourselves. + // Requires this listener to not be passive. + event.preventDefault() + + const atBoundary = (deltaX < 0 && currentIndex.value === feeds.value.length - 1) || + (deltaX > 0 && currentIndex.value === 0) + pendingDragOffset = atBoundary ? deltaX * BOUNDARY_DAMPING : deltaX + + // rAF-throttled: touchmove can fire faster than the display refreshes. If a + // frame is already scheduled, later events in that window just update + // pendingDragOffset above — the callback always reads the latest value + // once it actually runs, rather than whatever triggered the first one. + if (dragRafPending) return + dragRafPending = true + requestAnimationFrame(() => { + dragOffsetX.value = pendingDragOffset + dragRafPending = false + }) } function onArticleTouchEnd(event) { if (touchStartX === null) return - const touch = event.changedTouches[0] - const deltaX = touch.clientX - touchStartX - const deltaY = touch.clientY - touchStartY + const wasDraggingHorizontally = gestureAxis === 'horizontal' + // Computed fresh from the release position rather than read back from + // dragOffsetX, which is rAF-throttled and so can be a frame stale. + const deltaX = event.changedTouches[0].clientX - touchStartX + const elapsed = Date.now() - touchStartTime + touchStartX = null touchStartY = null + gestureAxis = null + isDragging.value = false - if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return + if (!wasDraggingHorizontally) return + + // Date.now() only has ~1ms resolution, so a genuinely fast flick often + // measures as elapsed === 0 — treat that as "as fast as it gets" (clears + // the flick check) rather than dividing by zero into "no velocity at all". + const isFastFlick = elapsed > 0 ? Math.abs(deltaX / elapsed) > COMMIT_VELOCITY : deltaX !== 0 + const shouldComplete = Math.abs(deltaX) > articleWidth * COMMIT_DISTANCE_RATIO || isFastFlick + dragOffsetX.value = 0 // springs back to center — or, if completing, the (now off-screen-bound) article being replaced + + if (!shouldComplete) return if (deltaX < 0) { navigateToNextArticle() @@ -181,7 +246,13 @@ onMounted(async () => { >↓ -
+