motion_core 0.2.1
motion_core: ^0.2.1 copied to clipboard
A high-performance Flutter plugin that provides a simple, unified stream of fused device motion data, abstracting native APIs like iOS Core Motion and Android's Rotation Vector sensor.
motion_core #
A Flutter plugin that provides a simple, high-performance, and unified stream of fused device motion data from native platform APIs.
Table of Contents #
- Overview
- Getting Started
- Platform Specific Setup
- Usage
- Conventions
- API Details
- Migrating from 0.0.x
- License
- Author
Overview #
While Flutter provides access to raw sensors (via plugins like sensors_plus), there is no out-of-the-box equivalent to iOS Core Motion or Android's fused sensor APIs. To get a device's true orientation (attitude), developers typically need to implement complex sensor fusion algorithms (like Madgwick or Mahony filters) in Dart or build a native bridge.
motion_core is that bridge. It does the native work for you, exposing a single, clean stream of calibrated motion data by leveraging the best available technology on each platform, and it normalises the axes, units and reference frames so the numbers mean the same thing on iOS and Android.
Features #
- ✅ Unified Stream: One
Stream<MotionData>with identical conventions on both platforms. - 🚀 Native Performance: iOS
CMDeviceMotion; AndroidTYPE_ROTATION_VECTOR/TYPE_GAME_ROTATION_VECTORplus the gravity, linear-acceleration, gyroscope and magnetometer sensors. Hardware-accelerated, battery-efficient fusion, no Dart-side filtering. - 📦 Complete Motion State in one object:
- Attitude as a
Quaternion(device → world). - Gravity and user acceleration in m/s².
- Rotation rate (bias-corrected gyroscope, rad/s).
- Calibrated magnetic field (µT) with calibration accuracy.
- Heading in degrees, heading accuracy (Android), timestamp, and the reference frame in use.
- Convenience
pitch,roll,yawgetters.
- Attitude as a
- 🧭 Reference frame selection: arbitrary (no compass, no drift correction), magnetic north or true north, mirroring
CMAttitudeReferenceFrame. - ⏱️ Configurable update rate.
- 🛠️
MotionCore.isAvailable()andMotionCore.availableReferenceFrames()to check hardware support.
Getting Started #
1. Add to pubspec.yaml #
dependencies:
flutter:
sdk: flutter
motion_core: ^0.2.1
vector_math: ^2.1.4 # for Quaternion / Vector3
2. Install #
flutter pub get
Platform Specific Setup #
iOS #
-
iOS 12 or later. Works with Swift Package Manager (Flutter 3.24+) and CocoaPods.
-
CMMotionManageritself does not need a permission, but addingNSMotionUsageDescriptiontoios/Runner/Info.plistis recommended (it is required as soon as your app touches any other Core Motion API such as the pedometer or activity manager):<key>NSMotionUsageDescription</key> <string>This app uses motion data to determine device orientation and movement.</string> -
AttitudeReferenceFrame.trueNorthZVerticalneeds location services (and usually location authorization) so Core Motion can apply magnetic declination. Without it the result equals magnetic north. -
The magnetic frames may show the system compass-calibration prompt; disable it with
configure(showsCalibrationDisplay: false).
Android #
- Minimum API 21. No permissions are required.
- Requires Flutter 3.44 or newer. The plugin does not apply the Kotlin Gradle Plugin, so it is ready for Android Gradle Plugin 9's built-in Kotlin (see Flutter's migration guide).
- Requesting more than 200 Hz on Android 12+ requires the
HIGH_SAMPLING_RATE_SENSORSpermission; otherwise the system silently caps the rate.
Usage #
import 'dart:async';
import 'dart:math' show pi;
import 'package:flutter/material.dart';
import 'package:motion_core/motion_core.dart';
import 'package:vector_math/vector_math_64.dart' as v;
class MotionDemoScreen extends StatefulWidget {
const MotionDemoScreen({super.key});
@override
State<MotionDemoScreen> createState() => _MotionDemoScreenState();
}
class _MotionDemoScreenState extends State<MotionDemoScreen> {
MotionData? _motionData;
StreamSubscription<MotionData>? _subscription;
bool _isAvailable = false;
@override
void initState() {
super.initState();
_start();
}
Future<void> _start() async {
final available = await MotionCore.isAvailable();
if (!mounted) return;
setState(() => _isAvailable = available);
if (!available) return;
// Optional: pick a frame and rate before listening.
await MotionCore.configure(
referenceFrame: AttitudeReferenceFrame.magneticNorthZVertical,
updateInterval: const Duration(milliseconds: 16),
);
_subscription = MotionCore.motionStream.listen(
(data) => setState(() => _motionData = data),
onError: (Object e) => debugPrint('motion error: $e'),
);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final data = _motionData;
if (!_isAvailable) return const Center(child: Text('No motion sensors'));
if (data == null) return const Center(child: CircularProgressIndicator());
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..multiply(v.Matrix4.fromQuaternion(data.attitude)),
alignment: FractionalOffset.center,
child: const SizedBox(width: 150, height: 150, child: ColoredBox(color: Colors.blue)),
),
Text('Pitch: ${(data.pitch * 180 / pi).toStringAsFixed(1)}°'),
Text('Roll: ${(data.roll * 180 / pi).toStringAsFixed(1)}°'),
Text('Yaw: ${(data.yaw * 180 / pi).toStringAsFixed(1)}°'),
Text('Heading: ${data.heading?.toStringAsFixed(0) ?? 'n/a'}°'),
],
);
}
}
Managing the Stream #
motionStream is a broadcast stream. The native sensors start when the first listener subscribes and stop when the last one cancels, so always cancel subscriptions you no longer need.
_subscription?.pause(); // stop receiving (sensors keep running)
_subscription?.resume();
_subscription?.cancel(); // last cancel releases the sensors
If the device lacks the required sensors the stream emits a PlatformException with code UNAVAILABLE and closes. Check MotionCore.isAvailable() first to avoid that.
Configuration #
await MotionCore.configure(
updateInterval: const Duration(milliseconds: 10), // hint, ~100 Hz
referenceFrame: AttitudeReferenceFrame.arbitraryZVertical, // no magnetometer
showsCalibrationDisplay: false, // iOS only
);
configure can be called before or while listening; the sensors restart with the new settings. Only the parameters you pass are changed. The configuration lives in the native plugin, so it persists for the life of the Flutter engine.
| Frame | iOS CMAttitudeReferenceFrame |
Android sensor | Heading |
|---|---|---|---|
arbitraryZVertical |
xArbitraryZVertical |
TYPE_GAME_ROTATION_VECTOR |
null |
arbitraryCorrectedZVertical |
xArbitraryCorrectedZVertical |
TYPE_GAME_ROTATION_VECTOR (reports arbitraryZVertical) |
null |
magneticNorthZVertical (default) |
xMagneticNorthZVertical |
TYPE_ROTATION_VECTOR (TYPE_GEOMAGNETIC_ROTATION_VECTOR fallback) |
✅ |
trueNorthZVertical |
xTrueNorthZVertical |
TYPE_ROTATION_VECTOR (reports magneticNorthZVertical) |
✅ |
If the requested frame is not available the platform falls back to the closest one and reports the frame actually used in MotionData.referenceFrame. MotionCore.availableReferenceFrames() lists what the device supports natively.
Conventions #
These are identical on both platforms.
- Device axes: +X to the right of the screen (portrait), +Y toward the top edge, +Z out of the screen toward the user. Right-handed.
- World axes (north-referenced frames): East-North-Up. +X east, +Y north, +Z up. The arbitrary frames only fix +Z.
- Attitude: unit quaternion that rotates device-frame vectors into the world frame. Identity = flat, face up, top edge pointing north. Use
Matrix4.fromQuaternion(data.attitude)fromvector_mathto render it. - Gravity / user acceleration: m/s², device frame, Android /
sensors_plussign convention. Flat face-up device:gravity ≈ (0, 0, +9.81).userAccelerationis positive in the direction the device is moving. (Core Motion's G-unit, opposite-sign values are converted natively.) - Rotation rate: rad/s about the device axes, positive counter-clockwise (right-hand rule).
- Magnetic field: µT, device frame, hard-iron calibrated.
- Euler angles (
pitch,roll,yaw, radians): Tait-Bryan Z-Y'-X''.pitch= rotation about device X (+π/2 when the phone is held upright in portrait, range ±π),roll= rotation about device Y (range ±π/2, gimbal lock when the device is on its side),yaw= rotation about Z (positive counter-clockwise from above; 0 = top edge pointing north for north-referenced frames). Prefer the quaternion for anything that needs full orientation. - Heading: degrees clockwise from north of the device's +Y axis projected onto the horizontal plane,
[0, 360). Ill-defined when the device is upright in portrait.
API Details #
MotionData #
| Property | Type | Description |
|---|---|---|
attitude |
Quaternion |
Device orientation, device → world. |
gravity |
Vector3 |
Gravity in m/s², device frame. |
userAcceleration |
Vector3 |
Acceleration imparted by the user in m/s², gravity removed. |
rotationRate |
Vector3? |
Bias-corrected angular velocity in rad/s. null only on Android devices without a gyroscope. |
magneticField |
CalibratedMagneticField? |
Field in µT plus MagneticFieldCalibrationAccuracy (uncalibrated, low, medium, high). null for arbitrary frames or without a magnetometer. |
heading |
double? |
Compass heading in degrees [0, 360). null for arbitrary frames. |
headingAccuracy |
double? |
Android only. Estimated heading accuracy in radians (smaller is better). null on iOS and on devices that do not estimate it. |
referenceFrame |
AttitudeReferenceFrame |
The frame actually in use for this sample. |
timestamp |
double |
Seconds since boot (monotonic). Use differences between samples. |
pitch, roll, yaw |
double |
Euler angles in radians, see Conventions. |
eulerAngles |
Vector3 |
(pitch, roll, yaw). |
MotionCore #
| Member | Description |
|---|---|
motionStream |
Broadcast Stream<MotionData>. |
isAvailable() |
Whether fused device motion is available. false on unsupported platforms. |
availableReferenceFrames() |
Frames the device supports natively. |
configure(...) |
Update rate, reference frame, iOS calibration prompt. |
defaultUpdateInterval |
≈ 60 Hz. |
Platform mapping #
MotionData |
iOS (CMDeviceMotion) |
Android |
|---|---|---|
attitude |
attitude.quaternion, rotated into ENU |
TYPE_ROTATION_VECTOR / TYPE_GAME_ROTATION_VECTOR / TYPE_GEOMAGNETIC_ROTATION_VECTOR |
gravity |
gravity × −9.80665 |
TYPE_GRAVITY |
userAcceleration |
userAcceleration × −9.80665 |
TYPE_LINEAR_ACCELERATION |
rotationRate |
rotationRate |
TYPE_GYROSCOPE |
magneticField |
magneticField |
TYPE_MAGNETIC_FIELD + sensor accuracy |
headingAccuracy |
— | rotation vector values[4] |
timestamp |
timestamp |
SensorEvent.timestamp |
| update interval | deviceMotionUpdateInterval |
registerListener sampling period |
Migrating from 0.0.x #
- Gravity and user acceleration on iOS are now in m/s² with the opposite sign (multiply your old values by −9.80665). Android values are unchanged.
- Yaw on iOS is now north-referenced by default and rotated so that
yaw == 0means "top edge pointing north" on both platforms. UseAttitudeReferenceFrame.arbitraryZVerticalto get the old battery-friendly behaviour. pitchandrollwere swapped; if you relied on the old getters, swap them back in your code.eulerAngles.x/.y/.zare unchanged.headingAccuracyis nullable (nullinstead of-1.0).- Android now emits data as soon as the sensors report, instead of waiting for high compass accuracy. Inspect
headingAccuracy/magneticField.accuracyif you need to gate on quality.
License #
This project is licensed under the MIT License - see the LICENSE file for details.