我正在参加「掘金·启航计划」
前沿
最近有刷到一篇《Compose挑灯夜看 - 照亮手机屏幕里面的书本内容》的文章,觉得非常有意思。自己作为一名Flutter开发人员,第一件事当然是想着通过Flutter来实现功能。因此有了这一篇《Flutter开发之挑灯夜读》的文章,希望对大家有所帮助。
演示效果
按照国际惯例,我们先演示下实现的最终效果:
可以看到,我们通过下拉开关来控制吊灯的打开和关闭。当吊灯被打开时,通过吊灯散发的灯光来照亮文字,距离吊灯越远灯光就越弱,文字的显示就越暗。我们则借助灯光,上下滑动文字来进行阅读,最终达到 挑灯夜读 的效果。
实现思路
针对上述功能,我们先将其拆分为 元素 和 操作 两部分。
一、元素
可以看到整个界面包含的元素有:
-
挑灯:灯、开关;
-
夜读:书本、文字;
二、操作
用户的操作主要是:
-
开关手势:通过下拉吊灯的开关线来实现吊灯的开关
-
文字滑动手势:通过上下滑动来实现文字的移动
三、手势冲突
由于文字本身可以上下滑动,同时我们需要监听吊灯开关的下拉手势,这势必会产生冲突,需要我们解决。
实现
一、元素的实现
- 书本 和 文字。我们先实现书本文字的展示。文本的展示非常简单,通过 SingleChildScrollView + RichText 即可实现标题和内容的展示。
SingleChildScrollView(
child: RichText(
text: const TextSpan(
style: TextStyle(fontSize: 20, color: Colors.white),
children: [
TextSpan(
text: HongLouMeng.title,
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
)),
TextSpan(text: HongLouMeng.content),
],
),
),
);
实现效果如下:
- 灯 和 开关。考虑到开关最后是需要拖动的,我们通过 CustomPaint 绘制来实现。
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
var width = constraints.maxWidth;
var height = constraints.maxHeight;
initOffset = Offset(width * 0.8, 300);
switchOffset.value = initOffset;
return CustomPaint(
size: Size(width, height),
painter: SwitchPainter(
switchOffset: switchOffset, isOpen: isOpen, lightImage: _image),
);
})
实现效果如下:
SwitchPainter 通过 drawImage 、drawPath 、drawOval 来绘制吊灯和开关。
/// SwitchPainter
@override
void paint(Canvas canvas, Size size) {
_drawLight(canvas, size);
_drawSwitch(canvas, size);
}
void _drawLight(Canvas canvas, Size size) {
var width = _image!.width.toDouble();
canvas.save();
canvas.translate(size.width * 0.5, 0);
canvas.drawImage(_image!, Offset(-width * 0.5, 0), _imagePaint);
canvas.restore();
}
void _drawSwitch(Canvas canvas, Size size) {
Offset offset = initOffset;
Path path = Path()
..moveTo(size.width * 0.8, 0)
..lineTo(offset.dx, offset.dy);
canvas.drawPath(path, _linePaint);
canvas.drawOval(
Rect.fromCenter(center: offset, width: 30, height: 30), _switchPaint);
}
- 文字效果。对于 挑灯夜读 的文字效果,是我们功能实现的关键。
- 不同于 Android 对于文字的处理,Futter 有自己独特的实现方式:ShaderMask。ShaderMask 可以允许我们将 Shader 效果运用到任何控件上。
ShaderMask(
shaderCallback: _buildShader,
child: SingleChildScrollView(
child: RichText(
text: const TextSpan(
style: TextStyle(fontSize: 20, color: Colors.white),
children: [
TextSpan(
text: HongLouMeng.title,
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
)),
TextSpan(text: HongLouMeng.content),
],
),
),
))
Shader _buildShader(Rect bounds) {
return RadialGradient(
center: const Alignment(0, -0.7),
colors: [isOpen.value ? candlelight : night, night],
tileMode: TileMode.clamp,
).createShader(bounds);
}
实现效果如下:
二、操作的实现
-
文字手势。对于文字的滑动手势,我们简单通过 SingleChildScrollView 实现即可。
-
开关手势。开关主要用到手势的点击、移动,我们通过 GestureDetector 来实现。
- 我们通过校验用户的点击位置来判断是否点击了开关,通过手势移动的位置不同步更新开关的位置,手指抬起时重置开关的位置。代码如下:
GestureDetector(
onPanStart: _onPanStart,
onPanEnd: _onPanEnd,
onPanUpdate: _onPanUpdate,
child: _buildLight(),
)
/// 手指点击
void _onPanStart(DragStartDetails details) {
final Offset offset = details.localPosition;
double dx = offset.dx - switchOffset.value.dx;
double dy = offset.dy - switchOffset.value.dy;
isTouchSwitch.value = sqrt(dx * dx + dy * dy).abs() < (15 + 10);
}
/// 手指抬起
void _onPanEnd(DragEndDetails details) {
final Offset offset = switchOffset.value;
double dx = offset.dx - initOffset.dx;
double dy = offset.dy - initOffset.dy;
isOpen.value =
dy > 0 && sqrt(dx * dx + dy * dy) > 50 ? !isOpen.value : isOpen.value;
isTouchSwitch.value = false;
switchOffset.value = initOffset;
}
/// 手指移动
void _onPanUpdate(DragStartDetails details) {
if (isTouchSwitch.value) {
switchOffset.value = details.localPosition;
}
}
同样,我们需要在 CustomPainter 中更新开关的手势移动位置。
CustomPaint(
size: Size(width, height),
painter: SwitchPainter(
switchOffset: switchOffset, //开关移动位置
isOpen: isOpen, //是否开灯
lightImage: _image),
);
void _drawSwitch(Canvas canvas, Size size) {
Offset offset = switchOffset.value;
...
}
void _drawLight(Canvas canvas, Size size) {
if (!isOpen.value) return; //若当前开关没有打开,则不显示吊灯
...
}
这里通过 CustomPainter 的 repaint 来代替 setState 更新界面,提升性能。
SwitchPainter({required this.switchOffset,
required this.isOpen,
required ui.Image? lightImage})
: super(repaint: Listenable.merge([switchOffset, isOpen]))
三、手势冲突的处理
关于 Flutter 手势冲突的产生和处理方式这里不做过多介绍,感兴趣的同学可以看这篇《手势原理与手势冲突》文章,这里主要介绍项目中的处理方式。
我们将 GestureDetector 改为 Listener 来解决手势冲突。原因是手势竞争只是针对手势的,而 Listener 是监听原始指针事件,原始指针事件并非语义化的手势,所以根本不会走手势竞争的逻辑,所以也就不会相互影响。
Listener(
onPointerDown: _onPanStart,
onPointerUp: _onPanEnd,
onPointerMove: _onPanUpdate,
child: Stack(
children: [
_buildBook(),
_buildLight(),
],
),
)
至此,整个挑灯夜读的功能开发完成。完整源码:Github
总结
当我们把 挑灯夜读 功能一步步拆解后,发现整个功能的实现技术点并不复杂。真正的难点在于把学习过的知识点结合实际需求连贯起来,制定一个合适的技术方案来实现功能。
作为前端(大前端)开发人员,相较于后端开发,需要拥有更多的思维发散、丰富的技术应用结合实际场景的创造能力。