Xây dựng giao diện người dùng thế hệ mới trong Flutter

1. Trước khi bắt đầu

Flutter có thể giúp nhà phát triển nhanh chóng tạo giao diện người dùng mới bằng cách kết hợp giữa thao tác tải lại nóng và giao diện người dùng khai báo. Tuy nhiên, cũng có lúc bạn cần thêm tính tương tác bổ sung vào giao diện. Những thao tác nhấn này có thể đơn giản như tạo ảnh động cho một nút khi di chuột lên hoặc ấn tượng như một chương trình đổ bóng làm cong giao diện người dùng bằng sức mạnh của GPU.

Trong lớp học lập trình này, bạn sẽ xây dựng một ứng dụng Flutter sử dụng sức mạnh của ảnh động, chương trình đổ bóng và trường hạt để xây dựng một giao diện người dùng gợi lên những bộ phim khoa học viễn tưởng và chương trình truyền hình mà chúng ta đều thích xem khi không lập trình.

Sản phẩm bạn sẽ tạo ra

Bạn sẽ tạo trang trình đơn ban đầu cho một trò chơi theo chủ đề khoa học viễn tưởng hậu tận thế. Có một tiêu đề chứa chương trình đổ bóng mảnh lấy mẫu văn bản để tạo hiệu ứng trực quan cho văn bản đó, trình đơn độ khó thay đổi chủ đề màu sắc của trang với nhiều ảnh động và một quả cầu động được vẽ bằng chương trình đổ bóng mảnh thứ hai. Nếu như vậy vẫn chưa đủ, vào cuối lớp học lập trình này, bạn sẽ thêm hiệu ứng hạt tinh tế để thu hút sự chuyển động và thu hút sự quan tâm cho trang.

Các ảnh chụp màn hình sau đây cho thấy ứng dụng bạn sẽ xây dựng trên 3 hệ điều hành dành cho máy tính được hỗ trợ: Windows, Linux và macOS. Để đảm bảo tính hoàn chỉnh, trang web sẽ cung cấp phiên bản trình duyệt web (cũng được hỗ trợ). Ảnh động và chương trình đổ bóng mảnh ở mọi nơi!

Ứng dụng đã hoàn thiện chạy trên Windows

Ứng dụng đã hoàn thiện đang chạy trong trình duyệt Chrome

Ứng dụng đã hoàn thiện chạy trên Linux

Ứng dụng đã hoàn thiện chạy trên macOS

Điều kiện tiên quyết

Kiến thức bạn sẽ học được

Bạn cần có

2. Bắt đầu

Tải đoạn mã khởi đầu xuống

  1. Chuyển đến kho lưu trữ GitHub.
  2. Nhấp vào Mã > Tải tệp zip xuống để tải tất cả mã nguồn cho lớp học lập trình này.
  3. Giải nén tệp zip đã tải xuống để giải nén thư mục gốc codelabs-main. Bạn chỉ cần thư mục con next-gen-ui/, trong đó chứa các thư mục từ step_01 đến step_06. Các thư mục này chứa mã nguồn mà bạn sẽ xây dựng cho mỗi bước trong lớp học lập trình này.

Tải các phần phụ thuộc của dự án xuống

  1. Trong VS Code, hãy nhấp vào File > (Tệp >) Mở thư mục > lớp học lập trình-chính > tiếp-gen-uis > bước_01 để mở dự án khởi đầu.
  2. Nếu bạn thấy hộp thoại VS Code nhắc bạn tải các gói cần thiết xuống cho ứng dụng khởi đầu, hãy nhấp vào Get package (Tải gói).

Hộp thoại VS Code nhắc bạn tải các gói cần thiết xuống cho ứng dụng khởi đầu.

  1. Nếu bạn không thấy hộp thoại VS Code nhắc bạn tải các gói cần thiết xuống cho ứng dụng khởi đầu, hãy mở cửa sổ dòng lệnh, sau đó chuyển đến thư mục step_01 rồi chạy lệnh flutter pub get.

Chạy ứng dụng khởi đầu

  1. Trong VS Code, hãy chọn hệ điều hành máy tính bạn đang chạy hoặc Chrome nếu bạn muốn thử nghiệm ứng dụng của mình trong một trình duyệt web.

Ví dụ: sau đây là những nội dung bạn sẽ thấy khi sử dụng macOS làm mục tiêu triển khai:

Trang trí thanh trạng thái VSCode cho thấy mục tiêu Flutter là macOS (darwin)

Dưới đây là những gì bạn sẽ thấy khi sử dụng Chrome làm mục tiêu triển khai:

Trang trí thanh trạng thái VSCode cho biết mục tiêu Flutter là Chrome (web-javascript)

  1. Mở tệp lib/main.dart rồi nhấp vào Nút Phát qua VSCode Bắt đầu gỡ lỗi. Ứng dụng sẽ chạy trên hệ điều hành của máy tính hoặc trong trình duyệt Chrome.

Khám phá ứng dụng khởi đầu

Trong ứng dụng khởi đầu, hãy lưu ý những nội dung sau:

  • Giao diện người dùng đã sẵn sàng để bạn tạo.
  • Thư mục assets có tài sản hình ảnh và 2 chương trình đổ bóng mảnh mà bạn sẽ sử dụng.
  • Tệp pubspec.yaml đã liệt kê các tài sản và tập hợp các gói nhà xuất bản mà bạn sẽ sử dụng.
  • Thư mục lib chứa tệp main.dart bắt buộc, một tệp assets.dart liệt kê đường dẫn của thành phần hình minh hoạ và chương trình đổ bóng mảnh, cũng như tệp styles.dart liệt kê TextStyles và Color (Màu sắc) mà bạn sẽ sử dụng.
  • Thư mục lib cũng chứa một thư mục common chứa một số tiện ích hữu ích mà bạn sẽ sử dụng trong lớp học lập trình này, và thư mục orb_shader chứa Widget được dùng để hiển thị quả cầu với chương trình đổ bóng đỉnh.

Dưới đây là nội dung bạn sẽ thấy sau khi khởi động ứng dụng.

Ứng dụng trong lớp học lập trình đang chạy có tiêu đề "Insert Next-Generation UI Here..." (Chèn giao diện người dùng thế hệ tiếp theo tại đây...)

3. Tô điểm cho cảnh

Ở bước này, bạn sẽ đặt tất cả thành phần hình nền theo lớp trên màn hình. Bạn có thể cho rằng ảnh đơn sắc lúc đầu sẽ trông có vẻ đơn sắc một cách kỳ lạ, nhưng bạn sẽ thêm màu sắc cho cảnh ở cuối bước này.

Thêm thành phần vào cảnh

  1. Tạo thư mục title_screen trong thư mục lib, sau đó thêm một tệp title_screen.dart. Thêm nội dung sau đây vào tệp:

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

Tiện ích này chứa cảnh có các thành phần xếp chồng lên nhau. Mỗi lớp nền, lớp giữa và lớp nền trước được thể hiện bằng một nhóm gồm hai hoặc ba hình ảnh. Những hình ảnh này sẽ được chiếu sáng bằng các màu khác nhau để ghi lại cách ánh sáng di chuyển qua cảnh.

  1. Trong tệp main.dart, hãy thêm nội dung sau:

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

Thao tác này sẽ thay thế giao diện người dùng của ứng dụng bằng cảnh đơn sắc mà thành phần hình minh hoạ tạo ra. Tiếp theo, bạn tô màu từng lớp.

Ứng dụng của lớp học lập trình này chỉ chạy đơn sắc với các thành phần nghệ thuật ở nền trước, nền sau và nền trước.

Thêm một tiện ích tô màu hình ảnh

Thêm một tiện ích tô màu hình ảnh bằng cách thêm nội dung sau vào tệp 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.

Tiện ích tiện ích _LitImage này đổi màu của từng tài sản hình minh hoạ, tuỳ thuộc vào việc chúng đang phát hay nhận ánh sáng. Việc này có thể kích hoạt cảnh báo linter vì bạn chưa sử dụng tiện ích mới này.

Tạo màu

Tô màu bằng cách sửa đổi tệp title_screen.dart như sau:

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

Đây là ứng dụng một lần nữa, lần này với tài sản hình ảnh được tô màu xanh lục.

Ứng dụng của lớp học lập trình chạy với các tài sản nghệ thuật, được phủ màu xanh lục.

4. Thêm giao diện người dùng

Ở bước này, bạn đặt giao diện người dùng lên cảnh đã tạo ở bước trước. Bao gồm tiêu đề, các nút chọn độ khó và nút Bắt đầu vô cùng quan trọng.

Thêm tiêu đề

  1. Tạo một tệp title_screen_ui.dart bên trong thư mục lib/title_screen rồi thêm nội dung sau vào tệp đó:

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

Tiện ích này chứa tiêu đề và tất cả các nút tạo nên giao diện người dùng cho ứng dụng này.

  1. Cập nhật tệp lib/title_screen/title_screen.dart như sau:

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

Khi chạy mã này, bạn sẽ thấy tiêu đề (tức là phần đầu của giao diện người dùng).

Ứng dụng của lớp học lập trình chạy với tiêu đề là "Outpost [57] Into the Unknown"

Thêm nút độ khó

  1. Cập nhật title_screen_ui.dart bằng cách thêm dữ liệu nhập mới cho gói focusable_control_builder:

lib/title_screen/title_screen_ui.dart

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

import '../assets.dart';
import '../common/ui_scaler.dart';
import '../styles.dart';
  1. Đối với tiện ích TitleScreenUi, hãy thêm đoạn mã sau:

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. Thêm hai tiện ích sau để triển khai nút độ khó:

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. Chuyển đổi tiện ích TitleScreen từ không có trạng thái sang có trạng thái và thêm trạng thái để cho phép thay đổi bảng phối màu dựa trên độ khó:

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

Dưới đây là giao diện người dùng ở hai chế độ cài đặt độ khó khác nhau. Lưu ý rằng độ khó được áp dụng làm mặt nạ cho hình ảnh thang màu xám sẽ tạo ra hiệu ứng phản chiếu chân thực!

Ứng dụng của lớp học lập trình với độ khó được chọn thông thường, cho thấy các thành phần hình ảnh được phủ màu tím và xanh lục.

Ứng dụng của lớp học lập trình với độ khó được chọn, cho thấy các thành phần hình ảnh được phủ màu cam rực lửa.

Thêm nút bắt đầu

  1. Cập nhật tệp title_screen_ui.dart. Đối với tiện ích TitleScreenUi, hãy thêm đoạn mã sau:

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. Thêm tiện ích sau để triển khai nút bắt đầu:

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

Và đây là ứng dụng đang chạy với một tập hợp đầy đủ các nút.

Ứng dụng của lớp học lập trình với độ khó thông thường được chọn, cho thấy tiêu đề, nút độ khó và nút bắt đầu.

5. Thêm hoạt ảnh

Ở bước này, bạn tạo ảnh động cho giao diện người dùng và hiệu ứng chuyển đổi màu cho tài sản hình minh hoạ.

Mờ dần trong tiêu đề

Trong bước này, bạn sử dụng nhiều phương pháp để tạo ảnh động cho một ứng dụng Flutter. Một trong những phương pháp đó là sử dụng flutter_animate. Ảnh động do gói này hỗ trợ có thể tự động phát lại mỗi khi bạn tải lại ứng dụng để tăng tốc độ lặp lại quá trình phát triển.

  1. Sửa đổi mã trong lib/main.dart như sau:

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. Để tận dụng gói flutter_animate, bạn phải nhập gói này. Thêm dữ liệu đã nhập vào lib/title_screen/title_screen_ui.dart như sau:

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. Thêm ảnh động vào tiêu đề bằng cách chỉnh sửa tiện ích _TitleText như sau:

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. Nhấn Tải lại để xem tiêu đề hiện dần.

Nút độ khó mờ dần

  1. Thêm ảnh động vào giao diện ban đầu của các nút khó bằng cách chỉnh sửa tiện ích _DifficultyBtns như sau:

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. Nhấn Tải lại để xem các nút độ khó xuất hiện theo thứ tự rồi trượt lên nhẹ nhàng như một phần thưởng.

Nút bắt đầu mờ dần

  1. Thêm ảnh động vào nút bắt đầu bằng cách chỉnh sửa lớp trạng thái _StartBtnState như sau:

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. Nhấn Tải lại để xem các nút độ khó xuất hiện theo thứ tự rồi trượt lên nhẹ nhàng như một phần thưởng.

Tạo ảnh động cho hiệu ứng di chuột khó

Thêm ảnh động vào các nút độ khó trạng thái di chuột bằng cách chỉnh sửa lớp trạng thái _DifficultyBtn như sau:

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

Giờ đây, các nút độ khó sẽ hiển thị BoxDecoration khi chuột di chuột qua một nút chưa được chọn.

Tạo ảnh động cho phần thay đổi màu

  1. Sự thay đổi màu nền diễn ra tức thì và mạnh. Tốt hơn là bạn nên tạo hiệu ứng ảnh động cho các hình ảnh được chiếu sáng giữa các bảng phối màu. Thêm flutter_animate vào 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. Thêm tiện ích _AnimatedColors trong lib/title_screen/title_screen.dart:

lib/title_screen/title_screen.dart

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

  final Color emitColor;
  final Color orbColor;

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

  @override
  Widget build(BuildContext context) {
    final duration = .5.seconds;
    return TweenAnimationBuilder(
      tween: ColorTween(begin: emitColor, end: emitColor),
      duration: duration,
      builder: (_, emitColor, __) {
        return TweenAnimationBuilder(
          tween: ColorTween(begin: orbColor, end: orbColor),
          duration: duration,
          builder: (context, orbColor, __) {
            return builder(context, orbColor!, emitColor!);
          },
        );
      },
    );
  }
}
  1. Sử dụng tiện ích bạn vừa tạo để tạo hiệu ứng ảnh động cho màu sắc của hình ảnh được chiếu sáng bằng cách cập nhật phương thức build trong _TitleScreenState như sau:

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

Với lần chỉnh sửa cuối cùng này, bạn đã thêm ảnh động vào mọi thành phần trên màn hình và giao diện trông đẹp hơn rất nhiều!

6. Thêm chương trình đổ bóng mảnh

Ở bước này, bạn thêm chương trình đổ bóng mảnh vào ứng dụng. Đầu tiên, bạn sử dụng chương trình đổ bóng để sửa đổi tiêu đề nhằm mang lại cảm giác đen tối hơn. Sau đó, bạn thêm chương trình đổ bóng thứ hai để tạo một quả cầu đóng vai trò là tâm điểm của trang.

Làm méo tiêu đề bằng chương trình đổ bóng mảnh

Với thay đổi này, bạn sẽ giới thiệu gói provider. Gói này cho phép truyền chương trình đổ bóng đã biên dịch xuống cây tiện ích. Nếu bạn quan tâm đến cách tải chương trình đổ bóng, hãy xem cách triển khai trong lib/assets.dart.

  1. Sửa đổi mã trong lib/main.dart như sau:

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. Để tận dụng gói provider và các tiện ích chương trình đổ bóng có trong step_01, bạn cần nhập các tiện ích đó. Thêm các lệnh nhập mới vào lib/title_screen/title_screen_ui.dart như sau:

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. Làm méo tiêu đề bằng chương trình đổ bóng bằng cách chỉnh sửa tiện ích _TitleText như sau:

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.
  }
}

Bạn sẽ thấy tiêu đề bị biến dạng như bạn có thể mong đợi trong một tương lai đen tối.

Thêm quả cầu

Giờ thì hãy thêm quả cầu ở giữa cửa sổ. Bạn cần thêm một lệnh gọi lại onPressed vào nút bắt đầu.

  1. Trong lib/title_screen/title_screen_ui.dart, hãy sửa đổi TitleScreenUi như sau:

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

Bây giờ, bạn đã sửa đổi nút bắt đầu bằng lệnh gọi lại, bạn cần thực hiện sửa đổi hàng loạt đối với tệp lib/title_screen/title_screen.dart.

  1. Sửa đổi dữ liệu nhập như sau:

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. Sửa đổi _TitleScreenState để phù hợp với mục sau. Hầu như mọi phần của lớp đều được sửa đổi theo một cách nào đó.

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. Sửa đổi _LitImage như sau:

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.
  }
}

Đây là kết quả của việc bổ sung này.

7. Thêm ảnh động phần tử

Ở bước này, bạn thêm ảnh động hạt để tạo chuyển động phát ra ánh sáng tinh tế cho ứng dụng.

Thêm các phần tử ở mọi nơi

  1. Tạo tệp lib/title_screen/particle_overlay.dart mới rồi thêm đoạn mã sau:

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. Sửa đổi lệnh nhập cho lib/title_screen/title_screen.dart như sau:

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. Thêm ParticleOverlay vào giao diện người dùng bằng cách sửa đổi phương thức build của _TitleScreenState như sau:

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

Kết quả cuối cùng bao gồm ảnh động, chương trình đổ bóng mảnh và hiệu ứng phần tử trên nhiều nền tảng!

Thêm các phần tử ở mọi nơi – ngay cả trên web

Có một vấn đề nhỏ về mã đang chờ xử lý. Khi Flutter chạy trên web, bạn có thể sử dụng hai công cụ kết xuất thay thế: công cụ CanvasKit được dùng theo mặc định trên các trình duyệt dành cho máy tính và trình kết xuất HTML DOM được sử dụng theo mặc định cho thiết bị di động. Vấn đề là trình kết xuất HTML DOM không hỗ trợ chương trình đổ bóng mảnh.

Cách khắc phục là xây dựng cho web bằng cách chỉ sử dụng trình kết xuất CanvasKit. Để thực hiện việc này, hãy thêm một cờ vào lệnh tạo như sau:

$ 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

Đây là tất cả nỗ lực của bạn, được thể hiện lần này trong trình duyệt Chrome.

8. Xin chúc mừng

Bạn đã xây dựng một màn hình giới thiệu trò chơi đầy đủ tính năng với ảnh động, chương trình đổ bóng mảnh và ảnh động phần tử! Giờ đây, bạn có thể sử dụng các kỹ thuật này trên tất cả các nền tảng mà Flutter hỗ trợ.

Tìm hiểu thêm