CSS change animation duration without jumping

2019-04-23 05:43发布

I have a simple animation of a wheel spinning. I am trying to control the speed of the spinning wheel using a slider (input range). I have managed to do this, but every time I change the animation the animation restarts (it jumps). Looking for a solution to create a smooth increase of the speed. As the user increases the value of the slider, the wheel rotates with an increased speed.

In the code below, #loading is the spinning wheel.

$(document).on('input', '#slider', function() {
  var speed = $(this).val();
  $('#speed').html(speed);
  $("#loading").css("animation-duration", 50 / speed + "s");
});
#loading {
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
  transform-origin: 50% 50%;
  animation: rotateRight infinite linear;
  animation-duration: 0;
}

@keyframes rotateRight {
  100% {
    transform: rotate(360deg);
  }
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<img id="loading" src="//placehold.it/100">
<input type="range" min="0" max="100" value="0" class="slider" id="slider">
<p>Speed: <span id="speed"></span></p>

4条回答
疯言疯语
2楼-- · 2019-04-23 06:03

Yes, we can!

Here's the approach we'll take:

  1. On input change, get the new speed value
  2. Grab the current transform value of the element, which is returned as a matrix()
  3. Convert the matrix() to a rotate value in degrees
  4. Remove the existing animation
  5. Create a new animation based on the current speed and rotate value
  6. Apply the new animation to the element

The main issue we need to overcome is creating a new animation keyframes based on the current rotate value - we need to create a new animation with a starting value equal to the current rotate value, and an end value equal to the current rotate value + 360.

In other words, if our element is rotated 90deg and the speed is changed, we need to create a new @keyframes of:

@keyframes updatedKeyframes {
  0% {
    transform: rotate(90deg);
  },
  100% {
    transform: rotate(450deg); // 90 + 360 
  }
}

To create the dynamic animation I'm using the jQuery.keyframes plugin to create dynamic @keyframes.

While this solution works, I don't believe it's super performant based on how the jQuery.keyframes plugin works. For every new animation keyframes the plugin appends an inline <style>. This results in potentially dozens, hundreds, or even thousands of keyframes being defined. In the example below I'm using the speed variable to create the @keyframe names, so it will create up to 100 unique @keyframes styles. There are a few optimizations we could make here but that's outside the scope of this solution.

Here's the working example:

$(document).on('input', '#slider', function() {
  var speed = $(this).val();
  $('#speed').html(speed);
  var transform = $("#loading").css('transform');

  var angle = getRotationDegrees(transform);
  
  $("#loading").css({
    "animation": "none"
  });
  
  $.keyframe.define([{
    name: `rotateRight_${speed}`,
    "0%": {
      "transform": "rotate(" + angle + "deg)"
    },
    "100%": {
      'transform': "rotate(" + (angle + 360) + "deg)"
    }
  }]);
  
  if (speed === "0") {
    $("#loading").css("transform", "rotate(" + angle + "deg)");
  } else {
    $("#loading").playKeyframe({
      name: `rotateRight_${speed}`,
      duration: 50 / speed + "s",
      timingFunction: "linear",
      iterationCount: "infinite"
    });
  }

});

function getRotationDegrees(matrix) {
  if (matrix !== 'none') {
    var values = matrix.split('(')[1].split(')')[0].split(',');
    var a = values[0];
    var b = values[1];
    var angle = Math.round(Math.atan2(b, a) * (180 / Math.PI));
  } else {
    var angle = 0;
  }
  return angle;
}
#loading {
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
  transform-origin: 50% 50%;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://rawgit.com/jQueryKeyframes/jQuery.Keyframes/master/jquery.keyframes.js"></script>
<img id="loading" src="http://via.placeholder.com/100x100">
<input type="range" min="0" max="100" value="0" class="slider" id="slider">
<p>Speed: <span id="speed"></span></p>

查看更多
地球回转人心会变
3楼-- · 2019-04-23 06:07

Classic Question
(with jumping.. now yet )

Version with jQuery

var lasttime = 0,  lastduration = 0,  angle = 0;
$(document).on('input', '#slider', function(event) {
  var speed = $(this).val();
  $('#speed').html(speed);
  
  var el = $("#loading");
  var duration = (speed > 0) ? 50 / speed : 0;
  var currenttime = event.originalEvent.timeStamp / 1000;
  var difftime = currenttime - lasttime;
  el.removeClass("enable_rotate").show();
  
  if (!lastduration && duration)
      el.css("transform", "");
  else
      angle += (difftime % lastduration) / lastduration;
      
  if (duration){     
    el.css("animation-duration", duration + "s")
    .css("animation-delay", -duration * angle + "s")    
    .addClass("enable_rotate");
    }
  else
    el.css("transform", "rotate(" + 360 * angle + "deg)");
  
  angle -= angle | 0; //use fractional part only
  lasttime = currenttime;
  lastduration = duration;
});
.anime_object {
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
}

.enable_rotate {
  animation: rotateRight infinite linear;
}

@keyframes rotateRight {
  100% {
    transform: rotate(360deg);
  }
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<img id="loading" class="anime_object" src="//placehold.it/100">
<input type="range" min="0" max="100" value="0" id="slider">
<p>Speed: <span id="speed"></span></p>

Work draft

save variable of currentanimation
http://www.w3.org/TR/css-animations-1/#interface-animationevent-attributes

  1. SO
  2. css-tricks.com
查看更多
叼着烟拽天下
4楼-- · 2019-04-23 06:16

I'm not entirely sure that this will be possible without a different approach that doesn't use CSS animations. The issue is that the animation does not normalize whenever you change the speed. It is always animating from 0% of the animation to 100% of the animation. Every time you adjust the animation-duration, you're going to re-interpolate using the current position in the animation.

In other words, if you change from animation-duration: 25 to 50 at t=12, well the animation was halfway finished (180 degrees); now it's only a quarter finished (90 degrees). You can't control t though, that's the browser's. If you could, you would want to set t to remain where it was in the interpolation, in this example, t=25, so that you remain at the same percentage complete of the animation that you were, but you stretch the remaining time.

I modified your script a little to try and show what I'm describing a little better. It will increment the speed by 0.25 every second between speed 0 and 5. You can kind of see how the problem is that the browser controlled t is the issue.

You can rewrite this in order to control t yourself with JavaScript, but I think you'll have to drop the CSS animations.

To talk a little bit more to the point of this browser controlled t variable, take a look at this article on CSS-Tricks: Myth Busting: CSS Animations vs. JavaScript

Some browsers allow you to pause/resume a CSS keyframes animation, but that's about it. You cannot seek to a particular spot in the animation, nor can you smoothly reverse part-way through or alter the time scale or add callbacks at certain spots or bind them to a rich set of playback events. JavaScript provides great control, as seen in the demo below.

That's your problem, you want to be able to change the duration of your animation, but then also seek to the correct spot in the animation.

$(function() {
  var speed = parseInt($("#slider").val(), 10);
  $("#speed").html(speed);
  $("#loading").css("animation-duration", 50 / speed + "s");

  var forward = true;
  setInterval(function() {
    speed += (forward ? 0.25 : -0.25);
    if (speed >= 5) {
      forward = false;
    } else if (speed <= 0) {
      forward = true;
    }
    $("#loading").css("animation-duration", 50 / speed + "s");
    $("#slider").val(speed);
    $("#speed").html(speed);
  }, 1000);
});
#loading {
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
  transform-origin: 50% 50%;
  animation: rotateRight infinite linear;
  animation-duration: 0;
}

@keyframes rotateRight {
  100% {
    transform: rotate(360deg);
  }
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<img id="loading" src="//placehold.it/100">
<input type="range" min="0" max="100" value="0" step=".25" class="slider" id="slider" value="0">
<p>Speed: <span id="speed"></span></p>

查看更多
趁早两清
5楼-- · 2019-04-23 06:22

TL;DR

No, you can't (according to my tests)

  • Firs of all, let's get rid of the animation declaration from your css and move it up to Javascript so the animation loop doesn't start (even if you can't visually see it running).

Have you noticed that even when you move your slider from the initial position the box appears to start from a random position? that's because the animation loop has actually been running.

  • Now, you can actually get the current transform value applied to your box at any given time by your animation, using getComputedStyle(loadingElement).getPropertyValue('transform'); this will return a matrix which doesn't give you much just like that but we can calculate the angle of the rotation from that matrix:

(using some maths explained here)

Math.round(Math.atan2(b, a) * (180/Math.PI));

Now that we have this value we have to normalize it to have only positive values for the angle, and then we can apply this angle as the base value for transform: rotate(Xdeg)

so far so good, you can see this working in the code snippet, however even when you do this, and increment/decrement the speed value, the animation loop is already running with a set time scale and you can't reset this loop.

My answer so far is so someone else with a deeper understanding of the animation loop can build from, and maybe come up with a working code.

If you are still reading this you might think, well just drop the animation with loadingElement.style.removeProperty('animation') and the assign it again, tried it doesn't work. And what about starting the animation again with a setInterval(...,0) so it runs in the next loop, won't work either.

$(document).on('input', '#slider', function() {
	var speed = $(this).val();
	var loadingElement = document.querySelector("#loading")
	$('#speed').html(speed);
	//get the current status of the animation applied to the element (this is a matrix)
	var currentCss = getComputedStyle(loadingElement).getPropertyValue('transform'); 

	if (currentCss !== 'none'){
		//parse each value we need from the matrix (there is a total of 6)
		var values = currentCss.split('(')[1];
		values = values.split(')')[0];
		values = values.split(',');

		var a = values[0];
		var b = values[1];
		var c = values[2];
		var d = values[3];

		//here we make the actual calculation
		var angle = Math.round(Math.atan2(b, a) * (180/Math.PI)); 

		//normalize to positive values
		angle = angle < 0 ? angle + 360 : angle;

		loadingElement.style.removeProperty('animation'); //remove the property for testing purposes
		$("#loading").css('transform', 'rotate(' + angle + 'deg)');
	}
	else{ //else for testing purposes, this will change the speed of the animation only on the first slider input change
		$("#loading").css('animation', 'rotateRight infinite linear'); //see how the animation now actually starts from the initial location
		$("#loading").css("animation-duration", 50 / speed + "s");
	}

});
#loading {
  position: absolute;
  left: 0;
  right: 0;
  margin: auto;
  transform-origin: 50% 50%;
  /*I removed the initialization of the animation here*/
}

@keyframes rotateRight {
  100% {
    transform: rotate(360deg);
  }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<img id="loading" src="https://placehold.it/100">
<input type="range" min="0" max="100" value="0" class="slider" id="slider">
<p>Speed: <span id="speed"></span></p>

查看更多
登录 后发表回答