分析 FlutterClcok 大赛 AnalogClock 项目。

August

老规矩,先上动图

AnalogClock

先看一下 build 中内容:

//...
Stack(
 children: [
  OuterShadows(customTheme: customTheme, unit: unit),
  AnimatedClockIcon(customTheme: customTheme, unit: unit, icon: icon),
  InnerShadows(customTheme: customTheme, unit: unit),
  ClockTicks(customTheme: customTheme, unit: unit),
  HourHandShadow(customTheme: customTheme, unit: unit, now: _now),
  MinuteHandShadow(customTheme: customTheme, unit: unit, now: _now),
  SecondHandShadow(customTheme: customTheme, unit: unit, now: _now),
  HourHand(customTheme: customTheme, unit: unit, now: _now),
  MinuteHand(customTheme: customTheme, unit: unit, now: _now),
  SecondHand(customTheme: customTheme, now: _now, unit: unit),
  SecondHandCircle(customTheme: customTheme, now: _now, unit: unit),
  ClockPin(customTheme: customTheme, unit: unit),
 ],
)
//...

从这可以明显的看出来“图层”,学过ps、ae等软件的同学应该了解。所以我们遵循“图层”模式去分析。


拆块分析,见 analog_clock.dart

OuterShadows

OuterShadows
点击查看代码

class OuterShadows extends StatelessWidget {
  const OuterShadows({
    Key key,
    @required this.customTheme,
    @required this.unit,
  }) : super(key: key);

  final ThemeData customTheme;

  final double unit;

  @override
  Widget build(BuildContext context) {
    final darkMode = Theme.of(context).brightness == Brightness.dark;

    // 通过 Container + 装饰物(BoxDecoration)
    return Container(
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: customTheme.backgroundColor,
        boxShadow: [
          // 亮色
          BoxShadow(
            color: darkMode ? Colors.white.withOpacity(0.2) : Colors.white,
            offset: Offset(-unit / 2, -unit / 2),
            blurRadius: 1.5 * unit,
          ),
          // 暗色
          BoxShadow(
            color: customTheme.dividerColor,
            offset: Offset(unit / 2, unit / 2),
            blurRadius: 1.5 * unit,
          ),
        ],
      ),
    );
  }
}


通过把 BoxDecoration 装饰器,形状设为圆形,加两个阴影。调整阴影偏移值,顺带模糊阴影得到了上图。

下面分析一下 BoxShadow 用法:

Container(
  decoration: BoxDecoration(
	shape: BoxShape.circle,
	color: Colors.redAccent,
	boxShadow: [
	  // 亮色
	  BoxShadow(
		color: Colors.blueAccent,
		offset: Offset(-10,0),
		blurRadius: 0,
	  ),
	],
  ),
)

上面代码可以得到下图:

offset 为 (-10,0)

这是 offset 为 (-10,0) 的结果。

offset 为 (-100,0)

这是 offset 为 (-100,0) 的结果。

从 offset 构造不难看出,dx 为 x轴偏移量,dy 为 y 轴偏移量。

  const Offset(double dx, double dy) : super(dx, dy);

偏移量是相对于原始图形 (BoxDecoration)。

blurRadius 代表阴影模糊半径。上图:

blurRadius: 100

到此,BoxShadow 介绍完成,本文不对源码进行深究。

回到项目的 OuterShadows

          // 亮色
          BoxShadow(
            color: darkMode ? Colors.white.withOpacity(0.2) : Colors.white,
            offset: Offset(-unit / 2, -unit / 2),
            blurRadius: 1.5 * unit,
          ),
          // 暗色
          BoxShadow(
            color: customTheme.dividerColor,
            offset: Offset(unit / 2, unit / 2),
            blurRadius: 1.5 * unit,
          ),

通过使用两个 BoxShadow 形成高光和阴影,从而模拟出拟物效果。


AnimatedClockIcon

点击查看代码

class AnimatedClockIcon extends StatelessWidget {
  const AnimatedClockIcon({
    Key key,
    @required this.unit,
    @required this.icon,
    @required this.customTheme,
  }) : super(key: key);

  final double unit;
  final IconData icon;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(1.5 * unit),
      child: Center(
        child: ClipOval(
          child: AspectRatio(
            aspectRatio: 1,
            child: Container(
              height: double.infinity,
              width: double.infinity,
              child: Transform.translate(
                offset: Offset(7 * unit, 3 * unit),
                child: Center(
                  child: AnimatedSwitcher(
                      duration: Duration(seconds: 2),
                      switchInCurve: Curves.easeInOut,
                      switchOutCurve: Curves.easeInOut,
                      transitionBuilder: (child, anim) {
                        return SlideTransition(
                          position: Tween(
                            begin: const Offset(1, 0),
                            end: Offset.zero,
                          ).animate(anim),
                          child: FadeTransition(
                            opacity: anim,
                            child: child,
                          ),
                        );
                      },
                      child: Icon(
                        icon,
                        size: 17 * unit,
                        color: customTheme.errorColor,
                        key: ValueKey(icon),
                      )),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}


在这里, 可以看到,作者通过使用 PaddingEdgeInsets.all(1.5 * unit)),形成时钟边框:

Padding

接着使用 Center 将其固定在布局中间,然后使用 ClipOval 圆形裁剪,剪去图标右边一部分。

裁剪演示

如上图所示,ClipOval 会裁剪红圈以外部分(红圈也就是ClipOval 本身不可见)。icon 之所以在右下角,分析源码可知是通过 Transform.translate

Transform.translate(
    offset: Offset(7 * unit, 3 * unit),
    child: xxx(),
)

offset 在上面分析过,所以上面这块代码不难,作用也就是之前所说,将 icon 固定到右下角。

继续分析源码可知 AnimatedClockIcon 最终是通过 AnimatedSwitcher 来展示动画的。AnimatedSwitcher 可以同时对其新、旧子元素添加显示、隐藏动画。也就是说在AnimatedSwitcher的子元素发生变化时,会对其旧元素和新元素:

AnimatedSwitcher(
	duration: Duration(seconds: 2),
	switchInCurve: Curves.easeInOut,
	switchOutCurve: Curves.easeInOut,
	transitionBuilder: (child, anim) {
	  return SlideTransition(
		position: Tween<Offset>(
		  begin: const Offset(1, 0),
		  end: Offset.zero,
		).animate(anim),
		child: FadeTransition(
		  opacity: anim,
		  child: child,
		),
	  );
	},
	child: Icon(
	  icon,
	  size: 17 * unit,
	  color: customTheme.errorColor,
	  key: ValueKey(icon),
	),
  )

switchInCurveswitchOutCurve 是调整动画进出的速率。如果学过动画制作,可以理解为“平滑关键帧”。

transitionBuilder 动画构建器:

transitionBuilder: (child, anim) {
  return SlideTransition(
	position: Tween<Offset>(
	  begin: const Offset(1, 0),
	  end: Offset.zero,
	).animate(anim),
	child: FadeTransition(
	  opacity: anim,
	  child: child,
	),
  );
},

使用 SlideTransition 进行平移动画,这里同样使用了 offset (Animation<Offset>)。对偏移值做动画。 child 是 FadeTransition 渐变动画。

动画

InnerShadows

点击查看代码

class InnerShadows extends StatelessWidget {
  const InnerShadows({
    Key key,
    @required this.unit,
    @required this.customTheme,
  }) : super(key: key);

  final double unit;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    final darkMode = Theme.of(context).brightness == Brightness.dark;
    return Padding(
      // 通过 Padding 与外阴影
      padding: EdgeInsets.all(1.5 * unit),
      child: Stack(
        children: [
          Container(
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: customTheme.backgroundColor,
              gradient: RadialGradient(
                colors: [
                  darkMode
                      ? customTheme.backgroundColor.withOpacity(0.0)
                      : Colors.white.withOpacity(0.0),
                  customTheme.dividerColor,
                ],
                center: AlignmentDirectional(0.1, 0.1),
                focal: AlignmentDirectional(0.0, 0.0),
                radius: 0.65,
                focalRadius: 0.001,
                stops: [0.3, 1.0],
              ),
            ),
          ),
          Container(
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: customTheme.backgroundColor,
              gradient: RadialGradient(
                colors: [
                  darkMode
                      ? customTheme.backgroundColor.withOpacity(0.0)
                      : Colors.white.withOpacity(0.0),
                  darkMode ? Colors.white.withOpacity(0.3) : Colors.white,
                ],
                center: AlignmentDirectional(-0.1, -0.1),
                focal: AlignmentDirectional(0.0, 0.0),
                radius: 0.67,
                focalRadius: 0.001,
                stops: [0.75, 1.0],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

可以看到,在在这里通用使用了 PaddingEdgeInsets.all(1.5 * unit)),与上文 AnimatedClockIcon 的 padding 一致,形成时钟的边框。

然后他使用了 Stack 内叠了两层阴影,在这里阴影是通过径向渐变(RadialGradient)实现的。

  Container(
	decoration: BoxDecoration(
	  shape: BoxShape.circle,
	  color: customTheme.backgroundColor,
	  gradient: RadialGradient(
		colors: [
		  darkMode
			  ? customTheme.backgroundColor.withOpacity(0.0)
			  : Colors.white.withOpacity(0.0),
		  customTheme.dividerColor,
		],
		center: AlignmentDirectional(0.1, 0.1),
		focal: AlignmentDirectional(0.0, 0.0),
		radius: 0.65,
		focalRadius: 0.001,
		stops: [0.3, 1.0],
	  ),
	),
  ),

BoxDecoration 之前有所介绍。关于渐变(RadialGradient)可以看看这篇文章

ClockTicks

class ClockTicks extends StatelessWidget {
  const ClockTicks({
    Key key,
    @required this.unit,
    @required this.customTheme,
  }) : super(key: key);

  final double unit;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        for (var i = 0; i < 12; i++)
          Center(
            child: Transform.rotate(
              angle: radians(0 + 360 / 12 * i),
              child: Transform.translate(
                offset: Offset(0, i % 3 == 0 ? -10 * unit : -10.2 * unit),
                child: Container(
                  color: customTheme.cursorColor,
                  height: i % 3 == 0 ? 2.4 * unit : 2.0 * unit,
                  width: i % 3 == 0 ? 0.3 * unit : 0.2 * unit,
                ),
              ),
            ),
          )
      ],
    );
  }
}

从代码中可以看到,创建了 12 个刻度。通过 Transform.rotate 将每个刻度转到相应位置。然后通过 Transform.translate ,将每个刻度移动到指定位置。offset 同样讲过。

模拟刻度

上图是旋转后的刻度,然后通过 Transform.translate 进行位置设置的模拟动画,Offset(0, i % 3 == 0 ? -10 * unit : -10.2 * unit) 如果是 12, 3, 6 , 9点 那么向自身方向的上方(y轴)进行偏移:

轴向模拟

HourHand

我们先跳过 HourHandShadow,因为 HourHandShadow 的实现和 HourHand 基本一致。

点击查看代码

class HourHand extends StatelessWidget {
  const HourHand({
    Key key,
    @required this.unit,
    @required DateTime now,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final double unit;
  final DateTime _now;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(2 * unit),
      child: ContainerHand(
        color: Colors.transparent,
        size: 0.5,
        angleRadians:
            _now.hour * radiansPerHour + (_now.minute / 60) * radiansPerHour,
        child: Transform.translate(
          offset: Offset(0.0, -3 * unit),
          child: Semantics.fromProperties(
            properties: SemanticsProperties(
                value: '${_now.hour}',
                label: 'Hour hand of the clock at position ${_now.hour} hrs.'),
            child: Container(
              width: 1.5 * unit,
              height: 7 * unit,
              decoration: BoxDecoration(
                color: customTheme.primaryColor,
              ),
            ),
          ),
        ),
      ),
    );
  }
}


这里有个自定义 widget :ContainerHand,而 ContainerHand 继承于 Hand,Hand 定义比较简单:

/// A base class for an analog clock hand-drawing widget.
///
/// This only draws one hand of the analog clock. Put it in a [Stack] to have
/// more than one hand.
abstract class Hand extends StatelessWidget {
  /// Create a const clock [Hand].
  ///
  /// All of the parameters are required and must not be null.
  const Hand({
    @required this.color,
    @required this.size,
    @required this.angleRadians,
  })  : assert(color != null),
        assert(size != null),
        assert(angleRadians != null);

  /// Hand color.
  final Color color;

  /// Hand length, as a percentage of the smaller side of the clock's parent
  /// container.
  final double size;

  /// The angle, in radians, at which the hand is drawn.
  ///
  /// This angle is measured from the 12 o'clock position.
  final double angleRadians;
}

ps: 这里的 color 并没有什么意义,完全可以删除。

这边存储指针颜色、指针大小、指针角度。下面看看 Hand 实现类 ContainerHand

/// A clock hand that is built out of the child of a [Container].
///
/// This hand does not scale according to the clock's size.
/// This hand is used as the hour hand in our analog clock, and demonstrates
/// building a hand using existing Flutter widgets.
class ContainerHand extends Hand {
  /// Create a const clock [Hand].
  ///
  /// All of the parameters are required and must not be null.
  const ContainerHand({
    @required Color color,
    @required double size,
    @required double angleRadians,
    this.child,
  })  : assert(size != null),
        assert(angleRadians != null),
        super(
          color: color,
          size: size,
          angleRadians: angleRadians,
        );

  /// The child widget used as the clock hand and rotated by [angleRadians].
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SizedBox.expand(
        child: Transform.rotate(
          angle: angleRadians,
          alignment: Alignment.center,
          child: Transform.scale(
            scale: size,
            alignment: Alignment.center,
            child: Container(
              color: color,
              child: Center(child: child),
            ),
          ),
        ),
      ),
    );
  }
}

使用 Container 配上 SizedBox.expand 构造出长条形的指针形状,再用 Transform.scale 调整指针大小,Transform.rotate 旋转指针。

现在回过头看 HourHandContainerHandangleRadians 旋转角度传入:

_now.hour * radiansPerHour + (_now.minute / 60) * radiansPerHour

_now 是传入 HourHandDateTime 类型参数。radiansPerHour 则代表一小时所旋转的角度。

final radiansPerHour = radians(360 / 12);

通过传入 360 / 12 也就是 30° 然后返回弧度。

至于 (_now.minute / 60) * radiansPerHour 则为当前分钟(不满一小时)转为小时获得的弧度。因为在分针转动的时候,小时也在转动。

度数分析完了,下面 Transform.translate 移动时针,使得时针末端对准时钟中心。

末端对准中心

Semantics.fromProperties 是专门针对残障人士的控件,这边先忽略。至于它的 child 也非常容易理解。

MinuteHand

这边依旧先忽略 MinuteHandShadow 理由和 HourHand 一致。

点击查看代码

class MinuteHand extends StatelessWidget {
  const MinuteHand({
    Key key,
    @required this.unit,
    @required DateTime now,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final double unit;
  final DateTime _now;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(2 * unit),
      child: AnimatedContainerHand(
        size: 0.5,
        now: _now.minute,
        child: Transform.translate(
          offset: Offset(0.0, -8 * unit),
          child: Semantics.fromProperties(
            properties: SemanticsProperties(
              value: '${_now.minute}',
              label: 'Minute hand of the clock at position ${_now.minute} min.',
            ),
            child: Container(
              width: unit / 2,
              height: unit * 15,
              decoration: BoxDecoration(
                color: customTheme.highlightColor,
              ),
            ),
          ),
        ),
      ),
    );
  }
}


可以看到这边又是一个自定义widget AnimatedContainerHand ,分析它的源码:

点击查看代码

class AnimatedContainerHand extends StatelessWidget {
  const AnimatedContainerHand({
    Key key,
    @required int now,
    @required Widget child,
    @required double size,
  })  : _now = now,
        _child = child,
        _size = size,
        super(key: key);

  final int _now;
  final Widget _child;
  final double _size;

  @override
  Widget build(BuildContext context) {
    if (_now == 0) {
      return TweenAnimationBuilder(
        key: ValueKey('special_case_when_overflowing'),
        duration: Duration(milliseconds: 300),
        tween: Tween(
          begin: value(_now - 1),
          end: value(_now),
        ),
        curve: Curves.easeInQuint,
        builder: (context, anim, child) {
          return ContainerHand(
            size: _size,
            angleRadians: anim,
            child: child,
          );
        },
        child: _child,
      );
    }
    return TweenAnimationBuilder(
      key: ValueKey('normal_case'),
      duration: Duration(milliseconds: 300),
      tween: Tween(
        begin: value(_now - 1),
        end: value(_now),
      ),
      curve: Curves.easeInQuint,
      builder: (context, anim, child) {
        return ContainerHand(
          size: _size,
          angleRadians: anim,
          child: child,
        );
      },
      child: _child,
    );
  }

  double value(int second) {
    return second * radiansPerTick;
  }
}

先看构造 :

  • now 当前时间(分钟)
  • child 分钟widget
  • size 分钟widget 大小

我们可以在buid方法中看到:

if (_now == 0) {
    //...
}

其中 key 字段发生了变化,其他均和 if 语句块下面的 widget 一致,这个 if (_now == 0) 在这的作用就是为了防止秒针、分针到达 12 这个位置的时候“反向转动”。

倒转

先看 build 方法,返回 TweenAnimationBuilderTweenAnimationBuilder 是一个辅助做动画的 widget,duration 动画时常,在这里就是分针转动的时长。

tween 我们传入:

Tween<double>(
	begin: value(_now - 1),
	end: value(_now),
)

起始值 _now - 1 代表上次分钟转动的到的时间,_now 表示现在时间。这里通过 value 方法将秒数(或分钟数)转为弧度。

curve 我们之前有说过,代码动画速率。builder 传入 ContainerHand ,这个我们之前也讲过。把 _sizeanimchild 传入 ContainerHand,anim 就是指针转动幅度。

回过头看 MinuteHandAnimatedContainerHand 内包裹着 Transform.translate,给予偏移值是的分针尾端对其时钟中心。Semantics.fromProperties 辅助残障人士。最后构建一个 Container 用于形成分针。

SecondHand

点击查看代码

class SecondHand extends StatelessWidget {
  const SecondHand({
    Key key,
    @required DateTime now,
    @required this.unit,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final DateTime _now;
  final double unit;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    print(_now.second);
    return AnimatedContainerHand(
      now: _now.second,
      size: 0.6,
      child: Transform.translate(
        offset: Offset(0.0, -4 * unit),
        child: Semantics.fromProperties(
          properties: SemanticsProperties(
              value: '${_now.second}',
              label:
                  'Seconds hand of the clock at position ${_now.second} sec.'),
          child: Container(
            width: unit / 2,
            height: double.infinity,
            decoration: BoxDecoration(
              color: customTheme.accentColor,
            ),
          ),
        ),
      ),
    );
  }
}


秒针,大体逻辑与分针一致,略过。

SecondHandCircle & ClockPin

这两个widget相似,故放于一起分析。

class SecondHandCircle extends StatelessWidget {
  const SecondHandCircle({
    Key key,
    @required DateTime now,
    @required this.unit,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final DateTime _now;
  final double unit;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return AnimatedContainerHand(
      now: _now.second,
      size: 0.6,
      child: Transform.translate(
        offset: Offset(0.0, 4 * unit),
        child: Container(
          width: 2 * unit,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: customTheme.accentColor,
          ),
        ),
      ),
    );
  }
}
class ClockPin extends StatelessWidget {
  const ClockPin({
    Key key,
    @required this.unit,
    @required this.customTheme,
  }) : super(key: key);

  final double unit;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Semantics.fromProperties(
        properties: SemanticsProperties(label: 'Clock center'),
        child: Container(
          height: 0.8 * unit,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: customTheme.accentColor,
          ),
        ),
      ),
    );
  }
}

与分针相似,只不过使用了圆形 BoxDecoration

HourHandShadowMinuteHandShadowSecondHandShadow

点击查看代码

class SecondHandShadow extends StatelessWidget {
  const SecondHandShadow({
    Key key,
    @required this.unit,
    @required DateTime now,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final double unit;
  final DateTime _now;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Transform.translate(
      offset: Offset(unit / 2, unit / 1.9),
      child: AnimatedContainerHand(
        now: _now.second,
        size: 0.6,
        child: Transform.translate(
          offset: Offset(0.0, -4 * unit),
          child: Container(
            width: unit / 3,
            height: double.infinity,
            decoration: BoxDecoration(
              color: Colors.transparent,
              boxShadow: [
                BoxShadow(
                  color: customTheme.canvasColor,
                  blurRadius: unit,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}


class MinuteHandShadow extends StatelessWidget {
  const MinuteHandShadow({
    Key key,
    @required this.unit,
    @required DateTime now,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final double unit;
  final DateTime _now;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Transform.translate(
      offset: Offset(unit / 3, unit / 3),
      child: Padding(
        padding: EdgeInsets.all(2 * unit),
        child: AnimatedContainerHand(
          size: 0.5,
          now: _now.minute,
          child: Transform.translate(
            offset: Offset(0.0, -8 * unit),
            child: Container(
              width: unit / 2,
              height: unit * 15,
              decoration: BoxDecoration(
                color: Colors.transparent,
                boxShadow: [
                  BoxShadow(
                    color: customTheme.canvasColor,
                    blurRadius: unit,
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}


class HourHandShadow extends StatelessWidget {
  const HourHandShadow({
    Key key,
    @required this.unit,
    @required DateTime now,
    @required this.customTheme,
  })  : _now = now,
        super(key: key);

  final double unit;
  final DateTime _now;
  final ThemeData customTheme;

  @override
  Widget build(BuildContext context) {
    return Transform.translate(
      offset: Offset(unit / 4, unit / 5),
      child: Padding(
        padding: EdgeInsets.all(2 * unit),
        child: ContainerHand(
          size: 0.5,
          angleRadians:
              _now.hour * radiansPerHour + (_now.minute / 60) * radiansPerHour,
          child: Transform.translate(
            offset: Offset(0.0, -3 * unit),
            child: Container(
              width: 1.5 * unit,
              height: 7 * unit,
              decoration: BoxDecoration(
                color: Colors.transparent,
                boxShadow: [
                  BoxShadow(
                    color: customTheme.canvasColor,
                    blurRadius: unit,
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}


到这边就基本分析完成了。


August

Android 开发者、影视后期内容(包装)制作者、Unity 2D游戏开发者

0 条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注