Is there a way to make this custom slider input smoother?

Viewed 42

I'm developing a custom input for questions with only two possible answers, eg.: "Yes" or "No". Here's a working example and here you can read the source code (it also work in touch screens).

The idea is to use a similar principle to the "Slide to open" slider input, but I'm trying to make the thumb put a bit of resistance. My implementation consists of calculating the speed of the dragging movement and make the thumb respond to it, so, though the pointer (finger or mouse) might be close to the answer, if the speed decreases, the thumb gets back to origin. So, it's not so much about actually dragging the thumb but about moving the pointer quickly in the direction of the desired answer.

The problem is, it's currently a bit wonky and shaky. So, is there a way to make the thumb move smoother? No part of my implementation is permanent, so feel free to experiment and modify anything. Also, I'm no expert in JS by any means, so don't be too hasrh, please? Thanks in advance. Cheers

The code is here too.

HTML

<!DOCTYPE html>
<html>
<head>
  <title>Yes or No?</title>
</head>
<body>
<canvas id="display"></canvas>
</body>
</html>

JS

const displayCanvas = document.querySelector("#display");
const displayContext = displayCanvas.getContext("2d");

const maxX = displayCanvas.width = 400;
const maxY = displayCanvas.height = 100;

const bgColor = "#000";
const fgColor = "#fff";

const thumbRestX = maxX / 2;

let thumbX = thumbRestX;
let thumbY = maxY / 2;

let yesAnswerX = (maxX / 6) * 5;
let yesAnswerY = maxY / 2;

let noAnswerX = maxX / 6;
let noAnswerY = maxY / 2;

let pointerPrevX = thumbX;
let pointerX = thumbX;

let isDragging = false;
let isAnswered = false;

const setup = () => {
  const startDragging = () => {
    isDragging = true;
  };
  
  const stopDragging = () => {
    isDragging = false;
  };
  
  const monitorPointer = (newX) => {
    pointerPrevX = pointerX;
    pointerX = newX;
  };
  
  displayCanvas
    .addEventListener("mousedown", startDragging);
  displayCanvas
    .addEventListener("mouseup", stopDragging);
  displayCanvas
    .addEventListener("mousemove", (e) => {
      monitorPointer(
        e.clientX - e.target.getBoundingClientRect().left);
    });
  
  displayCanvas
    .addEventListener("touchstart", (e) => {
      e.preventDefault();
      startDragging();
    });
  displayCanvas
    .addEventListener("touchend", stopDragging);
  displayCanvas
    .addEventListener("touchmove", (e) => {
      const touch = e.touches[0];
    
      monitorPointer(
        touch.clientX - e.target.getBoundingClientRect().left);
    });
};

const evaluate = () => {
  if (!isAnswered && isDragging) {
    thumbX = thumbRestX + (pointerX - pointerPrevX - 1) * 2;
    
    if (thumbX >= yesAnswerX) {
      isAnswered = true;
      thumbX = yesAnswerX;
    }
    
    if (thumbX <= noAnswerX) {
      isAnswered = true;
      thumbX = noAnswerX;
    }
  }
};

const render = () => {
  const ctx = displayContext;
  
  ctx.clearRect(0, 0, maxX, maxY);
  
  // Background
  ctx.fillStyle = bgColor;
  ctx.fillRect(0, 0, maxX, maxY);
  
  // Thumb
  
  ctx.fillStyle = fgColor;
  ctx.beginPath();
  ctx.arc(thumbX, thumbY, 20, 0, Math.PI * 2, true);
  ctx.fill();
  
  // Yes answer
  
  ctx.fillStyle = fgColor;
  ctx.font = "50px monospace";
  ctx.textAlign = "center";
  ctx.fillText("YES", yesAnswerX, yesAnswerY);
  
  // No answer
  
  ctx.fillStyle = fgColor;
  ctx.font = "50px monospace";
  ctx.textAlign = "center";
  ctx.fillText("NO", noAnswerX, noAnswerY);

};

function run () {
  const evaluateTimeoutRate = 20;
  let evaluateTimeoutID;
  
  setup();
  
  const evaluateLoop = () => {
    evaluate();
    
    evaluateTimeoutID = 
      setTimeout(evaluateLoop, evaluateTimeoutRate);
  };
  
  evaluateTimeoutID = 
    setTimeout(evaluateLoop, evaluateTimeoutRate);
  
  const renderLoop = () => {
    render();
    
    requestAnimationFrame(renderLoop);
  };
  
  requestAnimationFrame(renderLoop);
}

run();
1 Answers

The problem is, it's currently a bit wonky and shaky. So, is there a way to make the thumb move smoother?

In my experience, UI often feels smooth if you model it to behave a bit like things in the real world.

For this control, a fun way to do that would be to implement a mass-spring-damper model.

The "model"

I imagine the circular handle to be connected to the center of the control by a spring.

To move the center disk, I attach a second spring and start pulling it left or right. Once the disk manages to get above the "No" or "Yes" label, I (1) detach the center spring, (2) connect the center spring to the new label, and (3) cut off the string I was pulling (all in a split second).

To ensure the control doesn't keep oscillating forever when I let go of our spring, I introduce a damper.

The physics

The control's center disk has a position (p), speed (v) and acceleration (a).

Its position produces 2 forces:

  • Force from the spring attached to either the left, center or right of the control
  • Force from the spring pulled by the user (during interaction)

Its velocity produces 1 force:

  • Force from the damper working in the opposite direction from the speed

Using F = m * a, we can now derive the disk's acceleration:

a = F / m

Rendering

Each render loop, we calculate the following:

  • Use p and v to calculate spring and damper forces
  • Use forces to calculate a
  • Update speed with acceleration (v += a)
  • Update position using speed (p += v)

Putting it all together

Note, I kind of ignored the code you provided. I hope this answer is useful not because you can copy paste it, but because you can implement some of its ideas

const Spring = (anchorPos, k, maxLength = Infinity) => {
  return {
    getForce: p => k * Math.max(Math.min((anchorPos - p), maxLength), -maxLength),
    setAnchorPos: newA => { anchorPos = newA; },
    setSpringConstant: newK => { k = newK; },
    get anchorPos() {
      return anchorPos;
    }
  }
};

const Slider = el => {
  const CENTER_SPRING_K = 0.1;
  const CENTER_PULL_RATIO = 0.8;
  const DAMPING = 0.15;
  
  // The spring attaching the disc to the current control state
  const centerSpring = Spring(0, CENTER_SPRING_K);
  // The spring attached to the user's pointer
  // Note: to "cut" the spring, we set its constant to 0
  const pullSpring = Spring(0, 0, 100);
  
  const attachPullSpring = () => {
    pullSpring.setAnchorPos(centerSpring.anchorPos);
    pullSpring.setSpringConstant(CENTER_PULL_RATIO * CENTER_SPRING_K);
    
    window.addEventListener("mousemove", onMouseMove);
    window.addEventListener("mouseup", onMouseUp);
  }
  
  const detachPullSpring = () => {
    pullSpring.setSpringConstant(0);
    
    window.removeEventListener("mouseup", onMouseUp);
    window.removeEventListener("mousemove", onMouseMove);
  }
  
  // Handle mouse events
  let mouseStartX = null;
  const onMouseDown = e => {
    mouseStartX = e.clientX;
    attachPullSpring();
  };
  
  const onMouseMove = e => {
    const dx = e.clientX - mouseStartX;
    pullSpring.setAnchorPos(centerSpring.anchorPos + dx);
    e.preventDefault();
  };
  
  const onMouseUp = e => {
    mouseStartX = null;
    detachPullSpring();
  }
  
  el.addEventListener("mousedown", onMouseDown);
  
  // Mass
  const M = 1;
  
  let p = 0;
  let v = 0;
  let a = 0;
  
  const update = () => {
    const springForces = [
      centerSpring.getForce(p),
      pullSpring.getForce(p)
    ];
    
    // One damper is enough for now, and we never update it
    const damperForces = [
      DAMPING * -v
    ];
    
    const fTot = springForces
      .concat(damperForces)
      .reduce((a, b) => a + b, 0);
    
    // f = m * a
    a = fTot / M;
    v += a;
    p += v;
    
    // Check if we've reached a new state
    const offset = p - centerSpring.anchorPos;
    if (Math.abs(offset) > 100) {
      const dir = offset > 0 ? 1 : -1;
      
      // Attach centerSpring to new state
      centerSpring.setAnchorPos(centerSpring.anchorPos + dir * 100);
      
      // Stop pulling
      detachPullSpring();
      mouseStartX = null;
    }
  }
  
  const render = () => {
    el.style.transform = `translateX(${p}px)`;
  }
  
  return { update, render }
}


const slider = Slider(document.querySelector(".Slider"));

const loop = () => {
  slider.update();
  slider.render();
  
  requestAnimationFrame(loop);
}

loop();
* {
  position: relative;
  margin: 0;
  padding: 0;
}

body {
  font-family: sans-serif;
  font-weight: bold;
  display: flex;
  width: 400px;
}

.Label {
  touchaction: none;
  flex: 1;
  padding: 10px;
  border: 1px solid red;
  text-align: center;  
  background: #efefef;
}

.Slider {
  position: absolute;
  width: 20px;
  height: 20px;
  
  top: calc(50% - (20px / 2));
  left: calc(50% - (20px / 2));
  border-radius: 50%;
  background: black;
}
<p class="Label">No</p>
<p class="Label">Yes</p>
<div class="Slider"></div>

Notes:

  • I tried to balance the system to make it just hard enough to flip it between the values
  • If you want it to be harder to flip, you can:
    • decrease the spring constant of the pulling string, or
    • increase the center spring constant, or
    • increase the damping constant
  • I didn't use a canvas, but went with some regular HTML elements.
  • You might want to invest some time in making the control accessible.
Related