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:
onErrornon è 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.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.