Analog Clock using CustomPaint — Flutter

Sanjay Sharma
7 min readJul 11, 2024

--

by Sanjay Sharma

Creating a custom analog clock using CustomPaint in Flutter allows you to have full control over the appearance and behaviour of the clock.

Here are some reasons why using CustomPaint is advantageous:

1. Custom Design: You can design the clock to look exactly how you want it. This includes custom shapes, colors, and styles for the clock face, hands, and other elements.

2. Performance: CustomPaint is optimised for performance. By directly drawing the clock using the canvas, you can achieve smooth animations and updates.

3. Flexibility: You can easily add additional features or modify the clock’s behavior. For example, you could add animations, interactive elements, or dynamically update the clock based on certain conditions.

4. Learning Experience: Working with CustomPaint and the Canvas API provides a deeper understanding of Flutter’s rendering pipeline. This knowledge can be beneficial for other custom UI tasks.

Here’s a basic example to get you started with a custom analog clock using CustomPaint: Create Class AnalogClockPainter and extend with CustomPaint


import 'dart:math';

import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as INTL;

class AnalogClockPainter extends CustomPainter {
DateTime datetime;
final bool showDigitalClock;
final bool showTicks;
final bool showNumbers;
final bool showAllNumbers;
final bool showSecondHand;
final bool useMilitaryTime;
final Color hourHandColor;
final Color minuteHandColor;
final Color secondHandColor;
final Color tickColor;
final Color digitalClockColor;
final Color numberColor;
final double textScaleFactor;

static const double BASE_SIZE = 320.0;
static const double MINUTES_IN_HOUR = 60.0;
static const double SECONDS_IN_MINUTE = 60.0;
static const double HOURS_IN_CLOCK = 12.0;
static const double HAND_PIN_HOLE_SIZE = 8.0;
static const double STROKE_WIDTH = 5.0;

AnalogClockPainter({
required this.datetime,
this.showDigitalClock = true,
this.showTicks = true,
this.showNumbers = true,
this.showSecondHand = true,
this.hourHandColor = Colors.black,
this.minuteHandColor = Colors.black,
this.secondHandColor = Colors.redAccent,
this.tickColor = Colors.grey,
this.digitalClockColor = Colors.black,
this.numberColor = Colors.black,
this.showAllNumbers = false,
this.textScaleFactor = 1.0,
this.useMilitaryTime = true,
});

@override
void paint(Canvas canvas, Size size) {
double scaleFactor = size.shortestSide / BASE_SIZE;

if (showTicks) _paintTickMarks(canvas, size, scaleFactor);
if (showNumbers) {
_drawIndicators(canvas, size, scaleFactor, showAllNumbers);
}

if (showDigitalClock) _paintDigitalClock(canvas, size, scaleFactor, useMilitaryTime);

_paintClockHands(canvas, size, scaleFactor);
_paintPinHole(canvas, size, scaleFactor);
}

@override
bool shouldRepaint(AnalogClockPainter oldDelegate) {
return oldDelegate.datetime.isBefore(datetime);
}

_paintPinHole(canvas, size, scaleFactor) {
Paint midPointStrokePainter = Paint()
..color = showSecondHand ? secondHandColor : minuteHandColor
..strokeWidth = STROKE_WIDTH * scaleFactor
..isAntiAlias = true
..style = PaintingStyle.stroke;

canvas.drawCircle(size.center(Offset.zero), HAND_PIN_HOLE_SIZE * scaleFactor, midPointStrokePainter);
}

void _drawIndicators(Canvas canvas, Size size, double scaleFactor, bool showAllNumbers) {
TextStyle style = TextStyle(color: numberColor, fontWeight: FontWeight.bold, fontSize: 18.0 * scaleFactor * textScaleFactor);
double p = 12.0;
if (showTicks) p += 24.0;

double r = size.shortestSide / 2;
double longHandLength = r - (p * scaleFactor);

for (var h = 1; h <= 12; h++) {
if (!showAllNumbers && h % 3 != 0) continue;
double angle = (h * pi / 6) - pi / 2;
Offset offset = Offset(longHandLength * cos(angle), longHandLength * sin(angle));
TextSpan span = TextSpan(style: style, text: h.toString());
TextPainter tp = TextPainter(text: span, textAlign: TextAlign.center, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, size.center(offset - tp.size.center(Offset.zero)));
}
}

Offset _getHandOffset(double percentage, double length) {
final radians = 2 * pi * percentage;
final angle = -pi / 2.0 + radians;

return Offset(length * cos(angle), length * sin(angle));
}

void _paintTickMarks(Canvas canvas, Size size, double scaleFactor) {
double r = size.shortestSide / 2;
double tick = 5 * scaleFactor, mediumTick = 2.0 * tick, longTick = 3.0 * tick;
double p = longTick + 4 * scaleFactor;
Paint tickPaint = Paint()
..color = tickColor
..strokeWidth = 2.0 * scaleFactor;

for (int i = 1; i <= 60; i++) {
// default tick length is short
double len = tick;
if (i % 15 == 0) {
// Longest tick on quarters (every 15 ticks)
len = longTick;
} else if (i % 5 == 0) {
// Medium ticks on the '5's (every 5 ticks)
len = mediumTick;
}
// Get the angle from 12 O'Clock to this tick (radians)
double angleFrom12 = i / 60.0 * 2.0 * pi;
// Get the angle from 3 O'Clock to this tick
// Note: 3 O'Clock corresponds with zero angle in unit circle
// Makes it easier to do the math.
double angleFrom3 = pi / 2.0 - angleFrom12;

canvas.drawLine(size.center(Offset(cos(angleFrom3) * (r + len - p), sin(angleFrom3) * (r + len - p))), size.center(Offset(cos(angleFrom3) * (r - p), sin(angleFrom3) * (r - p))), tickPaint);
}
}

void _paintClockHands(Canvas canvas, Size size, double scaleFactor) {
double r = size.shortestSide / 2;
double p = 0.0;
if (showTicks) p += 28.0;
if (showNumbers) p += 24.0;
if (showAllNumbers) p += 24.0;
double longHandLength = r - (p * scaleFactor);
double shortHandLength = r - (p + 36.0) * scaleFactor;

Paint handPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.bevel
..strokeWidth = STROKE_WIDTH * scaleFactor;
double seconds = datetime.second / SECONDS_IN_MINUTE;
double minutes = (datetime.minute + seconds) / MINUTES_IN_HOUR;
double hour = (datetime.hour + minutes) / HOURS_IN_CLOCK;

canvas.drawLine(size.center(_getHandOffset(hour, HAND_PIN_HOLE_SIZE * scaleFactor)), size.center(_getHandOffset(hour, shortHandLength)), handPaint..color = hourHandColor);

canvas.drawLine(size.center(_getHandOffset(minutes, HAND_PIN_HOLE_SIZE * scaleFactor)), size.center(_getHandOffset(minutes, longHandLength)), handPaint..color = minuteHandColor);
if (showSecondHand) canvas.drawLine(size.center(_getHandOffset(seconds, HAND_PIN_HOLE_SIZE * scaleFactor)), size.center(_getHandOffset(seconds, longHandLength)), handPaint..color = secondHandColor);
}

void _paintDigitalClock(Canvas canvas, Size size, double scaleFactor, bool useMilitaryTime) {
int hourInt = datetime.hour;
String meridiem = '';
if (!useMilitaryTime) {
if (hourInt > 12) {
hourInt = hourInt - 12;
meridiem = ' PM';
} else {
meridiem = ' AM';
}
}
String hour = hourInt.toString().padLeft(2, "0");
String minute = datetime.minute.toString().padLeft(2, "0");
String second = datetime.second.toString().padLeft(2, "0");
TextSpan digitalClockSpan = TextSpan(style: TextStyle(color: digitalClockColor, fontSize: 18 * scaleFactor * textScaleFactor), text: "$hour:$minute:$second$meridiem");
TextPainter digitalClockTP = TextPainter(text: digitalClockSpan, textAlign: TextAlign.center, textDirection: TextDirection.ltr);
digitalClockTP.layout();
digitalClockTP.paint(canvas, size.center(-digitalClockTP.size.center(Offset(0.0, -size.shortestSide / 6))));
}
}

class DigitalClockPainter extends CustomPainter {
DateTime datetime;
final String? format;
final Color digitalClockTextColor;
final double textScaleFactor;
final TextStyle? textStyle;
//digital clock
final bool showSeconds;

DigitalClockPainter({
required this.datetime,
this.textStyle,
this.format,
this.showSeconds = true,
this.digitalClockTextColor = Colors.black,
this.textScaleFactor = 1.0,
});

@override
void paint(Canvas canvas, Size size) {
double scaleFactor = 1;
_paintDigitalClock(canvas, size, scaleFactor);
}

@override
bool shouldRepaint(DigitalClockPainter oldDelegate) {
return oldDelegate.datetime.isBefore(datetime);
}

void _paintDigitalClock(Canvas canvas, Size size, double scaleFactor) {
String textToBeDisplayed = (!(format?.isEmpty ?? true))
? INTL.DateFormat(format).format(datetime)
: showSeconds
? INTL.DateFormat('h:mm:ss a').format(datetime)
: INTL.DateFormat('h:mm a').format(datetime);
TextSpan digitalClockSpan = TextSpan(style: textStyle ?? TextStyle(color: digitalClockTextColor, fontSize: 18 * scaleFactor * textScaleFactor, fontWeight: FontWeight.bold), text: textToBeDisplayed);
TextPainter digitalClockTP = TextPainter(text: digitalClockSpan, textAlign: TextAlign.center, textDirection: TextDirection.ltr);
digitalClockTP.layout();
digitalClockTP.paint(canvas, size.center(-digitalClockTP.size.center(const Offset(0.0, 0.0))));
}
}

Create Analog Widget



import 'dart:async';
import 'package:flutter/material.dart';
import 'package:new_flutter_beacons/analogPaint.dart';

class AnalogClock extends StatefulWidget {

final DateTime? datetime;

final bool showDigitalClock;
final bool showTicks;
final bool showNumbers;
final bool showAllNumbers;
final bool showSecondHand;
final bool useMilitaryTime;
final Color hourHandColor;
final Color minuteHandColor;
final Color secondHandColor;
final Color tickColor;
final Color digitalClockColor;
final Color numberColor;
final bool isLive;
final double textScaleFactor;
final double width;
final double height;
final BoxDecoration decoration;

const AnalogClock(
{this.datetime,
this.showDigitalClock = true,
this.showTicks = true,
this.showNumbers = true,
this.showSecondHand = true,
this.showAllNumbers = false,
this.useMilitaryTime = true,
this.hourHandColor = Colors.white,
this.minuteHandColor = Colors.white,
this.secondHandColor = Colors.redAccent,
this.tickColor = Colors.white,
this.digitalClockColor = Colors.white,
this.numberColor = Colors.white,
this.textScaleFactor = 1.0,
this.width = double.infinity,
this.height = double.infinity,
this.decoration = const BoxDecoration(),
this.isLive = true,
Key? key})
: super(key: key);

const AnalogClock.dark(
{this.datetime,
this.showDigitalClock = true,
this.showTicks = true,
this.showNumbers = true,
this.showAllNumbers = false,
this.showSecondHand = true,
this.useMilitaryTime = true,
this.isLive = true,
this.textScaleFactor = 1.0,
this.width = double.infinity,
this.height = double.infinity,
this.hourHandColor = Colors.white,
this.minuteHandColor = Colors.white,
this.secondHandColor = Colors.orange,
this.tickColor = Colors.grey,
this.digitalClockColor = Colors.white,
this.numberColor = Colors.white,
this.decoration = const BoxDecoration(color: Colors.black, shape: BoxShape.circle),
Key? key})
: super(key: key);

@override
AnalogClockState createState() => AnalogClockState(datetime);
}

class AnalogClockState extends State<AnalogClock> {
DateTime initialDatetime;
DateTime datetime;
Duration updateDuration = const Duration(seconds: 1);
AnalogClockState(datetime)
: datetime = datetime ?? DateTime.now(),
initialDatetime = datetime ?? DateTime.now();

@override
initState() {
super.initState();
updateDuration = const Duration(seconds: 1);
if (widget.isLive) {
Timer.periodic(updateDuration, update);
}
}

update(Timer timer) {
if (mounted) {
datetime = initialDatetime.add(updateDuration * timer.tick);
setState(() {});
}
}

@override
Widget build(BuildContext context) {
return Container(
width: widget.width,
height: widget.height,
decoration: widget.decoration,
child: Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
constraints: const BoxConstraints(minWidth: 48.0, minHeight: 48.0),
width: double.infinity,
child: CustomPaint(
painter: AnalogClockPainter(
datetime: datetime,
showDigitalClock: widget.showDigitalClock,
showTicks: widget.showTicks,
showNumbers: widget.showNumbers,
showAllNumbers: widget.showAllNumbers,
showSecondHand: widget.showSecondHand,
useMilitaryTime: widget.useMilitaryTime,
hourHandColor: widget.hourHandColor,
minuteHandColor: widget.minuteHandColor,
secondHandColor: widget.secondHandColor,
tickColor: widget.tickColor,
digitalClockColor: widget.digitalClockColor,
textScaleFactor: widget.textScaleFactor,
numberColor: widget.numberColor),
)))),
);
}

@override
void didUpdateWidget(AnalogClock oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.isLive && widget.datetime != oldWidget.datetime) {
datetime = widget.datetime ?? DateTime.now();
} else if (widget.isLive && widget.datetime != oldWidget.datetime) {
initialDatetime = widget.datetime ?? DateTime.now();
}
}
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:new_flutter_beacons/analogClock.dart';


const grad1 = Color(0xFF0061FF);
const grad2 = Color(0xFF60EFFF);

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});

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

class MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Clock'),
),
body: Center(
child: AnalogClock(
isLive: true,
width: 300,
height: 300,
decoration: const BoxDecoration(shape: BoxShape.circle,gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
// stops: [0.0, 0.5, 1.0],
colors: [
grad1,
grad2,
],
)),
datetime: DateTime.now(),
),
),
),
);
}
}

Output:

I hope you found this article enjoyable! If you appreciate the information provided, you have the option to support me by Buying Me A Coffee! Your gesture would be greatly appreciated!

--

--