• How to build a circular slider in a Flutter
  • Originally written by David Anaya
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: DevMcryYu
  • Proofread by: MollyAredtana, JasonLinkinBright

Have you ever wanted to make a slider look less boring by adding a double slider or modifying its layout?

In this article I will show how to build a circular slider in Flutter by integrating GestureDetector and Canvas.

If you are not interested in the process of building it, and use it just to get the parts, you can use me in pub.dartlang.org/packages/fl… Published packages.

Why a round slider?

Most of the time you won’t need it. But think about it: if you want the user to select a time period, or just want a scene with a regular slider that’s a little more interesting than a straight line shape, you can use a round slider.

What to build it with?

The first thing we need to prepare is to create a real slider. To do this, we use a perfect circle as the background and then draw a circle on top of it that can be dynamically displayed based on user interaction. To implement our idea, we will use a special widget called CustomPaint, which provides a Canvas that allows usto create freely.

When the slider is rendered, we want the user to be able to interact with it, so we choose to encapsulate it with GestureDetector to capture click and drag events.

The complete process is:

  • Draw the slider
  • This event is recognized when the user interacts with the circular slider by clicking on one of the sliders and dragging it.
  • Passing additional information about the event down to the Canvas, where we will redraw the top circle.
  • The new value is passed all the way up to the appropriate Handler so that the user can observe the change. (For example, update the text display in the center of the slider).

(Just focus on the yellow part of the image above)

Let me draw some circles

The first thing we’re going to do is draw two circles. One is static (without change) and the other is dynamic (in response to user interaction), and I use two Painters to draw them separately.

Both Painters inherit from CustomPainter — a class that is provided by Flutter and implements the paint() and shouldRepaint() methods. The first method is used to draw the shape we want to draw, and the second method is called when we redraw when there are changes. For BasePainter we never need to redraw, so its return value is always false. For the SliderPainter, it always returns true, because every change means that the user has moved the slider and must update the selected item.

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; }}Copy the code

As you can see, the paint() method takes a Canvas and a Size argument. Canvas provides a set of methods that let us draw any shape: circle, line, arc, rectangle, etc. The Size parameter is the Size of the canvas, which is determined by the Size of the parts to which the canvas fits. We also need a Paint that allows us to customize styles, colors, and other things.

While the use of the BasePainter function is now self-explanatory, SliderPainter is a bit unusual in that not only do we draw an arc instead of a circle, we also need to draw a 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) {
    ifStartAngle == 0.0 &&endAngle == 0.0return;

    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); // Map handler initHandler = radiansToCoordinates(center, -pi / 2 + startAngle, radius); Canvas. Methods like drawCircle (initHandler, 8.0, handler); Canvas. Methods like drawCircle (initHandler, 12.0, handlerOutter); endHandler = radiansToCoordinates(center, -pi / 2 + endAngle, radius); Canvas. Methods like drawCircle (endHandler, 8.0, handler); Canvas. Methods like 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; }}Copy the code

Again, we get the center and RADIUS values, but this time we’re drawing arcs. SliderPainter will use the start, end, and SWEAP properties as values based on user interaction feedback so that we can draw arcs based on these parameters. It is worth mentioning that we need to subtract PI /2 from the initial Angle, because the arc of our slider starts directly above the circle, whereas the drawArc() method uses the X-axis as the starting position.

Once we have drawn the arc we need to prepare the drawing Handler. To do this, we will draw two separate circles, one filled inside and one wrapped around the outside. I called some toolset functions to convert radians to the coordinates of the circle. You can check out these functions in the Github repository.

Let the slider respond to the interaction

For now, just using CustomPaint and two Painters is enough to draw what you want. But they still can’t interact. Therefore, GestureDetector is used to encapsulate it. This allows us to process user events on the canvas.

We’ll start by assigning initial values to handlers, and when we get the coordinates of these handlers, we’ll do the following:

  • Listen for hits to handlers and update the status of the Handler. (_xHandlerSelected = true).
  • Listen for the drag update event of the selected Handler and update its coordinates, passing them down and up to SliderPainter and our callback function, respectively.
  • Listen for Handler’s click (lift) event and reset the status of an unchecked Handler.

Our Circular Paint must be a StatefulWidget because we need to evaluate the coordinate value, the new Angle value, and pass it to Handler and Painter, respectively.

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; /// The initial Angle, expressed in radians, is used to determine the position of init Handler. double _startAngle; /// The end Angle in radians is used to determine the position of the end Handler. double _endAngle; Double _sweepAngle; double _sweepAngle; @override voidinitState() { super.initState(); _calculatePaintData(); } // We need to use the Gesture Detector to update this widget, as well as when the parent rebuilds itself. @override void didUpdateWidget(CircularSliderPaint oldWidget) { super.didUpdateWidget(oldWidget);if(oldWidget.init ! = widget.init || oldWidget.end ! = widget.end) { _calculatePaintData(); } } @override Widget build(BuildContext context) {returnGestureDetector( 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.if(! _isInitHandlerSelected) {_isEndHandlerSelected = isPointInsideCircle(position, _painter. EndHandler, 12.0); }}}}Copy the code

Here are a few things to note:

  • We want to notify the parent when the location of the Handler (and selection interval) is updated, which exposes a callback functiononSelectionChange()The reason why.
  • The widget needs to be re-rendered when the user interacts with the slider, as well as when the starting position parameter values change. That’s why we need to use it, rightdidUpdateWidget()Methods.
  • CustomPaintYou can also receive onechildParameter so that we can use it to render something else inside the circle. Just by exposing the same parameters in the final widget, the consumer can pass in any desired value.
  • We use an interval to set the value of the slider. We can conveniently express the selection interval as a percentage.
  • Again, I called different toolset functions to convert between percentages, radians, and coordinates. There are some differences between the coordinate system in Canvas and the general coordinate system. For example, the coordinate system in Canvas takes the upper left corner as the origin of coordinate, so the value of x and y will always be a positive value. Similarly, the radian representation is measured from 0 to 2 PI in a clockwise (always positive) direction starting with the x axis.
  • Finally, the coordinates of the Handler are computed with reference to the origin of the canvas, whileGestureDetectorThe coordinates are relative to the device, global, so we need to useRenderBox.globalToLocal()Method to convert them. This method uses the Context of the widget as a reference.

With that, we have everything we need to create a circular slider.

Additional features

Due to lack of space, I don’t cover all the details here. You can check out the project’s warehouse and I’ll be happy to answer any questions in the comments.

In the final version I added some additional features, such as custom selection ranges and Handler colors; If you want to implement a clock-like style (hours and minutes) you can choose according to your requirements. For your convenience, I also packaged everything into one final widget.

You can also through from pub.dartlang.org/packages/fl… Import the library to use the widget.

This is the end of the article, thank you for reading!

If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.