Skip to content
academia.sh

Lesson 13 / 19

Animations

An animation runs the layout again on every frame, but the owner of the width may not change on every frame. The lesson moves the width of a panel from 80 to 240 in five frames and measures the sizing of each frame.

Published Updated

In this lesson

  • Tells the animation controller, the curve, and the tween apart
  • States the difference between implicit and explicit animation
  • Measures the width and the sizing of the panel in each of the five frames
  • Shows that the owner of the width does not change when the curve changes
Contents

The previous lesson measured that the font scale enlarges the request of a button, and whether the growing request fits inside the range. In this lesson the request changes over time: the width of a panel grows a little more on every frame. An animation stops the layout from being a one-time computation and runs it again for every frame. The course question is the same again: who determines this widget’s width? The lesson looks for the answer through a panel that grows across frames.

Controller, Curve, and Tween

An animation has three parts. The animation controller carries the progress from 0 to 1; who starts it and stops it, and when, is up to the controller. The curve shapes the progress: a linear curve moves in equal steps, and an accelerating curve moves slowly first and quickly later. The tween turns the progress into a value between a start and an end. In this lesson the tween is the width from 80 to 240.

In an implicit animation the widget takes the new value and builds the transition itself; the framework manages the controller. In an explicit animation the controller is held in code and the progress is driven by hand.

The Core

Every lesson uses the same core. measure walks the tree with a tight constraint at the screen width, produces a record for each node, and gives the sizing in the record by a rule: if the constraint is tight, forced; if the request stays inside the range, chosen; if the request falls outside the range, or there is no request and there is no upper bound, clamped; if there is no request and the upper bound is finite, stretched. The four names are written in ASCII in the output: forced, chosen, clamped, stretched. The parent: line is the total of forced, clamped, and stretched; it counts the records whose width is not the widget’s own request.

// sizing.dart — classifies who decides a widget's width
const sizings = ['forced', 'chosen', 'clamped', 'stretched'];

class Limit {
  final int min;
  final int? max; // null: unbounded
  const Limit(this.min, this.max);

  bool get tight => max == min;
  Limit loosen() => Limit(0, max);
  Limit deflate(int by) => Limit(_floor(min - by), max == null ? null : _floor(max! - by));
  String bounds() => '$min..${max ?? 'inf'}';
}

int _floor(int value) => value < 0 ? 0 : value;

class Part {
  final String kind;
  final String name;
  final int? width; // null: as wide as it can get
  final List<Part> children;
  final int factor;
  final bool tightFlex;
  const Part(this.kind, this.name, this.width, this.children,
      {this.factor = 0, this.tightFlex = true});
}

Part leaf(String name, int width) => Part('leaf', name, width, const []);
Part fill(String name) => Part('leaf', name, null, const []);
Part pad(String name, int padding, Part child) => Part('pad', name, padding, [child]);
Part center(String name, Part child) => Part('center', name, null, [child]);
Part fixed(String name, int width, Part child) => Part('fixed', name, width, [child]);
Part column(String name, List<Part> children) => Part('column', name, null, children);
Part row(String name, List<Part> children) => Part('row', name, null, children);
Part flex(String name, int factor, Part child, {bool tight = true}) =>
    Part('flex', name, null, [child], factor: factor, tightFlex: tight);

class Sizing {
  final String sizing;
  final String path;
  final int width;
  final Limit limit;
  const Sizing(this.sizing, this.path, this.width, this.limit);
}

Sizing classify(String path, int? desire, Limit limit) {
  if (limit.tight) return Sizing('forced', path, limit.min, limit);
  if (desire == null) {
    return limit.max == null
        ? Sizing('clamped', path, limit.min, limit)
        : Sizing('stretched', path, limit.max!, limit);
  }
  if (desire < limit.min) return Sizing('clamped', path, limit.min, limit);
  if (limit.max != null && desire > limit.max!) {
    return Sizing('clamped', path, limit.max!, limit);
  }
  return Sizing('chosen', path, desire, limit);
}

int layout(Part part, Limit limit, List<Sizing> log, [String path = '']) {
  final here = '$path/${part.name}';
  final below = <Sizing>[];
  if (part.kind == 'fixed') {
    final own = classify(here, part.width, limit);
    layout(part.children.first, Limit(own.width, own.width), below, here);
    log..add(own)..addAll(below);
    return own.width;
  }
  int? desire;
  switch (part.kind) {
    case 'leaf':
      desire = part.width;
    case 'pad':
      final space = 2 * part.width!;
      desire = layout(part.children.first, limit.deflate(space), below, here) + space;
    case 'center':
      final inner = layout(part.children.first, limit.loosen(), below, here);
      desire = limit.max == null ? inner : null;
    case 'flex':
      desire = layout(part.children.first, limit, below, here);
    case 'column':
      var widest = 0;
      for (final child in part.children) {
        final width = layout(child, limit.loosen(), below, here);
        if (width > widest) widest = width;
      }
      desire = widest;
    case 'row':
      final logs = [for (final _ in part.children) <Sizing>[]];
      final bounded = limit.max != null;
      var used = 0;
      var factors = 0;
      for (var i = 0; i < part.children.length; i++) {
        final child = part.children[i];
        if (child.kind == 'flex' && bounded) {
          factors += child.factor;
        } else {
          used += layout(child, const Limit(0, null), logs[i], here);
        }
      }
      final left = bounded ? _floor(limit.max! - used) : 0;
      var given = 0;
      var seen = 0;
      for (var i = 0; i < part.children.length; i++) {
        final child = part.children[i];
        if (child.kind != 'flex' || !bounded) continue;
        seen += child.factor;
        final share = (seen == factors ? left : left * seen ~/ factors) - given;
        given += share;
        final room = child.tightFlex ? Limit(share, share) : Limit(0, share);
        used += layout(child, room, logs[i], here);
      }
      for (final entries in logs) {
        below.addAll(entries);
      }
      desire = bounded ? null : used;
  }
  final own = classify(here, desire, limit);
  log..add(own)..addAll(below);
  return own.width;
}

List<Sizing> measure(Part root, int screen) {
  final log = <Sizing>[];
  layout(root, Limit(screen, screen), log);
  return log;
}

void printSizes(List<Sizing> log) {
  for (final entry in log) {
    print('${entry.sizing}: ${entry.path} width=${entry.width} constraint=${entry.limit.bounds()}');
  }
}

void countSizings(List<Sizing> log) {
  for (final name in sizings) {
    print('$name: ${log.where((entry) => entry.sizing == name).length}');
  }
  print('parent: ${log.where((entry) => entry.sizing != 'chosen').length}');
}

The Framework Side

The snippet below shows the framework side of an explicit animation. The snippet is not measured; the model does not run the code of the framework.

// Platform code: explicit animation
class _PanelState extends State<PanelView> with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(vsync: this);
  late final Animation<double> _width = Tween<double>(begin: 80, end: 240)
      .animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));

  void open() => _controller.animateTo(1.0, duration: widget.span);
}

The controller drives the progress, CurvedAnimation applies the curve, and Tween produces the width. Every frame means a new width, and a new width means the layout runs again. The question of this lesson is the owner of the width as a result of that run.

From Frame to Tree

The model has no time; it has only the order of frames. Five frames are numbered from 0 to 4, and the tween produces an integer on every frame. The helper file carries two curves and a frame counter. The linear curve takes equal steps; the accelerating curve scales the step with its square. The counter is lesson-local and counts the frames. The helper file prints nothing to the output.

// tween.dart — tweens and the frame counter
int linearWidth(int from, int to, int step, int steps) =>
    from + (to - from) * step ~/ steps;

int easedWidth(int from, int to, int step, int steps) =>
    from + (to - from) * step * step ~/ (steps * steps);

class FrameCounter {
  int frames = 0;
  void tick() => frames++;
}

buildPanel takes the width of the panel and builds the tree: a column inside the screen, and inside the column a title and the panel. The first measurement is the start frame of the animation.

// main.dart
import 'sizing.dart';
import 'tween.dart';

Part buildPanel(int width) {
  return pad('Screen', 16, column('Body', [
    leaf('Title', 120),
    leaf('Panel', width),
  ]));
}

void main() {
  final log = measure(buildPanel(linearWidth(80, 240, 0, 4)), 360);
  printSizes(log);
  countSizings(log);
}
forced: /Screen width=360 constraint=360..360
forced: /Screen/Body width=328 constraint=328..328
chosen: /Screen/Body/Title width=120 constraint=0..328
chosen: /Screen/Body/Panel width=80 constraint=0..328
forced: 2
chosen: 2
clamped: 0
stretched: 0
parent: 2

In the start frame the panel asks for 80, and the range is 0..328. /Screen/Body/Panel is chosen and its width is 80. The title is also chosen. The animation has not changed anything yet; the tree at frame zero would be the same without the animation.

Five Frames

Now measure the five frames in order with two curves. Each frame is a separate measure call, and from each frame only the record of the panel is printed.

// main.dart
import 'sizing.dart';
import 'tween.dart';

Part buildPanel(int width) {
  return pad('Screen', 16, column('Body', [
    leaf('Title', 120),
    leaf('Panel', width),
  ]));
}

void main() {
  final curves = {'linear': linearWidth, 'eased': easedWidth};
  curves.forEach((name, curve) {
    for (var step = 0; step <= 4; step++) {
      final width = curve(80, 240, step, 4);
      final log = measure(buildPanel(width), 360);
      final panel = log.firstWhere((entry) => entry.path == '/Screen/Body/Panel');
      print('$name frame=$step request=$width ${panel.sizing} width=${panel.width}');
    }
  });
}
linear frame=0 request=80 chosen width=80
linear frame=1 request=120 chosen width=120
linear frame=2 request=160 chosen width=160
linear frame=3 request=200 chosen width=200
linear frame=4 request=240 chosen width=240
eased frame=0 request=80 chosen width=80
eased frame=1 request=90 chosen width=90
eased frame=2 request=120 chosen width=120
eased frame=3 request=170 chosen width=170
eased frame=4 request=240 chosen width=240

The linear curve produces 80, 120, 160, 200, and 240; the accelerating curve produces 80, 90, 120, 170, and 240. The two curves give different widths in between, and both end at 240. But in ten of the ten lines the sizing is chosen, and the width is equal to the request. The curve shapes the value of the request; it does not change the relation of the request to the range.

The question how many times did it run counts how many times a job ran. An animation runs the layout again on every frame, and the number of runs really does grow; this lesson does not take it as a measure. What is measured on every frame is the owner of the width in that frame.

The Bounding Measurement

Count the frames with the counter and fold the sizing of each frame into a tally. If all five frames give the same sizing, the tally comes down to a single line. Also print the line of the panel in the last frame as the focus.

// main.dart
import 'sizing.dart';
import 'tween.dart';

Part buildPanel(int width) {
  return pad('Screen', 16, column('Body', [
    leaf('Title', 120),
    leaf('Panel', width),
  ]));
}

void main() {
  final counter = FrameCounter();
  final tally = <String, int>{};
  String last = '';
  for (var step = 0; step <= 4; step++) {
    counter.tick();
    final log = measure(buildPanel(linearWidth(80, 240, step, 4)), 360);
    final panel = log.firstWhere((entry) => entry.path == '/Screen/Body/Panel');
    tally[panel.sizing] = (tally[panel.sizing] ?? 0) + 1;
    last = '${panel.path} ${panel.sizing} width=${panel.width} constraint=${panel.limit.bounds()}';
  }
  print('frames=${counter.frames}');
  tally.forEach((sizing, count) => print('$sizing=$count'));
  print('last frame: $last');
}
frames=5
chosen=5
last frame: /Screen/Body/Panel chosen width=240 constraint=0..328

The counter prints 5, and the tally is a single line: five of the five frames are chosen. The focus node is /Screen/Body/Panel in the last frame. Its width is 240, its constraint is 0..328, and the panel has chosen its own request. While the panel grew from 80 to 240, the tree was measured again five times; but on every frame the panel determined the width. A growing width does not change the owner of the width.

AS1. The model measures only the width. The duration of a frame, the progress of the curve in real time, starting and cancelling the animation, and the height of the panel are not in the model. The model builds this sizing’s rule; the model is not the framework itself. The integer progress of five frames is the assumption of the lesson; a real animation produces fractional values.

The result has a limit, and it is not measured in this lesson: by the definition of the rule, after a frame whose request exceeds 328 the panel becomes clamped. Whether the end value fits on the screen is not the job of the tween, but of the side that knows the range.

Summary

  • This lesson’s sizing: chosen. The focus node is /Screen/Body/Panel in the last frame: the request of 240 stays inside the 0..328 range, and the panel chooses its own request.
  • The controller drives the progress, the curve shapes the progress, and the tween produces the width; in an implicit animation the framework manages the controller.
  • The linear and accelerating curves gave different widths in the in-between frames (160 and 120 at frame 2); in ten of ten measurements the sizing came out chosen.
  • The five frames were measured separately, the counter printed 5, and all five were chosen.
  • The rule sees not the number of runs, but the owner of the width in that frame.

Next Step

In this lesson the request changed on every frame and the owner did not change. The next lesson, Platform Channels, measures the case where the request comes from outside the framework, from the side of the platform itself: it looks at how the width that an embedded platform view asks for is pulled to the width the slot gives, however many messages arrive.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close