Aujourd’hui, nous savons qu’une personne passe en moyenne 3 heures sur son smartphone par jour. Un argument de poids, pour comprendre que l’usage et l’émotion sont deux aspects primordiaux dans la réalisation de nos applications.

La majeure partie de l’utilisation du smartphone passe par les réseaux sociaux et les jeux, nous devons donc faire en sorte que l’utilisateur ait envie de revenir sur notre application.

Il existe différentes façons de procurer une émotion dans notre application. Certains de mes articles Flutter Tips of the month se focaliseront sur les animations et transitions qui ne sont bien entendu qu’un des multiples facteurs d’émotions.

Hero

Source : https://wallpaperaccess.com/abstract-superhero

Ce super widget permet de créer une transition entre deux pages. Il a la faculté de se déplacer, se transformer tel un super-heros ! Si vous êtes accoutumé au développement mobile, vous devez connaître ce type de transition sous un autre nom : Shared Element Transitions.

Un peu de théorie

Le widget Hero est identifié grâce à un tag unique par page. Il est important de prendre en compte cette contrainte si vous l’utilisez dans des ListView par exemple. Lors d’une transition, tous les widgets Hero des pages source et cible sont récupérés afin de créer une animation pour chaque correspondance avec leurs contraintes (taille, position, etc).

À la transition un Overlay est créé dans le Navigator (grâce au widget OverlayEntry) pour interpoler le rendu pendant la durée de transition.

Voici en image ce qui se passe :

Source : https://flutter.dev/docs/development/ui/animations/hero-animations

Lorsque vous utilisez ce widget pour la première fois tout s’anime par magie ! J’ai donc décidé de creuser un peu afin de comprendre comment tout cela se passe.

HeroController

Le Navigator que nous utilisons au quotidien nous permet d’écouter chaque action grâce à la classe NavigatorObserver. Celle-ci permet de recevoir tous les états avant qu’ils ne se produisent et d’appliquer une stratégie pour l’ensemble des méthodes suivantes :

  • didPush
  • didPop
  • didRemove
  • didReplace
  • didStartUserGesture
  • didStopUserGesture

HeroController hérite de NavigatorObserver, il récupère l’ensemble des Hero, ajoute et applique tous les traitements d’animations évoqués précédemment sur la propriété overlay de votre navigateur.

Voici comment vous pouvez ajouter un widget par dessus toute votre navigation :

OverlayEntry overlayEntry;
// ...
overlayEntry = OverlayEntry(
  builder: (BuildContext context) => Positioned(
    top: 30,
    right: 30,
    child: RepaintBoundary(
      child: RaisedButton(
        onPressed: () {
          overlayEntry.remove(); // Remove in Navigator.of(context).overlay
        },
        child: Text("test"),
      ),
    ),
  ),
);
Navigator.of(context).overlay.insert(overlayEntry);

Comment HeroController récupére la liste des Hero dans une page ?

L’arbre des Element

Lorsque nous construisons une application Flutter, nous manipulons uniquement (le plus souvent dans un usage classique) un arbre de widgets, mais il faut savoir qu’il en existe deux autres :

Source : https://www.raywenderlich.com/4562681-flutter-text-rendering

Je ne vais pas rentrer dans le détail, mais lorsque nous récupérons un BuildContext nous récupérons en réalité un Element. Le plus souvent nous récupérons les implémentations StatefulElement et StatelessElement mais il en existe près d’une trentaine :

HeroController utilise l’Element parent d’une page pour récupérer l’ensemble des Hero. Pour parcourir tous les éléments sous-jacents, il suffira d’utiliser la méthode visiteChildElements sur le BuildContext.

Voici un exemple pour récupérer tous nos Hero :

Map<Object, Hero> _heroes = {};

void visitHero(BuildContext context) {
  context.visitChildElements((Element element) {
    if (element.widget is Hero) {
      final StatefulElement hero = element;
      final Hero heroWidget = element.widget;
      final dynamic tag = heroWidget.tag;
      final Widget child = heroWidget.child;
      _heroes[tag] = heroWidget;
      print("Add new Hero, tag = $tag, type = ${child.runtimeType}");
    } else {
      element.visitChildren(visitHero);
    }
  });
}

Les différents Hero seront récupérés, stockés et ajoutés à Navigator.overlay pour effectuer l’animation.

Maintenant nous savons comment fonctionne Hero place à la pratique !

Commençons simplement par un Hero par dessus un widget Image.

Sur votre première page, ajoutez le body suivant (en utilisant l’image de votre choix) à votre Scaffold :

Container(
  padding: const EdgeInsets.only(bottom: 48.0),
  alignment: Alignment.bottomCenter,
  child: Hero(
    tag: "poulpy",
    child: Image.asset(
      "assets/poulpy.webp",
      width: 100,
      height: 100,
      color: Colors.red,
    ),
  ),
)

Remarquez que le widget Image sait gérer les images au format webp 😁

Puis sur la deuxième page, insérez ce body :

Container(
  alignment: Alignment.center,
  child: Hero(
    tag: "poulpy",
    child: Image.asset(
      "assets/poulpy.webp",
      width: 300,
      height: 300,
      color: Colors.red,
    ),
  ),
)

Dans la fonction de votre choix (au clic sur un FloatingActionButton par exemple), effectuez une redirection entre vos écrans grâce à Navigator.push :

final route = MaterialPageRoute(builder: (_) => Page2());
Navigator.push(context, route);

Vous devriez avoir un résultat similaire à ceci :

La transition de notre mascotte « Pouply » s’effectue correctement. Mais lorsqu’on utilise les gestures de navigation (notamment sur iOS) nous remarquons que la transition ne s’effectue pas :

Pour prendre en compte les gestures, il faut spécifier la valeur true à la propriété transitionOnUserGestures sur chaque widget Hero :

Hero(
  tag: "poulpy",
  transitionOnUserGestures: true,
  child: ...
)      

Vous obtiendrez le résultat suivant :

FlightShuttleBuilder

Ce builder a pour but d’appliquer une animation sur un widget Hero pendant une transition entre deux pages. La callback retourne différents BuildContext permettant de récupérer leurs widgets respectifs et d’une animation correspond à l’interpolation. Voici une façon simple d’animer une rotation en utilisant le FlightShuttleBuilder:

Hero(
  tag: "poulpy",
  // ...
  flightShuttleBuilder: (BuildContext flightContext,
    Animation<double> animation,
    HeroFlightDirection flightDirection,
    BuildContext fromHeroContext,
    BuildContext toHeroContext) {
      final Hero toHero = toHeroContext.widget;
      return RotationTransition(
        turns: animation,
        child: toHero.child,
      );
  },
)

Afin d’obtenir le résultat suivant :

Vous pouvez définir un flightShuttleBuilder à la fois dans le widget Hero de départ et de destination.

Il sera donc possible d’avoir deux animations différentes selon le sens de votre transition sur votre navigateur (push ou pop).

CreateRectTween

Cette propriété va permettre de changer la courbure de la trajectoire lors du survole. Si aucune valeur n’est renseignée, alors une courbure par défaut sera appliquée. Celle-ci est définit dans le widget MaterialApp directement dans l’instanciation du HeroController :

class _MaterialAppState extends State<MaterialApp> {
  HeroController _heroController;

  @override
  void initState() {
    super.initState();
    _heroController = HeroController(createRectTween: _createRectTween);
    _updateNavigator();
  }
  
  // ...

  RectTween _createRectTween(Rect begin, Rect end) {
    return MaterialRectArcTween(begin: begin, end: end);
  }

}

Pour appliquer une courbure personnalisée dans le widget Hero, veuillez appliquer le code suivant :

Hero(
  tag: "poulpy",
  createRectTween: (begin, end) {
    return BounceInRectTween(begin, end);
  },
  // ...
)

class BounceInRectTween extends RectTween {
  final Rect begin;
  final Rect end;

  BounceInRectTween(this.begin, this.end);

  @override
  Rect lerp(double t) {
    return RectTween(begin: begin, end: end).transform(Curves.bounceIn.transform(t));
  }
}

BounceInRectTween est un RectTween qui aura le rôle de définir un Rect pendant la transition. Les valeurs d’interpolation seront comprises 0.0 et 1.0.

Dans le code ci-dessus, j’utilise l’instance statique bounceIn de la classe Curves qui contient plusieurs d’interpolateurs prédéfinis.

N’hésitez-pas à consulter l’ensemble des Curve prédéfinis.

Il est bon de savoir qu’il est possible de créer votre propre Curve si jamais les différentes instances dans la classe Curves ne vous conviennent pas.

Exemple pour créer un Curve personnalisé :

class DecelerateCurve extends Curve {

  @override
  double transformInternal(double t) {
    t = 1.0 - t;
    return 1.0 - t * t;
  }

}

Pour finir, vous obtiendrez le rendu suivant en utilisant la classe BounceInRectTween créée précédemment :

Utilisé à des endroits clés, Hero donnera du sens à certaines de vos transitions.

Grâce à Hero, vous n’aurez plus d’excuse pour ne pas amener un peu d’animation dans vos transitions ! Les animations sont des guides de navigation, mais il faut les utiliser à bon escient.

Série Flutter of the month