Flutter Tutorial - Circular Slider

Flutter Tutorial - Circular Slider

In this tutorial, you will learn how to integrate the GestureDetector and the Canvas to build a circular slider in Flutter. Flutter is Google's portable UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.

In this tutorial, you will learn how to integrate the GestureDetector and the Canvas to build a circular slider in Flutter. Flutter is Google's portable UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.

Have you ever wanted to spice up the usual boring sliders by providing a double handler or playing around with the layout?

If you are not that interested in how to build it but just want to get the widget and use it, you can use the package I published in .

Why do I need a circular slider?

In most cases you don’t, but imagine you want the user to select a time interval, or you just want a regular slider but want something a bit more interesting than a straight line.

What do we need in order to build it?

The first thing we need to do is create the actual slider. For this, we will draw a complete circle as the base and, on top of that, another one which will be dynamic depending on the user interaction. In order to do this we will use a special widget called CustomPaint, which provides a canvas on which we can draw what we need.

Once the slider is rendered, we need the user to be able to interact with it, so we will wrap it with a GestureDetector to capture tap and drag events.

The process will be:

  • Draw the slider
  • Recognize when the user interacts with the slider by tapping down on one of the handlers and dragging.
  • Pass the information attached to the event down to the canvas, where we will repaint the top circle.
  • Send the new values for the handlers all the way up so that the user can react to changes (i.e., updating the text in the center of the slider).

Let’s draw some circles

First thing we need to do is draw both circles. As one of them is static (doesn’t change) and the other one dynamic (changes with user interaction), I separated them in two different painters.

Both our painters need to extend CustomPainter, a class provided by Flutter, and implement two methods: paint() and shouldRepaint(), the first one being the one to actually draw what we want and the later a way to know if we need to repaint when there is a change. For the BasePainter we never need to repaint, so it will always be false. For SliderPainter it will always be true, because every change means that the user moved the slider and the selection has to be updated.

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;
  }
}

As you see, paint() gets a Canvas and a Size parameters. Canvas provides a set of methods that we can use to draw anything: circles, lines, arcs, rectangles, etc. Size is, well, the size of the canvas, and will be determined by the size of the widget where the canvas fits. We also need a Paint, which allows us to specify the style, color and many other things.

Now, the BasePainter is pretty self-explanatory, but the SliderPainter is a bit more tricky. Now not only need to draw an arc instead of a circle, we also need to draw the handlers.

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);

    // draw handlers
    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;
  }
}

Again, we get the center and radius, but now we draw an arc. Our SliderPainter will get as parameters the start, end and sweep angle to use based on the user interactions, so we can use those to draw the arc. The only thing worth mention here is that we need to subtract -pi/2 radians from the initial angle because our slider origin is on the top of the circle and the drawArc() function uses the positive x axis instead.

Once we have the arc we need to draw the handlers. For that we will draw two circles for each, an internal filled one and an external around it. I’m using some utility functions to translate from radians to coordinates in the circle. You can check these functions in the repo in github.

How do we make it interactive?

What we have right now would be enough to draw what we want, we just need to use CustomPaint and both our painters, but it’s still not interactive. We need to wrap it with a GestureDetector. That way we will be able to react to user events in the canvas.

We will define initial values for our handlers and then, as we know the coordinates for those handlers, our strategy will be as follows:

  • listen for a pan (tap) down on any of the handlers and update the status for that handler (_xHandlerSelected = true).
  • listen for a pan (drag) update event while any handler is selected, and then update the coordinates for that handler and pass them down to the SliderPainter and up in our callback method.
  • listen for a pan (tap) up event and reset the status of the handlers to not selected.

As we need to calculate the coordinates for the handlers and the new angles to pass down to the painter, our CircularSliderPaint has to be a 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;

  /// start angle in radians where we need to locate the init handler
  double _startAngle;

  /// end angle in radians where we need to locate the end handler
  double _endAngle;

  /// the absolute angle in radians representing the selection
  double _sweepAngle;

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

  // we need to update this widget both with gesture detector but
  // also when the parent widget 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) {
    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);
      }
    }
  }
}

A few things to notice here:

  • We want to notify the parent widget when the position of the handlers (and hence, the selection) is updated, that’s why the widget exposes a callback function onSelectionChange().
  • The widget needs to be re-rendered when the user interacts with the slider, but also if the initial parameters change, that’s why we use didUpdateWidget().
  • CustomPaint also allows a child parameter, so we can use that to render something inside our circle. We will just expose the same parameter in our final widget so that the user can pass whatever she wants.
  • We use intervals to set the number of possible values in the slider. With that we can conveniently express the selection as a percentage.
  • Again, I use different utility functions to translate between percentages, radians and coordinates. The coordinates system in a canvas is a bit different to a regular one, as it starts in the top left corner and so both x and y are always positive values. Also, radians start in the positive x axis and go clockwise (always positive) from 0 to 2*pi radians.
  • Finally, the coordinates for our handlers are related to the canvas origin, but the coordinates in GestureDetector are global to the device, so we need to transform those using RenderBox.globalToLocal() which uses the context in a widget as a reference.

With this we have all we need for our circular slider.

A few extra features

There’s quite a few ground to cover here so I didn’t go full into details, but you can check the repo for the project and I’ll be glad to answer any question in the comments.

In the final version I added some extra features, like custom colors for the selection and the handlers or the option to draw primary and secondary selectors to get that great look for the watch (hours, minutes) if we need it. I also wrapped everything in a final widget for clarity.

Remember you can also use this widget if you want by importing the library from .

Flutter Course - Learn Flutter From Scratch

Flutter Course - Learn Flutter From Scratch

Flutter Course - Learn Flutter From Scratch - This tutorial series is for everyone who wants to get started with Flutter

Flutter™ is Google’s UI toolkit for building building applications for mobile, web, and desktop from one single codebase. Flutter relies on the dart programming language and uses a compiler for natively building applications for the various target platforms.

This tutorial series is for everyone who wants to get started with Flutter. No prior knowledge is required as we’ll go through every step which is needed to build Flutter application in detail.

1. Flutter Course - Learn Flutter From Scratch: Setting Up The Development Environment

This first episode is about setting up the development environment for Flutter and Dart and running your first Flutter application. Let’s get started:

2. Flutter Course - Learn Flutter From Scratch: Project Structure

In this second part we’re going to dive deeper into the project structure so that you’ll be able to understand what the Flutter project is consisting of.

Testing Flutter UI with Flutter Driver

Testing Flutter UI with Flutter Driver

In this article, I am going to discuss how do we make use of Flutter Driver to test basic user interaction with the app.

In this article, I am going to discuss how do we make use of Flutter Driver to test basic user interaction with the app.

Flutter provides various classes to test user interface of an app

Mobile apps have become an important part of everyone’s life. Be it a Twitter, Facebook, Instagram, most of the people would be browsing one app or another.

Mobile Developers implement features in an app and testing their code is limited to writing unit tests which usually is testing a particular method or a class, basically non-UI testing. But, when it comes to full fledged end to end testing for the app or a new feature, the dedicated QEs need to write automated tests to validate correct functioning of the flow. For instance, how the app responds to a tap, scroll, input, navigation and other interactions. Take any app for instance and just think about what are the most common or basic steps we perform while browsing any app.

Is it a tap ? Yes. In order to like a post or if you want to comment on a post, first action you perform is to tap on that widget (We are talking about Flutter, so it has to be about widget, correct?)

Is it scroll ? Yes. We scroll to keep reading an article, or to see all the tweets on our timeline.

Search for a keyword or person ? Yes. We type a name or a keyword and then press search to see the search results.

Flutter provides various classes to test user interface of an app. Flutter Driver class is one from the pack that helps to drive the application in another process (read: instrumented app) and exposes different helpful methods to test user interaction and interface of the app.

What is Flutter Driver ?

In simple terms, if you have read or used various testing frameworks for web or mobile platforms such as Selenium WebDriver, Protractor for AngularJS and Google Espresso for Android, on similar lines, Flutter Driver is for Flutter. You can read more about Flutter Driver here.

Demo app

we’ll use a simple input textfield followed by a button at center of the screen and will validate functioning of these two widgets as a flow using Flutter driver methods.

Simple UI under test

Flutter Driver setup

Before we could start using Flutter Driver class, we need to make 2 new test files under a predefined folder named test_driver. First file will contain the method that enables Flutter driver extension followed by calling the function which contains the widgets we need to test. So, the first file, let’s name it as app.dart will look like this:

void main() {
  // This line enables the extension
  enableFlutterDriverExtension();

  // Call the `main()` function of your app or call `runApp` with any widget you
  // are interested in testing.
  app.main();
}

The second file will contain the actual test scripts that we are going to write by using driver methods. Let’s name it app_test.dart which will contain methods to connect to Flutter driver and closing the connection once all tests are completed, followed by test scripts.

void main() {
  group('Flutter Driver demo', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    tearDownAll(() async {
      if (driver != null) {
        await driver.close();
      }
    });

You can read about the driver setup in more details here.

Note: the above link shows how to add the flutter driver dependency in pubspec.yaml, so I am going to skip that part here.

Now that we have added the required files to connect to Flutter driver, we first need to check the status of the driver extension before writing and executing our test scripts. Enter checkHealth()

checkHealth(): This method verifies the status of Flutter Driver extension we use for our tests. It would be a good practice to execute this method first before we start running tests, because we don’t want our tests to fail due to unavailability or bad status of Flutter Driver.

In app_test.dart, let’s write our first test, as below:

test('check flutter driver health', () async {
  Health health = await driver.checkHealth();
  print(health.status);
});

Very simple method to implement. To see the status, we’ll need to run this test from command line. From terminal, go to the root path of your project and use this command to run our test:

$ flutter drive — target=test_driver/app.dart

00:01 +0: Flutter Driver demo check flutter driver health

HealthStatus.ok

00:01 +1: Flutter Driver demo (tearDownAll)

00:01 +1: All tests passed!

Stopping application instance.

Great ! we are now good to write actual test scripts to validate the UI.

Now, we will write a script to enter a text in input field, validate that the entered text is displayed in the field, update the input field with new text and then validate that the first text entered is not present, followed by tapping on the button and then scrolling to the widget present at the bottom of the screen. For this test, we’ll make use of following methods:

tap()

enterText()

waitFor()

waitForAbsent()

scrollIntoView()

Test script for above scenario will look like this:

test('Flutter drive methods demo', () async {

await driver.tap(find.byValueKey('inputKeyString'));
await driver.enterText('Hello !');
await driver.waitFor(find.text('Hello !'));
await driver.enterText('World');
await driver.waitForAbsent(find.text('Hello !'));
print('World');

await driver.waitFor(find.byValueKey('button'));
await driver.tap(find.byValueKey('button'));
print('Button clicked');

await driver.waitFor(find.byValueKey('text'));
await driver.scrollIntoView(find.byValueKey('text'));
await driver.waitFor(find.text('Scroll till here'));
print('I found you buddy !');

});

Let’s go through each line one by one.

**await** driver.tap(find.byValueKey(**‘inputKeyString’**));

When the instrumented app is launched, the input textfield and button widgets are rendered and in order to insert a text in input field, we first need to tell driver to find the input field and then tap on it to make the input field enabled.

inputKeyString is a key we need to declare in our main.dart file in order to uniquely identify each widget.

await driver.enterText('Hello !');

enterText as its name suggests, helps to enter the text-in-test to input in the given textfield. Since the textfield is now enabled, we instruct driver to enter the text Hello ! in it.

await driver.waitFor(find.text('Hello !'));

waitFor method tells the driver to wait till the given text is found.

Note: Ideally _getText()_ should work with all widgets like _TextField_, _RaisedButton_ and so on, but it seems it currently supports only _Text_ widget. Source: https://github.com/flutter/flutter/issues/16013

await driver.enterText('World');

Above line replaces the first text Hello ! with World

await driver.waitForAbsent(find.text('Hello !'));

waitForAbsent when used, tells driver to wait until the target specified in finder is no longer available.

await driver.waitFor(find.byValueKey('button'));
  await driver.tap(find.byValueKey('button'));

Before we could tap on the button, we first need to find it using find.byValueKey and then tap it.

await driver.waitFor(find.byValueKey('text'));
await driver.scrollIntoView(find.byValueKey('text'));
await driver.waitFor(find.text('Scroll till here'));

Now that we have validated button tap, we’ll need to find the text present at the bottom of the screen. For this, we’ll use scrollIntoView method, but before that we need to instruct driver to find the text widget then scroll into it using key and then validate the text displayed.

Let’s run the script and see the output:

00:01 +0: Flutter Driver demo Flutter drive methods demo

World

Button clicked

I found you buddy !

00:03 +1: Flutter Driver demo (tearDownAll)

00:03 +1: All tests passed!

Stopping application instance.

While running above tests or any instrumented test for that matter, I observed that the execution happens very fast, ie you hardly get to see what events occur on screen while these tests are being executed, so we rely on the command line execution result to see if a test has passed or failed. But it becomes tedious to debug when a test fails or to know why exactly a test failed.

Worry not. Flutter Driver provides another useful method screenshot to capture the screenshots while the tests are being executed. Moreover, we can make use of this method wherever we want throughout a test just like a breakpoint. Having screenshots is a great way to visualize the result and helps to analyze a test failure. Let’s see how to use this method.

screenshot returns a PNG image. Since, we will be calling this method at runtime, driver need to have a path where it can put all the screenshots. For this, we need to create a directory and feed path of this directory to put all screenshots.

Under app_test.dart, inside setupAll() method, enter below line of code, that will create a physical directory named screenshots under your root project.

new Directory(‘screenshots’).create();

Next, we’ll create a custom method that will take the driver instance and path where the image will be stored. First, we convert the png image into integer array and then write that array as bytes into the file path provided. Here’s how:

takeScreenshot(FlutterDriver driver, String path) async {
final List<int> pixels = await driver.screenshot();
final File file = new File(path);
await file.writeAsBytes(pixels);
print(path);

Now, we call this method wherever we want in our test script, as:

await driver.enterText('Hello !');
await takeScreenshot(driver, 'screenshots/entered_text.png');
await driver.waitFor(find.text('Hello !'));
await driver.enterText('World');
await takeScreenshot(driver, 'screenshots/new_text.png');

Let’s run the test script again and see the output:

00:01 +0: Flutter Driver demo Flutter drive methods demo

screenshots/entered_text.png

screenshots/new_text.png

World

Button clicked

I found you buddy !

00:07 +1: Flutter Driver demo (tearDownAll)

00:07 +1: All tests passed!

Stopping application instance.

And we get to see the screenshots created under screenshots folder and the actual images:

With this, we covered various methods Flutter Driver class provides that can be used to test various UI interactions and how these methods can help to write more concise and robust automated tests to validate end-to-end feature flows.

The entire code is available here.

Thanks for reading and feel free to comment below your thoughts or any suggestions / feedback on this article.