Smartphone movement speedometer in Flutter

flutterclutter

flutter-clutter

Posted on September 30, 2020

Smartphone movement speedometer in Flutter

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.

dependencies:
  flutter:
    sdk: flutter
  sensors: '>=0.4.2+5 <2.0.0'
Enter fullscreen mode Exit fullscreen mode

Now that we have the package added to our project, we want to use its API.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:sensors/sensors.dart';

import 'speedometer.dart';

class SpeedometerContainer extends StatefulWidget {
  @override
  _SpeedometerContainerState createState() => _SpeedometerContainerState();
}

class _SpeedometerContainerState extends State<SpeedometerContainer> {
  double velocity = 0;
  double highestVelocity = 0.0;

  @override
  void initState() {
    userAccelerometerEvents.listen((UserAccelerometerEvent event) {
      _onAccelerate(event);
    });
    super.initState();
  }

  void _onAccelerate(UserAccelerometerEvent event) {
    double newVelocity = sqrt(
        event.x * event.x + event.y * event.y + event.z * event.z
    );

    if ((newVelocity - velocity).abs() < 1) {
      return;
    }

    setState(() {
      velocity = newVelocity;

      if (velocity > highestVelocity) {
        highestVelocity = velocity;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: [
          Container(
            padding: EdgeInsets.only(bottom: 64),
            alignment: Alignment.bottomCenter,
            child: Text(
              'Highest speed:\n${highestVelocity.toStringAsFixed(2)} km/h',
              style: TextStyle(
                  color: Colors.white
              ),
              textAlign: TextAlign.center,
            )
          ),
          Center(
            child: Speedometer(
              speed: velocity,
              speedRecord: highestVelocity,
            )
          )
        ]
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

UserAccelerometerEvents [...] 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.

import 'dart:math';

import 'package:flutter/material.dart';

class Speedometer extends StatelessWidget {
  Speedometer({
    @required this.speed,
    @required this.speedRecord,
    this.size = 300
  });

  final double speed;
  final double speedRecord;
  final double size;

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: SpeedometerPainter(
        speed: speed,
        speedRecord: speedRecord
      ),
      size: Size(size, size)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

class SpeedometerPainter extends CustomPainter {
  SpeedometerPainter({
    this.speed,
    this.speedRecord
  });

  final double speed;
  final double speedRecord;

  Size size;
  Canvas canvas;
  Offset center;
  Paint paintObject;

  @override
  void paint(Canvas canvas, Size size) {}

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
Enter fullscreen mode Exit fullscreen mode

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.

void _init(Canvas canvas, Size size) {
  this.canvas = canvas;
  this.size = size;
  center = size.center(Offset.zero);
  paintObject = Paint();
}

void _drawOuterCircle() {
  paintObject
    ..color = Colors.red
    ..strokeWidth = 2.5
    ..style = PaintingStyle.stroke;

  canvas.drawCircle(
      size.center(Offset.zero),
      size.width / 2.2,
      paintObject
  );
}
Enter fullscreen mode Exit fullscreen mode

The inner circle is just an outline, so we switch the PaintStyle to stroke:

void _drawInnerCircle() {
  paintObject
    ..color = Colors.red.withOpacity(0.4)
    ..strokeWidth = 1.0
    ..style = PaintingStyle.stroke;

  canvas.drawCircle(
    size.center(Offset.zero),
    size.width / 4,
    paintObject
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

void _drawMarkers() {
  paintObject.style = PaintingStyle.fill;

  for (double relativeRotation = 0.15; relativeRotation <= 0.851; relativeRotation += 0.01) {
    double normalizedDouble = double.parse((relativeRotation - 0.15).toStringAsFixed(2));
    int normalizedPercentage = (normalizedDouble * 100).toInt();
    bool isBigMarker = normalizedPercentage % 10 == 0;

     _drawRotated(
        relativeRotation,
        () => _drawMarker(isBigMarker)
      );

      if (isBigMarker)
        _drawRotated(
          relativeRotation,
          () => _drawSpeedScaleText(relativeRotation, normalizedPercentage.toString())
        );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

  void _drawRotated(double angle, Function drawFunction) {
    canvas.save();
    canvas.translate(center.dx, center.dy);
    canvas.rotate(angle * pi * 2);
    canvas.translate(-center.dx, -center.dy);
    drawFunction();
    canvas.restore();
  }
Enter fullscreen mode Exit fullscreen mode

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.

We use that function with a callback called _drawMarker:

void _drawMarker(bool isBigMarker) {
  paintObject
    ..color = Colors.red
    ..shader = null;

  Path markerPath = Path()
    ..addRect(
      Rect.fromLTRB(
        center.dx - size.width / (isBigMarker ? 200 : 300),
        center.dy + (size.width / 2.2),
        center.dx + size.width / (isBigMarker ? 200 : 300),
        center.dy + (size.width / (isBigMarker ? 2.5 : 2.35)),
      )
    );

  canvas.drawPath(markerPath, paintObject);
}
Enter fullscreen mode Exit fullscreen mode

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.

void _drawSpeedScaleText(double rotation, String text) {
  TextSpan span = new TextSpan(
    style: new TextStyle(
      fontWeight: FontWeight.bold,
      color: Colors.red,
      fontSize: size.width / 20
    ),
    text: text
  );
  TextPainter textPainter = TextPainter(
    text: span,
    textDirection: TextDirection.ltr,
    textAlign: TextAlign.center
  );

  textPainter.layout();

  final textCenter = Offset(
    center.dx,
    size.width - (size.width / 5.5) + (textPainter.width / 2)
  );

  final textTopLeft = Offset(
    textCenter.dx - (textPainter.width / 2),
    textCenter.dy - (textPainter.height / 2)
  );

  textPainter.paint(canvas, textTopLeft);
}
Enter fullscreen mode Exit fullscreen mode

We use a TextPainter to paint our labels to the canvas.

Iteration 1

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.

canvas.save();

// Rotate the canvas around the position of the text so that the text is oriented properly

canvas.translate(
  textCenter.dx,
  textCenter.dy
 );
canvas.rotate(-rotation * pi * 2);
canvas.translate(
  -textCenter.dx,
  -textCenter.dy
);

textPainter.paint(canvas, textTopLeft);

canvas.restore();
Enter fullscreen mode Exit fullscreen mode

Iteration 2

Now let's draw the speed bars around the outer circle that mimic the current speed.

void _drawSpeedIndicators(Size size) {
  for (double percentage = 0.15; percentage <= 0.85; percentage += 4 / (size.width)) {
    _drawSpeedIndicator(percentage);
  }

  for (double percentage = 0.15; percentage < 0.15 + (speed / 100); percentage += 4 / (size.width)) {
    _drawSpeedIndicator(percentage, true);
  }
}

void _drawSpeedIndicator(double relativeRotation, [bool highlight = false]) {
  paintObject.shader = null;
  paintObject.strokeWidth = 1;
  paintObject.style = PaintingStyle.stroke;
  paintObject.color = Colors.white54;

  if (highlight) {
    paintObject.color = Color.lerp(
      Colors.yellow, Colors.red, (relativeRotation - 0.15) / 0.7
    );
    paintObject.style = PaintingStyle.fill;
  }

  Path markerPath = Path()
    ..addRect(
      Rect.fromLTRB(
        center.dx - size.width / 40,
        size.width - (size.width / 30),
        center.dx,
        size.width - (size.width / 100)
      )
    );

  _drawRotated(relativeRotation, () {
    canvas.drawPath(markerPath, paintObject);
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

Iteration 3

The needles are still missing! Remember: we want one needle to display the current speed and another to display the speed record.

_drawNeedle(
  0.15 + (speedRecord / 100),
  Colors.white54,
  size.width / 120
);
_drawNeedle(
  0.15 + (speed / 100),
  Colors.red,
  size.width / 70
);

void _drawNeedle(double rotation, Color color, double width) {
  paintObject
    ..style = PaintingStyle.fill
    ..color = color;

  Path needlePath = Path()
    ..moveTo(center.dx - width, center.dy)
    ..lineTo(center.dx + width, center.dy)
    ..lineTo(center.dx, center.dy + size.width / 2.5)
    ..moveTo(center.dx - width, center.dy);

  _drawRotated(rotation, () {
    canvas.drawPath(needlePath, paintObject);
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

void _drawNeedleHolder() {
  RadialGradient gradient = RadialGradient(
    colors: [Colors.orange, Colors.red, Colors.red, Colors.black],
    radius: 1.2,
    stops: [0.0, 0.7, 0.9, 1.0]
  );

  paintObject
    ..color = Colors.blueGrey
    ..shader = gradient.createShader(
      Rect.fromCenter(
        center: center,
        width: size.width / 20,
        height: size.width / 20
      )
    );

  canvas.drawCircle(
    size.center(Offset.zero),
    size.width / 15,
    paintObject
  );
}
Enter fullscreen mode Exit fullscreen mode

The needle is nothing more than a circle with a radial fill.

void _drawSpeed() {
  TextSpan span = new TextSpan(
    style: new TextStyle(
      fontWeight: FontWeight.bold,
      color: Colors.red,
      fontSize: size.width / 12
    ),
    text: '${speed.toStringAsFixed(0)}'
  );

  TextPainter textPainter = TextPainter(
    text: span,
    textDirection: TextDirection.ltr,
    textAlign: TextAlign.center
  );

  textPainter.layout();

  final textCenter = Offset(
    center.dx,
    center.dy + (size.width / 10) + (textPainter.width / 2)
  );

  final textTopLeft = Offset(
    textCenter.dx - (textPainter.width / 2),
    textCenter.dy - (textPainter.width / 2)
  );

  textPainter.paint(canvas, textTopLeft);
}
Enter fullscreen mode Exit fullscreen mode

The text of the current speed is the rounded value of speed and displayed below the needle holder.

Iteration 4

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!

FULL CODE

💖 💪 🙅 🚩
flutterclutter
flutter-clutter

Posted on September 30, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related