Maps, Location & Device Features

Reading Device Sensors

15 min Lesson 10 of 12

Reading Device Sensors in Flutter

Modern smartphones contain a rich array of hardware sensors that measure physical forces, orientation, and motion. Flutter provides access to these sensors through the sensors_plus package, which wraps the native iOS (Core Motion) and Android (SensorManager) APIs into a clean, stream-based Dart interface. In this lesson you will learn how to subscribe to the accelerometer and gyroscope streams, interpret the three-axis output, and build a live sensor dashboard that reacts in real time to device movement.

Adding the Dependency

Add sensors_plus to your pubspec.yaml. No additional platform permissions are required on either iOS or Android for reading the accelerometer and gyroscope — they are considered non-sensitive sensors.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  sensors_plus: ^4.0.2   # always check pub.dev for the latest stable version

Run flutter pub get after saving the file.

Understanding the Sensor Streams

The sensors_plus package exposes each sensor as a Dart Stream. The two most commonly used streams are:

  • accelerometerEventStream() — emits AccelerometerEvent objects containing x, y, and z values measured in m/s². These values include the force of gravity (~9.81 m/s² at rest on a flat surface).
  • gyroscopeEventStream() — emits GyroscopeEvent objects containing the rate of rotation around each axis in rad/s. Values near zero mean the device is stationary.
  • userAccelerometerEventStream() — same as accelerometerEventStream but with gravity subtracted, so a still device reports (0, 0, 0).
Note: Streams emit events continuously while the app is running. You must cancel stream subscriptions in dispose() to prevent memory leaks and battery drain after the widget is removed from the tree.

Axis Conventions

Both sensors follow the same coordinate system as the Android/iOS SDK. When the device lies flat on a table with the screen facing up:

  • X axis — points to the right of the device (landscape right = positive)
  • Y axis — points toward the top of the device (upward = positive)
  • Z axis — points out of the screen face (away from you = positive)
Tip: Use the userAccelerometerEventStream() instead of accelerometerEventStream() when you want to detect only user-initiated movement and ignore gravity. This is what fitness apps use to count steps.

Subscribing to Sensor Streams

The cleanest pattern is to store each StreamSubscription as a field in your State class, start them in initState(), and cancel them in dispose(). Inside the event callback, call setState() to trigger a UI rebuild.

Live Sensor Dashboard Widget

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.dart';

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

  @override
  State<SensorDashboard> createState() => _SensorDashboardState();
}

class _SensorDashboardState extends State<SensorDashboard> {
  // Latest accelerometer reading (m/s²)
  double _accelX = 0, _accelY = 0, _accelZ = 0;
  // Latest gyroscope reading (rad/s)
  double _gyroX = 0, _gyroY = 0, _gyroZ = 0;

  late final StreamSubscription<AccelerometerEvent> _accelSub;
  late final StreamSubscription<GyroscopeEvent> _gyroSub;

  @override
  void initState() {
    super.initState();

    // Subscribe to accelerometer at 20 Hz (Normal UI update rate)
    _accelSub = accelerometerEventStream(
      samplingPeriod: SensorInterval.normalInterval,
    ).listen((AccelerometerEvent event) {
      setState(() {
        _accelX = event.x;
        _accelY = event.y;
        _accelZ = event.z;
      });
    });

    // Subscribe to gyroscope at the same rate
    _gyroSub = gyroscopeEventStream(
      samplingPeriod: SensorInterval.normalInterval,
    ).listen((GyroscopeEvent event) {
      setState(() {
        _gyroX = event.x;
        _gyroY = event.y;
        _gyroZ = event.z;
      });
    });
  }

  @override
  void dispose() {
    // Always cancel subscriptions to avoid memory leaks
    _accelSub.cancel();
    _gyroSub.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sensor Dashboard')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _SensorCard(
              title: 'Accelerometer (m/s²)',
              x: _accelX, y: _accelY, z: _accelZ,
            ),
            const SizedBox(height: 16),
            _SensorCard(
              title: 'Gyroscope (rad/s)',
              x: _gyroX, y: _gyroY, z: _gyroZ,
            ),
          ],
        ),
      ),
    );
  }
}

// Reusable card for displaying three-axis sensor data
class _SensorCard extends StatelessWidget {
  final String title;
  final double x, y, z;

  const _SensorCard({
    required this.title,
    required this.x,
    required this.y,
    required this.z,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title,
                style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            Text('X: ${x.toStringAsFixed(3)}'),
            Text('Y: ${y.toStringAsFixed(3)}'),
            Text('Z: ${z.toStringAsFixed(3)}'),
          ],
        ),
      ),
    );
  }
}

Controlling the Sampling Rate

The samplingPeriod parameter controls how often the sensor fires an event. The package provides four convenience constants via SensorInterval:

  • SensorInterval.normalInterval — ~20 Hz, suitable for most UI updates
  • SensorInterval.uiInterval — matches the screen refresh rate (~60 Hz)
  • SensorInterval.gameInterval — ~100 Hz, for games needing fast response
  • SensorInterval.fastestInterval — maximum hardware rate (device-dependent)
Warning: Higher sampling rates drain the battery significantly faster and flood the UI thread with setState() calls. Use normalInterval for informational displays and only increase the rate when the use case genuinely requires it (e.g., a real-time game controller).

Smoothing Noisy Readings with a Low-Pass Filter

Raw sensor data is inherently noisy. A simple low-pass filter blends the new reading with the previous value to reduce jitter without adding significant latency. The alpha parameter (0–1) controls how much weight to give the new sample: 0.1 is very smooth (slow), 0.9 is very responsive (noisy).

Low-Pass Filter Applied to Accelerometer Data

const double _alpha = 0.15; // smoothing factor

double _smoothX = 0, _smoothY = 0, _smoothZ = 0;

void _onAccelEvent(AccelerometerEvent event) {
  setState(() {
    // low-pass filter: blend new value with running average
    _smoothX = _alpha * event.x + (1 - _alpha) * _smoothX;
    _smoothY = _alpha * event.y + (1 - _alpha) * _smoothY;
    _smoothZ = _alpha * event.z + (1 - _alpha) * _smoothZ;
  });
}

Summary

In this lesson you learned how to access device hardware sensors using the sensors_plus package. The key points to remember are:

  • Each sensor is exposed as a continuous Dart Stream of typed event objects containing x, y, z values.
  • Subscribe in initState() and always cancel in dispose() to prevent resource leaks.
  • Choose the appropriate SensorInterval — prefer normalInterval to balance responsiveness and battery life.
  • Apply a low-pass filter to reduce noise in raw sensor data when building smooth visual experiences.