Perché le curve non bastano

La maggior parte delle animazioni in Flutter si costruisce con una Duration e una Curve: easeInOut, fastOutSlowIn, elasticOut. Funziona benissimo quando l'animazione parte da uno stato noto e arriva a un altro stato noto, senza che l'utente interagisca durante il movimento.

Il discorso cambia quando l'animazione nasce da un gesto. Se trascino una card e la rilascio con un colpo secco, mi aspetto che continui a muoversi e poi rallenti; se la rilascio piano, mi aspetto che torni al suo posto con un movimento morbido. Una curva a durata fissa non ha modo di sapere quanto velocemente stava andando il dito nell'istante del rilascio: il risultato è quella sensazione di "animazione finta" che distingue subito un'app curata da una abbozzata.

La risposta di Flutter si chiama Simulation, una classe del package dart:ui/physics che descrive la posizione di un oggetto in funzione del tempo, partendo da una posizione e da una velocità iniziali.

L'API Simulation in due minuti

Una Simulation espone tre metodi fondamentali:

abstract class Simulation {
  double x(double time);        // posizione all'istante time (in secondi)
  double dx(double time);       // velocità all'istante time
  bool isDone(double time);     // la simulazione è terminata?
}

L'AnimationController sa consumarla con animateWith(): invece di interpolare da 0 a 1 in N millisecondi, a ogni frame chiede alla simulazione "dove ti trovi adesso?" e si ferma quando isDone restituisce true.

Flutter include già le simulazioni più utili:

  • SpringSimulation — una molla smorzata: perfetta per "torna al suo posto", snap, rimbalzi.
  • FrictionSimulation — decelerazione esponenziale: il classico fling/inerzia.
  • GravitySimulation — accelerazione costante verso un bordo: caduta libera.
  • ClampingScrollSimulation e BouncingScrollSimulation — quelle usate internamente dallo scroll Android e iOS.

Un dettaglio importante: quando usi animateWith conviene quasi sempre un controller unbounded, altrimenti i valori vengono clampati tra lowerBound e upperBound e una molla che oltrepassa il target perde l'effetto di rimbalzo.

late final AnimationController _controller =
    AnimationController.unbounded(vsync: this);

Molle: SpringDescription e SpringSimulation

Una molla in Flutter si descrive con tre parametri:

const spring = SpringDescription(
  mass: 1,        // massa dell'oggetto: più alta = movimento più "pesante"
  stiffness: 180, // rigidità: più alta = ritorno più rapido
  damping: 20,    // smorzamento: più alto = meno oscillazioni
);

Ragionare direttamente su damping è poco intuitivo. Molto più pratico è il costruttore che lavora sul damping ratio:

final spring = SpringDescription.withDampingRatio(
  mass: 1,
  stiffness: 200,
  ratio: 0.7, // < 1 sotto-smorzata (rimbalza), 1 critica, > 1 sovra-smorzata
);
  • ratio: 1.0 → la molla arriva al target nel minor tempo possibile senza superarlo. È la scelta giusta per snap sobri (bottom sheet, snap di una colonna).
  • ratio: 0.6–0.8 → un piccolo overshoot, percepito come "vivo". Ottimo per card, like, badge.
  • ratio: 0.3 → molto elastico. Usalo con parsimonia: su schermate dense stanca.

Nelle versioni recenti di Flutter esiste anche SpringDescription.withDurationAndBounce, che permette di descrivere la molla in termini di durata percepita e quantità di rimbalzo (lo stesso modello mentale delle spring di SwiftUI). Verifica che sia disponibile nella versione dell'SDK che stai usando prima di adottarlo.

Esempio completo: card trascinabile che torna al suo posto

Questo è il caso d'uso canonico. L'utente trascina la card, e al rilascio la molla la riporta al centro tenendo conto della velocità del gesto.

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

  @override
  State<SpringCard> createState() => _SpringCardState();
}

class _SpringCardState extends State<SpringCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController.unbounded(vsync: this);

  Alignment _alignment = Alignment.center;
  Animation<Alignment> _animation = const AlwaysStoppedAnimation(Alignment.center);

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      setState(() => _alignment = _animation.value);
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _runSpring(Offset pixelsPerSecond, Size size) {
    _animation = _controller.drive(
      AlignmentTween(begin: _alignment, end: Alignment.center),
    );

    // Normalizziamo la velocità del gesto nello spazio 0..1 della simulazione.
    final unitsPerSecond = Offset(
      pixelsPerSecond.dx / size.width,
      pixelsPerSecond.dy / size.height,
    );
    final unitVelocity = unitsPerSecond.distance;

    final spring = SpringDescription.withDampingRatio(
      mass: 1,
      stiffness: 220,
      ratio: 0.75,
    );

    // Da 1 (posizione attuale) a 0 (target), con velocità iniziale negativa.
    final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);
    _controller.animateWith(simulation);
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);

    return GestureDetector(
      onPanDown: (_) => _controller.stop(),
      onPanUpdate: (details) {
        setState(() {
          _alignment += Alignment(
            details.delta.dx / (size.width / 2),
            details.delta.dy / (size.height / 2),
          );
        });
      },
      onPanEnd: (details) => _runSpring(details.velocity.pixelsPerSecond, size),
      child: Align(
        alignment: _alignment,
        child: const Card(
          child: SizedBox(width: 160, height: 160, child: Center(child: Text('Trascinami'))),
        ),
      ),
    );
  }
}

Tre dettagli che fanno la differenza:

  1. onPanDown: (_) => _controller.stop() — se l'utente riafferra la card mentre sta tornando indietro, l'animazione deve cedere immediatamente il controllo. È la regola d'oro delle UI "interrompibili".
  2. La velocità viene normalizzata nello stesso spazio della simulazione (0..1), altrimenti i numeri non hanno senso fisico.
  3. Il segno della velocità è negativo perché ci muoviamo da 1 verso 0.

Fling e inerzia con FrictionSimulation

Quando l'elemento non deve tornare indietro ma continuare per inerzia, la simulazione giusta è FrictionSimulation:

void _fling(double velocityPx) {
  final simulation = FrictionSimulation(
    0.135,        // coefficiente di attrito: più basso = scivola più a lungo
    _position,    // posizione iniziale in pixel
    velocityPx,   // velocità iniziale in pixel/secondo
  );
  _controller.animateWith(simulation);
}

Se hai bisogno che il fling si fermi esattamente su un punto (ad esempio lo snap di un carosello), esiste il costruttore FrictionSimulation.through(startPosition, endPosition, startVelocity, endVelocity), che calcola l'attrito necessario per passare per quel punto.

Per il caso semplice, l'AnimationController offre già una scorciatoia:

_controller.fling(velocity: 2.0); // usa una spring di default verso 0 o 1

fling() è perfetto per toggle e pannelli a due stati (aperto/chiuso): decide la direzione in base al segno della velocità e anima con una molla predefinita.

Caduta e rimbalzo con GravitySimulation

GravitySimulation(acceleration, startPosition, endPosition, startVelocity) modella un corpo che accelera costantemente fino a una posizione finale. Combinandola in sequenza con sé stessa si ottiene un rimbalzo credibile:

void _drop(double fromY, double floorY, double velocity) {
  final sim = GravitySimulation(2000, fromY, floorY, velocity);
  _controller.animateWith(sim).whenComplete(() {
    final impact = _controller.velocity.abs() * 0.55; // coefficiente di restituzione
    if (impact < 50) return; // sotto soglia ci fermiamo
    _controller.animateWith(
      GravitySimulation(2000, floorY, floorY, -impact),
    );
  });
}

È il tipo di effetto che funziona bene per micro-interazioni giocose (gamification, badge conquistati), meno per UI di produttività.

Scrivere una Simulation personalizzata

Quando nessuna delle simulazioni incluse descrive il comportamento che vuoi, implementarne una è sorprendentemente semplice: basta fornire la formula del moto.

class PulseSimulation extends Simulation {
  PulseSimulation({this.amplitude = 0.08, this.frequency = 3, this.decay = 2.5});

  final double amplitude;
  final double frequency;
  final double decay;

  @override
  double x(double time) =>
      1 + amplitude * math.sin(frequency * 2 * math.pi * time) * math.exp(-decay * time);

  @override
  double dx(double time) => (x(time + 0.001) - x(time)) / 0.001;

  @override
  bool isDone(double time) => time > 1.5;
}

Il valore può essere usato direttamente come fattore di scala:

AnimatedBuilder(
  animation: _controller,
  builder: (context, child) =>
      Transform.scale(scale: _controller.value, child: child),
  child: const Icon(Icons.favorite, size: 48),
)

Nota il child passato fuori dalla builder: il sottoalbero non viene ricostruito a ogni frame, si ricostruisce solo il Transform. È la regola base per animazioni che non fanno scendere gli fps.

Dove si incastra lo scroll

Lo stesso meccanismo governa lo scrolling: una ScrollPhysics non fa altro che restituire una Simulation dal metodo createBallisticSimulation. Se ti serve uno scroll con inerzia personalizzata (per esempio uno snap magnetico), puoi estendere ScrollPhysics e restituire una SpringSimulation verso l'elemento più vicino:

class SnapPhysics extends ScrollPhysics {
  const SnapPhysics({super.parent, required this.itemExtent});

  final double itemExtent;

  @override
  SnapPhysics applyTo(ScrollPhysics? ancestor) =>
      SnapPhysics(parent: buildParent(ancestor), itemExtent: itemExtent);

  @override
  Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
    final target = (position.pixels / itemExtent).roundToDouble() * itemExtent;
    if ((target - position.pixels).abs() < tolerance.distance) return null;
    return ScrollSpringSimulation(spring, position.pixels, target, velocity,
        tolerance: tolerance);
  }
}

Buone pratiche

  • Tolleranza: una molla matematicamente non si ferma mai. Tolerance definisce quando considerarla conclusa; nelle simulazioni di scroll passa sempre la tolerance fornita dalla physics, altrimenti rischi animazioni che non terminano.
  • Attenzione a pumpAndSettle: nei widget test, un controller alimentato da una simulazione che non termina manda in timeout il test. Verifica isDone o usa pump con durate esplicite.
  • Rispetta le preferenze di accessibilità: se MediaQuery.disableAnimationsOf(context) è true, salta la simulazione e applica direttamente lo stato finale.
  • Un solo Ticker quando possibile: SingleTickerProviderStateMixin è più leggero di TickerProviderStateMixin, e ricordati sempre di fare dispose() del controller.
  • Non esagerare con i parametri: molle troppo elastiche su elementi grandi danno una sensazione di instabilità. Per le UI "serie" resta su ratio tra 0.8 e 1.

Conclusione

Le simulazioni fisiche sono uno degli angoli meno frequentati di Flutter, eppure sono ciò che separa un'interfaccia che risponde da una che semplicemente si anima. Il trucco concettuale è semplice: smettere di pensare in termini di "quanto dura" e iniziare a pensare in termini di "con che velocità entra nel movimento". Parti dalla SpringSimulation per gli snap, aggiungi FrictionSimulation per i fling, e tieni presente che AnimationController.animateWith accetta qualsiasi classe tu voglia scrivere: da lì in poi il limite è la tua fantasia (e un po' di buon senso sul design).