Search code examples
javascriptcsstouchcss-animationshammer.js

CSS width transition not fast enough and causes jittery UI when joined with translateX


The Code

Code at CodeSandBox -- Demo

There is a lot of code in that above codesandbox, so I'll try to paste the relevant parts here:

Based on the series of drag / pan gesture's total X-direction delta, I'm doing:

// before setting transform and width, I set the transition
// this way, there will be some tweening that occurs when I do
// change the properties -- relevant to the specific open / 
// close situation
if (this.isOpening) {
  this.content.style.transition = `width 0.1s linear, transform 0.1s linear`;
} else {
  this.content.style.transition = `width 0.0s linear, transform 0.1s linear`;
}


this.content.style.setProperty("--dx", `${nextX}`);
this.content.style.transform = `translateX(${nextX}px)`;
this.resize();


// and then in that resize function
let nextX = parseInt(this.content.style.getPropertyValue("--dx"), 10);
this.content.style.setProperty("width", `${window.innerWidth - nextX}px`);

The Behavior I Want

When I drag the content to the right and left (either with the mouse, or a touch event), I expect that the right edge of the content will stick to the right edge of the window. I know that doing translateX by itself has super smooth animation, but animating width is tricky, as it causes reflows, harming performance.

The Behavior I'm seeing

When I drag the content to the right and left, the right edge of the content does not stick to the right edge of the window. This is partially do to the fact that I'm using dynamic css-transitions depending on the scenario. I have one transition in use for opening, and one for closing (revealing / hiding the sidebar). The reason for this is that if I don't have transitions at all, the animation feels jerky, and very unsmooth. The way it is now is better, but still not not ideal.

What I've tried

No CSS Transitions

  • Right side of the content sticks to the right side of the window, as I want
  • but the animation of the left side of the content following my finger is jittery (no tweening between emitted touch events, I think)

Add transition to the transform property

  • Left side of the content is buttery smooth
  • when revealing the sidebar, the right side of the content is pushed outside the viewport
  • when hiding the sidebar (dragging to the left), the right side of the content is pulled into the viewport, revealing the background element(s) / body

Add transition to the transform and width properties

  • had to be situational depending on opening / closing
  • the current way I'm doing things (as seen in the codesandbox, and in my actual code in my app)
  • translateX animation is less smooth
  • width animation still goes off screen a little bit, but it doesn't reveal the body / background

Other Notes

I'm currently using hammerjs to get touch events / gestures -- I don't have to use this library, so if there is a better way to achieve what I want, I'm all ears.

I guess the last thing I haven't tried is requestAnimationFrame? not sure if that would help here based on what I've read -- but at this point I'm willing to try anything.


Solution

  • Figured it out after continuing to search for solutions, and eventually stumbling on this page: MDN's "Using the Web Animations API".

    The gist of it is that while css animations use the @keyframes declaration in a css file, the Web Animations API enables us to call .animate on an element, and allows many of the same options as the css way of animating.

    I'm very glad I discovered this API, as it has play, cause, and cancel abilities, which I don't be using for this sidebar animation, but it might mean that when evaluating an animation library, such as ember-animated, I could check to see if it uses the animations API to achieve cancellation, resulting in reduced payload size.

    What worked

    Code on CodeSandBox -- Demo

    The key differences are:

    • I got rid of any attempt to set the transition property. (went back to the first thing under What I've Tried)
    • the snap open and snap closed behavior was too quick, so, using the Web Animations API, I was able to smoothly animate both translateX and width -- even on my non-high end older phone, a Sony XPeria XZ1 (Compact)

       // e is a hammer.js Pan event 
       handleDrag(e) {
        // setup and various math omitted
      
        if (e.isFinal) {
          // code determining if a "snap" open or shut should happen
          // and what that x-axis value should be 
          // are omitted
      
          // reset state omitted 
      
          // call the Web Animations API 
          return this.finish(nextX);
        }
      
        // fallback code for when the gesture is still active
        // directly updates the transform property
        // (though, I should probably look in to using requestAnimationFrame
        // to improve performance on lower-end devices)
        this.content.style.transform = `translateX(${nextX}px)`;
        this.resize();
      }
      
      finish(nextX: number) {
        let prevX = this.currentLeft;
      
        let keyFrames = [
          { transform: `translateX(${prevX}px)` },
          { transform: `translateX(${nextX}px)` },
        ];
      
      
        // this covers the main scenario from the question here
        // adding a width animation to the translate X
        // (for medium sized screens and larger, it's important to have both)
        // NOTE: isPushing is a boolean that checks for a certain 
        //       screen size -- if the screen is small enough,
        //       no width animating occurs
        if (!this.isPushing) {
          keyFrames[0].width = `${window.innerWidth - prevX}px`;
          keyFrames[1].width = `${window.innerWidth - nextX}px`;
        }
      
        let easing = 'cubic-bezier(0.215, 0.610, 0.355, 1.000)';
        let animation = this.content.animate(keyFrames as any /* :( */, { duration: 200, easing });
      
        // without the onfinish, the content element will reappear
        // back in its pre-animation position / state.
        animation.onfinish = () => {
          this.content.style.transform = `translateX(${nextX}px)`;
      
          if (!this.isPushing) {
            this.content.style.setProperty('width', `${window.innerWidth - nextX}px`);
          }
        };
      }