Créer des interfaces utilisateur de nouvelle génération dans Flutter

1. Avant de commencer

Flutter permet aux développeurs de créer rapidement des interfaces utilisateur de manière itérative grâce à l'association de l'actualisation à chaud et de l'UI déclarative. Cependant, il peut arriver que vous deviez ajouter de l'interactivité à une interface. Ces gestes peuvent se traduire par une simple animation d'un bouton au passage de la souris ou par un nuanceur qui déforme l'interface utilisateur grâce à la puissance du GPU.

Dans cet atelier de programmation, vous allez créer une application Flutter qui exploite la puissance des animations, des nuanceurs et des champs de particules pour créer une interface utilisateur qui évoque les films de science-fiction et les séries TV que nous aimons tous regarder sans code.

Ce que vous allez faire

Vous allez créer la page de menu initiale d'un jeu de science-fiction post-apocalyptique. Vous trouverez un titre avec un nuanceur de fragments qui échantillonne le texte à animer visuellement, un menu de difficulté qui change le thème de couleur de la page avec des animations et un orbe animé peint avec un deuxième nuanceur de fragments. Si cela ne suffit pas, vous ajouterez un effet de particules subtil à la fin de l'atelier de programmation pour susciter le mouvement et susciter l'intérêt sur la page.

Les captures d'écran suivantes montrent l'application que vous allez créer sur les trois systèmes d'exploitation pour ordinateur compatibles: Windows, Linux et macOS. Par souci d'exhaustivité, une version de navigateur Web (également compatible) est fournie. Des animations et des nuanceurs fragmentés partout !

L'application de l'atelier de programmation terminée sous Windows

La version finale de l'atelier de programmation qui s'exécute dans le navigateur Chrome

Une fois l'atelier de programmation terminé, sous Linux

L'application de l'atelier de programmation terminée sous macOS

Prérequis

Points abordés

Prérequis

2. Premiers pas

Télécharger le code de démarrage

  1. Accédez à ce dépôt GitHub.
  2. Cliquez sur Code > Download ZIP (Code > Télécharger le fichier ZIP) afin de télécharger l'ensemble du code pour cet atelier de programmation.
  3. Décompressez le fichier ZIP téléchargé pour accéder au dossier racine codelabs-main. Vous n'avez besoin que du sous-répertoire next-gen-ui/, qui contient les dossiers step_01 à step_06, qui contiennent le code source à compiler pour chaque étape de cet atelier de programmation.

Télécharger les dépendances du projet

  1. Dans VS Code, cliquez sur Fichier > Ouvrir le dossier > ateliers de programmation-main > next-gen-uis > étape_01 pour ouvrir le projet de démarrage.
  2. Si une boîte de dialogue VS Code vous invite à télécharger les packages requis pour l'application de démarrage, cliquez sur Get packages (Télécharger les packages).

Boîte de dialogue VS Code qui vous invite à télécharger les packages requis pour l'application de démarrage.

  1. Si aucune boîte de dialogue VS Code ne vous invite à télécharger les packages requis pour l'application de démarrage, ouvrez votre terminal, puis accédez au dossier step_01 et exécutez la commande flutter pub get.

Exécuter l'application de démarrage

  1. Dans VS Code, sélectionnez le système d'exploitation de bureau que vous exécutez ou Chrome si vous souhaitez tester votre application dans un navigateur Web.

Par exemple, voici ce que vous voyez lorsque vous utilisez macOS comme cible de déploiement:

La décoration de la barre d'état VSCode indiquant que la cible Flutter est macOS (darwin)

Voici ce qui s'affiche lorsque vous utilisez Chrome comme cible de déploiement:

La décoration de la barre d'état VSCode indiquant que la cible Flutter est Chrome (web-javascript)

  1. Ouvrez le fichier lib/main.dart, puis cliquez sur Bouton de lecture de VSCode Démarrer le débogage. L'application se lance sur le système d'exploitation de votre ordinateur ou dans un navigateur Chrome.

Explorer l'application de démarrage

Dans l'application de démarrage, vous pouvez remarquer ce qui suit :

  • L'interface utilisateur est prête à être créée.
  • Le répertoire assets contient les assets graphiques et les deux nuanceurs de fragments que vous utiliserez.
  • Le fichier pubspec.yaml répertorie déjà les éléments et une collection de packages de pub que vous utiliserez.
  • Le répertoire lib contient le fichier main.dart obligatoire, un fichier assets.dart qui répertorie le chemin d'accès des éléments graphiques et des nuanceurs de fragment, ainsi qu'un fichier styles.dart qui liste les TextStyles et les couleurs que vous utiliserez.
  • Le répertoire lib contient également un répertoire common, qui contient quelques utilitaires utiles que vous utiliserez dans cet atelier de programmation, ainsi que le répertoire orb_shader, qui contient un Widget qui permettra d'afficher l'orbe avec un nuanceur de sommets.

Voici ce qui s'affiche au démarrage de l'application.

Application d'atelier de programmation intitulée "Insérer l'interface utilisateur nouvelle génération ici"

3. Peindre la scène

Au cours de cette étape, vous placez tous les éléments de l'illustration en arrière-plan sous forme de calques. Attendez-vous à ce qu'il semble étrangement monochrome au début, mais ajoutez des couleurs à la scène à la fin de cette étape.

Ajouter des éléments à la scène

  1. Créez un répertoire title_screen dans votre répertoire lib, puis ajoutez un fichier title_screen.dart. Ajoutez le contenu suivant au fichier:

lib/title_screen/title_screen.dart.

import 'package:flutter/material.dart';

import '../assets.dart';

class TitleScreen extends StatelessWidget {
  const TitleScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: [
            /// Bg-Base
            Image.asset(AssetPaths.titleBgBase),

            /// Bg-Receive
            Image.asset(AssetPaths.titleBgReceive),

            /// Mg-Base
            Image.asset(AssetPaths.titleMgBase),

            /// Mg-Receive
            Image.asset(AssetPaths.titleMgReceive),

            /// Mg-Emit
            Image.asset(AssetPaths.titleMgEmit),

            /// Fg-Rocks
            Image.asset(AssetPaths.titleFgBase),

            /// Fg-Receive
            Image.asset(AssetPaths.titleFgReceive),

            /// Fg-Emit
            Image.asset(AssetPaths.titleFgEmit),
          ],
        ),
      ),
    );
  }
}

Ce widget contient la scène dans laquelle les éléments sont empilés. Les calques d'arrière-plan, de mi-parcours et de premier plan sont chacun représentés par un groupe de deux ou trois images. Ces images seront éclairées de différentes couleurs pour capturer le mouvement de la lumière dans la scène.

  1. Dans le fichier main.dart, ajoutez le contenu suivant:

lib/main.dart

import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:window_size/window_size.dart';
                                                          // Remove 'styles.dart' import
import 'title_screen/title_screen.dart';                  // Add this import

void main() {
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
    WidgetsFlutterBinding.ensureInitialized();
    setWindowMinSize(const Size(800, 500));
  }
  runApp(const NextGenApp());
}

class NextGenApp extends StatelessWidget {
  const NextGenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      themeMode: ThemeMode.dark,
      darkTheme: ThemeData(brightness: Brightness.dark),
      home: const TitleScreen(),                          // Replace with this widget
    );
  }
}

Il remplace l'UI de l'application par la scène monochrome créée par les assets artistiques. Ensuite, vous colorez chaque calque.

L'application de l'atelier de programmation exécutée uniquement avec des assets artistiques en arrière-plan, au milieu et au premier plan, affichée en monochrome

Ajouter un utilitaire de coloration des images

Utilisez un utilitaire de coloration des images en ajoutant le contenu suivant au fichier title_screen.dart:

lib/title_screen/title_screen.dart.

import 'package:flutter/material.dart';

import '../assets.dart';

class TitleScreen extends StatelessWidget {
  const TitleScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: [
            /// Bg-Base
            Image.asset(AssetPaths.titleBgBase),

            /// Bg-Receive
            Image.asset(AssetPaths.titleBgReceive),

            /// Mg-Base
            Image.asset(AssetPaths.titleMgBase),

            /// Mg-Receive
            Image.asset(AssetPaths.titleMgReceive),

            /// Mg-Emit
            Image.asset(AssetPaths.titleMgEmit),

            /// Fg-Rocks
            Image.asset(AssetPaths.titleFgBase),

            /// Fg-Receive
            Image.asset(AssetPaths.titleFgReceive),

            /// Fg-Emit
            Image.asset(AssetPaths.titleFgEmit),
          ],
        ),
      ),
    );
  }
}

class _LitImage extends StatelessWidget {                 // Add from here...
  const _LitImage({
    required this.color,
    required this.imgSrc,
    required this.lightAmt,
  });
  final Color color;
  final String imgSrc;
  final double lightAmt;

  @override
  Widget build(BuildContext context) {
    final hsl = HSLColor.fromColor(color);
    return ColorFiltered(
      colorFilter: ColorFilter.mode(
        hsl.withLightness(hsl.lightness * lightAmt).toColor(),
        BlendMode.modulate,
      ),
      child: Image.asset(imgSrc),
    );
  }
}                                                         // to here.

Ce widget utilitaire _LitImage recolore chacun des assets graphiques, selon qu'ils émettent ou reçoivent de la lumière. Vous risquez de déclencher un avertissement lint, car vous n'utilisez pas encore ce nouveau widget.

Peindre en couleur

Peignez en couleur en modifiant le fichier title_screen.dart, comme suit:

lib/title_screen/title_screen.dart.

import 'package:flutter/material.dart';

import '../assets.dart';
import '../styles.dart';                                  // Add this import

class TitleScreen extends StatelessWidget {
  const TitleScreen({super.key});

  final _finalReceiveLightAmt = 0.7;                      // Add this attribute
  final _finalEmitLightAmt = 0.5;                         // And this attribute

  @override
  Widget build(BuildContext context) {
    final orbColor = AppColors.orbColors[0];              // Add this final variable
    final emitColor = AppColors.emitColors[0];            // And this one

    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: [
            /// Bg-Base
            Image.asset(AssetPaths.titleBgBase),

            /// Bg-Receive
            _LitImage(                                    // Modify from here...
              color: orbColor,
              imgSrc: AssetPaths.titleBgReceive,
              lightAmt: _finalReceiveLightAmt,
            ),                                            // to here.

            /// Mg-Base
            _LitImage(                                    // Modify from here...
              imgSrc: AssetPaths.titleMgBase,
              color: orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),                                            // to here.

            /// Mg-Receive
            _LitImage(                                    // Modify from here...
              imgSrc: AssetPaths.titleMgReceive,
              color: orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),                                            // to here.

            /// Mg-Emit
            _LitImage(                                    // Modify from here...
              imgSrc: AssetPaths.titleMgEmit,
              color: emitColor,
              lightAmt: _finalEmitLightAmt,
            ),                                            // to here.

            /// Fg-Rocks
            Image.asset(AssetPaths.titleFgBase),

            /// Fg-Receive
            _LitImage(                                    // Modify from here...
              imgSrc: AssetPaths.titleFgReceive,
              color: orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),                                            // to here.

            /// Fg-Emit
            _LitImage(                                    // Modify from here...
              imgSrc: AssetPaths.titleFgEmit,
              color: emitColor,
              lightAmt: _finalEmitLightAmt,
            ),                                            // to here.
          ],
        ),
      ),
    );
  }
}

class _LitImage extends StatelessWidget {
  const _LitImage({
    required this.color,
    required this.imgSrc,
    required this.lightAmt,
  });
  final Color color;
  final String imgSrc;
  final double lightAmt;

  @override
  Widget build(BuildContext context) {
    final hsl = HSLColor.fromColor(color);
    return ColorFiltered(
      colorFilter: ColorFilter.mode(
        hsl.withLightness(hsl.lightness * lightAmt).toColor(),
        BlendMode.modulate,
      ),
      child: Image.asset(imgSrc),
    );
  }
}

Voici à nouveau l'application, cette fois avec les tons vernis.

L'application de l'atelier de programmation qui s'exécute avec les éléments artistiques (teinte verte).

4. Ajouter une interface utilisateur

Au cours de cette étape, vous allez placer une interface utilisateur sur la scène créée à l'étape précédente. Cela inclut le titre, les boutons du sélecteur de difficulté et le bouton Démarrer important.

Ajouter un titre

  1. Créez un fichier title_screen_ui.dart dans le répertoire lib/title_screen et ajoutez-y le contenu suivant:

lib/title_screen/title_screen_ui.dart.

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';

class TitleScreenUi extends StatelessWidget {
  const TitleScreenUi({
    super.key,
  });
  @override
  Widget build(BuildContext context) {
    return const Padding(
      padding: EdgeInsets.symmetric(vertical: 40, horizontal: 50),
      child: Stack(
        children: [
          /// Title Text
          TopLeft(
            child: UiScaler(
              alignment: Alignment.topLeft,
              child: _TitleText(),
            ),
          ),
        ],
      ),
    );
  }
}

class _TitleText extends StatelessWidget {
  const _TitleText();

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Gap(20),
        Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Transform.translate(
              offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
              child: Text('OUTPOST', style: TextStyles.h1),
            ),
            Image.asset(AssetPaths.titleSelectedLeft, height: 65),
            Text('57', style: TextStyles.h2),
            Image.asset(AssetPaths.titleSelectedRight, height: 65),
          ],
        ),
        Text('INTO THE UNKNOWN', style: TextStyles.h3),
      ],
    );
  }
}

Ce widget contient le titre et tous les boutons qui composent l'interface utilisateur de cette application.

  1. Mettez à jour le fichier lib/title_screen/title_screen.dart, comme suit:

lib/title_screen/title_screen.dart.

import 'package:flutter/material.dart';

import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';                            // Add this import

class TitleScreen extends StatelessWidget {
  const TitleScreen({super.key});

  final _finalReceiveLightAmt = 0.7;
  final _finalEmitLightAmt = 0.5;

  @override
  Widget build(BuildContext context) {
    final orbColor = AppColors.orbColors[0];
    final emitColor = AppColors.emitColors[0];

    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: [
            /// Bg-Base
            Image.asset(AssetPaths.titleBgBase),

            /// Bg-Receive
            _LitImage(
              color: orbColor,
              imgSrc: AssetPaths.titleBgReceive,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Mg-Base
            _LitImage(
              imgSrc: AssetPaths.titleMgBase,
              color: orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Mg-Receive
            _LitImage(
              imgSrc: AssetPaths.titleMgReceive,
              color: orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Mg-Emit
            _LitImage(
              imgSrc: AssetPaths.titleMgEmit,
              color: emitColor,
              lightAmt: _finalEmitLightAmt,
            ),

            /// Fg-Rocks
            Image.asset(AssetPaths.titleFgBase),

            /// Fg-Receive
            _LitImage(
              imgSrc: AssetPaths.titleFgReceive,
              color: orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Fg-Emit
            _LitImage(
              imgSrc: AssetPaths.titleFgEmit,
              color: emitColor,
              lightAmt: _finalEmitLightAmt,
            ),

            /// UI
            const Positioned.fill(                        // Add from here...
              child: TitleScreenUi(),
            ),                                            // to here.
          ],
        ),
      ),
    );
  }
}

class _LitImage extends StatelessWidget {
  const _LitImage({
    required this.color,
    required this.imgSrc,
    required this.lightAmt,
  });
  final Color color;
  final String imgSrc;
  final double lightAmt;

  @override
  Widget build(BuildContext context) {
    final hsl = HSLColor.fromColor(color);
    return ColorFiltered(
      colorFilter: ColorFilter.mode(
        hsl.withLightness(hsl.lightness * lightAmt).toColor(),
        BlendMode.modulate,
      ),
      child: Image.asset(imgSrc),
    );
  }
}

L'exécution de ce code révèle le titre, qui est le début de l'interface utilisateur.

Application de l'atelier de programmation exécutée avec le titre "Poste [57] Into the unknown"

Ajouter les boutons de difficulté

  1. Mettez à jour title_screen_ui.dart en ajoutant une nouvelle importation pour le package focusable_control_builder:

lib/title_screen/title_screen_ui.dart.

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:focusable_control_builder/focusable_control_builder.dart'; // Add import
import 'package:gap/gap.dart';

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';
  1. Ajoutez le code suivant au widget TitleScreenUi:

lib/title_screen/title_screen_ui.dart.

class TitleScreenUi extends StatelessWidget {
  const TitleScreenUi({
    super.key,
    required this.difficulty,                            // Edit from here...
    required this.onDifficultyPressed,
    required this.onDifficultyFocused,
  });

  final int difficulty;
  final void Function(int difficulty) onDifficultyPressed;
  final void Function(int? difficulty) onDifficultyFocused; // to here.

  @override
  Widget build(BuildContext context) {
    return Padding(                                      // Move this const...
      padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50), // to here.
      child: Stack(
        children: [
          /// Title Text
          const TopLeft(                                 // Add a const here, as well
            child: UiScaler(
              alignment: Alignment.topLeft,
              child: _TitleText(),
            ),
          ),

          /// Difficulty Btns
          BottomLeft(                                    // Add from here...
            child: UiScaler(
              alignment: Alignment.bottomLeft,
              child: _DifficultyBtns(
                difficulty: difficulty,
                onDifficultyPressed: onDifficultyPressed,
                onDifficultyFocused: onDifficultyFocused,
              ),
            ),
          ),                                             // to here.
        ],
      ),
    );
  }
}
  1. Ajoutez les deux widgets suivants pour implémenter les boutons de difficulté:

lib/title_screen/title_screen_ui.dart.

class _DifficultyBtns extends StatelessWidget {
  const _DifficultyBtns({
    required this.difficulty,
    required this.onDifficultyPressed,
    required this.onDifficultyFocused,
  });

  final int difficulty;
  final void Function(int difficulty) onDifficultyPressed;
  final void Function(int? difficulty) onDifficultyFocused;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        _DifficultyBtn(
          label: 'Casual',
          selected: difficulty == 0,
          onPressed: () => onDifficultyPressed(0),
          onHover: (over) => onDifficultyFocused(over ? 0 : null),
        ),
        _DifficultyBtn(
          label: 'Normal',
          selected: difficulty == 1,
          onPressed: () => onDifficultyPressed(1),
          onHover: (over) => onDifficultyFocused(over ? 1 : null),
        ),
        _DifficultyBtn(
          label: 'Hardcore',
          selected: difficulty == 2,
          onPressed: () => onDifficultyPressed(2),
          onHover: (over) => onDifficultyFocused(over ? 2 : null),
        ),
        const Gap(20),
      ],
    );
  }
}

class _DifficultyBtn extends StatelessWidget {
  const _DifficultyBtn({
    required this.selected,
    required this.onPressed,
    required this.onHover,
    required this.label,
  });
  final String label;
  final bool selected;
  final VoidCallback onPressed;
  final void Function(bool hasFocus) onHover;

  @override
  Widget build(BuildContext context) {
    return FocusableControlBuilder(
      onPressed: onPressed,
      onHoverChanged: (_, state) => onHover.call(state.isHovered),
      builder: (_, state) {
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: SizedBox(
            width: 250,
            height: 60,
            child: Stack(
              children: [
                /// Bg with fill and outline
                Container(
                  decoration: BoxDecoration(
                    color: const Color(0xFF00D1FF).withOpacity(.1),
                    border: Border.all(color: Colors.white, width: 5),
                  ),
                ),

                if (state.isHovered || state.isFocused) ...[
                  Container(
                    decoration: BoxDecoration(
                      color: const Color(0xFF00D1FF).withOpacity(.1),
                    ),
                  ),
                ],

                /// cross-hairs (selected state)
                if (selected) ...[
                  CenterLeft(
                    child: Image.asset(AssetPaths.titleSelectedLeft),
                  ),
                  CenterRight(
                    child: Image.asset(AssetPaths.titleSelectedRight),
                  ),
                ],

                /// Label
                Center(
                  child: Text(label.toUpperCase(), style: TextStyles.btn),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}
  1. Convertissez le widget TitleScreen sans état en état, puis ajoutez un état pour modifier le jeu de couleurs en fonction de la difficulté:

lib/title_screen/title_screen.dart.

import 'package:flutter/material.dart';

import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';

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

  @override
  State<TitleScreen> createState() => _TitleScreenState();
}

class _TitleScreenState extends State<TitleScreen> {
  Color get _emitColor =>
      AppColors.emitColors[_difficultyOverride ?? _difficulty];
  Color get _orbColor =>
      AppColors.orbColors[_difficultyOverride ?? _difficulty];

  /// Currently selected difficulty
  int _difficulty = 0;

  /// Currently focused difficulty (if any)
  int? _difficultyOverride;

  void _handleDifficultyPressed(int value) {
    setState(() => _difficulty = value);
  }

  void _handleDifficultyFocused(int? value) {
    setState(() => _difficultyOverride = value);
  }

  final _finalReceiveLightAmt = 0.7;
  final _finalEmitLightAmt = 0.5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: [
            /// Bg-Base
            Image.asset(AssetPaths.titleBgBase),

            /// Bg-Receive
            _LitImage(
              color: _orbColor,
              imgSrc: AssetPaths.titleBgReceive,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Mg-Base
            _LitImage(
              imgSrc: AssetPaths.titleMgBase,
              color: _orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Mg-Receive
            _LitImage(
              imgSrc: AssetPaths.titleMgReceive,
              color: _orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Mg-Emit
            _LitImage(
              imgSrc: AssetPaths.titleMgEmit,
              color: _emitColor,
              lightAmt: _finalEmitLightAmt,
            ),

            /// Fg-Rocks
            Image.asset(AssetPaths.titleFgBase),

            /// Fg-Receive
            _LitImage(
              imgSrc: AssetPaths.titleFgReceive,
              color: _orbColor,
              lightAmt: _finalReceiveLightAmt,
            ),

            /// Fg-Emit
            _LitImage(
              imgSrc: AssetPaths.titleFgEmit,
              color: _emitColor,
              lightAmt: _finalEmitLightAmt,
            ),

            /// UI
            Positioned.fill(
              child: TitleScreenUi(
                difficulty: _difficulty,
                onDifficultyFocused: _handleDifficultyFocused,
                onDifficultyPressed: _handleDifficultyPressed,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _LitImage extends StatelessWidget {
  const _LitImage({
    required this.color,
    required this.imgSrc,
    required this.lightAmt,
  });
  final Color color;
  final String imgSrc;
  final double lightAmt;

  @override
  Widget build(BuildContext context) {
    final hsl = HSLColor.fromColor(color);
    return ColorFiltered(
      colorFilter: ColorFilter.mode(
        hsl.withLightness(hsl.lightness * lightAmt).toColor(),
        BlendMode.modulate,
      ),
      child: Image.asset(imgSrc),
    );
  }
}

Voici l'interface utilisateur avec deux paramètres de difficulté différents. Notez que les couleurs de difficulté appliquées en tant que masques aux nuances de gris créent un effet réaliste et réfléchi.

Application d'atelier de programmation avec la difficulté normale sélectionnée, avec les composants Image nuancés en violet et en cyan.

Application de l'atelier de programmation avec la difficulté hardcore sélectionnée, affichant les éléments image teinté en orange vif.

Ajouter le bouton de démarrage

  1. Mettez à jour le fichier title_screen_ui.dart. Ajoutez le code suivant au widget TitleScreenUi:

lib/title_screen/title_screen_ui.dart.

class TitleScreenUi extends StatelessWidget {
  const TitleScreenUi({
    super.key,
    required this.difficulty,
    required this.onDifficultyPressed,
    required this.onDifficultyFocused,
  });

  final int difficulty;
  final void Function(int difficulty) onDifficultyPressed;
  final void Function(int? difficulty) onDifficultyFocused;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
      child: Stack(
        children: [
          /// Title Text
          const TopLeft(
            child: UiScaler(
              alignment: Alignment.topLeft,
              child: _TitleText(),
            ),
          ),

          /// Difficulty Btns
          BottomLeft(
            child: UiScaler(
              alignment: Alignment.bottomLeft,
              child: _DifficultyBtns(
                difficulty: difficulty,
                onDifficultyPressed: onDifficultyPressed,
                onDifficultyFocused: onDifficultyFocused,
              ),
            ),
          ),

          /// StartBtn
          BottomRight(                                    // Add from here...
            child: UiScaler(
              alignment: Alignment.bottomRight,
              child: Padding(
                padding: const EdgeInsets.only(bottom: 20, right: 40),
                child: _StartBtn(onPressed: () {}),
              ),
            ),
          ),                                              // to here.
        ],
      ),
    );
  }
}
  1. Ajoutez le widget suivant pour implémenter le bouton de démarrage:

lib/title_screen/title_screen_ui.dart.

class _StartBtn extends StatefulWidget {
  const _StartBtn({required this.onPressed});
  final VoidCallback onPressed;

  @override
  State<_StartBtn> createState() => _StartBtnState();
}

class _StartBtnState extends State<_StartBtn> {
  AnimationController? _btnAnim;
  bool _wasHovered = false;

  @override
  Widget build(BuildContext context) {
    return FocusableControlBuilder(
      cursor: SystemMouseCursors.click,
      onPressed: widget.onPressed,
      builder: (_, state) {
        if ((state.isHovered || state.isFocused) &&
            !_wasHovered &&
            _btnAnim?.status != AnimationStatus.forward) {
          _btnAnim?.forward(from: 0);
        }
        _wasHovered = (state.isHovered || state.isFocused);
        return SizedBox(
          width: 520,
          height: 100,
          child: Stack(
            children: [
              Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)),
              if (state.isHovered || state.isFocused) ...[
                Positioned.fill(
                    child: Image.asset(AssetPaths.titleStartBtnHover)),
              ],
              Center(
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    Text('START MISSION',
                        style: TextStyles.btn
                            .copyWith(fontSize: 24, letterSpacing: 18)),
                  ],
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

Voici l'application qui fonctionne avec une collection complète de boutons.

Application de l'atelier de programmation avec la difficulté normale sélectionnée, avec le titre, les boutons de difficulté et le bouton de démarrage.

5. Ajouter une animation

Au cours de cette étape, vous allez animer l'interface utilisateur et les transitions de couleurs pour les éléments artistiques.

Fondu dans le titre

Au cours de cette étape, vous allez utiliser plusieurs approches pour animer une application Flutter. L'une des approches possibles consiste à utiliser flutter_animate. Les animations fournies par ce package peuvent être lues automatiquement chaque fois que vous actualisez votre application à chaud afin d'accélérer les itérations de développement.

  1. Modifiez le code dans lib/main.dart comme suit:

lib/main.dart

import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';   // Add this import
import 'package:window_size/window_size.dart';

import 'title_screen/title_screen.dart';

void main() {
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
    WidgetsFlutterBinding.ensureInitialized();
    setWindowMinSize(const Size(800, 500));
  }
  Animate.restartOnHotReload = true;                     // Add this line
  runApp(const NextGenApp());
}

class NextGenApp extends StatelessWidget {
  const NextGenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      themeMode: ThemeMode.dark,
      darkTheme: ThemeData(brightness: Brightness.dark),
      home: const TitleScreen(),
    );
  }
}
  1. Pour exploiter le package flutter_animate, vous devez l'importer. Ajoutez l'importation dans lib/title_screen/title_screen_ui.dart, comme suit:

lib/title_screen/title_screen_ui.dart.

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';   // Add this import
import 'package:focusable_control_builder/focusable_control_builder.dart';
import 'package:gap/gap.dart';

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';

class TitleScreenUi extends StatelessWidget {
  1. Ajoutez une animation au titre en modifiant le widget _TitleText, comme suit:

lib/title_screen/title_screen_ui.dart.

class _TitleText extends StatelessWidget {
  const _TitleText();

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Gap(20),
        Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Transform.translate(
              offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
              child: Text('OUTPOST', style: TextStyles.h1),
            ),
            Image.asset(AssetPaths.titleSelectedLeft, height: 65),
            Text('57', style: TextStyles.h2),
            Image.asset(AssetPaths.titleSelectedRight, height: 65),
          ],                                             // Edit from here...
        ).animate().fadeIn(delay: .8.seconds, duration: .7.seconds),
        Text('INTO THE UNKNOWN', style: TextStyles.h3)
            .animate()
            .fadeIn(delay: 1.seconds, duration: .7.seconds),
      ],                                                 // to here.
    );
  }
}
  1. Appuyez sur Actualiser pour que le titre apparaisse en fondu.

Application de l'atelier de programmation affichant le titre en fondu

Fondu sur les boutons de difficulté

  1. Ajoutez une animation à l'apparence initiale des boutons de difficulté en modifiant le widget _DifficultyBtns, comme suit:

lib/title_screen/title_screen_ui.dart.

class _DifficultyBtns extends StatelessWidget {
  const _DifficultyBtns({
    required this.difficulty,
    required this.onDifficultyPressed,
    required this.onDifficultyFocused,
  });

  final int difficulty;
  final void Function(int difficulty) onDifficultyPressed;
  final void Function(int? difficulty) onDifficultyFocused;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        _DifficultyBtn(
          label: 'Casual',
          selected: difficulty == 0,
          onPressed: () => onDifficultyPressed(0),
          onHover: (over) => onDifficultyFocused(over ? 0 : null),
        )                                                // Add from here...
            .animate()
            .fadeIn(delay: 1.3.seconds, duration: .35.seconds)
            .slide(begin: const Offset(0, .2)),          // to here
        _DifficultyBtn(
          label: 'Normal',
          selected: difficulty == 1,
          onPressed: () => onDifficultyPressed(1),
          onHover: (over) => onDifficultyFocused(over ? 1 : null),
        )                                                // Add from here...
            .animate()
            .fadeIn(delay: 1.5.seconds, duration: .35.seconds)
            .slide(begin: const Offset(0, .2)),          // to here
        _DifficultyBtn(
          label: 'Hardcore',
          selected: difficulty == 2,
          onPressed: () => onDifficultyPressed(2),
          onHover: (over) => onDifficultyFocused(over ? 2 : null),
        )                                                // Add from here...
            .animate()
            .fadeIn(delay: 1.7.seconds, duration: .35.seconds)
            .slide(begin: const Offset(0, .2)),          // to here
        const Gap(20),
      ],
    );
  }
}
  1. Appuyez sur Actualiser pour afficher les boutons de difficulté dans l'ordre avec une légère diapositive vers le haut en tant que bonus.

Application de l'atelier de programmation affichant les boutons "Titre" et "Difficulté" de fondu

Fondu au bouton de démarrage

  1. Ajoutez une animation au bouton de démarrage en modifiant la classe d'état _StartBtnState, comme suit:

lib/title_screen/title_screen_ui.dart.

class _StartBtnState extends State<_StartBtn> {
  AnimationController? _btnAnim;
  bool _wasHovered = false;

  @override
  Widget build(BuildContext context) {
    return FocusableControlBuilder(
      cursor: SystemMouseCursors.click,
      onPressed: widget.onPressed,
      builder: (_, state) {
        if ((state.isHovered || state.isFocused) &&
            !_wasHovered &&
            _btnAnim?.status != AnimationStatus.forward) {
          _btnAnim?.forward(from: 0);
        }
        _wasHovered = (state.isHovered || state.isFocused);
        return SizedBox(
          width: 520,
          height: 100,
          child: Stack(
            children: [
              Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)),
              if (state.isHovered || state.isFocused) ...[
                Positioned.fill(
                    child: Image.asset(AssetPaths.titleStartBtnHover)),
              ],
              Center(
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    Text('START MISSION',
                        style: TextStyles.btn
                            .copyWith(fontSize: 24, letterSpacing: 18)),
                  ],
                ),
              ),
            ],
          )                                              // Edit from here...
              .animate(autoPlay: false, onInit: (c) => _btnAnim = c)
              .shimmer(duration: .7.seconds, color: Colors.black),
        )
            .animate()
            .fadeIn(delay: 2.3.seconds)
            .slide(begin: const Offset(0, .2));
      },                                                 // to here.
    );
  }
}
  1. Appuyez sur Actualiser pour afficher les boutons de difficulté dans l'ordre avec une légère diapositive vers le haut en tant que bonus.

Application de l'atelier de programmation affichant le titre, les boutons de difficulté et le bouton de démarrage.

Animer l'effet de survol avec difficulté

Ajoutez une animation à l'état de survol des boutons de difficulté en modifiant la classe d'état _DifficultyBtn, comme suit:

lib/title_screen/title_screen_ui.dart.

class _DifficultyBtn extends StatelessWidget {
  const _DifficultyBtn({
    required this.selected,
    required this.onPressed,
    required this.onHover,
    required this.label,
  });
  final String label;
  final bool selected;
  final VoidCallback onPressed;
  final void Function(bool hasFocus) onHover;

  @override
  Widget build(BuildContext context) {
    return FocusableControlBuilder(
      onPressed: onPressed,
      onHoverChanged: (_, state) => onHover.call(state.isHovered),
      builder: (_, state) {
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: SizedBox(
            width: 250,
            height: 60,
            child: Stack(
              children: [
                /// Bg with fill and outline
                AnimatedOpacity(                         // Edit from here
                  opacity: (!selected && (state.isHovered || state.isFocused))
                      ? 1
                      : 0,
                  duration: .3.seconds,
                  child: Container(
                    decoration: BoxDecoration(
                      color: const Color(0xFF00D1FF).withOpacity(.1),
                      border: Border.all(color: Colors.white, width: 5),
                    ),
                  ),
                ),                                       // to here.

                if (state.isHovered || state.isFocused) ...[
                  Container(
                    decoration: BoxDecoration(
                      color: const Color(0xFF00D1FF).withOpacity(.1),
                    ),
                  ),
                ],

                /// cross-hairs (selected state)
                if (selected) ...[
                  CenterLeft(
                    child: Image.asset(AssetPaths.titleSelectedLeft),
                  ),
                  CenterRight(
                    child: Image.asset(AssetPaths.titleSelectedRight),
                  ),
                ],

                /// Label
                Center(
                  child: Text(label.toUpperCase(), style: TextStyles.btn),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

Les boutons de difficulté affichent désormais BoxDecoration lorsque la souris pointe sur un bouton qui n'a pas été sélectionné.

Application de l'atelier de programmation montrant l'interface utilisateur qui apparaît en fondu, puis passe d'un jeu de couleurs à un autre sur le bouton.

Animer le changement de couleur

  1. Le changement de couleur de l'arrière-plan est instantané et net. Il est préférable d'animer les images éclairées entre les jeux de couleurs. Ajoutez flutter_animate à lib/title_screen/title_screen.dart:

lib/title_screen/title_screen.dart.

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';    // Add this import

import '../assets.dart';
import '../styles.dart';
import 'title_screen_ui.dart';

class TitleScreen extends StatefulWidget {
  1. Ajoutez un widget _AnimatedColors dans lib/title_screen/title_screen.dart:

lib/title_screen/title_screen.dart.

class _AnimatedColors extends StatelessWidget {
  const _AnimatedColors({
    required this.emitColor,
    required this.orbColor,
    required this.builder,
  });

  final Color emitColor;
  final Color orbColor;

  final Widget Function(BuildContext context, Color orbColor, Color emitColor)
      builder;

  @override
  Widget build(BuildContext context) {
    final duration = .5.seconds;
    return TweenAnimationBuilder(
      tween: ColorTween(begin: emitColor, end: emitColor),
      duration: duration,
      builder: (_, emitColor, __) {
        return TweenAnimationBuilder(
          tween: ColorTween(begin: orbColor, end: orbColor),
          duration: duration,
          builder: (context, orbColor, __) {
            return builder(context, orbColor!, emitColor!);
          },
        );
      },
    );
  }
}
  1. Utilisez le widget que vous venez de créer pour animer les couleurs des images éclairées en mettant à jour la méthode build dans _TitleScreenState, comme suit:

lib/title_screen/title_screen.dart.

class _TitleScreenState extends State<TitleScreen> {
  Color get _emitColor =>
      AppColors.emitColors[_difficultyOverride ?? _difficulty];
  Color get _orbColor =>
      AppColors.orbColors[_difficultyOverride ?? _difficulty];

  /// Currently selected difficulty
  int _difficulty = 0;

  /// Currently focused difficulty (if any)
  int? _difficultyOverride;

  void _handleDifficultyPressed(int value) {
    setState(() => _difficulty = value);
  }

  void _handleDifficultyFocused(int? value) {
    setState(() => _difficultyOverride = value);
  }

  final _finalReceiveLightAmt = 0.7;
  final _finalEmitLightAmt = 0.5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: _AnimatedColors(                           // Edit from here...
          orbColor: _orbColor,
          emitColor: _emitColor,
          builder: (_, orbColor, emitColor) {
            return Stack(
              children: [
                /// Bg-Base
                Image.asset(AssetPaths.titleBgBase),

                /// Bg-Receive
                _LitImage(
                  color: orbColor,
                  imgSrc: AssetPaths.titleBgReceive,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Mg-Base
                _LitImage(
                  imgSrc: AssetPaths.titleMgBase,
                  color: orbColor,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Mg-Receive
                _LitImage(
                  imgSrc: AssetPaths.titleMgReceive,
                  color: orbColor,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Mg-Emit
                _LitImage(
                  imgSrc: AssetPaths.titleMgEmit,
                  color: emitColor,
                  lightAmt: _finalEmitLightAmt,
                ),

                /// Fg-Rocks
                Image.asset(AssetPaths.titleFgBase),

                /// Fg-Receive
                _LitImage(
                  imgSrc: AssetPaths.titleFgReceive,
                  color: orbColor,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Fg-Emit
                _LitImage(
                  imgSrc: AssetPaths.titleFgEmit,
                  color: emitColor,
                  lightAmt: _finalEmitLightAmt,
                ),

                /// UI
                Positioned.fill(
                  child: TitleScreenUi(
                    difficulty: _difficulty,
                    onDifficultyFocused: _handleDifficultyFocused,
                    onDifficultyPressed: _handleDifficultyPressed,
                  ),
                ),
              ],
            ).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
          },
        ),                                                // to here.
      ),
    );
  }
}

Avec cette modification finale, vous avez ajouté des animations à chaque élément à l'écran, et c'est encore mieux !

Application de l'atelier de programmation montrant le jeu de couleurs d'animation entre différents paramètres en pointant sur le bouton.

6. Ajouter des nuanceurs de fragments

Au cours de cette étape, vous ajouterez des nuanceurs de fragments à l'application. Tout d'abord, utilisez un nuanceur pour modifier le titre afin de lui donner une apparence plus dystopique. Ensuite, vous ajouterez un deuxième nuanceur pour créer une orbe servant de point central pour la page.

Distorsion du titre avec un nuanceur de fragments

Cette modification introduit le package provider, qui permet de transmettre les nuanceurs compilés dans l'arborescence des widgets. Si vous souhaitez en savoir plus sur le chargement des nuanceurs, consultez l'implémentation dans lib/assets.dart.

  1. Modifiez le code dans lib/main.dart comme suit:

lib/main.dart

import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';                 // Add this import
import 'package:window_size/window_size.dart';

import 'assets.dart';                                    // Add this import
import 'title_screen/title_screen.dart';

void main() {
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
    WidgetsFlutterBinding.ensureInitialized();
    setWindowMinSize(const Size(800, 500));
  }
  Animate.restartOnHotReload = true;
  runApp(                                                // Edit from here...
    FutureProvider<Shaders?>(
      create: (context) => loadShaders(),
      initialData: null,
      child: const NextGenApp(),
    ),
  );                                                     // to here.
}

class NextGenApp extends StatelessWidget {
  const NextGenApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      themeMode: ThemeMode.dark,
      darkTheme: ThemeData(brightness: Brightness.dark),
      home: const TitleScreen(),
    );
  }
}
  1. Pour bénéficier du package provider et des utilitaires de nuanceur inclus dans step_01, vous devez les importer. Ajoutez des importations dans lib/title_screen/title_screen_ui.dart, comme suit:

lib/title_screen/title_screen_ui.dart.

import 'package:extra_alignments/extra_alignments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:focusable_control_builder/focusable_control_builder.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';                 // Add this import

import '../assets.dart';
import '../common/shader_effect.dart';                   // And this import
import '../common/ticking_builder.dart';                 // And this import
import '../common/ui_scaler.dart';
import '../styles.dart';

class TitleScreenUi extends StatelessWidget {
  1. Distorsion du titre avec le nuanceur en modifiant le widget _TitleText, comme suit:

lib/title_screen/title_screen_ui.dart.

class _TitleText extends StatelessWidget {
  const _TitleText();

  @override
  Widget build(BuildContext context) {
    Widget content = Column(                             // Modify this line
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Gap(20),
        Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Transform.translate(
              offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0),
              child: Text('OUTPOST', style: TextStyles.h1),
            ),
            Image.asset(AssetPaths.titleSelectedLeft, height: 65),
            Text('57', style: TextStyles.h2),
            Image.asset(AssetPaths.titleSelectedRight, height: 65),
          ],
        ).animate().fadeIn(delay: .8.seconds, duration: .7.seconds),
        Text('INTO THE UNKNOWN', style: TextStyles.h3)
            .animate()
            .fadeIn(delay: 1.seconds, duration: .7.seconds),
      ],
    );
    return Consumer<Shaders?>(                           // Add from here...
      builder: (context, shaders, _) {
        if (shaders == null) return content;
        return TickingBuilder(
          builder: (context, time) {
            return AnimatedSampler(
              (image, size, canvas) {
                const double overdrawPx = 30;
                shaders.ui
                  ..setFloat(0, size.width)
                  ..setFloat(1, size.height)
                  ..setFloat(2, time)
                  ..setImageSampler(0, image);
                Rect rect = Rect.fromLTWH(-overdrawPx, -overdrawPx,
                    size.width + overdrawPx, size.height + overdrawPx);
                canvas.drawRect(rect, Paint()..shader = shaders.ui);
              },
              child: content,
            );
          },
        );
      },
    );                                                   // to here.
  }
}

Le titre devrait être déformé, comme vous pouvez l'imaginer dans un avenir dystopique.

L'application de l'atelier de programmation montre que le titre est déformé par le nuanceur de fragments.

Ajouter l'orbe

Ajoutez l'orbe au centre de la fenêtre. Vous devez ajouter un rappel onPressed au bouton de démarrage.

  1. Dans lib/title_screen/title_screen_ui.dart, modifiez TitleScreenUi comme suit:

lib/title_screen/title_screen_ui.dart.

class TitleScreenUi extends StatelessWidget {
  const TitleScreenUi({
    super.key,
    required this.difficulty,
    required this.onDifficultyPressed,
    required this.onDifficultyFocused,
    required this.onStartPressed,                         // Add this argument
  });

  final int difficulty;
  final void Function(int difficulty) onDifficultyPressed;
  final void Function(int? difficulty) onDifficultyFocused;
  final VoidCallback onStartPressed;                      // Add this attribute

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50),
      child: Stack(
        children: [
          /// Title Text
          const TopLeft(
            child: UiScaler(
              alignment: Alignment.topLeft,
              child: _TitleText(),
            ),
          ),

          /// Difficulty Btns
          BottomLeft(
            child: UiScaler(
              alignment: Alignment.bottomLeft,
              child: _DifficultyBtns(
                difficulty: difficulty,
                onDifficultyPressed: onDifficultyPressed,
                onDifficultyFocused: onDifficultyFocused,
              ),
            ),
          ),

          /// StartBtn
          BottomRight(
            child: UiScaler(
              alignment: Alignment.bottomRight,
              child: Padding(
                padding: const EdgeInsets.only(bottom: 20, right: 40),
                child: _StartBtn(onPressed: onStartPressed),  // Edit this line
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Maintenant que vous avez modifié le bouton de démarrage avec un rappel, vous devez apporter d'importantes modifications au fichier lib/title_screen/title_screen.dart.

  1. Modifiez les importations comme suit:

lib/title_screen/title_screen.dart.

import 'dart:math';                                       // Add this import
import 'dart:ui';                                         // And this import

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';                   // Add this import
import 'package:flutter_animate/flutter_animate.dart';

import '../assets.dart';
import '../orb_shader/orb_shader_config.dart';            // And this import
import '../orb_shader/orb_shader_widget.dart';            // And this import too
import '../styles.dart';
import 'title_screen_ui.dart';

class TitleScreen extends StatefulWidget {
  1. Modifiez _TitleScreenState pour qu'il corresponde à ce qui suit. Presque toutes les parties de la classe sont modifiées d'une manière ou d'une autre.

lib/title_screen/title_screen.dart.

class _TitleScreenState extends State<TitleScreen>
    with SingleTickerProviderStateMixin {
  final _orbKey = GlobalKey<OrbShaderWidgetState>();

  /// Editable Settings
  /// 0-1, receive lighting strength
  final _minReceiveLightAmt = .35;
  final _maxReceiveLightAmt = .7;

  /// 0-1, emit lighting strength
  final _minEmitLightAmt = .5;
  final _maxEmitLightAmt = 1;

  /// Internal
  var _mousePos = Offset.zero;

  Color get _emitColor =>
      AppColors.emitColors[_difficultyOverride ?? _difficulty];
  Color get _orbColor =>
      AppColors.orbColors[_difficultyOverride ?? _difficulty];

  /// Currently selected difficulty
  int _difficulty = 0;

  /// Currently focused difficulty (if any)
  int? _difficultyOverride;
  double _orbEnergy = 0;
  double _minOrbEnergy = 0;

  double get _finalReceiveLightAmt {
    final light =
        lerpDouble(_minReceiveLightAmt, _maxReceiveLightAmt, _orbEnergy) ?? 0;
    return light + _pulseEffect.value * .05 * _orbEnergy;
  }

  double get _finalEmitLightAmt {
    return lerpDouble(_minEmitLightAmt, _maxEmitLightAmt, _orbEnergy) ?? 0;
  }

  late final _pulseEffect = AnimationController(
    vsync: this,
    duration: _getRndPulseDuration(),
    lowerBound: -1,
    upperBound: 1,
  );

  Duration _getRndPulseDuration() => 100.ms + 200.ms * Random().nextDouble();

  double _getMinEnergyForDifficulty(int difficulty) {
    if (difficulty == 1) {
      return .3;
    } else if (difficulty == 2) {
      return .6;
    }
    return 0;
  }

  @override
  void initState() {
    super.initState();
    _pulseEffect.forward();
    _pulseEffect.addListener(_handlePulseEffectUpdate);
  }

  void _handlePulseEffectUpdate() {
    if (_pulseEffect.status == AnimationStatus.completed) {
      _pulseEffect.reverse();
      _pulseEffect.duration = _getRndPulseDuration();
    } else if (_pulseEffect.status == AnimationStatus.dismissed) {
      _pulseEffect.duration = _getRndPulseDuration();
      _pulseEffect.forward();
    }
  }

  void _handleDifficultyPressed(int value) {
    setState(() => _difficulty = value);
    _bumpMinEnergy();
  }

  Future<void> _bumpMinEnergy([double amount = 0.1]) async {
    setState(() {
      _minOrbEnergy = _getMinEnergyForDifficulty(_difficulty) + amount;
    });
    await Future<void>.delayed(.2.seconds);
    setState(() {
      _minOrbEnergy = _getMinEnergyForDifficulty(_difficulty);
    });
  }

  void _handleStartPressed() => _bumpMinEnergy(0.3);

  void _handleDifficultyFocused(int? value) {
    setState(() {
      _difficultyOverride = value;
      if (value == null) {
        _minOrbEnergy = _getMinEnergyForDifficulty(_difficulty);
      } else {
        _minOrbEnergy = _getMinEnergyForDifficulty(value);
      }
    });
  }

  /// Update mouse position so the orbWidget can use it, doing it here prevents
  /// btns from blocking the mouse-move events in the widget itself.
  void _handleMouseMove(PointerHoverEvent e) {
    setState(() {
      _mousePos = e.localPosition;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: MouseRegion(
          onHover: _handleMouseMove,
          child: _AnimatedColors(
            orbColor: _orbColor,
            emitColor: _emitColor,
            builder: (_, orbColor, emitColor) {
              return Stack(
                children: [
                  /// Bg-Base
                  Image.asset(AssetPaths.titleBgBase),

                  /// Bg-Receive
                  _LitImage(
                    color: orbColor,
                    imgSrc: AssetPaths.titleBgReceive,
                    pulseEffect: _pulseEffect,
                    lightAmt: _finalReceiveLightAmt,
                  ),

                  /// Orb
                  Positioned.fill(
                    child: Stack(
                      children: [
                        // Orb
                        OrbShaderWidget(
                          key: _orbKey,
                          mousePos: _mousePos,
                          minEnergy: _minOrbEnergy,
                          config: OrbShaderConfig(
                            ambientLightColor: orbColor,
                            materialColor: orbColor,
                            lightColor: orbColor,
                          ),
                          onUpdate: (energy) => setState(() {
                            _orbEnergy = energy;
                          }),
                        ),
                      ],
                    ),
                  ),

                  /// Mg-Base
                  _LitImage(
                    imgSrc: AssetPaths.titleMgBase,
                    color: orbColor,
                    pulseEffect: _pulseEffect,
                    lightAmt: _finalReceiveLightAmt,
                  ),

                  /// Mg-Receive
                  _LitImage(
                    imgSrc: AssetPaths.titleMgReceive,
                    color: orbColor,
                    pulseEffect: _pulseEffect,
                    lightAmt: _finalReceiveLightAmt,
                  ),

                  /// Mg-Emit
                  _LitImage(
                    imgSrc: AssetPaths.titleMgEmit,
                    color: emitColor,
                    pulseEffect: _pulseEffect,
                    lightAmt: _finalEmitLightAmt,
                  ),

                  /// Fg-Rocks
                  Image.asset(AssetPaths.titleFgBase),

                  /// Fg-Receive
                  _LitImage(
                    imgSrc: AssetPaths.titleFgReceive,
                    color: orbColor,
                    pulseEffect: _pulseEffect,
                    lightAmt: _finalReceiveLightAmt,
                  ),

                  /// Fg-Emit
                  _LitImage(
                    imgSrc: AssetPaths.titleFgEmit,
                    color: emitColor,
                    pulseEffect: _pulseEffect,
                    lightAmt: _finalEmitLightAmt,
                  ),

                  /// UI
                  Positioned.fill(
                    child: TitleScreenUi(
                      difficulty: _difficulty,
                      onDifficultyFocused: _handleDifficultyFocused,
                      onDifficultyPressed: _handleDifficultyPressed,
                      onStartPressed: _handleStartPressed,
                    ),
                  ),
                ],
              ).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
            },
          ),
        ),
      ),
    );
  }
}
  1. Modifiez _LitImage comme suit:

lib/title_screen/title_screen.dart.

class _LitImage extends StatelessWidget {
  const _LitImage({
    required this.color,
    required this.imgSrc,
    required this.pulseEffect,                            // Add this parameter
    required this.lightAmt,
  });
  final Color color;
  final String imgSrc;
  final AnimationController pulseEffect;                  // Add this attribute
  final double lightAmt;

  @override
  Widget build(BuildContext context) {
    final hsl = HSLColor.fromColor(color);
    return ListenableBuilder(                             // Edit from here...
      listenable: pulseEffect,
      child: Image.asset(imgSrc),
      builder: (context, child) {
        return ColorFiltered(
          colorFilter: ColorFilter.mode(
            hsl.withLightness(hsl.lightness * lightAmt).toColor(),
            BlendMode.modulate,
          ),
          child: child,
        );
      },
    );                                                    // to here.
  }
}

Résultat de cet ajout.

Application d'atelier de programmation présentée avec toutes les animations et les deux nuanceurs.

7. Ajouter des animations de particules

Au cours de cette étape, vous allez ajouter des animations de particules pour créer un mouvement clignotant subtil vers l'application.

Ajouter des particules partout

  1. Créez un fichier lib/title_screen/particle_overlay.dart, puis ajoutez le code suivant:

lib/title_screen/particle_Overlay.dart

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:particle_field/particle_field.dart';
import 'package:rnd/rnd.dart';

class ParticleOverlay extends StatelessWidget {
  const ParticleOverlay({super.key, required this.color, required this.energy});

  final Color color;
  final double energy;

  @override
  Widget build(BuildContext context) {
    return ParticleField(
      spriteSheet: SpriteSheet(
        image: const AssetImage('assets/images/particle-wave.png'),
      ),
      // blend the image's alpha with the specified color:
      blendMode: BlendMode.dstIn,

      // this runs every tick:
      onTick: (controller, _, size) {
        List<Particle> particles = controller.particles;

        // add a new particle with random angle, distance & velocity:
        double a = rnd(pi * 2);
        double dist = rnd(1, 4) * 35 + 150 * energy;
        double vel = rnd(1, 2) * (1 + energy * 1.8);
        particles.add(Particle(
          // how many ticks this particle will live:
          lifespan: rnd(1, 2) * 20 + energy * 15,
          // starting distance from center:
          x: cos(a) * dist,
          y: sin(a) * dist,
          // starting velocity:
          vx: cos(a) * vel,
          vy: sin(a) * vel,
          // other starting values:
          rotation: a,
          scale: rnd(1, 2) * 0.6 + energy * 0.5,
        ));

        // update all of the particles:
        for (int i = particles.length - 1; i >= 0; i--) {
          Particle p = particles[i];
          if (p.lifespan <= 0) {
            // particle is expired, remove it:
            particles.removeAt(i);
            continue;
          }
          p.update(
            scale: p.scale * 1.025,
            vx: p.vx * 1.025,
            vy: p.vy * 1.025,
            color: color.withOpacity(p.lifespan * 0.001 + 0.01),
            lifespan: p.lifespan - 1,
          );
        }
      },
    );
  }
}
  1. Modifiez les importations pour lib/title_screen/title_screen.dart, comme suit:

lib/title_screen/title_screen.dart.

import 'dart:math';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';

import '../assets.dart';
import '../orb_shader/orb_shader_config.dart';
import '../orb_shader/orb_shader_widget.dart';
import '../styles.dart';
import 'particle_overlay.dart';                          // Add this import
import 'title_screen_ui.dart';

class TitleScreen extends StatefulWidget {
  1. Ajoutez ParticleOverlay à l'UI en modifiant la méthode build de _TitleScreenState, comme suit:

lib/title_screen/title_screen.dart.

@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Colors.black,
    body: Center(
      child: MouseRegion(
        onHover: _handleMouseMove,
        child: _AnimatedColors(
          orbColor: _orbColor,
          emitColor: _emitColor,
          builder: (_, orbColor, emitColor) {
            return Stack(
              children: [
                /// Bg-Base
                Image.asset(AssetPaths.titleBgBase),

                /// Bg-Receive
                _LitImage(
                  color: orbColor,
                  imgSrc: AssetPaths.titleBgReceive,
                  pulseEffect: _pulseEffect,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Orb
                Positioned.fill(
                  child: Stack(
                    children: [
                      // Orb
                      OrbShaderWidget(
                        key: _orbKey,
                        mousePos: _mousePos,
                        minEnergy: _minOrbEnergy,
                        config: OrbShaderConfig(
                          ambientLightColor: orbColor,
                          materialColor: orbColor,
                          lightColor: orbColor,
                        ),
                        onUpdate: (energy) => setState(() {
                          _orbEnergy = energy;
                        }),
                      ),
                    ],
                  ),
                ),

                /// Mg-Base
                _LitImage(
                  imgSrc: AssetPaths.titleMgBase,
                  color: orbColor,
                  pulseEffect: _pulseEffect,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Mg-Receive
                _LitImage(
                  imgSrc: AssetPaths.titleMgReceive,
                  color: orbColor,
                  pulseEffect: _pulseEffect,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Mg-Emit
                _LitImage(
                  imgSrc: AssetPaths.titleMgEmit,
                  color: emitColor,
                  pulseEffect: _pulseEffect,
                  lightAmt: _finalEmitLightAmt,
                ),

                /// Particle Field
                Positioned.fill(                          // Add from here...
                  child: IgnorePointer(
                    child: ParticleOverlay(
                      color: orbColor,
                      energy: _orbEnergy,
                    ),
                  ),
                ),                                        // to here.

                /// Fg-Rocks
                Image.asset(AssetPaths.titleFgBase),

                /// Fg-Receive
                _LitImage(
                  imgSrc: AssetPaths.titleFgReceive,
                  color: orbColor,
                  pulseEffect: _pulseEffect,
                  lightAmt: _finalReceiveLightAmt,
                ),

                /// Fg-Emit
                _LitImage(
                  imgSrc: AssetPaths.titleFgEmit,
                  color: emitColor,
                  pulseEffect: _pulseEffect,
                  lightAmt: _finalEmitLightAmt,
                ),

                /// UI
                Positioned.fill(
                  child: TitleScreenUi(
                    difficulty: _difficulty,
                    onDifficultyFocused: _handleDifficultyFocused,
                    onDifficultyPressed: _handleDifficultyPressed,
                    onStartPressed: _handleStartPressed,
                  ),
                ),
              ],
            ).animate().fadeIn(duration: 1.seconds, delay: .3.seconds);
          },
        ),
      ),
    ),
  );
}

Le résultat final inclut des animations, des nuanceurs de fragments et des effets de particules sur plusieurs plates-formes.

Application de l'atelier de programmation montrant le nuanceur de fragments et les effets de particules.

Ajoutez des particules partout, même sur le Web

Il existe un léger problème au niveau du code. Lorsque Flutter s'exécute sur le Web, il existe deux autres moteurs de rendu: le moteur CanvasKit, qui est utilisé par défaut sur les navigateurs de bureau, et un moteur de rendu DOM HTML, qui est utilisé par défaut pour les appareils mobiles. Le problème est que le moteur de rendu DOM HTML n'est pas compatible avec les nuanceurs de fragments. La solution consiste à configurer l'expérience Web pour qu'elle utilise le moteur CanvasKit partout.

  • Modifiez web/index.html comme suit:

web/index.html

<!DOCTYPE html>
<html>
<head>
  <!--
    If you are serving your web app in a path other than the root, change the
    href value below to reflect the base path you are serving from.

    The path provided below has to start and end with a slash "/" in order for
    it to work correctly.

    For more details:
    * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base

    This is a placeholder for base href that will be replaced by the value of
    the `--base-href` argument provided to `flutter build`.
  -->
  <base href="$FLUTTER_BASE_HREF">

  <meta charset="UTF-8">
  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
  <meta name="description" content="A new Flutter project.">

  <!-- iOS meta tags & icons -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="next_gen_ui">
  <link rel="apple-touch-icon" href="icons/Icon-192.png">

  <!-- Favicon -->
  <link rel="icon" type="image/png" href="favicon.png"/>

  <title>next_gen_ui</title>
  <link rel="manifest" href="manifest.json">

  <script>
    // The value below is injected by flutter build, do not touch.
    var serviceWorkerVersion = null;
  </script>
  <!-- This script adds the flutter initialization JS code -->
  <script src="flutter.js" defer></script>
</head>
<body>
  <script>
    window.addEventListener('load', function (ev) {
      // Download main.dart.js
      _flutter.loader.loadEntrypoint({
        serviceWorker: {
          serviceWorkerVersion: serviceWorkerVersion,
        },
        onEntrypointLoaded: function (engineInitializer) {  // Edit from here...
          engineInitializer.initializeEngine({
            renderer: 'canvaskit'
          }).then(function (appRunner) {                    // to here.
            appRunner.runApp();
          });
        }
      });
    });
  </script>
</body>
</html>

Voici votre travail, illustré cette fois dans un navigateur Chrome.

L'application de l'atelier de programmation s'exécutant dans un navigateur Chrome, avec toutes les animations, les nuanceurs de fragments et les effets de particules.

8. Félicitations

Vous avez créé un écran d'introduction de jeu complet avec des animations, des nuanceurs de fragments et des animations de particules. Vous pouvez désormais utiliser ces techniques sur toutes les plates-formes compatibles avec Flutter.

Application de l'atelier de programmation montrant le nuanceur de fragments et les effets de particules.

En savoir plus