Replace threshold-based swipe with live drag-follow in article view

The article now visually tracks the finger in real time while swiping
(via a rAF-throttled touchmove handler), instead of only reacting
after the fact on touchend. On release it completes the page-turn if
dragged past ~35% of the article's width, or on a fast-enough flick
even short of that distance; otherwise it springs back to center.
Dragging past the first/last article is damped rather than free, for
tactile feedback that there's nothing further that way. A vertical
drag is detected within the first ~10px of movement and left alone so
it can't fight normal page scrolling.

Also fixes a real bug found while testing the flick path: Date.now()
has ~1ms resolution, so a genuinely fast flick often measures as
elapsed === 0. The velocity calculation was treating that as "zero
velocity" (dividing 0 elapsed into "no flick") instead of "as fast as
it gets" — now a zero-elapsed gesture with real movement counts as a
flick outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:03:41 +02:00
co-authored by Claude Sonnet 5
parent 77438d0532
commit bfb926cec6
2 changed files with 343 additions and 173 deletions
+101 -14
View File
@@ -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 () => {
>&darr;</button>
</div>
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
<div
v-else
class="article-single"
@touchstart.passive="onArticleTouchStart"
@touchmove="onArticleTouchMove"
@touchend.passive="onArticleTouchEnd"
>
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
@@ -191,7 +262,12 @@ onMounted(async () => {
</div>
<template v-else>
<Transition :name="`slide-${slideDirection}`">
<article class="article-feature" :key="currentIndex">
<article
class="article-feature"
:class="{ 'article-feature--dragging': isDragging }"
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null"
:key="currentIndex"
>
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
@@ -372,6 +448,17 @@ onMounted(async () => {
.article-feature {
width: 100%;
max-width: 720px;
/* Governs the drag-release spring-back (dragOffsetX snapping to 0 after a
cancelled swipe). Overridden by .article-feature--dragging (below, so it
wins the tie) while actively tracking a touchmove, and by the
.slide-next/.slide-prev *-active rules (further below, so they win too)
during an actual page-turn — both need their own transform transition
timing instead. */
transition: transform 0.2s ease;
}
.article-feature--dragging {
transition: none;
}
/* Slide-in/out when paging between articles (swipe or nav buttons). The
+117 -34
View File
@@ -360,105 +360,191 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Article three'])
})
it('swipes left to navigate to the next article', async () => {
describe('swipe navigation in article view', () => {
// The drag-follow handler is rAF-throttled (dragOffsetX only updates once
// per frame) and reads the article's real offsetWidth for the
// commit-distance check — stub both so gestures are deterministic. Per
// the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd
// also fake rAF and clobber this manual stub. (Faking Date, even scoped,
// was tried for the release-velocity check below and broke test-utils'
// event dispatch outright — real small setTimeout delays instead.)
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400)
})
afterEach(() => {
// Not vi.unstubAllGlobals(): it would also remove the file-level
// IntersectionObserver stub (stubGlobal isn't describe-scoped), and
// every test in here re-stubs requestAnimationFrame in beforeEach
// anyway, so there's nothing to reset it back to for.
vi.restoreAllMocks()
})
// Drives one full touchstart -> touchmove -> touchend gesture. delayMs is
// a genuine wall-clock wait before releasing — it's how tests control the
// release velocity (deltaX / elapsed) the component computes, alongside
// the distance dragged.
async function swipe(wrapper, { startX, startY = 100, endX, endY = 100, delayMs = 0 }) {
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: startX, clientY: startY }] })
await article.trigger('touchmove', { touches: [{ clientX: endX, clientY: endY }] })
if (delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
await article.trigger('touchend', { changedTouches: [{ clientX: endX, clientY: endY }] })
await flushPromises()
}
it('drags the article to follow the finger while swiping', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
const dragged = wrapper.find('.article-feature')
expect(dragged.classes()).toContain('article-feature--dragging')
expect(dragged.attributes('style')).toContain('translateX(-50px)')
wrapper.unmount()
})
it('completes the page-turn on a swipe past the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px.
await swipe(wrapper, { startX: 300, endX: 80 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('swipes right to navigate to the previous article', async () => {
it('completes the page-turn backwards on a swipe past the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
await flushPromises()
await swipe(wrapper, { startX: 80, endX: 300 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
it('springs back without navigating on a short, slow drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
// Only 20px of a 400px-wide article (well under the 140px distance
// threshold), released slowly enough that velocity doesn't qualify
// either (well under the 0.5px/ms flick threshold).
await swipe(wrapper, { startX: 200, endX: 180, delayMs: 150 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
wrapper.unmount()
})
it('completes the page-turn on a fast flick even under the distance threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
// Only 60px (well under the 140px distance threshold), released with
// no extra delay. Date.now() only has ~1ms resolution, so a gesture
// this fast typically measures as 0ms elapsed — the component treats
// that as clearing the flick check outright (see the comment by
// isFastFlick in onArticleTouchEnd) rather than as "no velocity".
await swipe(wrapper, { startX: 200, endX: 140 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('does not drag or navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await swipe(wrapper, { startX: 200, startY: 100, endX: 205, endY: 250 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a mostly-vertical drag', async () => {
it('does not navigate when the vertical component dominates a diagonal drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
await flushPromises()
// More horizontal travel (70px) than in the previous test, but the
// vertical component (100px) is still larger — locks as a vertical
// scroll, not a swipe, regardless of the sizeable deltaX.
await swipe(wrapper, { startX: 200, startY: 100, endX: 130, endY: 200 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
it('applies resistance when dragging past the last article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click') // now on the last article
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
await flushPromises()
await article.trigger('touchstart', { touches: [{ clientX: 300, clientY: 100 }] })
await article.trigger('touchmove', { touches: [{ clientX: 200, clientY: 100 }] }) // still swiping towards "next"
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
// Damped to 35% of the raw -100px drag, not the full amount.
expect(wrapper.find('.article-feature').attributes('style')).toContain('translateX(-35px)')
wrapper.unmount()
})
@@ -469,7 +555,6 @@ describe('RssFeeds', () => {
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
@@ -485,7 +570,7 @@ describe('RssFeeds', () => {
wrapper.unmount()
})
it('does not navigate when a swipe starts inside a code block', async () => {
it('does not drag or navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
@@ -503,15 +588,16 @@ describe('RssFeeds', () => {
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchmove', { touches: [{ clientX: 130, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
wrapper.unmount()
})
@@ -521,17 +607,14 @@ describe('RssFeeds', () => {
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
await swipe(wrapper, { startX: 200, endX: 80 })
expect(wrapper.text()).toContain('All caught up')
wrapper.unmount()
})
})
})