Flutter开发之挑灯夜读

264 阅读4分钟

我正在参加「掘金·启航计划」

前沿

最近有刷到一篇《Compose挑灯夜看 - 照亮手机屏幕里面的书本内容》的文章,觉得非常有意思。自己作为一名Flutter开发人员,第一件事当然是想着通过Flutter来实现功能。因此有了这一篇《Flutter开发之挑灯夜读》的文章,希望对大家有所帮助。

演示效果

按照国际惯例,我们先演示下实现的最终效果:

图1.gif

可以看到,我们通过下拉开关来控制吊灯的打开和关闭。当吊灯被打开时,通过吊灯散发的灯光来照亮文字,距离吊灯越远灯光就越弱,文字的显示就越暗。我们则借助灯光,上下滑动文字来进行阅读,最终达到 挑灯夜读 的效果。

实现思路

针对上述功能,我们先将其拆分为 元素操作 两部分。

一、元素

可以看到整个界面包含的元素有:

  1. 挑灯:灯、开关;

  2. 夜读:书本、文字;

二、操作

用户的操作主要是:

  1. 开关手势:通过下拉吊灯的开关线来实现吊灯的开关

  2. 文字滑动手势:通过上下滑动来实现文字的移动

三、手势冲突

由于文字本身可以上下滑动,同时我们需要监听吊灯开关的下拉手势,这势必会产生冲突,需要我们解决。

实现

一、元素的实现

  1. 书本文字。我们先实现书本文字的展示。文本的展示非常简单,通过 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),
        ],
      ),
    ),
  );

实现效果如下:

图2.png

  1. 开关。考虑到开关最后是需要拖动的,我们通过 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),
        );
      }) 

实现效果如下:

图3.png

SwitchPainter 通过 drawImagedrawPathdrawOval 来绘制吊灯和开关。

/// 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);
  }
  1. 文字效果。对于 挑灯夜读 的文字效果,是我们功能实现的关键。
  • 不同于 Android 对于文字的处理,Futter 有自己独特的实现方式:ShaderMaskShaderMask 可以允许我们将 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);
  }

实现效果如下:

图4.png

二、操作的实现

  1. 文字手势。对于文字的滑动手势,我们简单通过 SingleChildScrollView 实现即可。

  2. 开关手势。开关主要用到手势的点击、移动,我们通过 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; //若当前开关没有打开,则不显示吊灯
   	...
  }

这里通过 CustomPainterrepaint 来代替 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

总结

当我们把 挑灯夜读 功能一步步拆解后,发现整个功能的实现技术点并不复杂。真正的难点在于把学习过的知识点结合实际需求连贯起来,制定一个合适的技术方案来实现功能。

作为前端(大前端)开发人员,相较于后端开发,需要拥有更多的思维发散、丰富的技术应用结合实际场景的创造能力。