本文共 8771 字,大约阅读时间需要 29 分钟。
原文地址:How to build a circular slider in Flutter原文作者:David Anaya译文出自:掘金翻译计划本文永久链接:github.com/xitu/gold-m…译者:DevMcryYu校对者:MollyAredtana,JasonLinkinBright
你是否也曾想要通过为滑块添加双重滑块或修改其布局来让它看起来不那么无聊?
在这篇文章中我会展示如何通过整合 以及 来在 Flutter 中构建一个圆形滑块。
如果你对构建它的过程不感兴趣,仅仅是为了获取此部件并使用它,那么你可以使用我在 发布的程序包。
大多数情况下你并不会需要它。但想象一下:如果你想要用户选定一个时间段,或者只是想要一个比直线形状更有趣一点的常规滑块的场景时,就可以使用圆形滑块。
我们要准备的第一件事就是创建一个真正的滑块。为此,我们要用一个完美的圆形作为背景,在它的基础上再画一个根据用户交互可以动态显示的圆。为了实现我们的想法,我们将用到一个名为 CustomPaint 的特殊部件,它提供一个允许让我们自由创作的画布(Canvas)。
当滑块渲染完成以后,我们希望用户能够和它进行交互,因此我们选择使用 GestureDetector 封装它来捕获点击及拖动事件。
完整流程是:
(只需关注上图黄色部分)
我们要做的第一件事就是画两个圆。一个静态样式(无需改变),另一个则是动态的样式(响应用户交互),我使用两个 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 了。为此,我们将分别绘制两个圆,一个在内部填充,一个在外部包裹。我调用了一些工具集函数用来将弧度转换为圆的坐标。你可以在 。
目前来看,仅仅使用 CustomPaint 以及两个 Painter 就已经足够绘制想要的东西了。然而它们还是不能够进行交互。因此就要使用 GestureDetector 来对它进行封装。这样一来我们就可以在画布上对用户事件做出相应处理。
一开始我们将为 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{ 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); } } }}
这里有几点需要注意:
onSelectionChange()
的原因。didUpdateWidget()
方法。RenderBox.globalToLocal()
方法来对它们进行转换。该方法使用部件的 Context 作为参考。有了这些,我们也就拥有了打造圆形滑块的一切需要。
由于篇幅有限,在这里并没有展开讲解所有的细节。你可以查看本项目的仓库,我会乐于回答评论中的任何问题。
在最终的版本里我添加了一些额外的功能,比如自定义选择区间和 Handler 的颜色;如果你想实现类似时钟的样式(小时和分钟)你可以根据需求进行选择。为了方便各位使用,我同样将所有内容打包放进了一个最终的部件内。
你也可以通过从 导入本库的方式来使用这个部件。
文章至此告一段落,感谢各位的阅读!
转载地址:http://ronci.baihongyu.com/