1. 始める前に
Flutter は、ホットリロードと宣言型 UI の組み合わせを使用して、新しいユーザー インターフェースをすばやく反復的に作成できる点で優れています。ただし、インターフェースにインタラクティブな機能を追加する必要がある場合もあります。このようなタップは、ボタンにカーソルを合わせるとアニメーションが切り替わる場合や、シェーダーのように GPU のパワーでユーザー インターフェースをゆがめるような場合などがあります。
この Codelab では、アニメーション、シェーダー、粒子フィールドを活用して、コーディングをしなくても大好きな SF 映画やテレビ番組を連想させるユーザー インターフェースを作成する Flutter アプリを作成します。
作成するアプリの概要
終末後の SF をテーマにしたゲームの初回メニューページを作成します。テキストを視覚的にアニメーション化するためのフラグメント シェーダー、多数のアニメーションでページのカラーテーマを変更する難易度メニュー、2 番目のフラグメント シェーダーで描画されたアニメーション オーブを含むタイトルがあります。それだけでは不十分な場合は、Codelab の最後に、ページに動きや関心を与える微妙な粒子効果を追加します。
次のスクリーンショットは、サポート対象の 3 つのデスクトップ オペレーティング システム(Windows、Linux、macOS)でビルドするアプリを示しています。完全性を期すために、ウェブブラウザのバージョンも提供されています(これもサポートされています)。アニメーションとフラグメント シェーダーをあらゆる場所で使用できるようになりました。
|
|
|
|
Prerequisites
- 初めての Flutter アプリ Codelab で説明されている、Dart を使った Flutter の開発に関する基本的な知識
学習内容
flutter_animateを使用して表現力の高いアニメーションを作成する方法- デスクトップとウェブでの Flutter のフラグメント シェーダーのサポートを使用する方法
particle_fieldを使用してパーティクル アニメーションをアプリに追加する方法
必要なもの
- Flutter SDK
- Flutter と Dart を使用するための VS Code の設定
- Windows、Linux、macOS 用の Flutter のデスクトップ サポート設定
- Flutter のウェブサポート設定
2. 始める
スターター コードをダウンロードする
- この GitHub リポジトリに移動します。
- [Code] > [Download zip] をクリックして、この Codelab のすべてのコードをダウンロードします。
- ダウンロードした zip ファイルを解凍して、
codelabs-mainルートフォルダを展開します。必要なnext-gen-ui/サブディレクトリには、step_01からstep_06へのフォルダが含まれています。このフォルダには、この Codelab の各ステップでビルドするソースコードが含まれています。
プロジェクトの依存関係をダウンロードする
- VS Code で、[File] > [Open folder] > [Codelab-main] > [next-gen-uis] > [step_01] をクリックして、スターター プロジェクトを開きます。
- スターター アプリに必要なパッケージのダウンロードを求める VS Code ダイアログが表示されたら、[Get packages] をクリックします。

- スターター アプリに必要なパッケージのダウンロードを求める VS Code ダイアログが表示されない場合は、ターミナルを開いて
step_01フォルダに移動し、flutter pub getコマンドを実行します。
スターター アプリを実行する
- VS Code で、実行しているデスクトップ オペレーティング システムを選択します。ウェブブラウザでアプリをテストする場合は、Chrome を選択します。
たとえば、macOS をデプロイ ターゲットとして使用すると、次のように表示されます。

Chrome をデプロイ ターゲットとして使用すると、次のようになります。

lib/main.dartファイルを開き、
[デバッグを開始] をクリックします。アプリがパソコンのオペレーティング システムまたは Chrome ブラウザで起動します。
スターター アプリを確認する
スターター アプリでは、次の点に注意してください。
- UI を作成する準備が整いました。
assetsディレクトリには、使用するアートアセットと 2 つのフラグメント シェーダーがあります。pubspec.yamlファイルには、これから使用するアセットとアセット パッケージのコレクションがリストされます。libディレクトリには、必須のmain.dartファイル、アートアセットとフラグメント シェーダーのパスを一覧表示するassets.dartファイル、使用する TextStyles と Color をリストしたstyles.dartファイルが含まれています。libディレクトリには、この Codelab で使用するいくつかの便利なユーティリティが格納されているcommonディレクトリと、頂点シェーダーで Orb を表示するために使用されるWidgetを含むorb_shaderディレクトリも含まれています。
アプリを起動すると、次のように表示されます。

3. シーンを描画する
このステップでは、すべての背景アート アセットを画面上に重ねて配置します。最初はモノクロとして奇妙に見えるはずですが、このステップの最後にシーンに色を追加します。
シーンにアセットを追加する
libディレクトリにtitle_screenディレクトリを作成し、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), ], ), ), ); } } このウィジェットには、アセットがレイヤにスタックされたシーンが含まれています。背景、中間、前景の各レイヤはそれぞれ 2 つまたは 3 つの画像のグループで表されます。撮影中の光がシーン内をどのように移動しているかを捉えるため、これらの画像はさまざまな色で点灯します。
main.dartファイルに次の内容を追加します。
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 ); } } これにより、アプリの UI が、アートアセットによって作成されたモノクロシーンに置き換えられます。次に、各レイヤの色を指定します。

カラーリング ユーティリティの追加
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. この _LitImage ユーティリティ ウィジェットは、光の発光に応じて、各アートアセットの色を変更できます。この新しいウィジェットを使用していないため、リンター警告がトリガーされることがあります。
カラーでペイント
次のように title_screen.dart ファイルを変更して、色をペイントします。
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), ); } } 同じくアプリアセットは緑色です。

4. UI を追加する
このステップでは、前のステップで作成したシーンにユーザー インターフェースを配置します。これには、タイトル、難易度セレクタ ボタン、非常に重要なスタートボタンが含まれます。
タイトルを追加
lib/title_screenディレクトリ内にtitle_screen_ui.dartファイルを作成し、そのファイルに以下の内容を追加します。
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), ], ); } } このウィジェットには、このアプリのユーザー インターフェースを構成するタイトルとすべてのボタンが含まれています。
lib/title_screen/title_screen.dartファイルを次のように更新します。
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), ); } } このコードを実行すると、ユーザー インターフェースの先頭にあるタイトルが表示されます。
![「Outpost [57] Into the 不明」というタイトルの Codelab アプリ](https://bestbuyvn.site/index.php/https://codelabs.developers.google.com/static/codelabs/flutter-next-gen-uis/img/8916d68d8a851a1b.png?hl=ja)
難易度ボタンを追加する
focusable_control_builderパッケージに新しいインポートを追加して、title_screen_ui.dartを更新します。
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'; 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. ], ), ); } } - 次の 2 つのウィジェットを追加して、難易度ボタンを実装します。
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), ), ], ), ), ); }, ); } } TitleScreenウィジェットをステートレスからステートフルに変換し、難易度に基づいてカラーパターンを変更できるように状態を追加します。
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), ); } } 2 つの難易度設定の UI です。グレースケールの画像にマスクとして適用する難色が現実的で反射的な効果をもたらすことに注目してください。
|
|
開始ボタンを追加する
title_screen_ui.dartファイルを更新します。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. ], ), ); } } - 次のウィジェットを追加して、スタートボタンを実装します。
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)), ], ), ), ], ), ); }, ); } } こちらは、ボタンの完全なコレクションで実行されているアプリです。

5. アニメーションを追加する
このステップでは、ユーザー インターフェースとアートアセットの色の遷移をアニメーション化します。
タイトルでフェード
このステップでは、複数の方法で Flutter アプリをアニメーション化します。その方法の一つが、flutter_animate の使用です。このパッケージを搭載したアニメーションは、アプリをホットリロードするたびに自動的に再生されるため、開発イテレーションを高速化できます。
lib/main.dartのコードを次のように変更します。
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(), ); } } flutter_animateパッケージを利用するには、このパッケージをインポートする必要があります。次のようにインポートをlib/title_screen/title_screen_ui.dartに追加します。
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 { - 次のように
_TitleTextウィジェットを編集することで、タイトルにアニメーションを追加します。
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. ); } } - [再読み込み] を押すとタイトルのフェードインを確認できます。

難易度ボタンのフェードアウト
- 次のように、
_DifficultyBtnsウィジェットを編集して、難易度ボタンの初期表示にアニメーションを追加します。
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), ], ); } } - [再読み込み] をクリックすると、難易度の高いボタンが少しずつ上がった状態でボーナス ボタンが順番に表示されます。

開始ボタンをフェードインする
- 開始ボタンにアニメーションを追加するには、
_StartBtnState状態クラスを次のように編集します。
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. ); } } - [再読み込み] をクリックすると、難易度の高いボタンが少しずつ上がった状態でボーナス ボタンが順番に表示されます。

「難易度にカーソルを合わせた」効果をアニメーション化する
次のように、_DifficultyBtn 状態クラスを編集して、難易度ボタンのマウスオーバー状態にアニメーションを追加します。
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), ), ], ), ), ); }, ); } } 難易度ボタンは、選択されていないボタンにカーソルを合わせたときに、BoxDecoration を表示するようになりました。

色の変化にアニメーションを付ける
- 背景色は瞬時に変更され、印象的になります。カラーパターン間でライト付きの画像をアニメーション化することをおすすめします。
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 { lib/title_screen/title_screen.dartに_AnimatedColorsウィジェットを追加します。
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!); }, ); }, ); } } - 作成したウィジェットを使用して、次のように
_TitleScreenStateのbuildメソッドを更新し、点灯している画像の色をアニメーション化します。
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. ), ); } } 今回の最終編集では、画面上のすべての要素にアニメーションを追加したことで、デザインが大幅に向上しました。

6. フラグメント シェーダーを追加する
このステップでは、フラグメント シェーダーをアプリに追加します。まず、シェーダーを使用してタイトルを修正し、よりディストピアなものにします。次に、2 つ目のシェーダーを追加して、ページの中心となるオーブを 1 つ作成します。
フラグメント シェーダーでタイトルをゆがめる
この変更により、provider パッケージが導入され、コンパイルされたシェーダーをウィジェット ツリーの下へ渡すことができるようになります。シェーダーの読み込みの仕組みにご関心がある場合は、lib/assets.dart の実装をご覧ください。
lib/main.dartのコードを次のように変更します。
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(), ); } } providerパッケージと、step_01に含まれるシェーダー ユーティリティを活用するには、それらをインポートする必要があります。次のように、lib/title_screen/title_screen_ui.dartに新しいインポートを追加します。
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 { - 次のように
_TitleTextウィジェットを編集することで、シェーダーでタイトルを歪めます。
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. } } おそらく、ディストピアでは予想どおり、歪んだタイトルが表示されるはずです。

オーブを追加する
次に、ウィンドウの中央にオーブを追加します。onPressed コールバックを開始ボタンに追加する必要があります。
lib/title_screen/title_screen_ui.dartで、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, 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 ), ), ), ], ), ); } } コールバックで開始ボタンを変更したので、lib/title_screen/title_screen.dart ファイルに大幅な変更を加える必要があります。
- 次のようにインポートを変更します。
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 { _TitleScreenStateを次のように変更します。クラスのほぼすべての部分が、なんらかの点で変更されています。
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); }, ), ), ), ); } } _LitImageを次のように変更します。
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. } } これは、この追加の結果です。

7. パーティクル アニメーションを追加する
このステップでは、パーティクル アニメーションを追加して、アプリで軽微な点滅を生み出します。
あらゆる場所に粒子を追加
- 新しい
lib/title_screen/particle_overlay.dartファイルを作成し、次のコードを追加します。
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, ); } }, ); } } lib/title_screen/title_screen.dartのインポートを次のように変更します。
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 { _TitleScreenStateのbuildメソッドを次のように変更して、UI にParticleOverlayを追加します。
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); }, ), ), ), ); } 最終結果には、複数のプラットフォーム上のアニメーション、フラグメント シェーダー、パーティクル エフェクトが含まれます。

あらゆる場所(ウェブ上のものも含む)の粒子の追加
現状のコードには 1 つの少し問題があります。Flutter をウェブで実行する場合は 2 つの代替レンダリング エンジンを使用できます。デスクトップ クラスのブラウザでデフォルトで使用される CanvasKit エンジンと、モバイル デバイスでデフォルトで使用される HTML DOM レンダラです。この問題は、HTML DOM レンダラがフラグメント シェーダーに対応していないことです。この問題は、CanvasKit エンジンをどこでも使用するためのウェブ エクスペリエンスを構成することです。
web/index.htmlを次のように変更します。
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> ここでは、Chrome ブラウザでご覧いただいたこれまでの作業すべてをご紹介します。

8. 完了
アニメーション、フラグメント シェーダー、パーティクル アニメーションを備えた、フル機能のゲーム イントロ画面を作成しました。これらの手法は、Flutter がサポートしているすべてのプラットフォームで利用できます。

その他の情報
flutter_animateパッケージを確認する- フラグメント シェーダーの Flutter サポートに関するドキュメントを確認する
- Patricio Gonzalez Vivo と Jen Lowe による The Book of Shaders
- Shader Toy - 共同シェーダー遊び場
- simple_shader: シンプルな Flutter フラグメント シェーダーのサンプル プロジェクト





