Smooth transition when position is not an integer

Viewed 278

I'm trying to display some sort of timeline and my goal is to get it smoothly refreshed.

I managed to get something better than absolute positionning using css transform property but I'm not very happy because there is some flickering (especially when the background is dark).

var background = document.querySelector('#background');
var position = document.querySelector('#position');
var transform = document.querySelector('#transform');

var backgroundColor, borderColor

background.addEventListener('change', e => {

 backgroundColor = e.target.checked ? '#333333' : 'white'
  position.style.backgroundColor = backgroundColor
  transform.style.backgroundColor = backgroundColor

});

let current = 0
let step = 30
for (let i = 0; i < 300; i++) {
 for (let j = 0; j < 2; j++) {
    var element = document.createElement('div')
    element.style.position = 'absolute'
    element.style.height = '50px'
    element.style.width = step + 'px'
    if(j) {
      element.style.left = current + 'px'
    } else {
      element.style.left = 0 + 'px'
      element.style.transform = 'translateX(' + current + 'px)'
    }
    element.style['border-left'] = '1px gray solid'
    if (j)
      position.appendChild(element)
    else
      transform.appendChild(element)
  }
  
  current += step
}

setInterval(refresh, 50);

let init = 0
function refresh() {
 init -= 0.2
 let current = init
  for (var i = 0; i < position.children.length; i++) {
    var c = position.children[i];
   c.style.left = current + 'px'
   current += step
  }
  current = init
  for (var i = 0; i < transform.children.length; i++) {
    var c = transform.children[i];
   c.style.transform = 'translateX(' + current + 'px)'
   current += step
  }
}
<html>
<body>
<div>
  <input type="checkbox" id="background" label="Dark"> 
  <label>Dark</label>
</div>
<span>Using css position<span>
<div id="position" style="width:100%;height:50px;margin-bottom:1em;"></div>

<span>Using css transform<span>
<div id="transform" style="width:100%;height:50px;"></div>
</body>
</html>

Is there a better way to do this?

2 Answers

You could try using translate3d which in theory will work better with fractions of pixels (handled by the gpu). Though, it may blur a bit when at fraction of pixels.

I think you may also get better results if you use requestAnimationFrame instead of setInterval. Using requestAnimationFrame will probably require setting up some sort of timer to calculate the animation amount (as you don't know how much time has elapsed between each call)

Below is an example of translate3d, though I think different browsers implement this differently, so it may or may not produce better results.

EDIT
I have added a requestAnimationFrame example, Maybe I didn't emphasize it enough originally that it should be used instead of setInterval. There are many resources on the interwebs as to why this is.

I also added an example of just animating a wrapper div rather than the 300 lines as 1 animation rather than 300 is obviously going to yield more performance. Why the OP is not doing it this way I am not sure, maybe there is a good reason.

While you may not see much of a difference on your machine, on a slower machine the difference will be more pronounced.

If you want to dive deeper into animation performance, here is a great video by Paul Lewis on the subject:
https://www.youtube.com/watch?v=ohc8ejzSn48

var background = document.querySelector('#background');
var position = document.querySelector('#position');
var transform = document.querySelector('#transform');
var translate = document.querySelector('#translate');
var animFrame = document.querySelector('#animFrame');
var animWrapOuter = document.querySelector('#animWrapOuter');
var animWrapper = document.querySelector('#animWrapper');

var backgroundColor, borderColor

background.addEventListener('change', e => {

 backgroundColor = e.target.checked ? '#333333' : 'white'
  position.style.backgroundColor = backgroundColor
  transform.style.backgroundColor = backgroundColor
  translate.style.backgroundColor = backgroundColor
  animFrame.style.backgroundColor = backgroundColor
  animWrapOuter.style.backgroundColor = backgroundColor

});

let current = 0
let step = 30
for (let i = 0; i < 300; i++) {
 for (let j = 0; j < 5; j++) {
    var element = document.createElement('div')
    element.style.position = 'absolute'
    element.style.height = '50px'
    element.style.width = step + 'px'
    if(j == 0) {
      element.style.left = current + 'px'
    } else if(j == 1) {
      element.style.left = 0 + 'px'
      element.style.transform = 'translateX(' + current + 'px)'
    } else if(j == 2) {
     element.style.left = 0 + 'px'
      element.style.transform = 'translate3d(' + current + 'px, 0px, 0px)'
    } else if(j == 3) {
     element.style.left = 0 + 'px'
      element.style.transform = 'translate3d(' + current + 'px, 0px, 0px)'
    } else if(j == 4) {
     element.style.left = 0 + 'px'
      element.style.transform = 'translate3d(' + current + 'px, 0px, 0px)'
    }
    
    element.style['border-left'] = '1px gray solid'
    if (j == 0) {
      position.appendChild(element)
    } else if(j == 1) {
      transform.appendChild(element)
    } else if(j == 2) {
     translate.appendChild(element)
    } else if(j == 3) {
     animFrame.appendChild(element)
    } else if(j == 4) {
     animWrapper.appendChild(element)
    }
  }
  
  current += step
}

setInterval(refresh, 50);

let init = 0
function refresh() {
 init -= 0.2
 let current = init
  for (var i = 0; i < position.children.length; i++) {
    var c = position.children[i];
   c.style.left = current + 'px'
   current += step
  }
  current = init
  for (var i = 0; i < transform.children.length; i++) {
    var c = transform.children[i];
   c.style.transform = 'translateX(' + current + 'px)'
   current += step
  }
  current = init
  for (var i = 0; i < translate.children.length; i++) {
    var c = translate.children[i];
   c.style.transform = 'translate3d(' + current + 'px, 0px, 0px)'
   current += step
  }
}

// Set a speed value.
let speed = -0.004;

function animLoop() {
 let then = Date.now();
  let current = 0;
 function loop() {
   requestAnimationFrame(loop);
    let now = Date.now();
    // Get the difference between now and the last time the loop ran.
    let delta = now - then;
    // Set the time when this loop ran.
    then = Date.now();
    current = current + (delta * speed);
    
    // Animate the wrapper (5th example)
    animWrapper.style.transform = 'translate3d(' + current + 'px, 0px, 0px)';
    // loop over and animate translate3d requestAnimationFrame children (4th example).
    for (var i = 0; i < animFrame.children.length; i++) {
     var c = animFrame.children[i];
      c.style.transform = 'translate3d(' + (current + (step * i)) + 'px, 0px, 0px)';
    }
  }
  loop();
}
animLoop();
<div>
  <input type="checkbox" id="background" label="Dark"> 
  <label>Dark</label>
</div>
<span>Using css position</span>
<div id="position" style="width:100%;height:50px;margin-bottom:1em;"></div>

<span>Using css transform</span>
<div id="transform" style="width:100%;height:50px;"></div>

<span>Using css translate3d</span>
<div id="translate" style="width:100%;height:50px;"></div>

<span>Using css translate3d and requestAnimationFrame()</span>
<div id="animFrame" style="width:100%;height:50px;"></div>

<span>Using css translate3d and requestAnimationFrame() animate on wrapper div</span>
<div id="animWrapOuter" style="width:100%;height:50px;">
  <div id="animWrapper" style="width:100%; height:100%"></div>
</div>

The reason the animation is flickering is that it's being translated very slowly at 20fps (1000ms/50). Aside from the low fps, using setInterval also does not guarantee that the callback function will be called every time (see here for an example). To make it smoother, simply add the refresh rate to 60 times per second, use requestAnimationFrame instead of setInterval to animate the objects, and increase the translation value per frame so that the animation is less choppy (imagine moving something by only .2px per 3 frames).

You're using a 300 divs to create the timeline animation and animating 300 divs at once. This can be a little more expensive. To streamline your animation, you can simply create enough amount of divs to fit the whole container plus one. Then, you simply need to translate until the leftmost div disappears before replaying the animation. It creates an illusion of a continuous animation when it actually isn't. Another more performant way is to only animate the container (i.e. wrapper) of the divs.

If you can alter either position or transform to create an animation, always opt for transform. Try reading the the link here in CSS Tricks and a very good explanation by Paul Irish here.

Furthermore, you shouldn't use setInterval to animate things; use requestAnimationFrame instead. There are, obviously, other methods too.

Using JS

Finally, here's a working example using both setInterval and requestAnimationFrame:

window.onload = (() => {
  var background = document.querySelector('#background')
  var interval = document.querySelector('#setInterval')
  var intervalFast = document.querySelector('#setIntervalFast')
  var raq = document.querySelector('#raq')
  var raqFast = document.querySelector('#raqFast')

  var backgroundColor, borderColor

  background.addEventListener('change', e => {
    backgroundColor = e.target.checked ? '#333333' : 'white'
    interval.style.backgroundColor = backgroundColor
    intervalFast.style.backgroundColor = backgroundColor
    raq.style.backgroundColor = backgroundColor
    raqFast.style.backgroundColor = backgroundColor
  })

  let intervalDocFrag = document.createDocumentFragment()
  let intervalFastDocFrag = document.createDocumentFragment()
  let raqDocFrag = document.createDocumentFragment()
  let raqFastDocFrag = document.createDocumentFragment()
  let current = 0
  let step = 30
  let divNeeded = Math.ceil(interval.getBoundingClientRect().width / 30) + 1 // Calculating how many divs are needed to fit one container + 1; 30 is the width of the div (29px + 1px of left border)
  for (let i = 0; i < divNeeded; i++) {
    for (let j = 0; j < 4; j++) {
      var element = document.createElement('div')
      element.style.position = 'absolute'
      element.style.height = '50px'
      element.style.width = step + 'px'

      element.style.left = current + 'px'

      element.style['border-left'] = '1px gray solid'
      if (j === 0)
        intervalDocFrag.appendChild(element)
      else if (j === 1)
        raqDocFrag.appendChild(element)
      else if (j === 2)
        intervalFastDocFrag.appendChild(element)
      else if (j === 3)
        raqFastDocFrag.appendChild(element)
    }

    current += step
  }

  interval.appendChild(intervalDocFrag)
  intervalFast.appendChild(intervalFastDocFrag)
  raq.appendChild(raqDocFrag)
  raqFast.appendChild(raqFastDocFrag)

  let intervalTranslateSlowValue = 0
  function intervalSlowAnimation() {
    if (Math.floor(intervalTranslateSlowValue) === -30) {
      intervalTranslateSlowValue = 0 // Resetting animation to create an endless timeline animating illusion
    } else {
      intervalTranslateSlowValue -= 0.064 // Gotten from 0.2 * 16 / 50
    }
    for (let child of interval.children) {
      child.style.transform = `translateX(${intervalTranslateSlowValue}px)`
    }
  }

  let intervalTranslateFastValue = 0
  function intervalFastAnimation() {
    if (Math.floor(intervalTranslateFastValue) === -30) {
      intervalTranslateFastValue = 0
    } else {
      intervalTranslateFastValue -= 0.2
    }
    for (let child of intervalFast.children) {
      child.style.transform = `translateX(${intervalTranslateFastValue}px)`
    }
  }

  function raqSlowAnimate(timeElapsed) {
    let translateValue = -1 * ((timeElapsed / (1000/60) * 0.064) % 30)
    for (let child of raq.children) {
      child.style.transform = `translateX(${translateValue}px)`
    }
    window.requestAnimationFrame(raqSlowAnimate)
  }

  function raqFastAnimate(timeElapsed) {
    let translateValue = -1 * ((timeElapsed / (1000/60) * 0.2) % 30)
    for (let child of raqFast.children) {
      child.style.transform = `translateX(${translateValue}px)`
    }
    window.requestAnimationFrame(raqFastAnimate)
  }

  window.setInterval(intervalSlowAnimation, 1000/60)
  window.setInterval(intervalFastAnimation, 1000/60)
  window.requestAnimationFrame(raqSlowAnimate)
  window.requestAnimationFrame(raqFastAnimate)
})
* {
  box-sizing: border-box;
}

#setInterval,
#setIntervalFast,
#raq,
#raqFast {
  position: relative;
  width: 100%;
  height: 50px;
  margin-bottom: 1em;
  overflow: hidden;
}
<div>
  <input type="checkbox" id="background" label="Dark">
  <label>Dark</label>
</div>

<span>Using setInterval at [1000/60]ms; translating .2px per 50ms (.064px per 16ms)</span>
<div id="setInterval"></div>

<span>Using requestAnimationFrame; translating .2px per 50ms (.064px per 16ms)</span>
<div id="raq"></div>

<span>Using setInterval at [1000/60]ms; translating .2px per 16ms</span>
<div id="setIntervalFast"></div>

<span>Using requestAnimationFrame; translating .2px per 16ms</span>
<div id="raqFast"></div>

Using CSS Animation (easier)

You can also use CSS animation to easily create the animation above:

window.onload = (() => {
  var background = document.querySelector('#background')
  var css = document.querySelector('#cssMethod')
  var cssFast = document.querySelector('#cssMethodFast')

  var backgroundColor, borderColor

  background.addEventListener('change', e => {
    backgroundColor = e.target.checked ? '#333333' : 'white'
    css.style.backgroundColor = backgroundColor
    cssFast.style.backgroundColor = backgroundColor
  })

  let cssDocFrag = document.createDocumentFragment()
  let cssFastDocFrag = document.createDocumentFragment()
  let current = 0
  let step = 30
  let divNeeded = Math.ceil(css.getBoundingClientRect().width / 30) + 1
  for (let i = 0; i < divNeeded; i++) {
    for (let j = 0; j < 2; j++) {
      var element = document.createElement('div')
      element.style.position = 'absolute'
      element.style.height = '50px'
      element.style.width = step + 'px'

      element.style.left = current + 'px'

      element.style['border-left'] = '1px gray solid'
      
      if (j == 0) css.appendChild(element)
      else if (j == 1) cssFast.appendChild(element)
    }

    current += step
  }

  css.appendChild(cssDocFrag)
  cssFast.appendChild(cssFastDocFrag)
})
* {
  box-sizing: border-box;
}

#cssMethod,
#cssMethodFast {
  position: relative;
  width: 100%;
  height: 50px;
  margin-bottom: 1em;
  overflow: hidden;
}

#cssMethod div {
  animation: 6s linear translation 0s infinite;
}

#cssMethodFast div {
  animation: 2.4s linear translation 0s infinite;
}

@keyframes translation {
  from { transform: translateX(-0px); }
  to { transform: translateX(-30px); }
}
<div>
  <input type="checkbox" id="background" label="Dark">
  <label>Dark</label>
</div>

<span>Using CSS animation; translating .2px per 50ms</span>
<div id="cssMethod"></div>

<span>Using CSS animation; translating .625px per 50ms</span>
<div id="cssMethodFast"></div>

Related