1. ก่อนเริ่มต้น
Flutter คือความสามารถที่ยอดเยี่ยมในการช่วยให้นักพัฒนาซอฟต์แวร์สร้างอินเทอร์เฟซผู้ใช้ใหม่ได้อย่างรวดเร็วซ้ำๆ โดยอาศัยการโหลดซ้ำและ UI แบบประกาศสถานะ แต่ก็มีบางครั้งที่คุณจำเป็นต้องเพิ่มการโต้ตอบเพิ่มเติมลงในอินเทอร์เฟซ การแตะเหล่านี้สามารถทำได้ง่าย เพียงแค่ทำให้ปุ่มเคลื่อนไหวเมื่อวางเมาส์เหนือปุ่ม หรือมีความตื่นตาตื่นใจเหมือนเป็นแถบเฉดสีที่บิดเบี้ยวอินเทอร์เฟซผู้ใช้ด้วยพลังของ GPU
ใน Codelab นี้ คุณจะได้สร้างแอป Flutter ที่ใช้พลังของภาพเคลื่อนไหว เครื่องมือให้เฉดสี และฟิลด์อนุภาคเพื่อสร้างอินเทอร์เฟซผู้ใช้ที่จะกระตุ้นให้มีภาพยนตร์และรายการทีวีแนวนิยายวิทยาศาสตร์ที่พวกเราทุกคนชอบดูตอนไม่ได้เขียนโค้ด
สิ่งที่คุณจะสร้าง
คุณจะได้สร้างหน้าเมนูเริ่มต้นสำหรับเกมธีมไซไฟหลังวันสิ้นโลก มีชื่อที่มีตัวใส่เฉดสี Fragment ที่แสดงตัวอย่างข้อความเพื่อทำให้เป็นภาพเคลื่อนไหว, เมนูระดับความยากที่เปลี่ยนธีมสีของหน้าด้วยการแสดงภาพเคลื่อนไหวจำนวนมาก และลูกแก้วที่เคลื่อนไหวได้ซึ่งแต่งด้วย Fragment Frame หากแค่นั้นยังไม่พอ ที่ส่วนท้ายของ Codelab จะใส่เอฟเฟกต์อนุภาคเล็กๆ เพื่อเพิ่มการเคลื่อนไหวและความน่าสนใจให้กับหน้าเว็บ
ภาพหน้าจอต่อไปนี้แสดงแอปที่คุณจะสร้างบนระบบปฏิบัติการเดสก์ท็อป 3 ระบบที่รองรับ ได้แก่ Windows, Linux และ macOS เพื่อความสมบูรณ์ ระบบจะจัดเตรียมเวอร์ชันของเว็บเบราว์เซอร์ (รองรับด้วย) ให้ด้วย ภาพเคลื่อนไหวและเครื่องมือให้เฉดสี Fragment ทุกที่
ข้อกำหนดเบื้องต้น
- ความรู้พื้นฐานเกี่ยวกับการพัฒนา Flutter ด้วย Dart ตามที่อธิบายไว้ใน Codelab ของแอป Flutter แรกของคุณ
สิ่งที่คุณจะได้เรียนรู้
- วิธีใช้
flutter_animate
เพื่อสร้างภาพเคลื่อนไหวที่สื่ออารมณ์ชัดเจน - วิธีใช้การรองรับเครื่องมือใส่เฉดสีส่วนย่อยของ Flutter ในเดสก์ท็อปและเว็บ
- วิธีเพิ่มภาพเคลื่อนไหวของอนุภาคลงในแอปด้วย
particle_field
สิ่งที่ต้องมี
- Flutter SDK
- การตั้งค่า VS Code สำหรับ Flutter และ Dart
- การตั้งค่าการรองรับเดสก์ท็อปสำหรับ Flutter สำหรับ Windows, Linux หรือ macOS
- การตั้งค่าการสนับสนุนผ่านเว็บสำหรับ Flutter
2. เริ่มต้นใช้งาน
ดาวน์โหลดโค้ดเริ่มต้น
- ไปที่ที่เก็บ GitHub นี้
- คลิกโค้ด > ดาวน์โหลดรหัสไปรษณีย์เพื่อดาวน์โหลดรหัสทั้งหมดสำหรับ Codelab นี้
- แตกไฟล์ ZIP ที่ดาวน์โหลดเพื่อแตกโฟลเดอร์รูท
codelabs-main
คุณต้องใช้เพียงไดเรกทอรีย่อยnext-gen-ui/
ซึ่งมีโฟลเดอร์step_01
ถึงstep_06
ซึ่งมีซอร์สโค้ดที่คุณสร้างขึ้นสำหรับแต่ละขั้นตอนใน Codelab นี้
ดาวน์โหลดทรัพยากร Dependency ของโปรเจ็กต์
- ใน VS Code ให้คลิก File > เปิดโฟลเดอร์ > Codelabs-main > UI รุ่นถัดไป > Step_01 เพื่อเปิดโปรเจ็กต์เริ่มต้น
- หากคุณเห็นกล่องโต้ตอบ VS Code ที่แจ้งให้ดาวน์โหลดแพ็กเกจที่จำเป็นสำหรับแอปเริ่มต้น ให้คลิกรับแพ็กเกจ
- หากไม่เห็นกล่องโต้ตอบ "VS Code" ที่แจ้งให้ดาวน์โหลดแพ็กเกจที่จำเป็นสำหรับแอปเริ่มต้น ให้เปิดเทอร์มินัลแล้วไปที่โฟลเดอร์
step_01
และเรียกใช้คำสั่งflutter pub get
เรียกใช้แอปเริ่มต้น
- ใน VS Code ให้เลือกระบบปฏิบัติการเดสก์ท็อปที่กำลังใช้อยู่หรือ Chrome หากต้องการทดสอบแอปในเว็บเบราว์เซอร์
ต่อไปนี้คือสิ่งที่คุณจะเห็นเมื่อใช้ macOS เป็นเป้าหมายในการทำให้ใช้งานได้
ต่อไปนี้คือสิ่งที่คุณจะเห็นเมื่อใช้ Chrome เป็นเป้าหมายในการทำให้ใช้งานได้
- เปิดไฟล์
lib/main.dart
แล้วคลิกเริ่มแก้ไขข้อบกพร่อง แอปจะเปิดขึ้นในระบบปฏิบัติการบนเดสก์ท็อปหรือในเบราว์เซอร์ Chrome
สำรวจแอปเริ่มต้น
ในแอปเริ่มต้น ให้สังเกตสิ่งต่อไปนี้
- UI พร้อมให้คุณสร้างแล้ว
- ไดเรกทอรี
assets
มีเนื้อหาศิลปะและเครื่องมือปรับแสงเงา Fragment 2 รายการที่คุณจะใช้ - ไฟล์
pubspec.yaml
แสดงรายการเนื้อหาและคอลเล็กชันแพ็กเกจผู้เผยแพร่โฆษณาที่จะใช้อยู่แล้ว - ไดเรกทอรี
lib
มีไฟล์main.dart
ที่บังคับใช้ ไฟล์assets.dart
ที่แสดงเส้นทางของเนื้อหาศิลปะและตัวปรับแสงเงา Fragment และไฟล์styles.dart
ที่แสดง TextStyles และ Colors ที่จะใช้ - ไดเรกทอรี
lib
ยังมีไดเรกทอรีcommon
ซึ่งมียูทิลิตีที่มีประโยชน์จำนวนหนึ่งที่คุณจะใช้ใน Codelab นี้ และไดเรกทอรีorb_shader
ซึ่งมีWidget
ที่จะใช้เพื่อแสดงลูกโลกที่มีตัวปรับแสงเงา Vertex
นี่คือสิ่งที่คุณจะเห็นเมื่อเริ่มแอป
3. ทาสีฉาก
ในขั้นตอนนี้ คุณจะได้วางเนื้อหาภาพพื้นหลังทั้งหมดบนหน้าจอเป็นเลเยอร์ ให้คาดว่าภาพจะมีลักษณะเป็นโมโนโครมแปลกๆ ในตอนแรก แต่คุณจะเพิ่มสีลงในฉากในตอนท้ายของขั้นตอนนี้
เพิ่มเนื้อหาลงในฉาก
- สร้างไดเรกทอรี
title_screen
ในไดเรกทอรีlib
แล้วเพิ่มไฟล์title_screen.dart
เพิ่มเนื้อหาต่อไปนี้ลงในไฟล์
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart'; import '../assets.dart'; class TitleScreen extends StatelessWidget { const TitleScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Center( child: Stack( children: [ /// Bg-Base Image.asset(AssetPaths.titleBgBase), /// Bg-Receive Image.asset(AssetPaths.titleBgReceive), /// Mg-Base Image.asset(AssetPaths.titleMgBase), /// Mg-Receive Image.asset(AssetPaths.titleMgReceive), /// Mg-Emit Image.asset(AssetPaths.titleMgEmit), /// Fg-Rocks Image.asset(AssetPaths.titleFgBase), /// Fg-Receive Image.asset(AssetPaths.titleFgReceive), /// Fg-Emit Image.asset(AssetPaths.titleFgEmit), ], ), ), ); } }
วิดเจ็ตนี้มีฉากที่มีเนื้อหากองซ้อนกันเป็นเลเยอร์ เลเยอร์พื้นหลัง เลเยอร์กลาง และเลเยอร์พื้นหน้าจะแสดงด้วยกลุ่มภาพ 2 หรือ 3 ภาพ รูปภาพเหล่านี้จะสว่างด้วยสีต่างๆ เพื่อจับภาพการเคลื่อนที่ของแสงผ่านฉาก
- เพิ่มเนื้อหาต่อไปนี้ในไฟล์
main.dart
lib/main.dart
import 'dart:io' 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
นี้จะเปลี่ยนสีชิ้นงานศิลปะแต่ละรายการ โดยขึ้นอยู่กับว่ามีการปล่อยหรือรับแสง ปัญหานี้อาจทริกเกอร์คำเตือนโปรแกรมวิเคราะห์โค้ดเนื่องจากคุณยังไม่ได้ใช้วิดเจ็ตใหม่นี้
ลงสี
ระบายสีโดยแก้ไขไฟล์ 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
ในขั้นตอนนี้ คุณจะวางอินเทอร์เฟซผู้ใช้บนฉากที่สร้างขึ้นในขั้นตอนก่อนหน้า ซึ่งรวมถึงชื่อ ปุ่มตัวเลือกความยาก และปุ่มเริ่มต้นที่สำคัญทั้งหมด
เพิ่มชื่อ
- สร้างไฟล์
title_screen_ui.dart
ภายในไดเรกทอรีlib/title_screen
และเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import '../assets.dart'; import '../common/ui_scaler.dart'; import '../styles.dart'; class TitleScreenUi extends StatelessWidget { const TitleScreenUi({ super.key, }); @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.symmetric(vertical: 40, horizontal: 50), child: Stack( children: [ /// Title Text TopLeft( child: UiScaler( alignment: Alignment.topLeft, child: _TitleText(), ), ), ], ), ); } } class _TitleText extends StatelessWidget { const _TitleText(); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Gap(20), Row( mainAxisSize: MainAxisSize.min, children: [ Transform.translate( offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0), child: Text('OUTPOST', style: TextStyles.h1), ), Image.asset(AssetPaths.titleSelectedLeft, height: 65), Text('57', style: TextStyles.h2), Image.asset(AssetPaths.titleSelectedRight, height: 65), ], ), Text('INTO THE UNKNOWN', style: TextStyles.h3), ], ); } }
วิดเจ็ตนี้มีชื่อและปุ่มทั้งหมดที่ประกอบขึ้นเป็นอินเทอร์เฟซผู้ใช้สำหรับแอปนี้
- อัปเดตไฟล์
lib/title_screen/title_screen.dart
ดังนี้
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart'; import '../assets.dart'; import '../styles.dart'; import 'title_screen_ui.dart'; // Add this import class TitleScreen extends StatelessWidget { const TitleScreen({super.key}); final _finalReceiveLightAmt = 0.7; final _finalEmitLightAmt = 0.5; @override Widget build(BuildContext context) { final orbColor = AppColors.orbColors[0]; final emitColor = AppColors.emitColors[0]; return Scaffold( backgroundColor: Colors.black, body: Center( child: Stack( children: [ /// Bg-Base Image.asset(AssetPaths.titleBgBase), /// Bg-Receive _LitImage( color: orbColor, imgSrc: AssetPaths.titleBgReceive, lightAmt: _finalReceiveLightAmt, ), /// Mg-Base _LitImage( imgSrc: AssetPaths.titleMgBase, color: orbColor, lightAmt: _finalReceiveLightAmt, ), /// Mg-Receive _LitImage( imgSrc: AssetPaths.titleMgReceive, color: orbColor, lightAmt: _finalReceiveLightAmt, ), /// Mg-Emit _LitImage( imgSrc: AssetPaths.titleMgEmit, color: emitColor, lightAmt: _finalEmitLightAmt, ), /// Fg-Rocks Image.asset(AssetPaths.titleFgBase), /// Fg-Receive _LitImage( imgSrc: AssetPaths.titleFgReceive, color: orbColor, lightAmt: _finalReceiveLightAmt, ), /// Fg-Emit _LitImage( imgSrc: AssetPaths.titleFgEmit, color: emitColor, lightAmt: _finalEmitLightAmt, ), /// UI const Positioned.fill( // Add from here... child: TitleScreenUi(), ), // to here. ], ), ), ); } } class _LitImage extends StatelessWidget { const _LitImage({ required this.color, required this.imgSrc, required this.lightAmt, }); final Color color; final String imgSrc; final double lightAmt; @override Widget build(BuildContext context) { final hsl = HSLColor.fromColor(color); return Image.asset( imgSrc, color: hsl.withLightness(hsl.lightness * lightAmt).toColor(), colorBlendMode: BlendMode.modulate, ); } }
การเรียกใช้โค้ดนี้จะแสดงชื่อ ซึ่งเป็นจุดเริ่มต้นของอินเทอร์เฟซผู้ใช้
เพิ่มปุ่มระดับความยาก
- อัปเดต
title_screen_ui.dart
ด้วยการเพิ่มการนำเข้าใหม่สำหรับแพ็กเกจ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';
- เพิ่มข้อมูลต่อไปนี้ลงในวิดเจ็ต
TitleScreenUi
lib/title_screen/title_screen_ui.dart
class TitleScreenUi extends StatelessWidget { const TitleScreenUi({ super.key, required this.difficulty, // Edit from here... required this.onDifficultyPressed, required this.onDifficultyFocused, }); final int difficulty; final void Function(int difficulty) onDifficultyPressed; final void Function(int? difficulty) onDifficultyFocused; // to here. @override Widget build(BuildContext context) { return Padding( // Move this const... padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50), // to here. child: Stack( children: [ /// Title Text const TopLeft( // Add a const here, as well child: UiScaler( alignment: Alignment.topLeft, child: _TitleText(), ), ), /// Difficulty Btns BottomLeft( // Add from here... child: UiScaler( alignment: Alignment.bottomLeft, child: _DifficultyBtns( difficulty: difficulty, onDifficultyPressed: onDifficultyPressed, onDifficultyFocused: onDifficultyFocused, ), ), ), // to here. ], ), ); } }
- เพิ่มวิดเจ็ต 2 รายการต่อไปนี้เพื่อใช้ปุ่มระดับความยาก:
lib/title_screen/title_screen_ui.dart
class _DifficultyBtns extends StatelessWidget { const _DifficultyBtns({ required this.difficulty, required this.onDifficultyPressed, required this.onDifficultyFocused, }); final int difficulty; final void Function(int difficulty) onDifficultyPressed; final void Function(int? difficulty) onDifficultyFocused; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ _DifficultyBtn( label: 'Casual', selected: difficulty == 0, onPressed: () => onDifficultyPressed(0), onHover: (over) => onDifficultyFocused(over ? 0 : null), ), _DifficultyBtn( label: 'Normal', selected: difficulty == 1, onPressed: () => onDifficultyPressed(1), onHover: (over) => onDifficultyFocused(over ? 1 : null), ), _DifficultyBtn( label: 'Hardcore', selected: difficulty == 2, onPressed: () => onDifficultyPressed(2), onHover: (over) => onDifficultyFocused(over ? 2 : null), ), const Gap(20), ], ); } } class _DifficultyBtn extends StatelessWidget { const _DifficultyBtn({ required this.selected, required this.onPressed, required this.onHover, required this.label, }); final String label; final bool selected; final VoidCallback onPressed; final void Function(bool hasFocus) onHover; @override Widget build(BuildContext context) { return FocusableControlBuilder( onPressed: onPressed, onHoverChanged: (_, state) => onHover.call(state.isHovered), builder: (_, state) { return Padding( padding: const EdgeInsets.all(8.0), child: SizedBox( width: 250, height: 60, child: Stack( children: [ /// Bg with fill and outline Container( decoration: BoxDecoration( color: const Color(0xFF00D1FF).withOpacity(.1), border: Border.all(color: Colors.white, width: 5), ), ), if (state.isHovered || state.isFocused) ...[ Container( decoration: BoxDecoration( color: const Color(0xFF00D1FF).withOpacity(.1), ), ), ], /// cross-hairs (selected state) if (selected) ...[ CenterLeft( child: Image.asset(AssetPaths.titleSelectedLeft), ), CenterRight( child: Image.asset(AssetPaths.titleSelectedRight), ), ], /// Label Center( child: Text(label.toUpperCase(), style: TextStyles.btn), ), ], ), ), ); }, ); } }
- แปลงวิดเจ็ต
TitleScreen
จาก "ไม่เก็บสถานะ" เป็นเก็บสถานะ และเพิ่มสถานะเพื่อเปิดใช้การเปลี่ยนรูปแบบสีตามความยาก:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart'; import '../assets.dart'; import '../styles.dart'; import 'title_screen_ui.dart'; class TitleScreen extends StatefulWidget { const TitleScreen({super.key}); @override State<TitleScreen> createState() => _TitleScreenState(); } class _TitleScreenState extends State<TitleScreen> { Color get _emitColor => AppColors.emitColors[_difficultyOverride ?? _difficulty]; Color get _orbColor => AppColors.orbColors[_difficultyOverride ?? _difficulty]; /// Currently selected difficulty int _difficulty = 0; /// Currently focused difficulty (if any) int? _difficultyOverride; void _handleDifficultyPressed(int value) { setState(() => _difficulty = value); } void _handleDifficultyFocused(int? value) { setState(() => _difficultyOverride = value); } final _finalReceiveLightAmt = 0.7; final _finalEmitLightAmt = 0.5; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Center( child: Stack( children: [ /// Bg-Base Image.asset(AssetPaths.titleBgBase), /// Bg-Receive _LitImage( color: _orbColor, imgSrc: AssetPaths.titleBgReceive, lightAmt: _finalReceiveLightAmt, ), /// Mg-Base _LitImage( imgSrc: AssetPaths.titleMgBase, color: _orbColor, lightAmt: _finalReceiveLightAmt, ), /// Mg-Receive _LitImage( imgSrc: AssetPaths.titleMgReceive, color: _orbColor, lightAmt: _finalReceiveLightAmt, ), /// Mg-Emit _LitImage( imgSrc: AssetPaths.titleMgEmit, color: _emitColor, lightAmt: _finalEmitLightAmt, ), /// Fg-Rocks Image.asset(AssetPaths.titleFgBase), /// Fg-Receive _LitImage( imgSrc: AssetPaths.titleFgReceive, color: _orbColor, lightAmt: _finalReceiveLightAmt, ), /// Fg-Emit _LitImage( imgSrc: AssetPaths.titleFgEmit, color: _emitColor, lightAmt: _finalEmitLightAmt, ), /// UI Positioned.fill( child: TitleScreenUi( difficulty: _difficulty, onDifficultyFocused: _handleDifficultyFocused, onDifficultyPressed: _handleDifficultyPressed, ), ), ], ), ), ); } } class _LitImage extends StatelessWidget { const _LitImage({ required this.color, required this.imgSrc, required this.lightAmt, }); final Color color; final String imgSrc; final double lightAmt; @override Widget build(BuildContext context) { final hsl = HSLColor.fromColor(color); return Image.asset( imgSrc, color: hsl.withLightness(hsl.lightness * lightAmt).toColor(), colorBlendMode: BlendMode.modulate, ); } }
ต่อไปนี้เป็น UI ในการตั้งค่าความยาก 2 แบบที่แตกต่างกัน โปรดสังเกตว่าสีระดับความยากที่ใช้เป็นมาสก์ให้กับรูปภาพโทนสีเทาจะสร้างเอฟเฟกต์ที่สมจริงและสะท้อนแสงได้
เพิ่มปุ่มเริ่มต้น
- อัปเดตไฟล์
title_screen_ui.dart
เพิ่มข้อมูลต่อไปนี้ลงในวิดเจ็ตTitleScreenUi
lib/title_screen/title_screen_ui.dart
class TitleScreenUi extends StatelessWidget { const TitleScreenUi({ super.key, required this.difficulty, required this.onDifficultyPressed, required this.onDifficultyFocused, }); final int difficulty; final void Function(int difficulty) onDifficultyPressed; final void Function(int? difficulty) onDifficultyFocused; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50), child: Stack( children: [ /// Title Text const TopLeft( child: UiScaler( alignment: Alignment.topLeft, child: _TitleText(), ), ), /// Difficulty Btns BottomLeft( child: UiScaler( alignment: Alignment.bottomLeft, child: _DifficultyBtns( difficulty: difficulty, onDifficultyPressed: onDifficultyPressed, onDifficultyFocused: onDifficultyFocused, ), ), ), /// StartBtn BottomRight( // Add from here... child: UiScaler( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only(bottom: 20, right: 40), child: _StartBtn(onPressed: () {}), ), ), ), // to here. ], ), ); } }
- เพิ่มวิดเจ็ตต่อไปนี้เพื่อใช้ปุ่มเริ่มต้น
lib/title_screen/title_screen_ui.dart
class _StartBtn extends StatefulWidget { const _StartBtn({required this.onPressed}); final VoidCallback onPressed; @override State<_StartBtn> createState() => _StartBtnState(); } class _StartBtnState extends State<_StartBtn> { AnimationController? _btnAnim; bool _wasHovered = false; @override Widget build(BuildContext context) { return FocusableControlBuilder( cursor: SystemMouseCursors.click, onPressed: widget.onPressed, builder: (_, state) { if ((state.isHovered || state.isFocused) && !_wasHovered && _btnAnim?.status != AnimationStatus.forward) { _btnAnim?.forward(from: 0); } _wasHovered = (state.isHovered || state.isFocused); return SizedBox( width: 520, height: 100, child: Stack( children: [ Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)), if (state.isHovered || state.isFocused) ...[ Positioned.fill( child: Image.asset(AssetPaths.titleStartBtnHover)), ], Center( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text('START MISSION', style: TextStyles.btn .copyWith(fontSize: 24, letterSpacing: 18)), ], ), ), ], ), ); }, ); } }
และนี่คือแอปที่กำลังทำงานโดยมีปุ่มต่างๆ มากมาย
5. เพิ่มภาพเคลื่อนไหว
ในขั้นตอนนี้ คุณจะทำให้อินเทอร์เฟซผู้ใช้และการเปลี่ยนสีสำหรับเนื้อหาศิลปะเคลื่อนไหว
ตั้งชื่อให้จางลง
ในขั้นตอนนี้ คุณจะใช้วิธีมากมายในการทำให้แอป Flutter เคลื่อนไหว หนึ่งในวิธีการคือการใช้ flutter_animate
ภาพเคลื่อนไหวที่ขับเคลื่อนโดยแพ็กเกจนี้จะเล่นซ้ำโดยอัตโนมัติทุกครั้งที่คุณ Hot Load แอปเพื่อเร่งการพัฒนาซอฟต์แวร์ให้เร็วขึ้น
- แก้ไขโค้ดใน
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(), ); } }
- หากต้องการใช้ประโยชน์จากแพ็กเกจ
flutter_animate
คุณต้องนำเข้าแพ็กเกจดังกล่าว เพิ่มการนําเข้าในlib/title_screen/title_screen_ui.dart
ดังนี้
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; // Add this import import 'package:focusable_control_builder/focusable_control_builder.dart'; import 'package:gap/gap.dart'; import '../assets.dart'; import '../common/ui_scaler.dart'; import '../styles.dart'; class TitleScreenUi extends StatelessWidget {
- เพิ่มภาพเคลื่อนไหวลงในชื่อโดยแก้ไขวิดเจ็ต
_TitleText
ดังนี้
lib/title_screen/title_screen_ui.dart
class _TitleText extends StatelessWidget { const _TitleText(); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Gap(20), Row( mainAxisSize: MainAxisSize.min, children: [ Transform.translate( offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0), child: Text('OUTPOST', style: TextStyles.h1), ), Image.asset(AssetPaths.titleSelectedLeft, height: 65), Text('57', style: TextStyles.h2), Image.asset(AssetPaths.titleSelectedRight, height: 65), ], // Edit from here... ).animate().fadeIn(delay: .8.seconds, duration: .7.seconds), Text('INTO THE UNKNOWN', style: TextStyles.h3) .animate() .fadeIn(delay: 1.seconds, duration: .7.seconds), ], // to here. ); } }
- กดโหลดซ้ำ เพื่อดูชื่อหนังสือที่ค่อยๆ ปรากฏขึ้น
ค่อยๆ เบาลงในปุ่มระดับความยาก
- เพิ่มภาพเคลื่อนไหวให้กับลักษณะที่ปรากฏเริ่มต้นของปุ่มความยากด้วยการแก้ไขวิดเจ็ต
_DifficultyBtns
ดังนี้
lib/title_screen/title_screen_ui.dart
class _DifficultyBtns extends StatelessWidget { const _DifficultyBtns({ required this.difficulty, required this.onDifficultyPressed, required this.onDifficultyFocused, }); final int difficulty; final void Function(int difficulty) onDifficultyPressed; final void Function(int? difficulty) onDifficultyFocused; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ _DifficultyBtn( label: 'Casual', selected: difficulty == 0, onPressed: () => onDifficultyPressed(0), onHover: (over) => onDifficultyFocused(over ? 0 : null), ) // Add from here... .animate() .fadeIn(delay: 1.3.seconds, duration: .35.seconds) .slide(begin: const Offset(0, .2)), // to here _DifficultyBtn( label: 'Normal', selected: difficulty == 1, onPressed: () => onDifficultyPressed(1), onHover: (over) => onDifficultyFocused(over ? 1 : null), ) // Add from here... .animate() .fadeIn(delay: 1.5.seconds, duration: .35.seconds) .slide(begin: const Offset(0, .2)), // to here _DifficultyBtn( label: 'Hardcore', selected: difficulty == 2, onPressed: () => onDifficultyPressed(2), onHover: (over) => onDifficultyFocused(over ? 2 : null), ) // Add from here... .animate() .fadeIn(delay: 1.7.seconds, duration: .35.seconds) .slide(begin: const Offset(0, .2)), // to here const Gap(20), ], ); } }
- กดโหลดซ้ำเพื่อดูปุ่มความยากปรากฏขึ้นตามลำดับพร้อมสไลด์เล็กๆ ขึ้นเป็นโบนัส
ปุ่มเริ่มค่อยๆ จางลง
- เพิ่มภาพเคลื่อนไหวลงในปุ่มเริ่มต้นโดยแก้ไขคลาสสถานะ
_StartBtnState
ดังนี้
lib/title_screen/title_screen_ui.dart
class _StartBtnState extends State<_StartBtn> { AnimationController? _btnAnim; bool _wasHovered = false; @override Widget build(BuildContext context) { return FocusableControlBuilder( cursor: SystemMouseCursors.click, onPressed: widget.onPressed, builder: (_, state) { if ((state.isHovered || state.isFocused) && !_wasHovered && _btnAnim?.status != AnimationStatus.forward) { _btnAnim?.forward(from: 0); } _wasHovered = (state.isHovered || state.isFocused); return SizedBox( width: 520, height: 100, child: Stack( children: [ Positioned.fill(child: Image.asset(AssetPaths.titleStartBtn)), if (state.isHovered || state.isFocused) ...[ Positioned.fill( child: Image.asset(AssetPaths.titleStartBtnHover)), ], Center( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text('START MISSION', style: TextStyles.btn .copyWith(fontSize: 24, letterSpacing: 18)), ], ), ), ], ) // Edit from here... .animate(autoPlay: false, onInit: (c) => _btnAnim = c) .shimmer(duration: .7.seconds, color: Colors.black), ) .animate() .fadeIn(delay: 2.3.seconds) .slide(begin: const Offset(0, .2)); }, // to here. ); } }
- กดโหลดซ้ำเพื่อดูปุ่มความยากปรากฏขึ้นตามลำดับพร้อมสไลด์เล็กๆ ขึ้นเป็นโบนัส
สร้างภาพเคลื่อนไหวของเอฟเฟกต์การวางเมาส์เหนือความยาก
เพิ่มภาพเคลื่อนไหวลงในปุ่มระดับความยาก สถานะโฮเวอร์โดยแก้ไขคลาสสถานะ _DifficultyBtn
ดังนี้
lib/title_screen/title_screen_ui.dart
class _DifficultyBtn extends StatelessWidget { const _DifficultyBtn({ required this.selected, required this.onPressed, required this.onHover, required this.label, }); final String label; final bool selected; final VoidCallback onPressed; final void Function(bool hasFocus) onHover; @override Widget build(BuildContext context) { return FocusableControlBuilder( onPressed: onPressed, onHoverChanged: (_, state) => onHover.call(state.isHovered), builder: (_, state) { return Padding( padding: const EdgeInsets.all(8.0), child: SizedBox( width: 250, height: 60, child: Stack( children: [ /// Bg with fill and outline AnimatedOpacity( // Edit from here opacity: (!selected && (state.isHovered || state.isFocused)) ? 1 : 0, duration: .3.seconds, child: Container( decoration: BoxDecoration( color: const Color(0xFF00D1FF).withOpacity(.1), border: Border.all(color: Colors.white, width: 5), ), ), ), // to here. if (state.isHovered || state.isFocused) ...[ Container( decoration: BoxDecoration( color: const Color(0xFF00D1FF).withOpacity(.1), ), ), ], /// cross-hairs (selected state) if (selected) ...[ CenterLeft( child: Image.asset(AssetPaths.titleSelectedLeft), ), CenterRight( child: Image.asset(AssetPaths.titleSelectedRight), ), ], /// Label Center( child: Text(label.toUpperCase(), style: TextStyles.btn), ), ], ), ), ); }, ); } }
ตอนนี้ปุ่มความยากจะแสดง BoxDecoration
เมื่อวางเมาส์เหนือปุ่มที่ยังไม่ได้เลือกไว้
ทำให้การเปลี่ยนสีเคลื่อนไหว
- การเปลี่ยนสีพื้นหลังเป็นไปทันทีและรุนแรง การแสดงภาพเคลื่อนไหวในโทนสีต่างๆ จะให้ผลดีกว่า เพิ่ม
flutter_animate
ไปยังlib/title_screen/title_screen.dart
:
lib/title_screen/title_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; // Add this import import '../assets.dart'; import '../styles.dart'; import 'title_screen_ui.dart'; class TitleScreen extends StatefulWidget {
- เพิ่มวิดเจ็ต
_AnimatedColors
ใน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!); }, ); }, ); } }
- ใช้วิดเจ็ตที่คุณเพิ่งสร้างเพื่อทำให้สีของรูปภาพที่สว่างเคลื่อนไหวโดยอัปเดตเมธอด
build
ใน_TitleScreenState
ดังนี้
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. เพิ่มตัวปรับแสงเงา Fragment
ในขั้นตอนนี้คุณจะเพิ่ม Fragment Shape ลงในแอป ขั้นแรก ให้ใช้เฉดสีเพื่อแก้ไขชื่อ ให้อารมณ์แบบดิสโทเปียมากขึ้น จากนั้นให้คุณเพิ่มตัวปรับแสงเงาที่ 2 เพื่อสร้างลูกโลกที่ทำหน้าที่เป็นจุดโฟกัสตรงกลางของหน้า
การบิดเบือนชื่อด้วย Fragment Frame
การเปลี่ยนแปลงนี้ทำให้คุณได้แนะนำแพ็กเกจ provider
ซึ่งช่วยให้ส่งตัวปรับแสงเงาที่คอมไพล์แล้วลงในแผนผังวิดเจ็ตได้ หากคุณสนใจวิธีโหลดตัวปรับแสงเงา ให้ดูการใช้งานใน lib/assets.dart
- แก้ไขโค้ดใน
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(), ); } }
- ในการใช้ประโยชน์จากแพ็กเกจ
provider
และยูทิลิตีตัวปรับแสงเงาที่รวมอยู่ในstep_01
คุณต้องนำเข้าแพ็กเกจเหล่านั้น เพิ่มการนำเข้าใหม่ในlib/title_screen/title_screen_ui.dart
ดังนี้
lib/title_screen/title_screen_ui.dart
import 'package:extra_alignments/extra_alignments.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:focusable_control_builder/focusable_control_builder.dart'; import 'package:gap/gap.dart'; import 'package:provider/provider.dart'; // Add this import import '../assets.dart'; import '../common/shader_effect.dart'; // And this import import '../common/ticking_builder.dart'; // And this import import '../common/ui_scaler.dart'; import '../styles.dart'; class TitleScreenUi extends StatelessWidget {
- ทำให้ชื่อบิดเบี้ยวด้วยตัวปรับแสงเงาโดยการแก้ไขวิดเจ็ต
_TitleText
ดังนี้
lib/title_screen/title_screen_ui.dart
class _TitleText extends StatelessWidget { const _TitleText(); @override Widget build(BuildContext context) { Widget content = Column( // Modify this line mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Gap(20), Row( mainAxisSize: MainAxisSize.min, children: [ Transform.translate( offset: Offset(-(TextStyles.h1.letterSpacing! * .5), 0), child: Text('OUTPOST', style: TextStyles.h1), ), Image.asset(AssetPaths.titleSelectedLeft, height: 65), Text('57', style: TextStyles.h2), Image.asset(AssetPaths.titleSelectedRight, height: 65), ], ).animate().fadeIn(delay: .8.seconds, duration: .7.seconds), Text('INTO THE UNKNOWN', style: TextStyles.h3) .animate() .fadeIn(delay: 1.seconds, duration: .7.seconds), ], ); return Consumer<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. } }
คุณควรจะเห็นชื่อที่ผิดเพี้ยนไป อย่างที่คิดไว้ในอนาคตแบบดิสโทเปีย
เพิ่มลูกโลก
จากนั้นเพิ่มลูกโลกที่กึ่งกลางของหน้าต่าง คุณต้องเพิ่ม Callback onPressed
ที่ปุ่มเริ่มต้น
- ใน
lib/title_screen/title_screen_ui.dart
ให้แก้ไขTitleScreenUi
ดังนี้
lib/title_screen/title_screen_ui.dart
class TitleScreenUi extends StatelessWidget { const TitleScreenUi({ super.key, required this.difficulty, required this.onDifficultyPressed, required this.onDifficultyFocused, required this.onStartPressed, // Add this argument }); final int difficulty; final void Function(int difficulty) onDifficultyPressed; final void Function(int? difficulty) onDifficultyFocused; final VoidCallback onStartPressed; // Add this attribute @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 50), child: Stack( children: [ /// Title Text const TopLeft( child: UiScaler( alignment: Alignment.topLeft, child: _TitleText(), ), ), /// Difficulty Btns BottomLeft( child: UiScaler( alignment: Alignment.bottomLeft, child: _DifficultyBtns( difficulty: difficulty, onDifficultyPressed: onDifficultyPressed, onDifficultyFocused: onDifficultyFocused, ), ), ), /// StartBtn BottomRight( child: UiScaler( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only(bottom: 20, right: 40), child: _StartBtn(onPressed: onStartPressed), // Edit this line ), ), ), ], ), ); } }
ตอนนี้คุณได้แก้ไขปุ่มเริ่มต้นด้วย Callback แล้ว คุณจะต้องทำการแก้ไขจำนวนมากกับไฟล์ lib/title_screen/title_screen.dart
- แก้ไขการนำเข้าดังนี้
lib/title_screen/title_screen.dart
import 'dart:math'; // Add this import import 'dart:ui'; // And this import import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // Add this import import 'package:flutter_animate/flutter_animate.dart'; import '../assets.dart'; import '../orb_shader/orb_shader_config.dart'; // And this import import '../orb_shader/orb_shader_widget.dart'; // And this import too import '../styles.dart'; import 'title_screen_ui.dart'; class TitleScreen extends StatefulWidget {
- แก้ไข
_TitleScreenState
ให้ตรงกับรายการต่อไปนี้ เกือบทุกส่วนของชั้นเรียนจะมีการดัดแปลงในทางใดทางหนึ่ง
lib/title_screen/title_screen.dart
class _TitleScreenState extends State<TitleScreen> with SingleTickerProviderStateMixin { final _orbKey = GlobalKey<OrbShaderWidgetState>(); /// Editable Settings /// 0-1, receive lighting strength final _minReceiveLightAmt = .35; final _maxReceiveLightAmt = .7; /// 0-1, emit lighting strength final _minEmitLightAmt = .5; final _maxEmitLightAmt = 1; /// Internal var _mousePos = Offset.zero; Color get _emitColor => AppColors.emitColors[_difficultyOverride ?? _difficulty]; Color get _orbColor => AppColors.orbColors[_difficultyOverride ?? _difficulty]; /// Currently selected difficulty int _difficulty = 0; /// Currently focused difficulty (if any) int? _difficultyOverride; double _orbEnergy = 0; double _minOrbEnergy = 0; double get _finalReceiveLightAmt { final light = lerpDouble(_minReceiveLightAmt, _maxReceiveLightAmt, _orbEnergy) ?? 0; return light + _pulseEffect.value * .05 * _orbEnergy; } double get _finalEmitLightAmt { return lerpDouble(_minEmitLightAmt, _maxEmitLightAmt, _orbEnergy) ?? 0; } late final _pulseEffect = AnimationController( vsync: this, duration: _getRndPulseDuration(), lowerBound: -1, upperBound: 1, ); Duration _getRndPulseDuration() => 100.ms + 200.ms * Random().nextDouble(); double _getMinEnergyForDifficulty(int difficulty) => 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); }, ), ), ), ); } }
- แก้ไข
_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. เพิ่มภาพเคลื่อนไหวของอนุภาค
ในขั้นตอนนี้ คุณจะเพิ่มภาพเคลื่อนไหวของอนุภาคเพื่อสร้างการเคลื่อนไหวที่กะพริบเล็กน้อยให้กับแอป
เพิ่มอนุภาคในทุกที่
- สร้างไฟล์
lib/title_screen/particle_overlay.dart
ใหม่แล้วเพิ่มโค้ดต่อไปนี้
lib/title_screen/particle_overlay.dart
import 'dart:math'; import 'package:flutter/material.dart'; import 'package:particle_field/particle_field.dart'; import 'package:rnd/rnd.dart'; class ParticleOverlay extends StatelessWidget { const ParticleOverlay({super.key, required this.color, required this.energy}); final Color color; final double energy; @override Widget build(BuildContext context) { return ParticleField( spriteSheet: SpriteSheet( image: const AssetImage('assets/images/particle-wave.png'), ), // blend the image's alpha with the specified color: blendMode: BlendMode.dstIn, // this runs every tick: onTick: (controller, _, size) { List<Particle> particles = controller.particles; // add a new particle with random angle, distance & velocity: double a = rnd(pi * 2); double dist = rnd(1, 4) * 35 + 150 * energy; double vel = rnd(1, 2) * (1 + energy * 1.8); particles.add(Particle( // how many ticks this particle will live: lifespan: rnd(1, 2) * 20 + energy * 15, // starting distance from center: x: cos(a) * dist, y: sin(a) * dist, // starting velocity: vx: cos(a) * vel, vy: sin(a) * vel, // other starting values: rotation: a, scale: rnd(1, 2) * 0.6 + energy * 0.5, )); // update all of the particles: for (int i = particles.length - 1; i >= 0; i--) { Particle p = particles[i]; if (p.lifespan <= 0) { // particle is expired, remove it: particles.removeAt(i); continue; } p.update( scale: p.scale * 1.025, vx: p.vx * 1.025, vy: p.vy * 1.025, color: color.withOpacity(p.lifespan * 0.001 + 0.01), lifespan: p.lifespan - 1, ); } }, ); } }
- แก้ไขการนำเข้าสำหรับ
lib/title_screen/title_screen.dart
ดังนี้
lib/title_screen/title_screen.dart
import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import '../assets.dart'; import '../orb_shader/orb_shader_config.dart'; import '../orb_shader/orb_shader_widget.dart'; import '../styles.dart'; import 'particle_overlay.dart'; // Add this import import 'title_screen_ui.dart'; class TitleScreen extends StatefulWidget {
- เพิ่ม
ParticleOverlay
ลงใน UI โดยแก้ไขเมธอดbuild
ของ_TitleScreenState
ดังนี้
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 ทำงานบนเว็บ จะมีเครื่องมือแสดงผล 2 แบบที่ใช้ได้ ได้แก่ เครื่องมือ 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 รองรับได้แล้ว
ดูข้อมูลเพิ่มเติม
- ดูแพ็กเกจ
flutter_animate
- ดูเอกสารประกอบการรองรับ Flutter สำหรับ Fragment Shaders
- The Book of Shaders โดย Patricio Gonzalez Vivo และ Jen Lowe
- Shader toy สนามเด็กเล่นเฉดสีสำหรับทำงานร่วมกัน
- simple_shader เป็นโปรเจ็กต์ตัวอย่างสำหรับ Fragment ของ Flutter อย่างง่าย