DEV Community

Cover image for heading_sensor: a compass heading for Flutter on Android, done carefully
Abdeldjalil Chougui
Abdeldjalil Chougui

Posted on Originally published at abdeldjalilchougui.biz

heading_sensor: a compass heading for Flutter on Android, done carefully

A compass screen looks simple: read a number between 0 and 360, rotate a needle. The number is the easy part. The rest is where compass apps go wrong.

The phone has several sensors that can give a heading, and they are not equally good. The heading has to follow the top of the screen, so it must change when the display rotates to landscape. A needle that is smoothed with a plain average spins the long way round when the heading goes from 359° to 1°. The sensor reports when it needs calibration, and the app should tell the user. And a magnetometer points to magnetic north, not true north.

heading_sensor is a Flutter plugin that handles those points. It reads the Android rotation-vector sensors and gives you heading, pitch and roll, a calibration accuracy, smoothing that is safe around 0°/360°, and optional true north. The README describes it as a maintained replacement for flutter_compass and flutter_compass_v2. It is Android only.

What it does

Best sensor first. It uses TYPE_ROTATION_VECTOR. If that is missing, it uses TYPE_GEOMAGNETIC_ROTATION_VECTOR, which draws less power. If neither exists, it combines the accelerometer and the magnetic field with SensorManager.getRotationMatrix. You can ask for the lower-power sensor with preferredSource.

Heading, pitch and roll that follow the screen. The rotation matrix is remapped with remapCoordinateSystem for the current Display.getRotation() (0°, 90°, 180°, 270°), so the values follow the top of the screen in portrait and in landscape.

Accuracy. Each event reports unreliable, low, medium or high, taken from onAccuracyChanged, and a needsCalibration flag. Where the device reports its own heading error estimate, you get that too, in degrees.

Rate and smoothing. Choose the sensor rate: fastest, game, ui, normal or a custom period. Add a low-pass filter that goes from 359° to 1° through 0°, not through 180°. The filter runs in Kotlin or in Dart, your choice.

True north. Pass a declinationProvider and set useTrueNorth. The README shows it with the WMM2025 model from the sibling package qibla_math.

Battery friendly. Sensors are registered only while a stream has a listener. They are unregistered when the app goes to the background (Activity onStop) and registered again when it comes back.

Capabilities. HeadingSensor.capabilities() tells you which sensors exist, for example that the device has no magnetometer at all.

How to use it

Add it from GitHub:

dependencies:
  heading_sensor:
    git: https://github.com/AbdeldjalilChougui/heading_sensor
Enter fullscreen mode Exit fullscreen mode

No permissions are needed, and minSdk is 21.

import 'package:heading_sensor/heading_sensor.dart';

Future<void> startCompass() async {
  if (!await HeadingSensor.isAvailable()) {
    print('No compass on this device');
    return;
  }

  final subscription = HeadingSensor.events(
    rate: SensorRate.ui,
    smoothing: const HeadingSmoothing.native(0.15),
  ).listen((event) {
    print('${event.heading.toStringAsFixed(0)}° '
        'pitch ${event.pitch.toStringAsFixed(0)}° '
        'roll ${event.roll.toStringAsFixed(0)}° '
        '${event.accuracy.name} via ${event.source.name}');
    if (event.needsCalibration) {
      print('Move the phone in a figure-8 to calibrate');
    }
  }, onError: (Object error) {
    // HeadingSensorException('NO_SENSOR') when there is no usable sensor.
    print(error);
  });

  // Cancel when done: this unregisters the Android sensors.
  await Future<void>.delayed(const Duration(seconds: 10));
  await subscription.cancel();
}
Enter fullscreen mode Exit fullscreen mode

To check the hardware before showing a compass at all:

final caps = await HeadingSensor.capabilities();
if (!caps.hasMagnetometer) {
  // No compass hardware at all.
}
print(caps.bestSource); // HeadingSource.rotationVector, or null
Enter fullscreen mode Exit fullscreen mode

Smoothing

HeadingSmoothing.native(alpha) filters in Kotlin, before events cross the platform channel. HeadingSmoothing.dart(alpha) filters in Dart, and each listener gets its own filter. alpha is the weight of each new sample: just above 0 is very smooth and slow, 1 is no smoothing.

The filter is also available on its own:

final filter = AngleLowPassFilter(alpha: 0.5);
filter.add(359);
filter.add(1); // 0.0, not 180
Enter fullscreen mode Exit fullscreen mode

That last line is the whole point. A plain average of 359 and 1 is 180, which is the opposite direction.

True north and the qibla

A magnetometer measures magnetic north. For true north, you add the magnetic declination at the user's position. qibla_math has the NOAA World Magnetic Model (WMM2025) and the qibla bearing. Since this plugin already does tilt compensation natively, you only need the declination and the qibla maths from it:

Stream<double> qiblaNeedle(double latitude, double longitude) {
  // The provider runs once per event, so compute the declination once.
  final declination = WorldMagneticModel.wmm2025
      .calculate(
        latitude: latitude,
        longitude: longitude,
        decimalYear: decimalYear(DateTime.now()),
      )
      .declination;
  final qibla = qiblaBearing(latitude, longitude);

  return HeadingSensor.events(
    smoothing: const HeadingSmoothing.native(0.15),
    useTrueNorth: true,
    declinationProvider: (_) => declination,
  ).map((e) => qiblaNeedleAngle(trueHeading: e.heading, qiblaBearing: qibla));
}
Enter fullscreen mode Exit fullscreen mode

Both packages export normalizeDegrees and signedAngleDifference, so the README hides them on the qibla_math import. If the provider returns null, isTrueNorth is false and heading is the magnetic heading. Smoothing is applied to the magnetic heading before the declination is added.

Calibration

When needsCalibration is true, ask the user to hold the phone and draw a figure-8 in the air a few times, rotating the phone in all directions, away from metal, magnets, laptops and magnetic phone cases. Android updates the accuracy, and you get the new value on the next event.

Migrating from flutter_compass

  • FlutterCompass.events (nullable) becomes HeadingSensor.events(). Check HeadingSensor.isAvailable() first.
  • CompassEvent.heading becomes HeadingEvent.heading, which is magnetic unless you set useTrueNorth.
  • CompassEvent.accuracy becomes the HeadingEvent.accuracy enum, plus headingAccuracyDegrees when the device provides it.
  • Instead of headingForCameraMode, use pitch and roll.
  • A null heading when there is no sensor becomes a stream error, HeadingSensorException('NO_SENSOR').

Things to know

  • Android only. iOS is not supported. The README points to CLLocationManager heading there.
  • Not yet tested on a physical phone. The pure maths (azimuth, remap table, smoothing, sensor selection) has JVM unit tests, and the Dart API has tests against a mocked channel. An emulator integration test sets the emulator's virtual accelerometer and magnetometer through the adb console and checks the heading, pitch, sensor registration and background pausing. It ran on the Android Emulator, Pixel 10 system image, API 37.
  • Only the fallback path was checked against known fields. On the emulator the rotation-vector sources deliver events, but the adb console cannot set them directly. So only the accelerometer + magnetometer heading was compared with known values.
  • One native configuration at a time. If you listen to a second HeadingSensor.events(...) stream with other options, it replaces the first. To use one reading in several widgets, listen to the same stream.
  • Only the heading is smoothed. Pitch and roll are passed through.
  • The fallback is noisier. With accelerometer + magnetometer, the heading uses the raw accelerometer as gravity, so it is noisier during motion than the rotation-vector sensors.
  • Timestamps assume the elapsed-realtime clock, as Android documents. A few old devices used a different clock.
  • Engines with no Activity keep sensing while there is a listener. Pausing happens when an Activity stops.
  • The compass reads the local magnetic field. Steel, magnets and electronics nearby bend it, and no sensor or model can correct that.

Why I built it

A heading is not one number. It comes from a specific sensor, with a stated accuracy, relative to the current screen rotation, and relative to either magnetic or true north. With flutter_compass, the stream itself is nullable, a missing sensor shows up as a null heading, and the README's migration table lists its accuracy value as degrees from iOS.

I wanted an event that carries all of it: the source that produced it, the accuracy and a calibration flag, pitch and roll, and whether it is true north. I also wanted the parts that every compass screen needs to be inside the plugin and tested: the display-rotation remap, the wrap-around filter, and stopping the sensors when nobody is listening or the app is in the background.

Get the code

The source, the README, the example app and its emulator integration test are on GitHub: https://github.com/AbdeldjalilChougui/heading_sensor. It's MIT licensed. Issues and pull requests are welcome, especially readings from physical phones.


Originally published at abdeldjalilchougui.biz.

Top comments (0)