[译] 用 Flutter 打造一个圆形滑块(Slider)

简介: 大多数情况下你并不会需要它。但想象一下:如果你想要用户选定一个时间段,或者只是想要一个比直线形状更有趣一点的常规滑块的场景时,就可以使用圆形滑块。

原文地址:How to build a circular slider in Flutter
原文作者:David Anaya
译文出自:掘金翻译计划
本文永久链接:github.com/xitu/gold-m…
译者:DevMcryYu
校对者:MollyAredtana,JasonLinkinBright


image.png

你是否也曾想要通过为滑块添加双重滑块或修改其布局来让它看起来不那么无聊?
在这篇文章中我会展示如何通过整合 GestureDetector 以及 Canvas 来在 Flutter 中构建一个圆形滑块。

如果你对构建它的过程不感兴趣,仅仅是为了获取此部件并使用它,那么你可以使用我在 pub.dartlang.org/packages/fl… 发布的程序包。

为什么要用圆形滑块?

大多数情况下你并不会需要它。但想象一下:如果你想要用户选定一个时间段,或者只是想要一个比直线形状更有趣一点的常规滑块的场景时,就可以使用圆形滑块。

用什么来构建它?

我们要准备的第一件事就是创建一个真正的滑块。为此,我们要用一个完美的圆形作为背景,在它的基础上再画一个根据用户交互可以动态显示的圆。为了实现我们的想法,我们将用到一个名为 CustomPaint 的特殊部件,它提供一个允许让我们自由创作的画布(Canvas)。

当滑块渲染完成以后,我们希望用户能够和它进行交互,因此我们选择使用 GestureDetector 封装它来捕获点击及拖动事件。
完整流程是:

  • 绘制滑块
  • 当用户通过点击其中一个滑块并拖动它来与圆形滑块交互时识别此事件。
  • 将事件的附加信息向下传递给画布(Canvas),在这里我们将重新绘制顶部圆形。
  • 将新值一路向上传递给相应的 Handler,以便让用户观察到变化。(例如,更新滑块中心的文字显示)。

image.png

(只需关注上图黄色部分)

来画几个圆吧

我们要做的第一件事就是画两个圆。一个静态样式(无需改变),另一个则是动态的样式(响应用户交互),我使用两个 Painter 来分别绘制它们。
两个 Painter 都继承自 CustomPainter —— 一个由 Flutter 提供并实现 paint() 及 shouldRepaint() 方法的类。第一个方法用来绘制我们想要绘制的形状,第二个方法在有变化时进行重新绘制的时候调用。对于 BasePainter 而言我们永远不会需要重绘,因此它的返回值总是 false。而对于 SliderPainter 来说它总是返回 true,因为每次更改都意味着用户移动了滑块,必须更新所选择的项。

import 'package:flutter/material.dart';

class BasePainter extends CustomPainter {
  Color baseColor;

  Offset center;
  double radius;

  BasePainter({@required this.baseColor});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
        ..color = baseColor
        ..strokeCap = StrokeCap.round
        ..style = PaintingStyle.stroke
        ..strokeWidth = 12.0;

    center = Offset(size.width / 2, size.height / 2);
    radius = min(size.width / 2, size.height / 2);

    canvas.drawCircle(center, radius, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}

可以看到,paint() 方法获得一个 Canvas 和一个 Size 参数。Canvas 提供一组方法可以让我们绘制任何形状:圆形、直线、圆弧、矩形等等。Size 参数即是画布的尺寸,由画布适配的部件尺寸决定。我们还需要一个 Paint,允许我们定制样式、颜色以及其他东西。
现在 BasePainter 的功能用法已经不言自明,然而 SliderPainter 却有一点儿不寻常,现在我们不仅要绘制一个圆弧而非圆,还需要绘制 Handler。

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_circular_slider/src/utils.dart';

class SliderPainter extends CustomPainter {
  double startAngle;
  double endAngle;
  double sweepAngle;
  Color selectionColor;

  Offset initHandler;
  Offset endHandler;
  Offset center;
  double radius;

  SliderPainter(
      {@required this.startAngle,
      @required this.endAngle,
      @required this.sweepAngle,
      @required this.selectionColor});

  @override
  void paint(Canvas canvas, Size size) {
    if (startAngle == 0.0 && endAngle == 0.0) return;

    Paint progress = _getPaint(color: selectionColor);

    center = Offset(size.width / 2, size.height / 2);
    radius = min(size.width / 2, size.height / 2);

    canvas.drawArc(Rect.fromCircle(center: center, radius: radius),
        -pi / 2 + startAngle, sweepAngle, false, progress);

    Paint handler = _getPaint(color: selectionColor, style: PaintingStyle.fill);
    Paint handlerOutter = _getPaint(color: selectionColor, width: 2.0);

    // 绘制 handler
    initHandler = radiansToCoordinates(center, -pi / 2 + startAngle, radius);
    canvas.drawCircle(initHandler, 8.0, handler);
    canvas.drawCircle(initHandler, 12.0, handlerOutter);

    endHandler = radiansToCoordinates(center, -pi / 2 + endAngle, radius);
    canvas.drawCircle(endHandler, 8.0, handler);
    canvas.drawCircle(endHandler, 12.0, handlerOutter);
  }

  Paint _getPaint({@required Color color, double width, PaintingStyle style}) =>
      Paint()
        ..color = color
        ..strokeCap = StrokeCap.round
        ..style = style ?? PaintingStyle.stroke
        ..strokeWidth = width ?? 12.0;

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

再一次地,我们获取了 center 和 radius 的值,但我们这次绘制的是圆弧。SliderPainter 将根据用户交互反馈的值作为 start、end 和 sweap 属性的值,以便于我们根据这些参数来绘制圆弧。值得一提的是我们需要从初始角度中减去 pi/2,因为我们的滑块的圆弧的起始位置是在圆形的正上方,而 drawArc() 方法使用 x 轴正轴作为起始位置。
当我们绘制好圆弧以后我们就需要准备绘制 Handler 了。为此,我们将分别绘制两个圆,一个在内部填充,一个在外部包裹。我调用了一些工具集函数用来将弧度转换为圆的坐标。你可以在 Github 仓库内查阅这些函数。

让滑块响应交互

目前来看,仅仅使用 CustomPaint 以及两个 Painter 就已经足够绘制想要的东西了。然而它们还是不能够进行交互。因此就要使用 GestureDetector 来对它进行封装。这样一来我们就可以在画布上对用户事件做出相应处理。
一开始我们将为 Handler 赋初值,当获取这些 Handler 的坐标后,我们将按照以下策略执行操作:

  • 监听对于 Handler 的点击(按下)事件并更新相应 Handler 的状态。(_xHandlerSelected = true)。
  • 监听被选中 Handler 的拖动更新事件,更新其坐标,同时分别向下、向上传递给 SliderPainter 和我们的回调函数。
  • 监听 Handler 的点击(抬起)事件并重置未选中 Handler 的状态。

因为我们需要分别计算出坐标值、新的角度值再传递给 Handler 和 Painter,所以我们的 CircularSliderPaint 必须是一个 StatefulWidget。

import 'package:flutter/material.dart';
import 'package:flutter_circular_slider/src/base_painter.dart';
import 'package:flutter_circular_slider/src/slider_painter.dart';
import 'package:flutter_circular_slider/src/utils.dart';

class CircularSliderPaint extends StatefulWidget {
  final int init;
  final int end;
  final int intervals;
  final Function onSelectionChange;
  final Color baseColor;
  final Color selectionColor;
  final Widget child;

  CircularSliderPaint(
      {@required this.intervals,
      @required this.init,
      @required this.end,
      this.child,
      @required this.onSelectionChange,
      @required this.baseColor,
      @required this.selectionColor});

  @override
  _CircularSliderState createState() => _CircularSliderState();
}

class _CircularSliderState extends State<CircularSliderPaint> {
  bool _isInitHandlerSelected = false;
  bool _isEndHandlerSelected = false;

  SliderPainter _painter;

  /// 用弧度制表示的起始角度,用来确定 init Handler 的位置。
  double _startAngle;

  /// 用弧度制表示的结束角度,用来确定 end Handler 的位置。
  double _endAngle;

  /// 用弧度制表示的选择区间的绝对角度(夹角)
  double _sweepAngle;

  @override
  void initState() {
    super.initState();
    _calculatePaintData();
  }

  // 我们需要使用 gesture detector 来更新此部件,
  // 当父部件重建自己时也是如此。
  @override
  void didUpdateWidget(CircularSliderPaint oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.init != widget.init || oldWidget.end != widget.end) {
      _calculatePaintData();
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanDown: _onPanDown,
      onPanUpdate: _onPanUpdate,
      onPanEnd: _onPanEnd,
      child: CustomPaint(
        painter: BasePainter(
            baseColor: widget.baseColor,
            selectionColor: widget.selectionColor),
        foregroundPainter: _painter,
        child: Padding(
          padding: const EdgeInsets.all(12.0),
          child: widget.child,
        ),
      ),
    );
  }

  void _calculatePaintData() {
    double initPercent = valueToPercentage(widget.init, widget.intervals);
    double endPercent = valueToPercentage(widget.end, widget.intervals);
    double sweep = getSweepAngle(initPercent, endPercent);

    _startAngle = percentageToRadians(initPercent);
    _endAngle = percentageToRadians(endPercent);
    _sweepAngle = percentageToRadians(sweep.abs());

    _painter = SliderPainter(
      startAngle: _startAngle,
      endAngle: _endAngle,
      sweepAngle: _sweepAngle,
      selectionColor: widget.selectionColor,
    );
  }

  _onPanUpdate(DragUpdateDetails details) {
    if (!_isInitHandlerSelected && !_isEndHandlerSelected) {
      return;
    }
    if (_painter.center == null) {
      return;
    }
    RenderBox renderBox = context.findRenderObject();
    var position = renderBox.globalToLocal(details.globalPosition);

    var angle = coordinatesToRadians(_painter.center, position);
    var percentage = radiansToPercentage(angle);
    var newValue = percentageToValue(percentage, widget.intervals);

    if (_isInitHandlerSelected) {
      widget.onSelectionChange(newValue, widget.end);
    } else {
      widget.onSelectionChange(widget.init, newValue);
    }
  }

  _onPanEnd(_) {
    _isInitHandlerSelected = false;
    _isEndHandlerSelected = false;
  }

  _onPanDown(DragDownDetails details) {
    if (_painter == null) {
      return;
    }
    RenderBox renderBox = context.findRenderObject();
    var position = renderBox.globalToLocal(details.globalPosition);
    if (position != null) {
      _isInitHandlerSelected = isPointInsideCircle(
          position, _painter.initHandler, 12.0);
      if (!_isInitHandlerSelected) {
        _isEndHandlerSelected = isPointInsideCircle(
            position, _painter.endHandler, 12.0);
      }
    }
  }
}

这里有几点需要注意:

  • 我们想要在 Handler(以及选择区间)的位置更新时通知父部件,这也是该部件对外暴露了一个回调函数 onSelectionChange() 的原因。
  • 当用户与滑块进行交互时,该部件需要被重新渲染,当起始位置的参数值改变时也需如此。这就是为什么我们有必要使用 didUpdateWidget() 方法。
  • CustomPaint 同样可以接收一个 child 参数,这样我们就可以使用它在圆的内部渲染生成一些其他东西。只需要在 final widget 里暴露相同的参数,使用者就可以向其中传入任何想要的值。
  • 我们使用一个间隔用以设置滑块的值。我们可以以此方便的将选择区间以百分比的形式表示。
  • 再一次申明,为了在百分比、弧度以及坐标之间转换我调用了不同的工具集函数。画布(Canvas)中的坐标系与一般坐标系有一些不同,比如说画布坐标系是以左上角作为坐标原点,这样一来 x、y 的值都将一直是一个正值。同样的,弧度制的表示是以 x 正坐标轴开始并以顺时针方向(总是正值)从 0 到 2*pi 计量。
  • 最后,Handler 的坐标计算以画布的原点为参考,而 GestureDetector 的坐标则是相对设备而言的,是全局的,因此我们需要用到 RenderBox.globalToLocal() 方法来对它们进行转换。该方法使用部件的 Context 作为参考。

有了这些,我们也就拥有了打造圆形滑块的一切需要。

额外的功能

由于篇幅有限,在这里并没有展开讲解所有的细节。你可以查看本项目的仓库,我会乐于回答评论中的任何问题。

在最终的版本里我添加了一些额外的功能,比如自定义选择区间和 Handler 的颜色;如果你想实现类似时钟的样式(小时和分钟)你可以根据需求进行选择。为了方便各位使用,我同样将所有内容打包放进了一个最终的部件内。

你也可以通过从 pub.dartlang.org/packages/fl… 导入本库的方式来使用这个部件。
文章至此告一段落,感谢各位的阅读


作者:DevMcryYu
链接:https://juejin.im/post/5cbec995f265da03576ec5a1
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

目录
相关文章
Flutter 121: 图解简易 Slider 滑动条
0 基础学习 Flutter,第一百二十一步:简单学习 Slider 滑动条!
1549 0
Flutter 121: 图解简易 Slider 滑动条
【交互 widget】Flutter Slider
【交互 widget】Flutter Slider
218 0
【交互 widget】Flutter Slider
Flutter基础widgets教程-Slider篇
Flutter基础widgets教程-Slider篇
370 0
|
1月前
|
Android开发 iOS开发 容器
鸿蒙harmonyos next flutter混合开发之开发FFI plugin
鸿蒙harmonyos next flutter混合开发之开发FFI plugin
|
4天前
|
传感器 前端开发 Android开发
在 Flutter 开发中,插件开发与集成至关重要,它能扩展应用功能,满足复杂业务需求
在 Flutter 开发中,插件开发与集成至关重要,它能扩展应用功能,满足复杂业务需求。本文深入探讨了插件开发的基本概念、流程、集成方法、常见类型及开发实例,如相机插件的开发步骤,同时强调了版本兼容性、性能优化等注意事项,并展望了插件开发的未来趋势。
17 2
|
1月前
|
开发者
鸿蒙Flutter实战:07-混合开发
鸿蒙Flutter混合开发支持两种模式:1) 基于har包,便于主项目开发者无需关心Flutter细节,但不支持热重载;2) 基于源码依赖,利于代码维护与热重载,需配置Flutter环境。项目结构包括AppScope、flutter_module等目录,适用于不同开发需求。
79 3
|
22天前
|
传感器 开发框架 物联网
鸿蒙next选择 Flutter 开发跨平台应用的原因
鸿蒙(HarmonyOS)是华为推出的一款旨在实现多设备无缝连接的操作系统。为了实现这一目标,鸿蒙选择了 Flutter 作为主要的跨平台应用开发框架。Flutter 的跨平台能力、高性能、丰富的生态支持和与鸿蒙系统的良好兼容性,使其成为理想的选择。通过 Flutter,开发者可以高效地构建和部署多平台应用,推动鸿蒙生态的快速发展。
150 0
|
24天前
|
Dart 安全 UED
Flutter&鸿蒙next中的表单封装:提升开发效率与用户体验
在移动应用开发中,表单是用户与应用交互的重要界面。本文介绍了如何在Flutter中封装表单,以提升开发效率和用户体验。通过代码复用、集中管理和一致性的优势,封装表单组件可以简化开发流程。文章详细讲解了Flutter表单的基础、封装方法和表单验证技巧,帮助开发者构建健壮且用户友好的应用。
62 0
|
1月前
|
开发框架 移动开发 Android开发
安卓与iOS开发中的跨平台解决方案:Flutter入门
【9月更文挑战第30天】在移动应用开发的广阔舞台上,安卓和iOS两大操作系统各自占据半壁江山。开发者们常常面临着选择:是专注于单一平台深耕细作,还是寻找一种能够横跨两大系统的开发方案?Flutter,作为一种新兴的跨平台UI工具包,正以其现代、响应式的特点赢得开发者的青睐。本文将带你一探究竟,从Flutter的基础概念到实战应用,深入浅出地介绍这一技术的魅力所在。
82 7
|
1月前
|
编解码 Dart API
鸿蒙Flutter实战:06-使用ArkTs开发Flutter鸿蒙插件
本文介绍了如何开发一个 Flutter 鸿蒙插件,实现 Flutter 与鸿蒙的混合开发及双端消息通信。通过定义 `MethodChannel` 实现 Flutter 侧的 token 存取方法,并在鸿蒙侧编写 `EntryAbility` 和 `ForestPlugin`,使用鸿蒙的首选项 API 完成数据的读写操作。文章还提供了注意事项和参考资料,帮助开发者更好地理解和实现这一过程。
70 0
下一篇
无影云桌面