Flutter实战 | 从 0 搭建「网易云音乐」APP(七、歌词(二))

本系列可能会伴随大家很长时间,这里我会从0开始搭建一个「网易云音乐」的APP出来。

下面是该APP 功能的思维导图:

前期回顾:

1.Flutter实战 | 从 0 搭建「网易云音乐」APP(一、创建项目、添加插件、通用代码)
2.Flutter实战 | 从 0 搭建「网易云音乐」APP(二、Splash Page、登录页、发现页)
3.Flutter实战 | 从 0 搭建「网易云音乐」APP(三、每日推荐、推荐歌单)
4.Flutter实战 | 从 0 搭建「网易云音乐」APP(四、排行榜、播放页面)
5.Flutter实战 | 从 0 搭建「网易云音乐」APP(五、播放功能逻辑)
6.Flutter实战 | 从 0 搭建「网易云音乐」APP(六、歌词(一))

本篇为第七篇,在这里我们会搭建歌词页面剩余的逻辑。

书接上文

我们书接上文,上文中说到歌词控件的需求:

一个歌词控件需要什么?

1.展示歌词
2.当前歌词高亮显示
3.跟随当前时间滚动
4.可以拖动
5.拖动时显示时间线
6.可以从时间线上点击播放

上文我们实现了前三个,那这篇文章就带大家来实现后三个功能。

下面我们就开始。

  1. 歌词可以拖动

不知道还记不记得,上篇文章中,我们是如何绘制歌词的:

  • _offsetY + size.height / 2 + lyricPaints[0].height / 2;

该段代码就是获取中间位置的。

其中有个 _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();
}

这样就完成了我们拖动歌词的需求。

  1. 拖拽时显示时间线

这是相对来说比较复杂的功能,涉及到的有:

1.拖拽时显示,不拖拽时不显示
2.拖拽到某一行改变颜色
3.显示拖拽到的那一行的起始时间
4.画时间线

首先不管拖拽的东西,先来显示这个时间线。

画时间线

因为歌词是使用 CustomPainter 来实现的,那时间线,我们也是,使用 CustomPainter 来实现。

首先看一下样式:

可以看到,这个「时间线」是由三部分组成:

1.播放按钮
2.一条线
3.当前行的时间

画播放按钮

播放按钮我们使用的是 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 -> 是否正在拖动中。

然后在 GestureDetectoronVerticalDragUpdate 方法中做操作:

onVerticalDragUpdate: (e) {
  if (!_lyricWidget.isDragging) {
    setState(() {
      _lyricWidget.isDragging = true;
    });
  }
  _lyricWidget.offsetY += e.delta.dy;
}

如果不是在拖动中,那么则改变它的状态。

并且在 CustomPainterpaint 方法中:

// 拖动状态下显示的东西
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);
}

逻辑如下:

1.首先判断该 Timer 是否为空
2.如果不为空则判断是否在活跃状态
3.如果都满足条件,则取消这个 Timer 的任务,并且置为空
4.最后重新赋值任务

这样就可以达到我们预期的结果:在最后一次拖动结束的一秒钟后,把时间线消失。

拖拽到某一行改变颜色

时间线的显示和消失,我们也搞定了,那么现在就开始搞拖拽的效果。

拖拽到某一行改变颜色,我们怎么知道是拖拽到了哪一行?

这还不简单,直接使用 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;

到这我们所有拖拽的功能算是结束了,就剩下一个点击事件。

  1. 可以从时间线上点击播放

写这个功能的时候,上来就遇到了一个问题,怎么样才算点击了这个 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 上的歌词控件,终于我们歌词就全部结束了,歌词的功能真的是不少,写起来也是挺难的,判断的东西有点多。(也可能是因为我第一次写歌词类的东西,比较菜)

最多阅读

在Flutter中添加资源和图片 1年以前  |  2355次阅读
Flutter的手势GestureDetector分析详解 1年以前  |  1884次阅读
发布Flutter开发的iOS程序 1年以前  |  1672次阅读
Flutter插件详解及其发布插件 11月以前  |  1664次阅读
Flutter Widget框架概述 1年以前  |  1415次阅读
在Flutter中发起HTTP网络请求 1年以前  |  1400次阅读
使用Inspector检查用户界面 1年以前  |  1369次阅读
JSON和序列化 1年以前  |  1276次阅读
Flutter框架概览 1年以前  |  1241次阅读
Flutter 状态管理指南之 Provider 1年以前  |  1228次阅读
为Flutter应用程序添加交互 1年以前  |  1187次阅读
使用自定义字体 1年以前  |  1172次阅读
Flutter for Web详细介绍 1年以前  |  1129次阅读
处理文本输入 1年以前  |  1073次阅读
Flutter路由详解 1年以前  |  1073次阅读
发布Flutter开发的Android程序 1年以前  |  1010次阅读
使用包来开发Flutter应用 1年以前  |  1008次阅读
编写国际化Flutter App 1年以前  |  986次阅读