Communicating with the hardware of a phone can be tricky in Flutter because it abstracts from the hardware and even the OS. However, regarding the accelerometer, there is an official package that is extremely simple to use.
Let’s utilize this package to show a speedometer that indicates the speed the smartphone is currently moving at.
Implementation
Before we start to draw anything onto the screen, let’s take care of capturing the current velocity of the phone’s movement using the sensors package.
Now that we have the package added to our project, we want to use its API.
1import 'dart:math';
2
3import 'package:flutter/material.dart';
4import 'package:sensors/sensors.dart';
5
6import 'speedometer.dart';
7
8class SpeedometerContainer extends StatefulWidget {
9 @override
10 _SpeedometerContainerState createState() => _SpeedometerContainerState();
11}
12
13class _SpeedometerContainerState extends State<SpeedometerContainer> {
14 double velocity = 0;
15 double highestVelocity = 0.0;
16
17 @override
18 void initState() {
19 userAccelerometerEvents.listen((UserAccelerometerEvent event) {
20 _onAccelerate(event);
21 });
22 super.initState();
23 }
24
25 void _onAccelerate(UserAccelerometerEvent event) {
26 double newVelocity = sqrt(
27 event.x * event.x + event.y * event.y + event.z * event.z
28 );
29
30 if ((newVelocity - velocity).abs() < 1) {
31 return;
32 }
33
34 setState(() {
35 velocity = newVelocity;
36
37 if (velocity > highestVelocity) {
38 highestVelocity = velocity;
39 }
40 });
41 }
42
43 @override
44 Widget build(BuildContext context) {
45 return Scaffold(
46 backgroundColor: Colors.black,
47 body: Stack(
48 children: [
49 Container(
50 padding: EdgeInsets.only(bottom: 64),
51 alignment: Alignment.bottomCenter,
52 child: Text(
53 'Highest speed:\n${highestVelocity.toStringAsFixed(2)} km/h',
54 style: TextStyle(
55 color: Colors.white
56 ),
57 textAlign: TextAlign.center,
58 )
59 ),
60 Center(
61 child: Speedometer(
62 speed: velocity,
63 speedRecord: highestVelocity,
64 )
65 )
66 ]
67 )
68 );
69 }
70}
This widget wraps the actual speedometer (that has a visual representation on the screen). It captures the current velocity and forwards this value to the widget that is to be created.
During initState()
, we bind a listener to userAccelerometerEvents
, which is a Stream
of events. The package description says about the event:
UserAccelerometerEvent
s […] describe the velocity of the device, but don’t include gravity. They can also be thought of as just the user’s affect on the device.
This is very good. It’s the velocity and not the acceleration so we don’t need to add or multiply anything, we can use the value as it is. Also, the gravity is not included.
The only thing we need to do, is to boil the acceleration down to one value. That’s because at the moment, the velocity is represented as x, y and z direction. That looks like a Vector3
so if we just take the square root of the sum of the different values squared, we should be good to go.
Because we don’t want the needle to move panicky whenever the velocity changes by a fraction of a minimal value, we decide to update it only when the change compared to the former value is greater than 1. We also store the highest velocity so that we can display the current record.
Let’s continue with creating the actual Speedometer
widget.
1import 'dart:math';
2
3import 'package:flutter/material.dart';
4
5class Speedometer extends StatelessWidget {
6 Speedometer({
7 @required this.speed,
8 @required this.speedRecord,
9 this.size = 300
10 });
11
12 final double speed;
13 final double speedRecord;
14 final double size;
15
16 @override
17 Widget build(BuildContext context) {
18 return CustomPaint(
19 painter: SpeedometerPainter(
20 speed: speed,
21 speedRecord: speedRecord
22 ),
23 size: Size(size, size)
24 );
25 }
26}
It’s fairly simple: we let speed
and speedRecord
be the only constructor arguments so that the widget we have just created can inject its sensor data into it. We also expect a Size
that determines the size the speedometer has on the screen (it’s a square). It defaults to 300 and is used as the size
argument for our CustomPainter
.
1class SpeedometerPainter extends CustomPainter {
2 SpeedometerPainter({
3 this.speed,
4 this.speedRecord
5 });
6
7 final double speed;
8 final double speedRecord;
9
10 Size size;
11 Canvas canvas;
12 Offset center;
13 Paint paintObject;
14
15 @override
16 void paint(Canvas canvas, Size size) {}
17
18 @override
19 bool shouldRepaint(CustomPainter oldDelegate) {
20 return true;
21 }
Before we start drawing the actual speedometer, let’s list all the parts of it:
- Outer circle: This is the circle that has the markers and speed texts inside of it
- Inner circle: Purely decorative circle having the current speed as text inside of it
- Speed markers: The markers indicating the different speeds to give the viewer an orientation what the needle points at
- Speed marker texts: The labels showing the different speeds
- Speed indicator bars: In addition to the needle and the text, we want to have bars at the outside of the circle indicating the current speed. Makes it look cooler
- Needle: Pointing towards the current speed
- Ghost needle: Pointing towards the speed record
- Needle holder: The circle at the center that mimics a component holding the needle
- Speed text: A text below the needle holder that shows the current speed
Let’s start with an initialization so that we don’t have to provide Canvas
and Size
argument to every private method and can use the member variables of or SpeedometerPainter
instead.
1void _init(Canvas canvas, Size size) {
2 this.canvas = canvas;
3 this.size = size;
4 center = size.center(Offset.zero);
5 paintObject = Paint();
6}
7
8void _drawOuterCircle() {
9 paintObject
10 ..color = Colors.red
11 ..strokeWidth = 2.5
12 ..style = PaintingStyle.stroke;
13
14 canvas.drawCircle(
15 size.center(Offset.zero),
16 size.width / 2.2,
17 paintObject
18 );
19}
The inner circle is just an outline, so we switch the PaintStyle
to stroke
:
1void _drawInnerCircle() {
2 paintObject
3 ..color = Colors.red.withOpacity(0.4)
4 ..strokeWidth = 1.0
5 ..style = PaintingStyle.stroke;
6
7 canvas.drawCircle(
8 size.center(Offset.zero),
9 size.width / 4,
10 paintObject
11 );
12}
Now it’s time to draw the markers around the circle as well as the speed labels. We want the important ones like every 10 km/h to appear bigger than the ones in between.
1void _drawMarkers() {
2 paintObject.style = PaintingStyle.fill;
3
4 for (double relativeRotation = 0.15; relativeRotation <= 0.851; relativeRotation += 0.01) {
5 double normalizedDouble = double.parse((relativeRotation - 0.15).toStringAsFixed(2));
6 int normalizedPercentage = (normalizedDouble * 100).toInt();
7 bool isBigMarker = normalizedPercentage % 10 == 0;
8
9 _drawRotated(
10 relativeRotation,
11 () => _drawMarker(isBigMarker)
12 );
13
14 if (isBigMarker)
15 _drawRotated(
16 relativeRotation,
17 () => _drawSpeedScaleText(relativeRotation, normalizedPercentage.toString())
18 );
19 }
20}
We iterate over a relative rotation value and draw the respective markers at certain values. relativeRotation
describes the amount of rotation along the outer circle (0 means zero rotation, starting at the bottom center, 1 means 360 ° rotation being again at the bottom center).
To make only the the important speed markers appear bigger, we need to determine, if in the current iteration, we have a percentage that is dividable by ten without a rest. We achieve that by normalizing the percentage using an Integer. This is necessary because the double precision would produce values like 0.150000001
instead. There might be a more elegant way than this data type conversion.
Let’s talk about the _drawRotated()
method we need to properly draw the marker.
1void _drawRotated(double angle, Function drawFunction) {
2 canvas.save();
3 canvas.translate(center.dx, center.dy);
4 canvas.rotate(angle * pi * 2);
5 canvas.translate(-center.dx, -center.dy);
6 drawFunction();
7 canvas.restore();
8 }
This is something we are going to need for multiple visual components of the speedometer. We can imagine this like you’re drawing on a piece of paper. Instead of drawing something rotated, we can only draw straight. But what we can do is to rotate the sheet of paper. So we move the sheet of paper to the center and rotate it by an angle. If we then finish drawing and rotate the sheet back (canvas.restore()
), we have drawn a rotated object.
This procedure is explained in more detail in the respective article about rotation on canvas.
We use that function with a callback called _drawMarker
:
1void _drawMarker(bool isBigMarker) {
2 paintObject
3 ..color = Colors.red
4 ..shader = null;
5
6 Path markerPath = Path()
7 ..addRect(
8 Rect.fromLTRB(
9 center.dx - size.width / (isBigMarker ? 200 : 300),
10 center.dy + (size.width / 2.2),
11 center.dx + size.width / (isBigMarker ? 200 : 300),
12 center.dy + (size.width / (isBigMarker ? 2.5 : 2.35)),
13 )
14 );
15
16 canvas.drawPath(markerPath, paintObject);
17}
We draw the marker to the bottom center. The rotation function being wrapped around this makes it start at 15 % and end at 85 % of the circle. If isBigMarker
is true, we decrease the size.
Every size used in this needs to be relative to the size.width
(or size.height
). This way we ensure that the whole painting will be responsive depending on the given size.
1void _drawSpeedScaleText(double rotation, String text) {
2 TextSpan span = new TextSpan(
3 style: new TextStyle(
4 fontWeight: FontWeight.bold,
5 color: Colors.red,
6 fontSize: size.width / 20
7 ),
8 text: text
9 );
10 TextPainter textPainter = TextPainter(
11 text: span,
12 textDirection: TextDirection.ltr,
13 textAlign: TextAlign.center
14 );
15
16 textPainter.layout();
17
18 final textCenter = Offset(
19 center.dx,
20 size.width - (size.width / 5.5) + (textPainter.width / 2)
21 );
22
23 final textTopLeft = Offset(
24 textCenter.dx - (textPainter.width / 2),
25 textCenter.dy - (textPainter.height / 2)
26 );
27
28 textPainter.paint(canvas, textTopLeft);
29}
We use a TextPainter
to paint our labels to the canvas.
Well, this is not exactly what we wanted. But why is that? Well imagine rotating the sheet of paper and drawing the text. It will always point towards you making it only readable when you rotate the sheet. We need to fix this by not only rotating the canvas round its center by the given amount but also rotate the sheet around the center of the text by the same amount but in the other direction. This way we correct the rotated text.
1canvas.save();
2
3// Rotate the canvas around the position of the text so that the text is oriented properly
4
5canvas.translate(
6 textCenter.dx,
7 textCenter.dy
8 );
9canvas.rotate(-rotation * pi * 2);
10canvas.translate(
11 -textCenter.dx,
12 -textCenter.dy
13);
14
15textPainter.paint(canvas, textTopLeft);
16
17canvas.restore();
Now let’s draw the speed bars around the outer circle that mimic the current speed.
1void _drawSpeedIndicators(Size size) {
2 for (double percentage = 0.15; percentage <= 0.85; percentage += 4 / (size.width)) {
3 _drawSpeedIndicator(percentage);
4 }
5
6 for (double percentage = 0.15; percentage < 0.15 + (speed / 100); percentage += 4 / (size.width)) {
7 _drawSpeedIndicator(percentage, true);
8 }
9}
10
11void _drawSpeedIndicator(double relativeRotation, [bool highlight = false]) {
12 paintObject.shader = null;
13 paintObject.strokeWidth = 1;
14 paintObject.style = PaintingStyle.stroke;
15 paintObject.color = Colors.white54;
16
17 if (highlight) {
18 paintObject.color = Color.lerp(
19 Colors.yellow, Colors.red, (relativeRotation - 0.15) / 0.7
20 );
21 paintObject.style = PaintingStyle.fill;
22 }
23
24 Path markerPath = Path()
25 ..addRect(
26 Rect.fromLTRB(
27 center.dx - size.width / 40,
28 size.width - (size.width / 30),
29 center.dx,
30 size.width - (size.width / 100)
31 )
32 );
33
34 _drawRotated(relativeRotation, () {
35 canvas.drawPath(markerPath, paintObject);
36 });
37}
We draw the outlines for every speed indicator that is not reached by the current speed. For every other indicator, we fill it with a color that is interpolated from yellow to red depending on how near it is to 0 / 70.
The needles are still missing! Remember: we want one needle to display the current speed and another to display the speed record.
1_drawNeedle(
2 0.15 + (speedRecord / 100),
3 Colors.white54,
4 size.width / 120
5);
6_drawNeedle(
7 0.15 + (speed / 100),
8 Colors.red,
9 size.width / 70
10);
11
12void _drawNeedle(double rotation, Color color, double width) {
13 paintObject
14 ..style = PaintingStyle.fill
15 ..color = color;
16
17 Path needlePath = Path()
18 ..moveTo(center.dx - width, center.dy)
19 ..lineTo(center.dx + width, center.dy)
20 ..lineTo(center.dx, center.dy + size.width / 2.5)
21 ..moveTo(center.dx - width, center.dy);
22
23 _drawRotated(rotation, () {
24 canvas.drawPath(needlePath, paintObject);
25 });
26}
The needle is just a triangle from the center to the outer circle. Because we need to draw two needles, we make the method abstract and expect a rotation, a color and a width.
The speed needle is based on speed
whereas the ghost needle is based on speedRecord
.
1void _drawNeedleHolder() {
2 RadialGradient gradient = RadialGradient(
3 colors: [Colors.orange, Colors.red, Colors.red, Colors.black],
4 radius: 1.2,
5 stops: [0.0, 0.7, 0.9, 1.0]
6 );
7
8 paintObject
9 ..color = Colors.blueGrey
10 ..shader = gradient.createShader(
11 Rect.fromCenter(
12 center: center,
13 width: size.width / 20,
14 height: size.width / 20
15 )
16 );
17
18 canvas.drawCircle(
19 size.center(Offset.zero),
20 size.width / 15,
21 paintObject
22 );
23}
The needle is nothing more than a circle with a radial fill.
1void _drawSpeed() {
2 TextSpan span = new TextSpan(
3 style: new TextStyle(
4 fontWeight: FontWeight.bold,
5 color: Colors.red,
6 fontSize: size.width / 12
7 ),
8 text: '${speed.toStringAsFixed(0)}'
9 );
10
11 TextPainter textPainter = TextPainter(
12 text: span,
13 textDirection: TextDirection.ltr,
14 textAlign: TextAlign.center
15 );
16
17 textPainter.layout();
18
19 final textCenter = Offset(
20 center.dx,
21 center.dy + (size.width / 10) + (textPainter.width / 2)
22 );
23
24 final textTopLeft = Offset(
25 textCenter.dx - (textPainter.width / 2),
26 textCenter.dy - (textPainter.width / 2)
27 );
28
29 textPainter.paint(canvas, textTopLeft);
30}
The text of the current speed is the rounded value of speed
and displayed below the needle holder.
Conclusion
The sensor package makes it very easy to fetch the current velocity of the phone’s movement. Using a CustomPainter
we were able to quickly draw a speedometer that displays it in a fancy way!
Anurag