Perché usare i sensori di movimento

Quasi ogni smartphone integra accelerometro, giroscopio e magnetometro. Sono sensori che usiamo ogni giorno senza accorgercene (rotazione dello schermo, bussola, contapassi) ma che in un'app Flutter possono diventare un elemento di interazione interessante:

  • shake to undo / shake to report: scuotere il dispositivo per annullare un'azione o aprire un form di segnalazione bug;
  • effetti parallax: sfondi o card che si muovono leggermente inclinando il telefono;
  • livella e inclinometro: strumenti di misura;
  • rilevamento di attività: capire se il dispositivo è fermo, in tasca o in movimento;
  • giochi: controllo del personaggio tramite inclinazione.

Il pacchetto ufficiale del team Flutter Community per accedere a questi dati è sensors_plus, parte della famiglia plus_plugins. In questo articolo vediamo come integrarlo correttamente, come filtrare i dati (che sono sempre rumorosi) e come evitare i due errori più comuni: subscription mai cancellate e consumo eccessivo di batteria.

Installazione e configurazione

Aggiungi la dipendenza:

flutter pub add sensors_plus

Su iOS non serve alcun permesso per accelerometro e giroscopio (solo il movimento con CMMotionActivity richiederebbe NSMotionUsageDescription, non usato da sensors_plus per questi stream).

Su Android, se imposti frequenze di campionamento superiori a 200 Hz e il target è Android 12 (API 31) o superiore, devi dichiarare il permesso nel AndroidManifest.xml:

<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />

Per i casi d'uso tipici (UI reattiva, shake detection) non serve: le frequenze normali sono più che sufficienti.

I sensori disponibili

sensors_plus espone diversi stream, ognuno con un proprio significato fisico. Confonderli è il primo errore da evitare.

Stream Cosa misura Unità
accelerometerEventStream() Accelerazione comprensiva della gravità m/s²
userAccelerometerEventStream() Accelerazione al netto della gravità m/s²
gyroscopeEventStream() Velocità angolare sui tre assi rad/s
magnetometerEventStream() Campo magnetico (utile per la bussola) microtesla
barometerEventStream() Pressione atmosferica (se il sensore esiste) hPa

Il sistema di riferimento è solidale al dispositivo: x da sinistra a destra, y dal basso verso l'alto dello schermo, z uscente dallo schermo verso l'utente. Con il telefono appoggiato sul tavolo, accelerometerEventStream() restituisce circa (0, 0, 9.81), mentre userAccelerometerEventStream() restituisce valori vicini a zero.

Regola pratica: se ti interessa l'orientamento del dispositivo usa l'accelerometro "completo"; se ti interessa il movimento impresso dall'utente usa userAccelerometer.

Lettura base dei dati

Gli stream sono Stream broadcast: puoi ascoltarli con StreamBuilder oppure con una StreamSubscription esplicita. La seconda opzione è preferibile quando devi elaborare i dati prima di mostrarli.

import 'dart:async';

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

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

  @override
  State<SensorDemo> createState() => _SensorDemoState();
}

class _SensorDemoState extends State<SensorDemo> {
  StreamSubscription<AccelerometerEvent>? _sub;
  AccelerometerEvent? _last;

  @override
  void initState() {
    super.initState();
    _sub = accelerometerEventStream(
      samplingPeriod: SensorInterval.uiInterval,
    ).listen(
      (event) => setState(() => _last = event),
      onError: (Object error) {
        // Il sensore potrebbe non esistere (emulatore, desktop, web)
        debugPrint('Sensore non disponibile: $error');
      },
      cancelOnError: true,
    );
  }

  @override
  void dispose() {
    _sub?.cancel(); // fondamentale
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final e = _last;
    return Center(
      child: Text(
        e == null
            ? 'In attesa di dati...'
            : 'x: ${e.x.toStringAsFixed(2)}\n'
              'y: ${e.y.toStringAsFixed(2)}\n'
              'z: ${e.z.toStringAsFixed(2)}',
        textAlign: TextAlign.center,
      ),
    );
  }
}

Due dettagli importanti:

  1. onError non è opzionale nella pratica: su emulatori, su Flutter Web e su alcuni dispositivi desktop il sensore non esiste e lo stream emette un errore. Senza handler, l'app crasha.
  2. dispose() deve sempre cancellare la subscription, altrimenti il plugin continua a ricevere eventi dal sistema operativo anche quando la schermata non è più visibile.

Scegliere la frequenza di campionamento

Il parametro samplingPeriod accetta una Duration. Le costanti di SensorInterval coprono i casi standard:

SensorInterval.normalInterval; // ~200 ms — cambi di orientamento
SensorInterval.uiInterval;     // ~60 ms  — animazioni UI
SensorInterval.gameInterval;   // ~20 ms  — giochi
SensorInterval.fastestInterval;// il più veloce possibile

Più alta è la frequenza, maggiore è il consumo di CPU e batteria. Per un effetto parallax uiInterval è già abbondante; per una shake detection gameInterval offre reattività migliore senza esagerare. Evita fastestInterval se non stai facendo elaborazione di segnale seria.

Caso pratico 1: shake detection

Il rilevamento dello "scuotimento" è il caso d'uso più richiesto. L'algoritmo è semplice: si calcola il modulo dell'accelerazione utente e, se supera una soglia, si registra un evento. Per evitare falsi positivi si applica un debounce temporale e si può richiedere un numero minimo di picchi ravvicinati.

import 'dart:async';
import 'dart:math' as math;

import 'package:sensors_plus/sensors_plus.dart';

class ShakeDetector {
  ShakeDetector({
    required this.onShake,
    this.threshold = 12.0, // m/s^2 sul modulo dell'accelerazione utente
    this.minGapBetweenShakes = const Duration(milliseconds: 800),
  });

  final VoidCallback onShake;
  final double threshold;
  final Duration minGapBetweenShakes;

  StreamSubscription<UserAccelerometerEvent>? _sub;
  DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);

  void start() {
    _sub ??= userAccelerometerEventStream(
      samplingPeriod: SensorInterval.gameInterval,
    ).listen(_onEvent, onError: (_) {});
  }

  void _onEvent(UserAccelerometerEvent event) {
    final magnitude = math.sqrt(
      event.x * event.x + event.y * event.y + event.z * event.z,
    );

    if (magnitude < threshold) return;

    final now = DateTime.now();
    if (now.difference(_lastShake) < minGapBetweenShakes) return;

    _lastShake = now;
    onShake();
  }

  Future<void> stop() async {
    await _sub?.cancel();
    _sub = null;
  }
}

VoidCallback arriva da package:flutter/foundation.dart. Usalo così in un widget:

late final ShakeDetector _detector;

@override
void initState() {
  super.initState();
  _detector = ShakeDetector(
    onShake: () {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Scuotimento rilevato!')),
      );
    },
  )..start();
}

@override
void dispose() {
  _detector.stop();
  super.dispose();
}

Come tarare la soglia: 12 m/s² è un buon punto di partenza per uno scuotimento volontario. Scendendo sotto 8 rischi falsi positivi mentre si cammina; sopra 20 servono movimenti molto bruschi. La cosa migliore è costruire una schermata di debug che mostra il modulo in tempo reale e provarla su device reali.

Caso pratico 2: effetto parallax con filtro passa-basso

I dati grezzi dell'accelerometro sono rumorosi: se li colleghi direttamente a una Transform, la UI trema. La soluzione classica è un filtro passa-basso (media esponenziale), che smussa i valori mantenendo la reattività.

class _ParallaxCardState extends State<ParallaxCard> {
  StreamSubscription<AccelerometerEvent>? _sub;
  double _dx = 0;
  double _dy = 0;

  static const _alpha = 0.15; // più basso = più morbido
  static const _maxOffset = 16.0; // pixel

  @override
  void initState() {
    super.initState();
    _sub = accelerometerEventStream(
      samplingPeriod: SensorInterval.uiInterval,
    ).listen((event) {
      // Normalizzo rispetto a g e limito l'escursione
      final targetX = (-event.x / 9.81).clamp(-1.0, 1.0) * _maxOffset;
      final targetY = (event.y / 9.81).clamp(-1.0, 1.0) * _maxOffset;

      setState(() {
        _dx = _dx + _alpha * (targetX - _dx);
        _dy = _dy + _alpha * (targetY - _dy);
      });
    }, onError: (_) {});
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Transform.translate(
      offset: Offset(_dx, _dy),
      child: widget.child,
    );
  }
}

Se l'effetto interessa solo una porzione della schermata, setState sull'intero widget è sprecato: meglio esporre un ValueNotifier<Offset> e avvolgere solo la parte animata in un ValueListenableBuilder, così il rebuild resta confinato.

final _offset = ValueNotifier<Offset>(Offset.zero);

// nel listener
_offset.value = Offset(newDx, newDy);

// nel build
ValueListenableBuilder<Offset>(
  valueListenable: _offset,
  builder: (context, offset, child) =>
      Transform.translate(offset: offset, child: child),
  child: const HeavyBackground(),
)

Caso pratico 3: calcolare l'inclinazione (livella)

Dall'accelerometro si ricavano facilmente pitch e roll, cioè l'inclinazione del dispositivo rispetto al piano orizzontale:

import 'dart:math' as math;

({double pitch, double roll}) tiltFrom(AccelerometerEvent e) {
  final pitch = math.atan2(-e.y, math.sqrt(e.x * e.x + e.z * e.z));
  final roll = math.atan2(e.x, e.z);
  return (
    pitch: pitch * 180 / math.pi,
    roll: roll * 180 / math.pi,
  );
}

Il valore restituito è un record di Dart 3 con i gradi. Anche qui conviene applicare un filtro passa-basso prima di mostrare il numero, altrimenti la lettura oscilla di continuo.

Per ottenere invece l'heading (la direzione della bussola) serve combinare accelerometro e magnetometro con una matrice di rotazione: se ti basta la bussola, valuta pacchetti dedicati come flutter_compass, che incapsulano già la fusione dei sensori e la correzione della declinazione magnetica.

Fermare i sensori quando l'app va in background

Un'app che continua a campionare l'accelerometro mentre è in background consuma batteria inutilmente (e su iOS potrebbe comunque non ricevere eventi). La soluzione più pulita è AppLifecycleListener:

late final AppLifecycleListener _lifecycle;

@override
void initState() {
  super.initState();
  _detector.start();
  _lifecycle = AppLifecycleListener(
    onPause: () => _detector.stop(),
    onResume: () => _detector.start(),
  );
}

@override
void dispose() {
  _lifecycle.dispose();
  _detector.stop();
  super.dispose();
}

Lo stesso ragionamento vale per le schermate: se una pagina con sensori attivi viene coperta da un'altra route, valuta di sospendere lo stream con un RouteAware o un semplice flag legato alla visibilità.

Rendere i sensori testabili

Dipendere direttamente dalle funzioni globali di sensors_plus rende difficile scrivere unit test. La soluzione è nascondere la sorgente dietro un'astrazione:

abstract interface class MotionSource {
  Stream<UserAccelerometerEvent> get userAcceleration;
}

class SensorsPlusMotionSource implements MotionSource {
  @override
  Stream<UserAccelerometerEvent> get userAcceleration =>
      userAccelerometerEventStream(
        samplingPeriod: SensorInterval.gameInterval,
      );
}

class FakeMotionSource implements MotionSource {
  final _controller = StreamController<UserAccelerometerEvent>.broadcast();

  @override
  Stream<UserAccelerometerEvent> get userAcceleration => _controller.stream;

  void emit(double x, double y, double z) => _controller.add(
        UserAccelerometerEvent(x, y, z, DateTime.now()),
      );
}

Nel test puoi così simulare uno scuotimento e verificare che la callback venga invocata una sola volta grazie al debounce, senza toccare hardware reale. Nota che il costruttore degli eventi di sensors_plus richiede anche un timestamp: se usi una versione precedente alla 5, il parametro potrebbe non essere presente, quindi verifica la firma nella tua versione.

Errori comuni da evitare

  • Dimenticare cancel(): è la causa numero uno di memory leak e di batteria che si scarica.
  • Confondere accelerometro e userAccelerometer: se la tua soglia di shake viene superata anche con il telefono fermo, stai quasi certamente misurando la gravità.
  • Aggiornare la UI a 200 Hz: il rendering non ne beneficia e il jank aumenta. Campiona veloce solo se elabori i dati, poi aggiorna la UI a frequenza ridotta (per esempio con un throttle).
  • Assumere che i sensori esistano sempre: emulatori, Flutter Web e molti PC desktop non li espongono. Prevedi sempre un fallback nell'interfaccia.
  • Ignorare l'orientamento dello schermo: in landscape gli assi restano solidali al dispositivo, non alla UI. Se la tua logica dipende dall'orientamento, leggi MediaQuery.orientationOf(context) e scambia gli assi di conseguenza.
  • Usare i sensori per funzionalità critiche: la precisione varia enormemente tra dispositivi economici e di fascia alta. Vanno bene per interazioni accessorie, non per misure di precisione.

Conclusioni

sensors_plus è un pacchetto piccolo e con un'API essenziale: qualche funzione che restituisce stream, nient'altro. La complessità non sta nel plugin ma in ciò che ci costruisci sopra — filtraggio del rumore, taratura delle soglie, gestione del ciclo di vita e dei consumi.

Se adotti fin da subito tre regole (cancellare sempre le subscription, filtrare i dati prima di mostrarli, sospendere il campionamento in background) i sensori diventano uno strumento affidabile per aggiungere un tocco di fisicità alle tue app, dallo shake-to-report agli effetti di profondità che rendono un'interfaccia più viva.