在 Flutter 中建構新一代 UI

在 Flutter 中建構新一代 UI

程式碼研究室簡介

subject上次更新時間:5月 13, 2024
account_circle作者:Brett Morgan

1. 事前準備

Flutter 可讓開發人員結合熱重載和宣告式 UI,以疊代方式快速建立新的使用者介面。不過,有時您會需要為介面加入額外的互動功能。這類觸控功能可以簡單的做法,像是在遊標懸停時建立動畫效果,或是做為著色器的擬真著色器,利用 GPU 的強大功能來填充使用者介面。

在本程式碼研究室中,您將建構一個 Flutter 應用程式,該應用程式會運用動畫、著色器和粒子欄位的強大功能來建構使用者介面,在未編寫程式碼的情況下,叫出大眾喜愛的科幻電影和電視節目。

建構項目

您將為末日後科幻主題遊戲建立初始選單頁面。例如使用片段著色器將文字取樣,然後以視覺化方式呈現文字;難度選單可藉由加入動畫來改變網頁的顏色主題,以及使用第二個片段著色器繪製的動畫 OB。假如這樣仍不足夠,只要在程式碼研究室結束時,您就會加入細微的粒子效果,讓頁面呈現動態效果和興趣。

以下螢幕截圖顯示您將透過三種支援的電腦作業系統 (Windows、Linux 和 macOS) 建構的應用程式。為求完整起見,我們提供了網路瀏覽器版本 (也有支援)。動畫和片段著色器到處都是!

在 Windows 上執行的完成應用程式

在 Chrome 瀏覽器中執行完成的應用程式

在 Linux 上執行的完成應用程式

在 macOS 上執行的完成應用程式

軟硬體需求

2. 開始使用

下載範例程式碼

  1. 前往這個 GitHub 存放區
  2. 按一下「程式碼」>下載 ZIP 檔案即可下載這個程式碼研究室的所有程式碼。
  3. 將下載的 ZIP 檔案解壓縮,將 codelabs-main 根資料夾解壓縮。您只需要 next-gen-ui/ 子目錄,其中包含 step_01step_06 資料夾,其中包含您在本程式碼研究室中每個步驟建構的原始碼。

下載專案依附元件

  1. 在 VS Code 中,按一下「File」(檔案) > 開啟資料夾 >Codes-main >Next-gen-uis >Step_01 開啟範例專案。
  2. 如果看到 VS Code 對話方塊,提示您下載範例應用程式所需的套件,請按一下「Get package」(取得套件)

VS Code 對話方塊,提示您下載範例應用程式所需的套件。

  1. 如果您沒有看到 VS Code 對話方塊,提示您下載範例應用程式所需的套件,請開啟終端機,然後前往 step_01 資料夾並執行 flutter pub get 指令。

執行範例應用程式

  1. 在 VS Code 中,請選取您執行的電腦作業系統;如要在網路瀏覽器中測試應用程式,請選取 Chrome。

舉例來說,使用 macOS 做為部署目標時,您會看到以下內容:

顯示 Flutter 目標的 VSCode 狀態列裝飾為 macOS (darwin)

使用 Chrome 做為部署目標時,您會看到以下內容:

顯示 Flutter 目標的 VSCode 狀態列裝飾為 Chrome (網頁 JavaScript)

  1. 開啟 lib/main.dart 檔案,然後按一下 VSCode 中的「播放」按鈕「Start debugging」。應用程式會在電腦作業系統或 Chrome 瀏覽器中啟動。

探索範例應用程式

在範例應用程式中,請注意下列事項:

  • 您隨時可以建構 UI。
  • assets 目錄包含圖片素材資源和您要使用的兩個片段著色器。
  • pubspec.yaml 檔案已列出素材資源,以及您將使用的 pub 套件。
  • lib 目錄包含必要 main.dart 檔案、列出藝術資產和片段著色器路徑的 assets.dart 檔案,以及列出您要使用的 TextStyles 和 Color 的 styles.dart 檔案。
  • lib 目錄也包含 common 目錄,其中包含您將在這個程式碼研究室中使用的一些實用公用程式,以及 orb_shader 目錄,其中包含用於透過頂點著色器顯示 Orb 的 Widget

啟動應用程式後,畫面會如下所示。

以「Insert Next-Generation UI Here...」的程式碼研究室應用程式執行程式碼

3. 繪製場景

在這個步驟中,您將所有背景藝術素材資源一層疊放在螢幕上。預期一開始看起來有奇怪的單色,但是在這個步驟最後,您為場景新增了幾個顏色。

在場景中新增素材資源

  1. 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),
         
],
       
),
     
),
   
);
 
}
}

這個小工具包含資產堆疊在圖層中的場景。背景、中景和前景圖層分別由兩或三張圖片組成。這些影像會以不同顏色照亮,捕捉光線在場景中移動的情形。

  1. main.dart 檔案中,新增以下內容:

lib/main.dart

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
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 Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}                                                         // to here.

這個 _LitImage 公用程式小工具可根據其發出的光或接收情形,為每個藝術資產重新上色。由於您尚未使用這個新的小工具,因此可能會觸發 Linter 警告。

塗料 (顏色)

修改 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 Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}

我再次回到應用程式的例子,圖片素材資源呈綠色。

搭配藝術資產執行的程式碼研究室應用程式,已色調為綠色。

4. 新增 UI

在這個步驟中,您將使用者介面放置於上一步建立的場景。包括標題、難度選取工具按鈕,以及重要的「開始」按鈕。

新增標題

  1. 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),
     
],
   
);
 
}
}

這個小工具包含標題,以及構成此應用程式使用者介面的所有按鈕。

  1. 依照下列方式更新 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 Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}

執行這個程式碼就會看到標題,也就是使用者介面的開頭。

程式碼研究室應用程式執行,標題顯示「Outpost [57] Into the unknown」

新增難度按鈕

  1. 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';
  1. 請將以下內容新增至 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. 新增以下兩個小工具,以實作難度按鈕:

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. 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 Image.asset(
      imgSrc
,
      color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
      colorBlendMode
: BlendMode.modulate,
   
);
 
}
}

這是使用者介面的兩種不同難度設定。您會發現,在灰階圖片上套用難度的遮罩顏色可創造出逼真的反光效果!

選用一般難度的程式碼研究室應用程式,顯示已色調為紫色和青色的圖片素材資源。

已選取硬核難度的程式碼研究室應用程式,顯示已色調橘色的圖片素材資源。

新增「開始」按鈕

  1. 更新 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.
       
],
     
),
   
);
 
}
}
  1. 新增下列小工具以實作啟動按鈕:

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。運用這個套件提供的動畫,可在您熱重重新載入應用程式時自動重播,藉此加快開發疊代速度。

  1. 按照下列方式修改 lib/main.dart 中的程式碼:

lib/main.dart

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
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. 若要使用 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 {
  1. 編輯 _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.
   
);
 
}
}
  1. 按下「重新載入」,即可看到標題淡入。

淡入難度按鈕

  1. 編輯 _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),
     
],
   
);
 
}
}
  1. 按下「重新載入」,查看難度按鈕就會依序出現,還有稍微向上滑動。

在「開始」按鈕淡入

  1. 編輯 _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.
   
);
 
}
}
  1. 按下「重新載入」,查看難度按鈕就會依序出現,還有稍微向上滑動。

為難度懸停效果建立動畫

在難度按鈕上加入動畫請編輯 _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

為顏色變更加上動畫效果

  1. 背景色彩瞬間變化非常清晰。在不同色彩配置之間為淺色圖片加上動畫效果較佳。將 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. 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!);
         
},
       
);
     
},
   
);
 
}
}
  1. 使用您剛建立的小工具,更新 _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. 新增片段著色器

在這個步驟中,您將在應用程式中新增片段著色器。首先,使用著色器修改標題,讓標題看起來更加反烏托邦。接下來,請新增第二個著色器,建立做為頁面主要焦點的 Orb。

使用片段著色器扭曲標題

這項變更會導入 provider 套件,藉此將已編譯的著色器向下傳遞至小工具樹狀結構。如要瞭解著色器的載入方式,請參閱 lib/assets.dart 中的實作。

  1. 按照下列方式修改 lib/main.dart 中的程式碼:

lib/main.dart

import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;
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<FragmentPrograms?>(
      create
: (context) => loadFragmentPrograms(),
      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. 如要利用 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 {
  1. 請編輯 _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<FragmentPrograms?>(                  // Add from here...
      builder
: (context, fragmentPrograms, _) {
       
if (fragmentPrograms == null) return content;
       
return TickingBuilder(
          builder
: (context, time) {
           
return AnimatedSampler(
             
(image, size, canvas) {
               
const double overdrawPx = 30;
               
final shader = fragmentPrograms.ui.fragmentShader();
                shader
                 
..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 = shader);
             
},
              child
: content,
           
);
         
},
       
);
     
},
   
);                                                   // to here.
 
}
}

您應該會看到標題變形,就像是反烏托邦的預期一樣。

新增 Orb

接著在視窗中央新增 Orb。您必須在開始按鈕中加入 onPressed 回呼。

  1. 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 檔案。

  1. 按照下列方式修改匯入內容:

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. 修改 _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) => switch (difficulty) {
       
1 => 0.3,
       
2 => 0.6,
        _
=> 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. 按照下列方式修改 _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,
      builder
: (context, child) {
       
return Image.asset(
          imgSrc
,
          color
: hsl.withLightness(hsl.lightness * lightAmt).toColor(),
          colorBlendMode
: BlendMode.modulate,
       
);
     
},
   
);                                                    // to here.
 
}
}

這就是新增的成因。

7. 新增粒子動畫

在這個步驟中,您將新增粒子動畫,在應用程式中產生細微的閃爍動作。

隨處加入粒子

  1. 建立新的 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,
         
);
       
}
     
},
   
);
 
}
}
  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 {
  1. 修改 _TitleScreenStatebuild 方法,將 ParticleOverlay 新增至 UI,如下所示:

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);
         
},
       
),
     
),
   
),
 
);
}

最終結果包含在多個平台上呈現的動畫、片段著色器和粒子效果!

隨處可見粒子,即使是網路也無妨

程式碼存在一個輕微問題。在網頁上執行 Flutter 時,可使用兩種替代的轉譯引擎:CanvasKit 引擎 (電腦類別瀏覽器預設使用) 與 HTML DOM 轉譯器 (行動裝置預設使用)。問題在於 HTML DOM 轉譯器不支援片段著色器。

解決方法是只使用 CanvasKit 轉譯器建構網頁版。方法是在建構指令中新增標記,如下所示:

$ flutter build web --web-renderer canvaskit
Font asset "MaterialIcons-Regular.otf" was tree-shaken, reducing it from 1645184 to 7692 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag
when building your app.
Font asset "CupertinoIcons.ttf" was tree-shaken, reducing it from 257628 to 1172 bytes (99.5% reduction). Tree-shaking can be disabled by providing the --no-tree-shake-icons flag when
building your app.
Compiling lib/main.dart for the Web...                             15.6s
✓ Built build/web

以下是您的一切努力,現在會顯示在 Chrome 瀏覽器中。

8. 恭喜

您建立了功能完整的遊戲簡介畫面,內含動畫、片段著色器和粒子動畫!您現在可以在 Flutter 支援的所有平台上使用這些技術。

瞭解詳情