This tutorial gives a introduction into developing a custom paint widget in Flutter.

1. Implementing a custom paint widget

A custom paint widget is an widget which takes a painter and takes a customer painter to execute paint commands. The painter is an instance of the CustomPainter class. You can either use the painter attribute which is executed before the child is drawn or the foregroundPainter which is executed after the child is drawn (hence you draw on top of the child).

CustomPaint(
  foregroundPainter: MyCustomPainer(),
  child: SomeWidget(),
)

The implementation of CustomPainter must implement two functions.

  • paint - Gets a canvas and a size and does the drawing

  • shouldRepaint - tells Flutter if redrawing is required, for example if the input of the widget change, it might tell Flutter to redraw itself

class MyCustomPainer extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(Offset(75, 75), 50, Paint());
    canvas.drawLine(Offset(200, 200), Offset(20, 40), Paint());
    canvas.drawRect(Rect.fromPoints(Offset.zero, Offset(50, 50)), Paint());
  }

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

For example, the following code will generate the image below

  Widget buildCustomPainer(BuildContext context) {
    return CustomPaint(
      child: Center(
        child: Container(
          color: Colors.red,
          width: 200,
          height: 200,
        ),
      ),
      foregroundPainter: MyCustomPainer(),
    );
  }
}

class MyCustomPainer extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(Offset(75, 75), 50, Paint());
    Paint p = Paint()
      ..style = PaintingStyle.stroke
      ..color = Colors.blue
      ..strokeWidth = 10;
    canvas.drawLine(Offset(200, 200), Offset(20, 40), p);
    canvas.drawRect(Rect.fromPoints(Offset(300, 300), Offset(50, 50)), p);
  }

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

2. Exercise: Implementing a custom painted widget

2.1. Creating the app frame

In this exercise you develop a clock as custom drawn widget.

clock result

This exercise is a based on the blog series from https://medium.com/@NPKompleet/creating-an-analog-clock-in-flutter-iv-3995d914c86e It has been updated to recent Dart API.

Run flutter create clock in this directory.

We use a few new widgets here:

  • AspectRatio - its child will always follow the defined aspectRadio, e.g., if 1.0 is given the widget and height will be the same

  • Stack - allow to stack widget on top of each other

Create the following widget to get your work started:

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Clock',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(
          actions: <Widget>[
            IconButton(
              icon: Icon(Icons.ac_unit),
              onPressed: null,
            ),
          ],
        ),
        body: ClockFrame(),
      ),
    );
  }
}

class ClockFrame extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(left: 40.0, right: 40.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          AspectRatio(
              aspectRatio: 1.0,
              child: Stack(children: <Widget>[
                Container(
                  width: double.infinity,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.black,
                    boxShadow: [
                      BoxShadow(
                        offset: Offset(0.0, 5.0),
                        blurRadius: 5.0,
                      )
                    ],
                  ),
                ),
                Placeholder(),
              ]))
        ],
      ),
    );
  }
}

2.2. Create the painter

Create hands_hour.dart file with the following widget.

import 'dart:math';

import 'package:flutter/material.dart';

class HourHandPainter extends CustomPainter {
  final Paint hourHandPaint;
  int hours;
  int minutes;

  HourHandPainter({this.hours, this.minutes}) : hourHandPaint = new Paint() {
    hourHandPaint.color = Colors.black87;
    hourHandPaint.style = PaintingStyle.fill;
  }

  @override
  void paint(Canvas canvas, Size size) {
    final radius = size.width / 2;
    // To draw hour hand
    canvas.save();

    canvas.translate(radius, radius);

    //checks if hour is greater than 12 before calculating rotation
    canvas.rotate(this.hours >= 12
        ? 2 * pi * ((this.hours - 12) / 12 + (this.minutes / 720))
        : 2 * pi * ((this.hours / 12) + (this.minutes / 720)));

    Path path = new Path();
    //hour hand stem
    path.moveTo(-1.0, -radius + radius / 4);
    path.lineTo(-5.0, -radius + radius / 2);
    path.lineTo(-2.0, 0.0);
    path.lineTo(2.0, 0.0);
    path.lineTo(5.0, -radius + radius / 2);
    path.lineTo(1.0, -radius + radius / 4);
    path.close();

    canvas.drawPath(path, hourHandPaint);
    canvas.drawShadow(path, Colors.black, 2.0, false);

    canvas.restore();
  }

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

Create hands_minute.dart file with the following widget.

import 'dart:math';

import 'package:flutter/material.dart';

class MinuteHandPainter extends CustomPainter {
  final Paint minuteHandPaint;
  int minutes;
  int seconds;

  MinuteHandPainter({this.minutes, this.seconds})
      : minuteHandPaint = new Paint() {
    minuteHandPaint.color = const Color(0xFF333333);
    minuteHandPaint.style = PaintingStyle.fill;
  }

  @override
  void paint(Canvas canvas, Size size) {
    final radius = size.width / 2;
    canvas.save();

    canvas.translate(radius, radius);

    canvas.rotate(2 * pi * ((this.minutes + (this.seconds / 60)) / 60));

    Path path = new Path();
    path.moveTo(-1.5, -radius - 10.0);
    path.lineTo(-2.0, 10.0);
    path.lineTo(2.0, 8.0);
    path.close();

    canvas.drawPath(path, minuteHandPaint);
    canvas.drawShadow(path, Colors.black, 4.0, false);

    canvas.restore();
  }

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

Create hand_second.dart file with the following widget.

import 'dart:math';

import 'package:flutter/material.dart';

class SecondHandPainter extends CustomPainter {
  final Paint secondHandPaint;
  final Paint secondHandPointsPaint;

  int seconds;

  SecondHandPainter({this.seconds})
      : secondHandPaint = new Paint(),
        secondHandPointsPaint = new Paint() {
    secondHandPaint.color = Colors.red;
    secondHandPaint.style = PaintingStyle.stroke;
    secondHandPaint.strokeWidth = 2.0;

    secondHandPointsPaint.color = Colors.red;
    secondHandPointsPaint.style = PaintingStyle.fill;
  }

  @override
  void paint(Canvas canvas, Size size) {
    final radius = size.width / 2;
    canvas.save();

    canvas.translate(radius, radius);

    canvas.rotate(2 * pi * this.seconds / 60);

    Path path1 = new Path();
    Path path2 = new Path();
    path1.moveTo(0.0, -radius);
    path1.lineTo(0.0, radius / 4);

    path2.addOval(
        Rect.fromCircle(radius: 7.0, center: new Offset(0.0, -radius)));
    path2.addOval(Rect.fromCircle(radius: 5.0, center: new Offset(0.0, 0.0)));

    canvas.drawPath(path1, secondHandPaint);
    canvas.drawPath(path2, secondHandPointsPaint);

    canvas.restore();
  }

  @override
  bool shouldRepaint(SecondHandPainter oldDelegate) {
    return this.seconds != oldDelegate.seconds;
  }
}

Create the clock_dial_painter.dart file with the following content:

import 'dart:math';

import 'package:flutter/material.dart';

class ClockDialPainter extends CustomPainter {
  final clockText;

  final hourTickMarkLength = 10.0;
  final minuteTickMarkLength = 5.0;

  final hourTickMarkWidth = 3.0;
  final minuteTickMarkWidth = 1.5;

  final Paint tickPaint;
  final TextPainter textPainter;
  final TextStyle textStyle;

  final romanNumeralList = [
    'XII',
    'I',
    'II',
    'III',
    'IV',
    'V',
    'VI',
    'VII',
    'VIII',
    'IX',
    'X',
    'XI'
  ];

  ClockDialPainter({this.clockText = ClockText.roman})
      : tickPaint = new Paint(),
        textPainter = new TextPainter(
          textAlign: TextAlign.center,
          textDirection: TextDirection.rtl,
        ),
        textStyle = const TextStyle(
          color: Colors.black,
          fontFamily: 'Times New Roman',
          fontSize: 15.0,
        ) {
    tickPaint.color = Colors.blueGrey;
  }

  @override
  void paint(Canvas canvas, Size size) {
    var tickMarkLength;
    final angle = 2 * pi / 60;
    final radius = size.width / 2;
    canvas.save();

    // drawing
    canvas.translate(radius, radius);
    for (var i = 0; i < 60; i++) {
      //make the length and stroke of the tick marker longer and thicker depending
      tickMarkLength = i % 5 == 0 ? hourTickMarkLength : minuteTickMarkLength;
      tickPaint.strokeWidth =
          i % 5 == 0 ? hourTickMarkWidth : minuteTickMarkWidth;
      canvas.drawLine(new Offset(0.0, -radius),
          new Offset(0.0, -radius + tickMarkLength), tickPaint);

      //draw the text
      if (i % 5 == 0) {
        canvas.save();
        canvas.translate(0.0, -radius + 20.0);

        textPainter.text = new TextSpan(
          text: this.clockText == ClockText.roman
              ? '${romanNumeralList[i ~/ 5]}'
              : '${i == 0 ? 12 : i ~/ 5}',
          style: textStyle,
        );

        //helps make the text painted vertically
        canvas.rotate(-angle * i);
        textPainter.layout();

        textPainter.paint(canvas,
            new Offset(-(textPainter.width / 2), -(textPainter.height / 2)));

        canvas.restore();
      }

      canvas.rotate(angle);
    }

    canvas.restore();
  }

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

enum ClockText { roman, arabic }

Create clock_face.dart with the following content.

import 'dart:async';
import 'dart:math';

import 'package:clock/clock_dial_painter.dart';
import 'package:clock/clock_hands.dart';
import 'package:flutter/material.dart';

class ClockFace extends StatefulWidget {
  @override
  _ClockFaceState createState() => _ClockFaceState();
}

class _ClockFaceState extends State<ClockFace> {
  Timer _timer;
  DateTime dateTime;

  @override
  void initState() {
    super.initState();
    dateTime = new DateTime.now();
    _timer = new Timer.periodic(const Duration(seconds: 1), setTime);
  }

  void setTime(Timer timer) {
    setState(() {
      dateTime = new DateTime.now();
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }
  // DateTime dateTime = calculateRandomTime();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(10.0),
      child: AspectRatio(
        aspectRatio: 1.0,
        child: Container(
          width: double.infinity,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Colors.white,
          ),
          child: GestureDetector(
            onTap: () {
              Scaffold.of(context).removeCurrentSnackBar();
              var snackebar = createSnackBar(dateTime);
              Scaffold.of(context).showSnackBar(snackebar);
            },
            // onDoubleTap: () {
            //   setState(() {
            //     dateTime = calculateRandomTime();
            //   });
            // },
            child: Stack(
              children: <Widget>[
                //dial and numbers go here
                new Container(
                  width: double.infinity,
                  height: double.infinity,
                  padding: const EdgeInsets.all(10.0),
                  child: new CustomPaint(
                    painter: new ClockDialPainter(clockText: ClockText.arabic),
                  ),
                ),

                //clock hands go here
                // the point in the middle
                Centerpoint(),
                ClockHands(dateTime: dateTime),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

SnackBar createSnackBar(DateTime date) {
  TimeOfDay time = TimeOfDay.fromDateTime(date);
  int myhour = time.hour == 0 ? 12 : time.hour;

  var snackbar = SnackBar(
    content: Text("$myhour:${time.minute}"),
    action: SnackBarAction(
      label: 'Done',
      onPressed: () {},
    ),
  );
  return snackbar;
}

class Centerpoint extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        width: 15.0,
        height: 15.0,
        decoration: new BoxDecoration(
          shape: BoxShape.circle,
          color: Colors.black,
        ),
      ),
    );
  }
}

DateTime calculateRandomTime() {
  int newHour = Random().nextInt(13);
  var newMinute = Random().nextInt(61);
  var datae = DateTime.now();
  DateTime time = datae.toLocal();
  time = new DateTime(
      time.year, time.month, time.day, newHour, newMinute, 0, 0, 0);
  return time;
}

Finally the Placeholder in ClockFrame with it your ClockFace widget.

3. Links and Literature