文章首发于公众号:技术最TOP
周末发表了一篇文章《这个项目也太屌了吧》,给大家推荐了一个炫酷的Flutter粒子时钟项目,不过没有将具体实现思路和代码,所幸,作者自己写了一篇博客将这个项目的背景、实现思路、和所遇到的问题,我觉得对非常有用,因此翻译出来,整理给大家!原文题目《我是如何创建粒子时钟,并赢得了#FlutterClock挑战的》
。
背景
Google在2019年11月18日发起了The Flutter Clock Challenge
挑战活动,内容很简单:使用Flutter UI工具包设计时钟
。Google专家小组将根据四个主要标准对参赛作品进行评判:视觉美感
,创意新颖性
,代码质量
和整体执行力
。
在这之前,我只使用过Flutter一两次,这对于我来说是一个潜在的机会。
最初的想法
挑战开始了大约两周后,我才有了一些想法,但还没有编写任何代码,解决此类问题时,我通常的方法是首先寻找现有的解决方案来找灵感。但这次不行。相反,我在Figma
上新建了一个文档,并列出了一些想法。它们都是非常简单数字时钟设计
和晦涩的单色组合
。
很快地,我就对这个设计感到厌倦了,所以我关闭了Figma,就去下载了Flutter Clock GitHub
(https://github.com/flutter/flutter_clock)库,以查看一些示例代码。
这个库包含了2个项目,一个是基于模拟时钟的演示,另一个是基于数字时钟的演示,我在Figma上设计都是数字的,所以自然而然地,我启动了基本的数字时钟项目。再次缺乏灵感,在示例项目的帮助下,我搁置了挑战,开始去做其他事儿。
几天之后,在一次晨跑中,我又开始认真的思考这个挑战,一个普通的成年人每天看几次手表?对我来说,让时钟变得有趣起来是真正的挑战。是否可以使“报时间”成为自动体验?比如:即使您对时间不感兴趣,看着手表也很有趣。这不仅需要视觉上令人惊叹的设计
或新颖的动画方案
。
- 1、如果每次您看钟时都有不同的外观会怎样?
- 2、我们能否激发好奇心,使您渴望下一次设计迭代?或者,当您喜欢的设计永久消失时,您会感到难过吗?
- 3、我们是否可以将背景形状、颜色、动画随机改变以使它:a. 看起来很酷,b. 足够满足1和2的随机性,c. 不会分散你看时间的注意力?
我以前从未完成过任何艺术,或者根本没有做过Flutter,所以我着手建造这样的时钟。
粒子与随机性
第一次迭代只是一个时钟。如上所述,我从示例数字时钟项目
开始,而不是从头开始。创建的第一个Widget是一个CustomPainter
,仅绘制了一个圆圈。很不错,但是从长远来看不是很有趣。
随机性增加了,从颜色开始,然后确定位置和大小。所有逻辑仍然在单个CustomPainter
的paint()
方法中,这几乎使动画变得不可能,因此需要将一堆逻辑重构成一个简单的粒子系统。我看了Flutter Vignettes
项目,以寻求启发。
这时候,制作模拟粒子时钟的想法变得更加明显了。
将粒子变成模拟时钟
提出想法后,我要做的就是编写代码以实现所有目标。数学部分花费了我最多的时间才最终完成,大多是我多年以前学过的数学,不过好多我都忘记了,角度,弧度,PI和类似的东西,网上又许多解决方案,但是你将不得不做一些修改以适应您的用例。
以下是获取时针弧度的方法:
/// Gets the radians of the hour hand.
double _getHourRadians() =>
(time.hour * pi / 6) +
(time.minute * pi / (6 * 60)) +
(time.second * pi / (360 * 60));
我在计算中包括了time.minute
和time.second
,以使时针在数小时之间平滑地动画。
然后,从弧度获得2D运动矢量就很简单了。
// Particle movement vector.
p.vx = sin(-angle);
p.vy = cos(-angle);
现在,p.vx
和p.vy
拥有 有关粒子在每个动画滴答声中
应该移动多远的信息,同时保持在时针的角度里。
除了钟针外,粒子还可能作为噪音产生。然后它将从中心沿随机方向发射。在发出时,还将为所有粒子分配随机的速度
,颜色
,大小
和绘画样式
(填充或笔划)。这使时钟看起来更有趣。
时钟的早期版本中。这有四分之一标记,有些粒子有速度标记。时钟的第一个版本只是粒子,这里就不花时间截图演示了。
使用Flutter Widgets 添加图层
到现在为止,所有内容都使用单个CustomPainter
小部件(即widget,下文也一样)绘制。时钟看起来不错,但是很难告诉你时间。而且,背景是单色,看上去很无聊。
Flutter非常适合构建复杂的布局。毕竟,它是一个用于用户界面的工具包。将一堆小部件彼此堆叠,您只需将它们包装在Stack widget中。粒子时钟的最后一个场景小部件负责构建3个主要层:
1、
Background
:一个带有CustomPaint
小部件的堆栈,该小部件绘制不同颜色和绘画样式的随机形状,以及一个应用了模糊效果的BackdropFilter
。-
2、
Clock Face
: 带有2个CustomPaint
小部件的堆栈,- a.
时钟标记
-绘制时钟标记。每分钟标记,每5分钟标记具有额外的可见性。 - b.
秒针
-绘制两秒的针弧。
- a.
3、
Particle FX
: 一个CustomPaint
小部件,用于绘制所有粒子。
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: Duration(milliseconds: 1500),
curve: Curves.easeOut,
color: _bgColor,
child: ClipRect(
child: Stack(
children: <Widget>[
_buildBgBlurFx(),
_buildClockFace(),
CustomPaint(
painter: ClockFxPainter(fx: _fx),
child: Container(),
),
],
),
),
);
}
即使底层代码很复杂,Flutter仍可以通过小部件组合来管理布局。
时钟绘图层和覆盖层。
这是与上述相同的图片,但没有覆盖层。
与时间同步的动画
我很早就想到,如果动画与时钟的滴答声同步发生,那就太酷了。最终版本中的解决方案非常简单。没有到达到想象中的目标。最初,我把它变成了一个非常复杂的问题,并尝试了各种怪异的技巧使它起作用。
@override
void tick(Duration duration) {
var secFrac = DateTime.now().millisecond / 1000;
var vecSpeed = duration.compareTo(easingDelayDuration) > 0
? max(.2, Curves.easeInOutSine.transform(1 - secFrac))
: 1;
particles.asMap().forEach((i, p) {
// Movement
p.x -= p.vx * vecSpeed;
p.y -= p.vy * vecSpeed;
// etc...
}
}
以上代码在每个动画刻度上运行。通过结合使用DateTime.now()
(以毫秒为单位)和Curves
,我们得到一个介于0
和1
之间的值。max
函数确保该数字保持在0.2
以上,以始终保持粒子随着每个刻度移动。
然后,在计算粒子的新x
和y
位置时,将vecSpeed
编号与运动矢量结合使用。
调色板和清晰度
在图形用户界面中随机分配颜色时,通常会让人感到厌烦。当然,这是有充分的理由,因为它通常会使GUI的访问性降低。在保持易读性的同时将随机颜色应用于GUI并不是一个容易解决的问题。幸运的是,Flutter有一些工具可以使我们更轻松。
首先,我使用了ColourLovers
API来获取其用户最喜欢的一些调色板。简而言之,许多调色板的颜色之间的对比度很差。我根据WCAG Contrast
指南,创建了一个过滤调色板阵列的脚本来解决了这一问题。过滤后,该列表仅包含调色板,其中至少存在一种对比度大于或等于4.5
的颜色组合。
然后,在Flutter中,我们仅需使用Color
类的computeLuminance
方法即可找到良好的匹配项。
/// Gets a random palette from a list of palettes and sorts its'
/// colors by luminance.
///
/// Given if [dark] or not, this method makes sure the luminance
/// of the background color is valid.
static Palette getPalette(List<Palette> palettes, bool dark) {
Palette result;
while (result == null) {
Palette palette = Rnd.getItem(palettes);
List<Color> colors = Rnd.shuffle(palette.components);
var luminance = colors[0].computeLuminance();
if (dark ? luminance <= .1 : luminance >= .1) {
var lumDiff = colors
.sublist(1)
.asMap()
.map(
(i, color) => MapEntry(
i,
[i, (luminance - color.computeLuminance()).abs()],
),
)
.values
.toList();
lumDiff.sort((List<num> a, List<num> b) {
return a[1].compareTo(b[1]);
});
List<Color> sortedColors =
lumDiff.map((d) => colors[d[0] + 1]).toList();
result = Palette(
components: [colors[0]] + sortedColors,
);
}
}
return result;
}
该代码返回一个Palette
,仅包含一个颜色列表。通过颜色之间的亮度差异
对调色板
进行排序。
然后,此方法的调用者可以确保组件的第一项
和最后一项
具有足够好的对比度。
一小部分,可能有许多不同颜色变化。请注意,就亮度而言,强调色
始终总是与背景色
最远的一种。
最后的润色
大部分魔术发生在编写此代码的最后几个小时。使发出的粒子从中心淡入
,而不是从无处突然弹出
。这使整体外观更加平滑。我对弧/速度
标记进行了相同的操作,并一次将它们限制为仅几个粒子,以减少视觉复杂性。
最初,我不确定如何避免沿钟针方向发射噪声粒子
,但知道必须这样做。在放弃寻找数学解之后,我用了一些蛮力的代码解决了这个问题(当然,数学解方案是有的,只是我没有耐心寻找到它)。
// Find a random angle while avoiding clutter at the hour & minute hands.
var am = _getMinuteRadians();
var ah = _getHourRadians() % (pi * 2);
var d = pi / 18;
// Probably not the most efficient solution right here.
do {
angle = Rnd.ratio * pi * 2;
} while (_isBetween(angle, am - d, am + d) || _isBetween(angle, ah - d, ah + d));
有效!这样一来,所有噪音颗粒都从针中移开,就更容易分辨时间了。
最后
有时令人沮丧(谢谢,数学!😅)。但最后,我对结果感到满意。我特别喜欢不断变化的色彩和有机的、不可预测的动画。
Flutter非常适合此类事情。创造力需要实验,这就是Flutter令人瞩目的地方。在这个项目的开始,我不知道它最终会变成这样。记住,我最初的想法是建立一个数字时钟。但是由于一些幸运的错误,以及对不同想法的成千上万次小迭代,它的变化比最初想象的要好。
最终演示视频地址:https://youtu.be/VPbcVhKIzIo
Google Flutter全球市场总监 Martin Aguinis发推文说,他们在86个国家/地区收到了850份独特的作品。在所有这些中,Google专家评审团选出了我的获得大奖(一台装有Apple iMac Pro的苹果,价值约10,000美元)。我从来没有认为自己是一个优秀的程序员,所以当Martin向我伸手时,我真的很惊讶!我现在仍然不敢相信自己赢了。
感谢Google和Flutter团队使这一挑战得以实现,也感谢所有为我加油并在Twitter上对我表示支持的人
!
项目Github地址:
https://github.com/miickel/flutter_particle_clock