1

I implement calendar which can be veristically dragged by a mouse. I would like to have some inertia on mouse release. I was able to achieve this functionality by using use-gesture + react-spring libs.

This is how it looks

enter image description here

The problem I would like to solve is how to force the inertia to stop at the nearest row? Currently it can be stopped at the middle of the row.

Here is my code. I removed some parts for simplicity

[...]
const [animatedOffsetY, animatedOffsetYProps] = useSpring(() => ({
  offsetY: 0,
  config: {
    decay: true
    frequency: 5.5,
  }
}))

[...]

const bind = useDrag((state) => {
  const [, currentY] = state.movement
  const [, lastOffsetY] = state.lastOffset
  const offsetY = lastOffsetY + currentY

  if (state.down) {
    setOffsetY(offsetY)
    animatedOffsetYProps.set({ offsetY: offsetY })
  } else {
    animatedOffsetYProps.start({
      offsetY: offsetY,
      config: {
        velocity: state.direction[1] * state.velocity[1],
      }
    })
  }
}, {
  from: [0, offsetY],
  bounds: { bottom: 0, top: (totalHeight - containerHeight) * -1 },
  rubberband: true
})

[...]

Update

I investigated, popmotion and framer-motion (which use popmotion under the hood) and they have modifyTarget prop which should solve my problem. I would prefer to stay with react-spring or custom solution.

Roman Mahotskyi
  • 4,576
  • 5
  • 35
  • 68
  • Do you have a defined size for your rows and columns? Maybe it's a good idea to share your JSX code too.. – Sanish Joseph Sep 06 '21 at 07:00
  • Yes, I have all parameters like height of the row and total height of the scrollable area. I’m not sure what JSX code to share… The most important part of the code was shown above. What kind of details you would like to know? – Roman Mahotskyi Sep 06 '21 at 08:17

2 Answers2

1

For some reason using decay in the spring config is giving me lots of trouble. I've got it working just fine with a standard friction/tension based spring.


You want to animate to a position that is at the exact top of the nearest row when ending a drag. While dragging, ie. if (state.down), you want to animate to the exact coordinates. When the mouse is released, ie. else, you want to compute the desired resting position and animate to that.

I'm calling this value snapOffsetY, and you can compute it by rounding how many boxes you've scrolled:

const snapIndex = Math.round(offsetY / boxHeight);
const snapOffsetY = snapIndex * boxHeight;

Instead of calling setOffsetY on every movement, I'm using a component state to store the last offset which was snapped to. So I'm only setting it on drag release.

We can then use this lastOffsetY as part of the bounds calculation. Initially, bottom is 0 meaning that we can't drag above the top. But you need to be able to scroll back up once you've scrolled down the list. So the bounds need to change based on the last stopping position.

bounds: {
  bottom: 0 - lastOffsetY,
  top: -1 * (totalHeight - containerHeight) - lastOffsetY
},

My code looks like this:

import React, { useState } from "react";
import { animated, config, useSpring } from "@react-spring/web";
import { useDrag } from "react-use-gesture";
import { range } from "lodash";
import "./styles.css";

export default function SnapGrid({
  boxHeight = 75,
  rows = 10,
  containerHeight = 300
}) {
  const totalHeight = rows * boxHeight;

  const [lastOffsetY, setLastOffsetY] = useState(0);

  const [animation, springApi] = useSpring(
    () => ({
      offsetY: 0,
      config: config.default
    }),
    []
  );

  const bind = useDrag(
    (state) => {
      const [, currentY] = state.movement;
      const offsetY = lastOffsetY + currentY;

      // while animating
      if (state.down) {
        springApi.set({ offsetY: offsetY });
      }
      // when stopping
      else {
        const snapIndex = Math.round(offsetY / boxHeight);
        const snapOffsetY = snapIndex * boxHeight;
        setLastOffsetY(snapOffsetY);
        springApi.start({
          to: {
            offsetY: snapOffsetY
          },
          config: {
            velocity: state.velocity
          }
        });
      }
    },
    {
      axis: "y",
      bounds: {
        bottom: 0 - lastOffsetY,
        top: -1 * (totalHeight - containerHeight) - lastOffsetY
      },
      rubberband: true
    }
  );

  return (
    <div className="container">
      <animated.ul
        {...bind()}
        className="grid"
        style={{
          y: animation.offsetY,
          touchAction: "none",
          height: containerHeight
        }}
      >
        {range(0, 5 * rows).map((n) => (
          <li key={n} className="box" style={{ height: boxHeight }}>
            {n}
          </li>
        ))}
      </animated.ul>
    </div>
  );
}

Code Sandbox Demo

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • First of all I want to say thank you for the provided example. The only problem that I see in your solution that "scrollable area" doesn't fill like inertia it stops immediately after mouse button released. If you check my gif in the question above you may notice that draggable area is still scrolling a bit to mimic inertia. I would like to achieve the same functionality but with snapping to the closest row (not to the top of the row, it can be bottom row or top row depending on its position). – Roman Mahotskyi Sep 13 '21 at 15:43
  • 1
    I think that I misunderstood your requirements. I designed it to snap to whichever is closest (up or down) due to `Math.round`. In order to continue the movement with inertia, you need to go to the next break *in the current direction* instead of the closest. – Linda Paiste Sep 13 '21 at 17:54
1

Are you sure you need to put this logic into JS? CSS might be all you need here:

.scroller {
  scroll-snap-type: y mandatory;

  max-height: 16em;
  overflow: scroll;
  letter-spacing: 2em;
}

.row {
  scroll-snap-align: start;
  box-sizing: border;
  
  padding: 1em;
}
.row:nth-child(2n) {
  background: #bbb;
}
<div class = "scroller">
  <div class = "row">AAAA</div>
  <div class = "row">BBBB</div>
  <div class = "row">CCCC</div>
  <div class = "row">AAAA</div>
  <div class = "row">BBBB</div>
  <div class = "row">CCCC</div>
  <div class = "row">AAAA</div>
  <div class = "row">BBBB</div>
  <div class = "row">CCCC</div>
  <div class = "row">AAAA</div>
  <div class = "row">BBBB</div>
  <div class = "row">CCCC</div>
  <div class = "row">AAAA</div>
  <div class = "row">BBBB</div>
  <div class = "row">CCCC</div>
  <div class = "row">AAAA</div>
  <div class = "row">BBBB</div>
  <div class = "row">CCCC</div>
</div>
Sean Morris
  • 384
  • 2
  • 9
  • Yes, I need to use JS. CSS snap property in Chrome works not as expected. It has a lag. Also I need to use mouse to be able to drag content instead of using scroll. – Roman Mahotskyi Sep 14 '21 at 11:02