本系列可能会伴随你们很长时间,这里我会从0开始搭建一个「网易云音乐」的APP出来。前端
下面是该APP 功能的思惟导图:git
前期回顾:github
本篇为第七篇,在这里咱们会搭建歌词页面剩余的逻辑。canvas
咱们书接上文,上文中说到歌词控件的需求:微信
一个歌词控件须要什么?布局
- 展现歌词
- 当前歌词高亮显示
- 跟随当前时间滚动
- 能够拖动
- 拖动时显示时间线
- 能够从时间线上点击播放
上文咱们实现了前三个,那这篇文章就带你们来实现后三个功能。字体
下面咱们就开始。动画
不知道还记不记得,上篇文章中,咱们是如何绘制歌词的:ui
_offsetY + size.height / 2 + lyricPaints[0].height / 2;
复制代码
该段代码就是获取中间位置的。spa
其中有个 _offsetY ,在上篇文章中,咱们使用它来作自动滚动效果,那在本功能中,咱们就可使用它来作拖动的效果。
直接在 CustomPaint
控件上套一个 GestureDetector
:
onVerticalDragUpdate: (e) {
_lyricWidget.offsetY += e.delta.dy;
}
复制代码
而后在 onVerticalDragUpdate
中使这个 offsetY
加上偏移量就好了。
可是关于歌词拖动这里有个细节:不能拖动到极限(上、下)。
这里的极限是什么?
上极限为 _offsetY.abs() < lyricPaints[0].height + ScreenUtil().setWidth(30)
,
下极限为 _offsetY.abs() > (totalHeight + lyricPaints[0].height + ScreenUtil().setWidth(30))
,
也就是咱们第一行和最后一行文字的地方。
赋值 _offsetY
方法所有代码以下:
set offsetY(double value) {
// 判断若是是在拖动状态下
if (isDragging) {
// 不能小于最开始的位置
if (_offsetY.abs() < lyricPaints[0].height + ScreenUtil().setWidth(30)) {
_offsetY = (lyricPaints[0].height + ScreenUtil().setWidth(30)) * -1;
} else if (_offsetY.abs() > (totalHeight + lyricPaints[0].height + ScreenUtil().setWidth(30))) {
// 不能大于最大位置
_offsetY = (totalHeight + lyricPaints[0].height + ScreenUtil().setWidth(30)) * -1;
} else {
_offsetY = value;
}
} else {
_offsetY = value;
}
notifyListeners();
}
复制代码
这样就完成了咱们拖动歌词的需求。
这是相对来讲比较复杂的功能,涉及到的有:
首先无论拖拽的东西,先来显示这个时间线。
由于歌词是使用 CustomPainter
来实现的,那时间线,咱们也是,使用 CustomPainter
来实现。
首先看一下样式:
能够看到,这个「时间线」是由三部分组成:
播放按钮咱们使用的是 icon,如何在 CustomPainter
中画 icon?
使用 Paragraph
:
// 画 icon
final icon = Icons.play_arrow;
var builder = prefix0.ParagraphBuilder(prefix0.ParagraphStyle(
fontFamily: icon.fontFamily,
fontSize: ScreenUtil().setWidth(60),
))
..addText(String.fromCharCode(icon.codePoint));
var para = builder.build();
para.layout(prefix0.ParagraphConstraints(
width: ScreenUtil().setWidth(60),
));
canvas.drawParagraph(
para,
Offset(ScreenUtil().setWidth(10),
size.height / 2 - ScreenUtil().setWidth(60)));
复制代码
其实这里是把 icon 当作字体来设置的,设置大小使用 fontSize
就行了。
线相对来讲是好画的了:
// 画线
canvas.drawLine(
Offset(ScreenUtil().setWidth(80),
size.height / 2 - ScreenUtil().setWidth(30)),
Offset(size.width - ScreenUtil().setWidth(120),
size.height / 2 - ScreenUtil().setWidth(30)),
linePaint);
复制代码
这其实也没什么好说的,就是画个文字,算好偏移量就好了:
draggingLineTimeTextPainter = TextPainter(
text: TextSpan(
text: DateUtil.formatDateMs(dragLineTime,
format: "mm:ss"),
style: smallGrayTextStyle),
textDirection: TextDirection.ltr,
);
draggingLineTimeTextPainter.layout();
draggingLineTimeTextPainter.paint(
canvas,
Offset(size.width - ScreenUtil().setWidth(80),
size.height / 2 - ScreenUtil().setWidth(45)));
复制代码
时间线画完了,就该来到拖拽环节,这个时候同窗确定会想到,咱们刚才套了一层 GestureDetector
。
没错,那在什么条件下显示和不显示?
咱们首先想到的确定是 onVerticalDragDown
+ onVerticalDragEnd
,由于毕竟是在按下时显示和抬起时消失嘛,
这就错了,咱们不该该在手指按下的时候就显示时间线,而应该是在拖动的时候显示时间线!
咱们给 CustomPainter
一个变量:isDragging
-> 是否正在拖动中。
而后在 GestureDetector
的 onVerticalDragUpdate
方法中作操做:
onVerticalDragUpdate: (e) {
if (!_lyricWidget.isDragging) {
setState(() {
_lyricWidget.isDragging = true;
});
}
_lyricWidget.offsetY += e.delta.dy;
}
复制代码
若是不是在拖动中,那么则改变它的状态。
而且在 CustomPainter
的 paint
方法中:
// 拖动状态下显示的东西
if (isDragging) {
// 画 icon
xxx;
// 画线
xxx;
// 画当前行的时间
xxx;
}
复制代码
这样就完成了咱们显示的问题,那何时不显示?
咱们能够经过查看网易云官方APP来看一下,拖动结束后大约一两秒钟的时间才会消失,这个时间差是为了给用户点击时间线上的播放按钮准备的。
那咱们也来实现一下。
首先咱们设置延迟消失时间是一秒,消失的动做其实就是把 isDragging
设置为 false:
dragEndFunc = () {
if (_lyricWidget.isDragging) {
setState(() {
_lyricWidget.isDragging = false;
});
}
};
复制代码
这里学过前端的同窗应该都据说过一个词:节流与防抖。
没错,若是这里咱们在结束拖动的一秒内,再次拖动,那么这个延迟的方法就会再次运行,这样确定是有问题的,因此咱们也要进行节流与防抖。
如何进行防抖?
其实上一篇文章中自动滚动歌词效果就带了防抖,可是那个是使用的动画,这里咱们就要使用 Timer
来进行防抖。
首先定义好方法和延迟时间:
dragEndDuration = Duration(milliseconds: 1000);
dragEndFunc = () {
if (_lyricWidget.isDragging) {
setState(() {
_lyricWidget.isDragging = false;
});
}
};
复制代码
接着在拖动结束后的方法中调用:
void cancelDragTimer() {
if (dragEndTimer != null) {
if (dragEndTimer.isActive) {
dragEndTimer.cancel();
dragEndTimer = null;
}
}
dragEndTimer = Timer(dragEndDuration, dragEndFunc);
}
复制代码
逻辑以下:
这样就能够达到咱们预期的结果:在最后一次拖动结束的一秒钟后,把时间线消失。
时间线的显示和消失,咱们也搞定了,那么如今就开始搞拖拽的效果。
拖拽到某一行改变颜色,咱们怎么知道是拖拽到了哪一行?
这还不简单,直接使用 offsetY
来判断就行了呀:
if (isDragging &&
i ==
(_offsetY / (lyricPaints[0].height + ScreenUtil().setWidth(30)))
.abs()
.round() - 1) {
// 若是是拖动状态中的当前行
lyricPaints[i].text =
TextSpan(text: lyric[i].lyric, style: commonWhite70TextStyle);
lyricPaints[i].layout();
}
复制代码
若是 i == 正在拖动中 而且 用**当前偏移量 / 每行的偏移量 获得的值的绝对值的四舍五入的值,**那么就表明是当前拖动中的行。(说的有点乱)
由于总长度就是用每行的偏移量加起来的,最大的偏移量也就是这么多,因此用偏移量除以每行的偏移量就能获得咱们当前拖动到的行了。
而后设置不一样颜色的字体就ok了。
既然咱们能获得当前是哪一行,那获取这一行的起始时间也不是难事:
dragLineTime = lyric[
(_offsetY / (lyricPaints[0].height + ScreenUtil().setWidth(30)))
.abs()
.round() -1]
.startTime.inMilliseconds;
复制代码
到这咱们全部拖拽的功能算是结束了,就剩下一个点击事件。
写这个功能的时候,上来就遇到了一个问题,怎么样才算点击了这个 icon???
CustomPainter
里面也没有给这个布局设置点击事件的地方,wdnmd,这咋整?
苦思冥想,大不了我判断点击的坐标!
说干咱就干,在 onTap 中没有返回这个坐标,那我先在 onPanDown 里试试:
onPanDown: (e){
print(e.localPosition);
},
复制代码
当我运行到手机,而且点击的时候,整我的都很差了!
坐标确实打印出来了,可是直接给我返回到碟片那个页面了!!!
我居然忘了还有这个操做!点击页面是 「歌词 」和 「碟片」 来回跳转的!
这可咋整,如何才能让他不跳转?也就是不走父组件的 onTap()
方法。
这里有一点,若是子组件有点击事件,而且父组件没有设置相对应的 behavior,那么事件是不会冒泡到父组件的。
因此,咱们只须要进行相对应的设置:
onTapDown: _lyricWidget.isDragging
? (e) {
if (e.localPosition.dx > 0 &&
e.localPosition.dx < ScreenUtil().setWidth(100) &&
e.localPosition.dy >
_lyricWidget.canvasSize.height / 2 -
ScreenUtil().setWidth(100) &&
e.localPosition.dy <
_lyricWidget.canvasSize.height / 2 +
ScreenUtil().setWidth(100)) {
widget.model.seekPlay(_lyricWidget.dragLineTime);
}
}
: null,
复制代码
若是是在拖动状态中,那么设置上点击事件,若是不是的话,设置为null 就行了,这也能解释咱们上面给 isDragging
赋值的时候为何会 setState() ,就是由于要设置这个点击事件。
最后判断点击的位置就ok了,也是很是简单的。
参考了不少 Android 上的歌词控件,终于咱们歌词就所有结束了,歌词的功能真的是很多,写起来也是挺难的,判断的东西有点多。(也多是由于我第一次写歌词类的东西,比较菜)
固然仍是那句话,该项目是我本人本身在工做之余写的,因此进度不会很快,可是会一直写下去。
你们若是有好的建议的话,欢迎提 issue,我会在第一时间回复。
该系列文章代码已传至 GitHub:github.com/wanglu1209/…
另我我的建立了一个「Flutter 交流群」,能够添加我我的微信 「17610912320」来入群。