How to animate scrolling in Dart?

Update: using only dart:html library.

These are the links

<nav>
    <ul class="nav-list">
        <li><a href="http://example.com/#title">Home</a></li>
        <li><a href="http://example.com/#about">About us</a></li>
        <li><a href="http://example.com/#products">Products</a></li>
        <li><a href="http://example.com/#services">Services</a></li>
        <li><a href="http://example.com/#contact">Contact</a></li>
    </ul>
</nav>

In your Dart code import the dart:html library for accessing the DOM.

import 'dart:html';

Here is the function, and this is what it does:

  • When you click a link, this is analyzed and the anchor is extracted
  • It uses the anchor to know which is the element/section than you are looking for
  • Then get the element/section position
  • Scroll to the element/section

.

void smoothScrolling(String selector, {int offset: 0, int duration: 500}) {

  // The detection of the clicks and the selectors of the links are specified at the bottom of the function
  // Let's suppose you click a link to visit 'http://example.com/#contact'
  // When you click the link
  void trigger(MouseEvent click) {
    // Prevent to visit the resources, like normally does
    click.preventDefault();
    // Get the resource link of the clicked element. In this case: 'http://example.com/#contact'
    String link = click.target.toString();
    // Extract the anchor. In this case from 'http://example.com/#contact' will extract '#contact'
    String anchor = link.substring( link.indexOf('#') );
    // With the extracted anchor, search the corresponding element and get his position.
    // In this case gets the position of the element with 'contact' in the 'id' attribute 
    int targetPosition = querySelector('$anchor').offsetTop;

    // Before to translate to the element,
    // you can specify if you want to translate some distance before or after the element
    targetPosition += offset;

    // Let's move in direction to the section
    // We know than in Dart there are 60 frames per second, that means a frame duration is 
    // 1000 milliseconds divided in 60 frames = per frame is 16.66 milliseconds long.
    // But 16.66 milliseconds multiplied by 60 = 999.99 milliseconds. That is ALMOST a second.
    // And that means than there will be 59.99 frames in 999.99 milliseconds
    // But we cannot handle frame fractions, we should round frames to integers
    // So 59.99 frames will be rounded to 60 frames

    // Calculate the total number of frames
    int totalFrames = ( duration / (1000 / 60) ).round();
    // The first animation frame will be the number 1, the number 0 will be the start point
    int currentFrame = 0;
    // In this case the start point will be the current position
    int currentPosition = window.scrollY;
    // The end point will be the target position, we should know how many distance there is between the start and end point.
    // The positive and negative numbers represents the same distance, that means than 'y' and '-y' are the same.
    // Example: 10 and -10 are the same distance.

    // Calculate the distance between the start and end point.
    int distanceBetween =  targetPosition - currentPosition;
    // Then calculate how many distance should move per frame
    num distancePerFrame = distanceBetween / totalFrames;

    // The animation function is triggered by first time more later in the code
    // And when is triggered
    void animation(num frame) { 
      // First we look the number of the frame we are going to run.
      // When all the frames are complete the animation function will not be executed again
      if ( totalFrames >= currentFrame ) {

        // In every frame we are going to move some distance with direction to the target.
        // The direction (in this case will be only up or down) depends of the 'distanceBetween' number.
        // Let's explore this part with an example: You are 10 pixels from your target, your target is at the point 20,
        // Remember, to calculate the distance between you and the target we do 'targetPosition - currentPosition'.
        // If you are 10 pixels on from the target (at the point 10) the result will be: 20 - 10 = 10.
        // If you are 10 pixels down from the target (at the point 30) the result will be: 20 - 30 = -10.
        // You see how the number is the same but with different polarity?. And 10 and - 10 represent the same distance
        // The direction depends of the number 0, if you move closer to the 0 you will go down, if you move away the 0 you will move up.
        // Let's move 5 pixels:
        // 10 + 5 = 15. You will move down, because you will be more away of the 0, your target is at the number 20. 
        // -10 + 5 = -5. You will move up, because you will be more closer to the 0, your target is at the number 0.

        // Let's move to the point where we should be in this frame
        window.scrollTo( 0, currentPosition );
        // Calculate the point where we should be in the next frame
        currentPosition += distancePerFrame;

        // We get ready to execute the next frame
        currentFrame++;
        // When the time of this frame (16.66 milliseconds) is complete immediately starts the next frame.
        window.animationFrame.then(animation);      
      }
    } 

    // Here is triggered the animation by first time
    window.animationFrame.then(animation);  
  }

  // Here are the links' selectors and the detection of the clicks
  querySelectorAll(selector).onClick.listen(trigger);
}

To use the function we should specify the links' selectors, and optionally an offset and/or the duration of the scrolling.

void main() {
  // To use the function we should specify the links' selectors,
  // and optionally an offset and/or the duration of the scrolling,
  smoothScrolling('.nav-list a',
                  offset: -45,
                  duration: 2500);
}

The animation

I will explain the main points, all details are commented in the code.

Other thing to mark is than I been using Blender a long time, an open source 3D Animation software, thanks to Blender I entered to the world of programming. So I know what I am talking about animation.

1. The Dart docs

Here is the first difficulty. If you search in the Dart docs something to handle an animation you will find animationFrame, that seems to be ok.

But first, what is a frame? Maybe you has heard something like 24 frames per second. That means than in this case a frame is 1 second divided by 24 = per frame is 0.0416 seconds long = a frame per 41.66 milliseconds.

Then let's handle frames as blocks of time, and this blocks of time are normally handle as integer. We usually say something like this: this screen is 24 frames per second, but not something like this screen is 24.6 frames per second.

But there is a problem in the docs.

The Future completes to a timestamp that represents a floating point value of the number of milliseconds that have elapsed since the page started to load (which is also the timestamp at this call to animationFrame).

Ok, that means than I can know how many time has been the user in the page, but there is not any reference of how many times per second I can know it, and that is what I expect to know when you talk about frames: How many frames there are in a second?

2. Frames in Dart

After some experiments I discovered than in Dart there are 60 frames per second

1000 milliseconds divided in 60 frames = per frame is 16.66 milliseconds long. But 16.66 milliseconds multiplied by 60 = 999.99 milliseconds. That is ALMOST a second. And that means than there will be 59.99 frames in 999.99 milliseconds But we cannot handle frame fractions, we should round frames to integers. So 59.99 frames will be rounded to 60 frames

Remember a frame is 16.66 milliseconds long.

3. An animation in Dart

// Set the duration of your animation in milliseconds
int duration = 1000;
// Calculate the total number of frames
int totalFrames = ( duration / (1000 / 60) ).round();
// The first animation frame will be the number 1, the number 0 will be the start point
int currentFrame = 0;

// The animation function is triggered by first time more later in the code
// And when is triggered
void animation(num frame) { 
  // First we look the number of the frame we are going to run.
  // When all the frames are complete the animation function will not be executed again
  if ( totalFrames >= currentFrame ) {

    // =========================================== 
    // Here what we are going to do in every frame 
    // ===========================================        

    // We get ready to execute the next frame
    currentFrame++;
    // When the time of this frame (16.66 milliseconds) is complete immediately starts the next frame.
    window.animationFrame.then(animation);      
  }
} 

// Here is triggered the animation by first time
window.animationFrame.then(animation);

Note:

1. I found a bug writing the smoothScrolling function, but do not worry, only affects in the Dart Virtual Machine, the compiled JavaScript works as expected. I am trying to find what is exactly what causes the bug and so can report it.

What I found is than using the number distancePerFrame within the animation function make than the function do not loop.

2. Technically this is not a 'smooth scrolling', is a 'linear scrolling'.


You can use the package animation to do the work

here an example of use:

import 'dart:html';
import 'package:animation/animation.dart');

main() {
  var el = query('#box');

  var properties = {
    'left': 1000,
    'top': 350
  };

  animate(el, properties: properties, duration: 5000);
}

Update: Final version with timings and auxiliary function to normalize duration of the scroll animation.
Should be tested in all target browsers

import 'dart:html';
import 'dart:async';
import 'dart:math';

void main() {
  Element a = querySelector("#a"),
      b = querySelector("#b"),
      c = querySelector("#c");

  document.onClick.first
      .then((_) => scrollTo(c, getDuration(c, 2), TimingFunctions.easeInOut))
      .then((_) => scrollTo(a, getDuration(a, 2), TimingFunctions.easeOut))
      .then((_) => scrollTo(c, getDuration(c, 5), TimingFunctions.easeOut))
      .then((_) => scrollTo(b, getDuration(b, 2), TimingFunctions.easeOut))
      .catchError(print);
}

double fullOffsetTop(Element element) =>
  element.getBoundingClientRect().top +
    window.pageYOffset -
    document.documentElement.clientTop;

Duration getDuration(Element targetElement, num speed){
    var distance = (window.pageYOffset - fullOffsetTop(targetElement)).abs();
    return new Duration(milliseconds: distance ~/ speed);
    }

Future scrollTo(Element el, Duration duration, TimingFunction tf) {

  var isCompleted = false,
      isInterrupted = false,
      completer = new Completer(),
      startPos = window.pageYOffset,
      targetPos = fullOffsetTop(el),
      overScroll =
      max(targetPos + window.innerHeight - document.body.scrollHeight, 0),
      startTime = null,
      direction = (targetPos - startPos).sign;

  targetPos -= overScroll;

  var totalDistance = (targetPos - startPos).abs();

  //make text unselectable and disable events
  //like onMouseOver for better performance during the scroll.
  String disable =
      "-webkit-user-select: none;"
      "-moz-user-select: none;"
      "-ms-user-select: none;"
      "-o-user-select: none;"
      "user-select: none;"
      "pointer-events: none;";

  String oldBodyStyle = document.body.getAttribute("style") != null ?
    document.body.getAttribute("style") : "";

  //return control to the user if he/she tries to interact with the page.
  window.onMouseWheel.first.then((_) => isInterrupted = isCompleted = true);
  window.onKeyDown.first.then((_) => isInterrupted = isCompleted = true);

  document.body.setAttribute("style", disable + oldBodyStyle);

  iter() {
    window.animationFrame.then((_) {

      if (startTime == null) startTime = window.performance.now();
      var deltaTime = window.performance.now() - startTime,
           progress = deltaTime / duration.inMilliseconds,
           precision = (1000 / 60 / duration.inMilliseconds) / 4,
           dist = totalDistance * tf(progress, precision);
      var curPos = startPos + dist * direction;

      if (progress >= 1.0) isCompleted = true;

      if (!isCompleted) {
        window.scrollTo(0, curPos.toInt());
        iter();
      } else {
        document.body.setAttribute("style", document.body.getAttribute("style"
            ).replaceFirst(disable, ""));
        isInterrupted ? completer.completeError("Interrupted by the user") :
            completer.complete("completed");
      }
    });
  }
  iter();
  return completer.future;
}

typedef num TimingFunction(num time,num precision);

abstract class TimingFunctions{
  static TimingFunction easeInOut = makeCubicBezier(0.42, 0, 0.58, 1);
  static TimingFunction easeOut = makeCubicBezier(0.25, 0.1, 0.25, 1);
}

TimingFunction makeCubicBezier(x1, y1, x2, y2) {
  var curveX = (t) {
    var v = 1 - t;
    return 3 * v * v * t * x1 + 3 * v * t * t * x2 + t * t * t;
  };

  var curveY = (t) {
    var v = 1 - t;
    return 3 * v * v * t * y1 + 3 * v * t * t * y2 + t * t * t;
  };

  var derivativeCurveX = (t) {
    var v = 1 - t;
    return 3 * (2 * (t - 1) * t + v * v) * x1 + 3 * (-t * t * t + 2 * v * t) *
        x2;
  };
  return (t, precision) {
    var x = t,t0,t1,t2,x2,d2,i;
    for (i = 0; i < 8; i++) {
      t2 = x;
      x2 = curveX(t2) - x;
      if (x2.abs() < precision) return curveY(t2);
      d2 = derivativeCurveX(t2);
      if (d2.abs() < 1e-6) break;
      t2 = t2 - x2 / d2;
    }
    t0 = 0;
    t1 = 1;
    t2 = x;

    if (t2 < t0) return curveY(t0);
    if (t2 > t1) return curveY(t1);
    while (t0 < t1) {
      x2 = curveX(t2);
      if ((x2 - x).abs() < precision) return curveY(t2);
      if (x > x2) {
        t0 = t2;
      } else {
        t1 = t2;
      }
      t2 = (t1 - t0) * .5 + t0;
    }
    return curveY(t2);
  };
}

https://www.youtube.com/watch?v=IyHb0SJms6w - explains how pointer-events: none; helps. https://www.youtube.com/watch?v=hAzhayTnhEI - why you should use AnimationFrame instead of timer.

It would be even better if you could use Web Animations, but, I think, it's impossible with the scroll effects, because it's not a css property.