分析 FlutterClcok 大赛 AnalogClock 项目。
August
老规矩,先上动图

先看一下 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 为 (-100,0) 的结果。
从 offset 构造不难看出,dx 为 x轴偏移量,dy 为 y 轴偏移量。
const Offset(double dx, double dy) : super(dx, dy);
偏移量是相对于原始图形 (BoxDecoration
)。
blurRadius
代表阴影模糊半径。上图:

到此,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
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),
)),
),
),
),
),
),
),
);
}
}
在这里, 可以看到,作者通过使用 Padding
(EdgeInsets.all(1.5 * unit)
),形成时钟边框:

接着使用 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),
),
)
switchInCurve
和 switchOutCurve
是调整动画进出的速率。如果学过动画制作,可以理解为“平滑关键帧”。
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
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],
),
),
),
],
),
);
}
}
可以看到,在在这里通用使用了 Padding
(EdgeInsets.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
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
旋转指针。
现在回过头看 HourHand
,ContainerHand
的 angleRadians
旋转角度传入:
_now.hour * radiansPerHour + (_now.minute / 60) * radiansPerHour
_now 是传入 HourHand
的 DateTime
类型参数。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 方法,返回 TweenAnimationBuilder
,TweenAnimationBuilder
是一个辅助做动画的 widget,duration 动画时常,在这里就是分针转动的时长。
tween
我们传入:
Tween<double>(
begin: value(_now - 1),
end: value(_now),
)
起始值 _now - 1
代表上次分钟转动的到的时间,_now
表示现在时间。这里通过 value 方法将秒数(或分钟数)转为弧度。
curve
我们之前有说过,代码动画速率。builder
传入 ContainerHand
,这个我们之前也讲过。把 _size
, anim
, child
传入 ContainerHand
,anim 就是指针转动幅度。
回过头看 MinuteHand
,AnimatedContainerHand
内包裹着 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相似,故放于一起分析。
pint & circle 总览
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
。
HourHandShadow
,MinuteHandShadow
,SecondHandShadow
指针阴影 总览
点击查看代码
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,
),
],
),
),
),
),
),
);
}
}
到这边就基本分析完成了。
Comments | NOTHING