vue-virtual-scroller icon indicating copy to clipboard operation
vue-virtual-scroller copied to clipboard

Implement scroll snaping

Open patchthecode opened this issue 1 year ago • 3 comments

Clear and concise description of the problem

When we scroll there is no snapping with this library. This lets the list scroll until decelerated, but many times for visual purposes, it would be nice that it decelerates with the object in the center of the screen.

Suggested solution

Im not sure how to implement this with this library, but with regular html and css, its as simple as giving the parent div the

scoll-snap-type: x;

and giving the child div

scroll-snap-align: center; // or the other options

Alternative

No response

Additional context

related issue. but dead.. no replies or comments for years... https://github.com/Akryum/vue-virtual-scroller/issues/543

Validations

  • [X] Read the docs.
  • [X] Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.

patchthecode avatar Apr 26 '23 01:04 patchthecode

Here's my workaround based on a bit of testing and tweaking - you might have to adjust it to your needs. It works great for my purposes, even feels a bit "bouncy".

<template>
	<recycle-scroller :items="items" key-field="i" size-field="scroll_height" :buffer="0" :emit-update="true" @update="scroller_updated" ref="commits_scroller_ref" tabindex="-1"/>
</template>
<script>
// ...
let scroll_item_offset = 0
let debouncer = 0
let ignore_next_scroll_event = false
const commits_scroller_updated = (start_index, end_index) => {
	if(ignore_next_scroll_event) {
		ignore_next_scroll_event = false
		return
	}
	window.clearTimeout(debouncer)
	debouncer = window.setTimeout(() => {
		// +1 because vue-virtual-scroller's start index is always one too far up it seems, but
		// check for !=0 because when we're at the very top, we want to stay there.
		// And once we have scrolled, we need to go to at least index 3 because
		// `scroller.scrollToItem(2)` actually results in the scroller to jump to 0 again.
		// Not sure if these tweaks (+1, !=0 and 3) are row height or container dependent, so
		// if it doesn't work for you, you'll have to tweak this line.
		const new_scroll_item_offset = start_index > 0 ? Math.max(3, start_index + 1) : 0
		// To avoid jumping back and forth while keeping the scroll bar pressed down with mouse:
		if(new_scroll_item_offset === scroll_item_offset)
			return
		scroll_item_offset = new_scroll_item_offset
		commits_scroller_ref.value?.scrollToItem(scroll_item_offset)
		// To avoid endless loops:
		ignore_next_scroll_event = true
	}, 70)
}

phil294 avatar Apr 26 '23 18:04 phil294

Here's my solution, only tested in horizontal mode

export default function scrollSnap () {
  let element: HTMLElement
  const start = {
    scrollY: 0,
    scrollX: 0,
    touchX: 0,
    touchY: 0,
    time: 0,
  }
  let scrollingDirection: null | 'vertical' | 'horizontal' = null

  function onTouchStart (event: TouchEvent) {
    start.touchX = event.touches[0].clientX
    start.touchY = event.touches[0].clientY
    start.scrollX = element.scrollLeft
    start.scrollY = element.scrollTop
    start.time = Date.now()
    scrollingDirection = null
  }

  function onTouchMove (event: TouchEvent) {
    const touchY = event.touches[0].pageY
    const touchX = event.touches[0].pageX
    const distanceY = start.touchY - touchY
    const distanceX = start.touchX - touchX

    if (scrollingDirection === null) {
      scrollingDirection = Math.abs(distanceX) > Math.abs(distanceY) ? 'horizontal' : 'vertical'
    }

    if (Math.abs(distanceX) > Math.abs(distanceY)) {
      event.preventDefault()
    }
    if (scrollingDirection === 'horizontal') {
      event.preventDefault()
      element.scrollLeft = start.scrollX + distanceX
    } else if (scrollingDirection === 'vertical') {
      // event.preventDefault()
      element.scrollTop = start.scrollY + distanceY
    }
  }

  function onTouchEnd (event: TouchEvent) {
    const elapsedTime = Date.now() - start.time

    // Vertical velocity calculation
    const distanceY = element.scrollTop - start.scrollY
    const velocityY = distanceY / elapsedTime

    // Multiplier to control the effect of the swipe velocity on scrolling
    const multiplier = 150

    // Predicted end position for vertical scroll
    const predictedEndY = element.scrollTop + (velocityY * multiplier)

    // Horizontal snap logic
    const distanceX = element.scrollLeft - start.scrollX
    const velocityX = distanceX / elapsedTime
    const thresholdVelocity = 0.5

    let targetPositionX
    if (Math.abs(velocityX) > thresholdVelocity) {
      targetPositionX = velocityX > 0 ? Math.ceil(element.scrollLeft / element.clientWidth) : Math.floor(element.scrollLeft / element.clientWidth)
    } else {
      targetPositionX = Math.round(element.scrollLeft / element.clientWidth)
    }

    element.scrollTo({
      top: predictedEndY,
      left: targetPositionX * element.clientWidth,
      behavior: 'smooth',
    })
    scrollingDirection = null
  }

  function bind (el: HTMLElement) {
    element = el
    element.addEventListener('touchstart', onTouchStart)
    element.addEventListener('touchmove', onTouchMove)
    element.addEventListener('touchend', onTouchEnd)
  }

  function unbind (element: HTMLElement) {
    element.removeEventListener('touchstart', onTouchStart)
    element.removeEventListener('touchmove', onTouchMove)
    element.removeEventListener('touchend', onTouchEnd)
  }

  return {
    bind,
    unbind,
  }
}

bitbytebit1 avatar Aug 30 '23 12:08 bitbytebit1

Excuse me, have you found a good solution so far?

I may need this feature at the moment, but the virtual window has caused a benchmark change in CSS, making it difficult to implement.

I tried the methods of the two above, but they did not achieve the expected results.

srackhall avatar Mar 06 '24 05:03 srackhall