阅读 1519

[译] Flutter 从 0 到 1, 第二部分

Flutter 从 0 到 1(第一部分)

探索如何在跨平台移动应用程序的上下文中为复合图形对象设置动画。引入一个新的概念,如何将 tween 动画应用于结构化值中,例如条形图表。全部代码,按步骤实现。

修订:2018 年 8 月 8 日适配 Dart 2。GitHub repo 并且差异链接于 2018 年 10 月 17 日添加。


如何进入一个新的编程领域 ?实践是至关重要的,因为那是学习和模仿更有经验同行的代码。我个人喜欢挖掘概念:试着从最基本的原则出发,识别各种概念,探索它们的优势,有意识地寻求它们的本质。这是一种理性主义的方法,它不能独立存在,而是一种智力刺激的方法,可以更快地引导你获得更深入的见解。

这是 Flutter 及其 widget 和 tween 概念介绍的第二部分也是最后一部分。在 Flutter 从 0 到 1 第一部分 最后,在我们这么多 widges 的选择中,这个 tree 包含了下面两个:

  • 一个使用自定义动画绘制代码, 绘制单 一 Bar 的 widget
  • 初始化一个 Bar 的高度的 widget

高度动画 制作 Bar 的高度的动画

这个动画是通过 BarTween 来实现的,在第一部分中我曾经表明 tween 的概念可以扩展开去解决更加复杂的问题,这里我们会将会通过为更多属性的和多种配置下的条形图作出设计来证明这一点。


首先我们为单个条形图添加颜色属性。在 Bar 类的 height 字段旁边添加一个 color 字段,并更新 Bar.lerp 对它们进行线性插值。这种模式很典型:

通过线性插值对应的元件,生成 tween 的合成值。

回想一下第一部分,lerp线性插值 的缩写。

import 'dart:ui' show lerpDouble;

import 'package:flutter/animation.dart';
import 'package:flutter/material.dart';

class Bar {
  Bar(this.height, this.color);

  final double height;
  final Color color;

  static Bar lerp(Bar begin, Bar end, double t) {
    return Bar(
      lerpDouble(begin.height, end.height, t),
      Color.lerp(begin.color, end.color, t),
    );
  }
}

class BarTween extends Tween<Bar> {
  BarTween(Bar begin, Bar end) : super(begin: begin, end: end);

  @override
  Bar lerp(double t) => Bar.lerp(begin, end, t);
}
复制代码

注意这里对与 lerp 的使用。如果没有 Bar.lerplerpDouble(通常为 double.lerp)和 Color.lerp,我们就必须通过为高度创建 Tween <double> 同时为颜色创建 Tween<Color> 来实现 BarTween。这些 tweens 将是 BarTween 的实例字段,由构造函数初始化,并在其 lerp 方法中使用。 我们将在 Bar 类之外多次重复访问 Bar 的属性。代码维护者可能会发现这并不是一个好主意。

为条形的颜色和高度制作动画。

为了在应用程序中使用彩色条,我们将更新 BarChartPainter 来从 Bar 获得条形图颜色。在 main.dart 中,我们需要有能力来创建一个空的 Bar 和一个随机的 Bar。我们将为前者使用完全透明的颜色,为后者使用随机颜色。 颜色将从一个简单的 ColorPalette 类中获取,我们会在它自己的文件中快速实现它。 我们将在 Bar 类中创建 Bar.emptyBar.random 两个工厂构造函数 (code listing, diff).


条形图涉及各种配置的多种形式。为了缓慢地引入复杂性,我们的第一个实现将适用于显示固定类别数的值条形图。示例包括每个工作日的访问者或每季度的销售额。对于此类图表,将数据集更改为另一周或另一年不会更改使用的类别,只会更改每个类别显示的栏。

我们首先更新 main.dart,用 BarChart 替换 Bar,用 BarChartTween 替换 BarTween代码列表差分)。

为了更好体现 Dart 语言优势,我们在 bar.dart 中创建 BarChart 类,并使用固定数目的 Bar 实例列表来实现它。我们将使用五个条形图,表示一周中的工作日。然后,我们需要将创建空条和随机条的函数从 Bar 类中转移到 BarChart 类中。对于固定类别,空条形图合理地被视为空条的集合。另一方面,让随机条形图成为随机条形图的集合会使我们的图表变得多种多样。相反,我们将为图表选择一种随机颜色,让每个仍然具有随机高度的条形继承该图形。

import 'dart:math';
import 'dart:ui' show lerpDouble;

import 'package:flutter/animation.dart';
import 'package:flutter/material.dart';

import 'color_palette.dart';

class BarChart {
  static const int barCount = 5;

  BarChart(this.bars) {
    assert(bars.length == barCount);
  }

  factory BarChart.empty() {
    return BarChart(List.filled(
      barCount,
      Bar(0.0, Colors.transparent),
    ));
  }

  factory BarChart.random(Random random) {
    final Color color = ColorPalette.primary.random(random);
    return BarChart(List.generate(
      barCount,
      (i) => Bar(random.nextDouble() * 100.0, color),
    ));
  }

  final List<Bar> bars;

  static BarChart lerp(BarChart begin, BarChart end, double t) {
    return BarChart(List.generate(
      barCount,
      (i) => Bar.lerp(begin.bars[i], end.bars[i], t),
    ));
  }
}

class BarChartTween extends Tween<BarChart> {
  BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end);

  @override
  BarChart lerp(double t) => BarChart.lerp(begin, end, t);
}

class Bar {
  Bar(this.height, this.color);

  final double height;
  final Color color;

  static Bar lerp(Bar begin, Bar end, double t) {
    return Bar(
      lerpDouble(begin.height, end.height, t),
      Color.lerp(begin.color, end.color, t),
    );
  }
}

class BarTween extends Tween<Bar> {
  BarTween(Bar begin, Bar end) : super(begin: begin, end: end);

  @override
  Bar lerp(double t) => Bar.lerp(begin, end, t);
}

class BarChartPainter extends CustomPainter {
  static const barWidthFraction = 0.75;

  BarChartPainter(Animation<BarChart> animation)
      : animation = animation,
        super(repaint: animation);

  final Animation<BarChart> animation;

  @override
  void paint(Canvas canvas, Size size) {
    void drawBar(Bar bar, double x, double width, Paint paint) {
      paint.color = bar.color;
      canvas.drawRect(
        Rect.fromLTWH(x, size.height - bar.height, width, bar.height),
        paint,
      );
    }

    final paint = Paint()..style = PaintingStyle.fill;
    final chart = animation.value;
    final barDistance = size.width / (1 + chart.bars.length);
    final barWidth = barDistance * barWidthFraction;
    var x = barDistance - barWidth / 2;
    for (final bar in chart.bars) {
      drawBar(bar, x, barWidth, paint);
      x += barDistance;
    }
  }

  @override
  bool shouldRepaint(BarChartPainter old) => false;
}
复制代码

BarChartPainter 在条形图中宽度均匀分布,使每个条形占据可用宽度的 75%。

固定类别条形图。

注意 BarChart.lerp 是如何调用 Bar.lerp 实现的,使用 List.generate 生产列表结构。固定类别条形图是复合值,对于这些复合值,直接使用 lerp 进行有意义的组合,正如具有多个属性的单个条形图一样(diff)。


这里有一种模式。当 Dart 类的构造函数采用多个参数时,你通常可以线性插值单个参数或多个。你可以任意地嵌套这种模式:在 dashboard 中插入 bar charts,在 bar charts 中插入 bars,在 bars 中插入它们的高度和颜色。颜色 RGB 和 alpha 通过线性插值来组合。整个过程,就是递归叶节点上的值,进行线性插值。

在数学上倾向于用 _C_(_x_, _y_) 来表达复合的线性插值结构,而编程实践中我们用 _lerp_(_C_(_x_1, _y_1), _C_(_x_2, _y_2), _t_) == _C_(_lerp_(_x_1, _x_2, _t_), _lerp_(_y_1, _y_2, _t_))

正如我们所看到的,这很好地概括了两个元件(条形图的高度和颜色)到任意多个元件(固定类别 n 条条形图)。

当然,(这个表示方法)也有一些这个解决不了的问题。我们希望在两个不以完全相同的方式组成的值之间进行动画处理。举个简单的例子,考虑动画图表处理从包含工作日,到包括周末的情况。

你可能很容易想出这个问题的几种不同的临时解决方案,然后可能会要求你的UX设计师在它们之间进行选择。这是一种有效的方法,但我认为在讨论过程中要记住这些不同解决方案共有的基本结构:tween。回忆第一部分:

**动画值从 0 到 1 运动时,通过遍历空间路径中所有 _T_ 的路径进行动画。用 Tween_ _<T>_ 对路径建模。_

用户体验设计师要回答的核心问题是:图表有五个条形图和一个有七个条形图的中间值是多少? 显而易见的选择是六个条形图。 但是要使他的动画平滑,我们需要比六个条形图更多中间值。我们需要以不同方式绘制条形图,跳出等宽,均匀间隔,适合的 200 像素设置 这些具体的设置。换句话说,T 的值必须是通用的。

通过将值嵌入到通用数据中,在具有不同结构的值之间进行线性插值,包括动画端点和所有中间值所需的特殊情况。

我们可以分两步完成。第一步,在 Bar 类中包含 x 坐标属性和宽度属性:

class Bar {
  Bar(this.x, this.width, this.height, this.color);

  final double x;
  final double width;
  final double height;
  final Color color;

  static Bar lerp(Bar begin, Bar end, double t) {
    return Bar(
      lerpDouble(begin.x, end.x, t),
      lerpDouble(begin.width, end.width, t),
      lerpDouble(begin.height, end.height, t),
      Color.lerp(begin.color, end.color, t),
    );
  }
}
复制代码

第二步,我们使 BarChart 支持具有不同条形数的图表。我们的新图表将适用于数据集,其中条形图 i 代表某些系列中的第 i 个值,例如产品发布后的第 i 天的销售额。Counting as programmers,任何这样的图表都涉及每个整数值 0..n 的条形图,但条形图数 n 可能在各个图表中表示的意义不同。

考虑两个图表分别有五个和七个条形图。五个常见类别的条形图 0..5 像上面我们看到的那样进行动画。索引为5和6的条形在另一个动画终点没有对应条,但由于我们现在可以自由地给每个条形图设置位置和宽度,我们可以引入两个不可见的条形来扮演这个角色。视觉效果是当动画进行时,第 5 和第 6 条会减弱或淡化为隐形的。

通过线性插值对应的元件,生成 tween 的合成值。如果某个端点缺少元件,在其位置使用不可见元件。

通常有几种方法可以选择隐形元件。假设我们友好的用户体验设计师决定使用零宽度,零高度的条形图,其中 x 坐标和颜色从它们的可见元件继承而来。我们将为 Bar 类添加一个方法,用于处理这样的实例。

class BarChart {
  BarChart(this.bars);

  final List<Bar> bars;

  static BarChart lerp(BarChart begin, BarChart end, double t) {
    final barCount = max(begin.bars.length, end.bars.length);
    final bars = List.generate(
      barCount,
      (i) => Bar.lerp(
            begin._barOrNull(i) ?? end.bars[i].collapsed,
            end._barOrNull(i) ?? begin.bars[i].collapsed,
            t,
          ),
    );
    return BarChart(bars);
  }

  Bar _barOrNull(int index) => (index < bars.length ? bars[index] : null);
}

class BarChartTween extends Tween<BarChart> {
  BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end);

  @override
  BarChart lerp(double t) => BarChart.lerp(begin, end, t);
}

class Bar {
  Bar(this.x, this.width, this.height, this.color);

  final double x;
  final double width;
  final double height;
  final Color color;

  Bar get collapsed => Bar(x, 0.0, 0.0, color);
  
  static Bar lerp(Bar begin, Bar end, double t) {
    return Bar(
      lerpDouble(begin.x, end.x, t),
      lerpDouble(begin.width, end.width, t),
      lerpDouble(begin.height, end.height, t),
      Color.lerp(begin.color, end.color, t),
    );
  }
}
复制代码

将上述代码集成到我们的应用程序中,涉及重新定义 BarChart.emptyBarChart.random。现在可以合理地将空条形图设置包含零条,而随机条形图可以包含随机数量的条,所有条都具有相同的随机选择颜色,并且每个条具有随机选择的高度。但由于位置和宽度现在是 Bar类定义的,我们需要 BarChart.random 来指定这些属性。用图表 Size 作为BarChart.random 的参数似乎是合理的,这样可以解除 BarChartPainter.paint 大部分计算(代码列表差分)。

隐藏条形图线性插值。


大多数读者可能已经注意 BarChart.lerp 有潜在的效率问题。我们创建 Bar 实例只是作为参数提供给 Bar.lerp 函数,并且对于每个动画参数的 t 值都是重复调用。每秒 60 帧,即使是相对较短的动画,也意味着很多 Bar 实例被送到垃圾收集器。我们还有其他选择:

  • Bar 实例可以通过在 Bar 类中创建一次而不是每次调用 collapsed 来重新生成。这种方法适用于此,但并不通用。

  • 可以用 BarChartTween 来处理重用问题,方法是让 BarChartTween 的构造函数创建条形图列表时使用的 BarTween 实例的列表 _tween(i)=> _tweens [i] .lerp(t )。这种方法打破了整个使用静态lerp方法的惯例。静态BarChart.lerp 不会在动画持续时间内存储 tween 列表的对象。相比之下,BarChartTween 对象非常适合这种情况。

  • 假设处理逻辑在 Bar.lerp 中,null 条可用于表示折叠条。这种方法既灵活又高效,但需要注意避免引用或误解 null。在 Flutter SDK 中,静态 lerp 方法倾向于接受 null 作为动画终点,通常将其解释为某种不可见元件,如完全透明的颜色或零大小的图形元件。作为最基本的例子,除非两个动画端点都是 null 之外 lerpDoublenull 视为 0。

下面的代码段显示了我们如何处理 null

class BarChart {
  BarChart(this.bars);

  final List<Bar> bars;

  static BarChart lerp(BarChart begin, BarChart end, double t) {
    final barCount = max(begin.bars.length, end.bars.length);
    final bars = List.generate(
      barCount,
      (i) => Bar.lerp(begin._barOrNull(i), end._barOrNull(i), t),
    );
    return BarChart(bars);
  }

  Bar _barOrNull(int index) => (index < bars.length ? bars[index] : null);
}

class BarChartTween extends Tween<BarChart> {
  BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end);

  @override
  BarChart lerp(double t) => BarChart.lerp(begin, end, t);
}

class Bar {
  Bar(this.x, this.width, this.height, this.color);

  final double x;
  final double width;
  final double height;
  final Color color;

  static Bar lerp(Bar begin, Bar end, double t) {
    if (begin == null && end == null)
      return null;
    return Bar(
      lerpDouble((begin ?? end).x, (end ?? begin).x, t),
      lerpDouble(begin?.width, end?.width, t),
      lerpDouble(begin?.height, end?.height, t),
      Color.lerp((begin ?? end).color, (end ?? begin).color, t),
    );
  }
}
复制代码

我认为公正的说 Dart 的 语法非常适合这项任务。但请注意,使用折叠(而不是透明)条形图作为不可见元件的决定现在隐藏在 Bar.lerp 中。这是我之前选择看似效率较低的解决方案的主要原因。与性能与可维护性一样,你的选择应基于实践。


在完整地处理条形图动画之前,我们还有一个步要做。考虑使用条形图的应用程序,按给定年份的产品类别显示销售额。用户可以选择另一年,然后应用应该为该年的条形图设置动画。如果两年的产品类别相同,或者恰好相同,除了其中一个图表右侧显示的其他类别,我们可以使用上面的现有代码。但是,如果公司在 2016 年拥有 A、B、C 和 X 类产品,但是已经停产 B 并在 2017 年引入了 D,那该怎么办?我们现有的代码动画如下:

2016  2017
  A -> A
  B -> C
  C -> D
  X -> X
复制代码

动画可能是美丽而流畅的,但它仍然会让用户感到困惑。为什么?因为它不保留语义。它将表示产品类别 B 的图形元件转换为表示类别 C 的图形元件,而将 C 表示元件转移到其他地方。仅仅因为 2016 B 恰好被绘制在 2017 C 后来出现的相同位置,并不意味着前者应该变成后者。相反,2016 B 应该消失,2016 C 应该向左移动并变为 2017 C,2017 D 应该出现在右边。我们可以使用书中最古老的算法之一来实现这种融合:合并排序列表。

通过线性插值对应的元件,生成 tween 的合成值。当元素形成排序列表时,合并算法可以使这些元素处于同等水平,根据需要使用不可见元素来处理单侧合并。

我们所需要的只是使 Bar 实例按线性顺序相互比较。然后我们可以合并它们,如下:

  static BarChart lerp(BarChart begin, BarChart end, double t) {
    final bars = <Bar>[];
    final bMax = begin.bars.length;
    final eMax = end.bars.length;
    var b = 0;
    var e = 0;
    while (b + e < bMax + eMax) {
      if (b < bMax && (e == eMax || begin.bars[b] < end.bars[e])) {
        bars.add(Bar.lerp(begin.bars[b], begin.bars[b].collapsed, t));
        b++;
      } else if (e < eMax && (b == bMax || end.bars[e] < begin.bars[b])) {
        bars.add(Bar.lerp(end.bars[e].collapsed, end.bars[e], t));
        e++;
      } else {
        bars.add(Bar.lerp(begin.bars[b], end.bars[e], t));
        b++;
        e++;
      }
    }
    return BarChart(bars);
  }
复制代码

具体地说,我们将为 bar 添加 rank 属性作一个排序键。rank 也可以方便地用于为每个栏分配调色板中的颜色,从而允许我们跟踪动画演示中各个小节的移动。

随机条形图现在将基于随机选择的 rank 来包括(代码列表diff)。

任意类别。合并基础,线性插值。

干的不错,但也许不是最有效的解决方案。 我们在 BarChart.lerp 中重复执行合并算法,对于 t 的每个值都执行一次。为了解决这个问题,我们将实现前面提到的想法,将可重用信息存储在 BarChartTween 中。

class BarChartTween extends Tween<BarChart> {
  BarChartTween(BarChart begin, BarChart end) : super(begin: begin, end: end) {
    final bMax = begin.bars.length;
    final eMax = end.bars.length;
    var b = 0;
    var e = 0;
    while (b + e < bMax + eMax) {
      if (b < bMax && (e == eMax || begin.bars[b] < end.bars[e])) {
        _tweens.add(BarTween(begin.bars[b], begin.bars[b].collapsed));
        b++;
      } else if (e < eMax && (b == bMax || end.bars[e] < begin.bars[b])) {
        _tweens.add(BarTween(end.bars[e].collapsed, end.bars[e]));
        e++;
      } else {
        _tweens.add(BarTween(begin.bars[b], end.bars[e]));
        b++;
        e++;
      }
    }
  }

  final _tweens = <BarTween>[];

  @override
  BarChart lerp(double t) => BarChart(
        List.generate(
          _tweens.length,
          (i) => _tweens[i].lerp(t),
        ),
      );
}
复制代码

我们现在可以删除静态方法 BarChart.lerpdiff)。


让我们总结一下到目前为止我们对 tween 概念的理解:

动画 T 通过在所有 T 的空间中描绘出一条路径作为动画值,在 0 到 1 之间运行。使用 _Tween <T> _ 路径建模。

先泛化 _T_ 的概念,直到它包含所有动画端点和中间值。

通过线性插值对应的元件,生成 tween 的合成值。

  • 相对应性应该基于语义,而不是偶然的图形定位。
  • 如果某个动画终点中缺少某个元件,在其位置使用不可见的元件,这个元件可能是从另一个端点派生出来的。
  • 在元件形成排序列表的位置,使用合并算法将语义上相应的元件放在一起,根据需要使用不可见元件来处理单侧合并。

考虑使用静态方法 _Xxx.lerp_ 实现 tweens,以便在实现复合 tween 实现时重用。对单个动画路径调用 _Xxx.lerp_ 进行重要的重新计算,请考虑将计算移动到 _XxxTween_ 类的构造函数,并让其实例承载计算结果。 。_


有了这些见解,我们终于有了将更复杂的图表动画化的能力。我们将快速连续地实现堆叠条形图,分组条形图和堆叠 + 分组条形图:

  • 堆叠条形用于二维类别数据集,并且条形高度的数量加起来是有意义的。一个典型的例子是产品和地理区域的收入。按产品堆叠可以轻松比较全球市场中的产品的表现。按区域堆叠显示哪些区域重要。

堆叠条形图。

  • 分组条也用于具有二维类别的数据集,这种情况使用堆叠条形图没有意义或不合适。例如,如果数据是每个产品和区域的市场份额百分比,则按产品堆叠是没有意义的。即使堆叠确实有意义,分组也是可取的,因为它可以更容易地同时对两个类别维度进行定量比较。

分组条形图。

  • 堆叠 + 分组条形图支持三维类别,好比产品的收入,地理区域和销售渠道。

堆叠 + 分组条形图。

在所有三种变体中,动画可用于可视化数据集更改,从而引入额外的维度(通常是时间)而不会使图表混乱。

为了使动画有用而不仅仅是漂亮,我们需要确保我们只在语义相应的元件之间进 lerp。因此,用于表示 2016 年特定产品/地区/渠道收入的条形段,应变为 2017 年相同产品/区域/渠道(如果存在)的收入。

合并算法可用于确保这一点。 正如你在前面的讨论中所猜测的那样,合并将被用于多个层面,来反应类别的维度。我们将在堆积图表中组合堆和条形图,在分组图表中合并组和条形图,以及堆叠 + 分组图表中组合上面三个。

为了减少重复代码,我们将合并算法抽象为通用工具,并将其放在自己的文件 tween.dart 中:

import 'package:flutter/animation.dart';
import 'package:flutter/material.dart';

abstract class MergeTweenable<T> {
  T get empty;

  Tween<T> tweenTo(T other);

  bool operator <(T other);
}

class MergeTween<T extends MergeTweenable<T>> extends Tween<List<T>> {
  MergeTween(List<T> begin, List<T> end) : super(begin: begin, end: end) {
    final bMax = begin.length;
    final eMax = end.length;
    var b = 0;
    var e = 0;
    while (b + e < bMax + eMax) {
      if (b < bMax && (e == eMax || begin[b] < end[e])) {
        _tweens.add(begin[b].tweenTo(begin[b].empty));
        b++;
      } else if (e < eMax && (b == bMax || end[e] < begin[b])) {
        _tweens.add(end[e].empty.tweenTo(end[e]));
        e++;
      } else {
        _tweens.add(begin[b].tweenTo(end[e]));
        b++;
        e++;
      }
    }
  }

  final _tweens = <Tween<T>>[];

  @override
  List<T> lerp(double t) => List.generate(
        _tweens.length,
        (i) => _tweens[i].lerp(t),
      );
}
复制代码

MergeTweenable <T> 接口精确获得合并两个有序的 T 列表的所需的 tween 内容。我们将使用 BarBarStackBarGroup 实例化泛型参数 T,并且实现 MergeTweenable <T>diff)。

stackeddiff)、groupeddiff)和 stacked+groupeddiff)已经完成实现。我建议你自己实践一下:

  • 更改 BarChart.random创建的 groups、stacks 和 bars 的数量。
  • 更改调色板。对于 stacked+grouped,我使用了单色调色板,因为我觉得它看起来更好。你和你的 UX 设计师可能并不认同。
  • BarChart.random 和浮动操作按钮替换为年份选择器,并以实际数据集创建 BarChart 实例。
  • 实现水平条形图。
  • 实现其他图表类型(饼图,线条,堆积区域)。使用 MergeTweenable <T> 或类似方法为它们设置动画。
  • 添加图表图例,标签,坐标轴,然后为它们设置动画。

最后两个任务非常具有挑战性。不妨试试。

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

关注下面的标签,发现更多相似文章
评论