ภาพเคลื่อนไหวใน Flutter

1. บทนำ

ภาพเคลื่อนไหวเป็นวิธีที่ยอดเยี่ยมในการปรับปรุงประสบการณ์ของผู้ใช้ในแอป สื่อสารข้อมูลสำคัญกับผู้ใช้ และทำให้แอปดูดีและน่าใช้งานยิ่งขึ้น

ภาพรวมของเฟรมเวิร์กภาพเคลื่อนไหวของ Flutter

Flutter แสดงเอฟเฟกต์ภาพเคลื่อนไหวโดยการสร้างส่วนหนึ่งของแผนผังวิดเจ็ตใหม่ในแต่ละเฟรม โดยมีเอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าและ API อื่นๆ เพื่อให้การสร้างและคอมโพสภาพเคลื่อนไหวเป็นเรื่องง่าย

  • ภาพเคลื่อนไหวโดยนัยคือเอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าซึ่งจะเรียกใช้ภาพเคลื่อนไหวทั้งหมดโดยอัตโนมัติ เมื่อค่าเป้าหมายของภาพเคลื่อนไหวเปลี่ยนไป ระบบจะเรียกใช้ภาพเคลื่อนไหวจากค่าปัจจุบันไปยังค่าเป้าหมาย และแสดงค่าแต่ละค่าระหว่างนั้นเพื่อให้วิดเจ็ตเคลื่อนไหวได้อย่างราบรื่น ตัวอย่างของภาพเคลื่อนไหวโดยนัย ได้แก่ AnimatedSize, AnimatedScale และ AnimatedPositioned
  • ภาพเคลื่อนไหวที่ชัดเจนเป็นเอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าเช่นกัน แต่ต้องมีAnimationออบเจ็กต์จึงจะทำงานได้ ตัวอย่างเช่น SizeTransition, ScaleTransition หรือ PositionedTransition
  • Animation คือคลาสที่แสดงภาพเคลื่อนไหวที่กำลังทำงานหรือหยุดทำงาน และประกอบด้วยvalue ที่แสดงค่าเป้าหมายที่ภาพเคลื่อนไหวทำงานอยู่ และstatus ที่แสดงค่าปัจจุบันที่ภาพเคลื่อนไหวแสดงบนหน้าจอในเวลาใดก็ตาม เป็นคลาสย่อยของ Listenable และจะแจ้งให้ Listener ทราบเมื่อสถานะเปลี่ยนแปลงขณะที่ภาพเคลื่อนไหวทำงานอยู่
  • AnimationController เป็นวิธีสร้างภาพเคลื่อนไหวและควบคุมสถานะของภาพเคลื่อนไหว คุณสามารถใช้วิธีการต่างๆ เช่น forward(), reset(), stop() และ repeat() เพื่อควบคุมภาพเคลื่อนไหวได้โดยไม่ต้องกำหนดเอฟเฟกต์ภาพเคลื่อนไหวที่แสดง เช่น สเกล ขนาด หรือตำแหน่ง
  • Tween ใช้เพื่อประมาณค่าในช่วงระหว่างค่าเริ่มต้นและค่าสิ้นสุด และสามารถแสดงค่าประเภทใดก็ได้ เช่น Double, Offset หรือ Color
  • เส้นโค้งใช้เพื่อปรับอัตราการเปลี่ยนแปลงของพารามิเตอร์เมื่อเวลาผ่านไป เมื่อภาพเคลื่อนไหวทำงาน โดยทั่วไปจะใช้เส้นโค้งการค่อยๆ เปลี่ยนเพื่อทำให้การเปลี่ยนแปลงเร็วขึ้นหรือช้าลงในช่วงต้นหรือช่วงท้ายของภาพเคลื่อนไหว เส้นโค้งจะรับค่าอินพุตระหว่าง 0.0 ถึง 1.0 และแสดงค่าเอาต์พุตระหว่าง 0.0 ถึง 1.0

สิ่งที่คุณจะสร้าง

ใน Codelab นี้ คุณจะได้สร้างเกมแบบทดสอบแบบหลายตัวเลือกที่มีเอฟเฟกต์และเทคนิคภาพเคลื่อนไหวต่างๆ

3026390ad413769c.gif

คุณจะเห็นวิธี...

  • สร้างวิดเจ็ตที่เคลื่อนไหวขนาดและสี
  • สร้างเอฟเฟกต์พลิกการ์ด 3 มิติ
  • ใช้เอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าจากแพ็กเกจภาพเคลื่อนไหว
  • เพิ่มการรองรับท่าทางสัมผัสการย้อนกลับที่คาดการณ์ได้ซึ่งมีให้บริการใน Android เวอร์ชันล่าสุด

สิ่งที่คุณจะได้เรียนรู้

ในโค้ดแล็บนี้ คุณจะได้เรียนรู้สิ่งต่อไปนี้

  • วิธีใช้เอฟเฟกต์ภาพเคลื่อนไหวโดยนัยเพื่อให้ได้ภาพเคลื่อนไหวที่ดูดีโดยไม่ต้องเขียนโค้ดมาก
  • วิธีใช้เอฟเฟกต์ภาพเคลื่อนไหวที่ชัดเจนเพื่อกำหนดค่าเอฟเฟกต์ของคุณเองโดยใช้วิดเจ็ตภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้า เช่น AnimatedSwitcher หรือ AnimationController
  • วิธีใช้ AnimationController เพื่อกำหนดวิดเจ็ตของคุณเองที่แสดงเอฟเฟกต์ 3 มิติ
  • วิธีใช้แพ็กเกจ animations เพื่อแสดงเอฟเฟกต์ภาพเคลื่อนไหวที่สวยงามโดยมีการตั้งค่าขั้นต่ำ

สิ่งที่คุณต้องมี

  • Flutter SDK
  • IDE เช่น VSCode หรือ Android Studio / IntelliJ

2. ตั้งค่าสภาพแวดล้อมในการพัฒนา Flutter

คุณต้องมีซอฟต์แวร์ 2 อย่างจึงจะทำแล็บนี้ให้เสร็จสมบูรณ์ ได้แก่ Flutter SDK และโปรแกรมแก้ไข

คุณเรียกใช้ Codelab ได้โดยใช้อุปกรณ์ต่อไปนี้

  • อุปกรณ์ Android (แนะนำให้ใช้เพื่อติดตั้งใช้งานการย้อนกลับแบบคาดการณ์ในขั้นตอนที่ 7) หรือ iOS จริงที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาแอป
  • โปรแกรมจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
  • Android Emulator (ต้องตั้งค่าใน Android Studio)
  • เบราว์เซอร์ (ต้องใช้ Chrome สำหรับการแก้ไขข้อบกพร่อง)
  • คอมพิวเตอร์เดสก์ท็อปที่ใช้ Windows, Linux หรือ macOS คุณต้องพัฒนาบนแพลตฟอร์มที่วางแผนจะใช้งาน ดังนั้น หากต้องการพัฒนาแอปเดสก์ท็อป Windows คุณต้องพัฒนาบน Windows เพื่อเข้าถึงห่วงโซ่การสร้างที่เหมาะสม มีข้อกำหนดเฉพาะของระบบปฏิบัติการที่อธิบายไว้โดยละเอียดใน docs.flutter.dev/desktop

ยืนยันการติดตั้ง

หากต้องการยืนยันว่ากำหนดค่า Flutter SDK อย่างถูกต้อง และคุณได้ติดตั้งแพลตฟอร์มเป้าหมายอย่างน้อย 1 แพลตฟอร์มข้างต้น ให้ใช้เครื่องมือ Flutter Doctor โดยทำดังนี้

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. เรียกใช้แอปเริ่มต้น

ดาวน์โหลดแอปเริ่มต้น

ใช้ git เพื่อโคลนแอปเริ่มต้นจากที่เก็บ flutter/samples ใน GitHub

git clone https://github.com/flutter/codelabs.git
cd codelabs/animations/step_01/

หรือจะดาวน์โหลดซอร์สโค้ดเป็นไฟล์ ZIP ก็ได้

เรียกใช้แอป

หากต้องการเรียกใช้แอป ให้ใช้คำสั่ง flutter run และระบุอุปกรณ์เป้าหมาย เช่น android, ios หรือ chrome ดูรายการแพลตฟอร์มทั้งหมดที่รองรับได้ในหน้าแพลตฟอร์มที่รองรับ

flutter run -d android

นอกจากนี้ คุณยังเรียกใช้และแก้ไขข้อบกพร่องของแอปโดยใช้ IDE ที่ต้องการได้ด้วย ดูข้อมูลเพิ่มเติมได้ในเอกสารประกอบอย่างเป็นทางการของ Flutter

ทัวร์ชมโค้ด

แอปเริ่มต้นเป็นเกมแบบทดสอบปรนัยที่มี 2 หน้าจอตามรูปแบบการออกแบบ Model-View-ViewModel หรือ MVVM QuestionScreen (View) ใช้คลาส QuizViewModel (View-Model) เพื่อถามคำถามแบบหลายตัวเลือกจากคลาส QuestionBank (Model) แก่ผู้ใช้

  • home_screen.dart - แสดงหน้าจอที่มีปุ่มเกมใหม่
  • main.dart - กำหนดค่า MaterialApp ให้ใช้ Material 3 และแสดงหน้าจอหลัก
  • model.dart - กำหนดคลาสหลักที่ใช้ทั่วทั้งแอป
  • question_screen.dart - แสดง UI สำหรับเกมแบบทดสอบ
  • view_model.dart - จัดเก็บสถานะและตรรกะสำหรับเกมแบบทดสอบ ซึ่งแสดงโดย QuestionScreen

fbb1e1f7b6c91e21.png

แอปยังไม่รองรับเอฟเฟกต์ภาพเคลื่อนไหวใดๆ ยกเว้นการเปลี่ยนมุมมองเริ่มต้นที่แสดงโดยคลาส Navigator ของ Flutter เมื่อผู้ใช้กดปุ่มเกมใหม่

4. ใช้เอฟเฟกต์ภาพเคลื่อนไหวโดยนัย

ภาพเคลื่อนไหวโดยนัยเป็นตัวเลือกที่ยอดเยี่ยมในหลายๆ สถานการณ์ เนื่องจากไม่จำเป็นต้องมีการกำหนดค่าพิเศษใดๆ ในส่วนนี้ คุณจะอัปเดตวิดเจ็ต StatusBar เพื่อให้แสดงสกอร์บอร์ดแบบเคลื่อนไหว หากต้องการค้นหาเอฟเฟกต์ภาพเคลื่อนไหวโดยนัยที่ใช้กันทั่วไป ให้เรียกดูเอกสารประกอบเกี่ยวกับ API ของ ImplicitlyAnimatedWidget

206dd8d9c1fae95.gif

สร้างวิดเจ็ตกระดานคะแนนแบบไม่มีภาพเคลื่อนไหว

สร้างไฟล์ใหม่ lib/scoreboard.dart ด้วยโค้ดต่อไปนี้

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color: score < i + 1
                  ? Colors.grey.shade400
                  : Colors.yellow.shade700,
            ),
        ],
      ),
    );
  }
}

จากนั้นเพิ่มวิดเจ็ต Scoreboard ในวิดเจ็ตย่อยของวิดเจ็ต StatusBar โดยแทนที่วิดเจ็ต Text ที่แสดงคะแนนและจำนวนคำถามทั้งหมดก่อนหน้านี้ เอดิเตอร์ควรเพิ่ม import "scoreboard.dart" ที่จำเป็นที่ด้านบนของไฟล์โดยอัตโนมัติ

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

วิดเจ็ตนี้จะแสดงไอคอนดาวสำหรับคำถามแต่ละข้อ เมื่อตอบคำถามถูกต้อง ดาวอีกดวงจะสว่างขึ้นทันทีโดยไม่มีภาพเคลื่อนไหว ในขั้นตอนต่อไปนี้ คุณจะช่วยแจ้งให้ผู้ใช้ทราบว่าคะแนนมีการเปลี่ยนแปลงโดยการเคลื่อนไหวขนาดและสีของคะแนน

ใช้เอฟเฟกต์ภาพเคลื่อนไหวโดยนัย

สร้างวิดเจ็ตใหม่ชื่อ AnimatedStar ที่ใช้วิดเจ็ต AnimatedScale เพื่อเปลี่ยนจำนวน scale จาก 0.5 เป็น 1.0 เมื่อดาวใช้งานได้

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

ตอนนี้เมื่อผู้ใช้ตอบคำถามถูกต้อง AnimatedStar วิดเจ็ตจะอัปเดตขนาดโดยใช้ภาพเคลื่อนไหวโดยนัย Iconcolor ไม่ได้เคลื่อนไหวที่นี่ มีเพียง scale เท่านั้น ซึ่งดำเนินการโดยวิดเจ็ต AnimatedScale

84aec4776e70b870.gif

ใช้ Tween เพื่อประมาณค่าระหว่าง 2 ค่า

โปรดสังเกตว่าสีของวิดเจ็ต AnimatedStar จะเปลี่ยนทันทีหลังจากฟิลด์ isActive เปลี่ยนเป็น "จริง"

หากต้องการสร้างเอฟเฟกต์สีแบบเคลื่อนไหว คุณอาจลองใช้วิดเจ็ต AnimatedContainer (ซึ่งเป็นคลาสย่อยอีกคลาสหนึ่งของ ImplicitlyAnimatedWidget) เนื่องจากวิดเจ็ตนี้สามารถเคลื่อนไหวแอตทริบิวต์ทั้งหมดได้โดยอัตโนมัติ รวมถึงสีด้วย ขออภัย วิดเจ็ตของเราต้องแสดงไอคอน ไม่ใช่คอนเทนเนอร์

คุณอาจลองใช้ AnimatedIcon ซึ่งจะใช้เอฟเฟกต์การเปลี่ยนระหว่างรูปร่างของไอคอน แต่ไม่มีการใช้งานไอคอนดาวเริ่มต้นในคลาส AnimatedIcons

แต่เราจะใช้คลาสย่อยอีกคลาสหนึ่งของ ImplicitlyAnimatedWidget ที่ชื่อ TweenAnimationBuilder ซึ่งใช้ Tween เป็นพารามิเตอร์ Tween คือคลาสที่รับค่า 2 ค่า (begin และ end) และคำนวณค่าระหว่างกลาง เพื่อให้ภาพเคลื่อนไหวแสดงค่าเหล่านั้นได้ ในตัวอย่างนี้ เราจะใช้ ColorTween ซึ่งเป็นไปตามอินเทอร์เฟซ Tween ที่จำเป็นต่อการสร้างเอฟเฟกต์ภาพเคลื่อนไหว

เลือกวิดเจ็ต Icon แล้วใช้การดำเนินการด่วน "Wrap with Builder" ใน IDE เปลี่ยนชื่อเป็น TweenAnimationBuilder จากนั้นระบุระยะเวลาและ ColorTween

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(Icons.star, size: 50, color: value);     // And modify this line.
        },
      ),
    );
  }
}

ตอนนี้ ให้โหลดแอปซ้ำอย่างรวดเร็วเพื่อดูภาพเคลื่อนไหวใหม่

8b0911f4af299a60.gif

โปรดสังเกตว่าค่า end ของ ColorTween จะเปลี่ยนแปลงตามค่าของพารามิเตอร์ isActive เนื่องจาก TweenAnimationBuilder จะเรียกใช้ภาพเคลื่อนไหวซ้ำทุกครั้งที่ค่า Tween.end เปลี่ยนแปลง เมื่อเกิดกรณีนี้ขึ้น ภาพเคลื่อนไหวใหม่จะทำงานจากค่าภาพเคลื่อนไหวปัจจุบันไปยังค่าสิ้นสุดใหม่ ซึ่งช่วยให้คุณเปลี่ยนสีได้ทุกเมื่อ (แม้ในขณะที่ภาพเคลื่อนไหวทำงานอยู่) และแสดงเอฟเฟกต์ภาพเคลื่อนไหวที่ราบรื่นด้วยค่าระหว่างกลางที่ถูกต้อง

ใช้เส้นโค้ง

เอฟเฟกต์ภาพเคลื่อนไหวทั้ง 2 แบบนี้ทำงานในอัตราคงที่ แต่ภาพเคลื่อนไหวมักจะน่าสนใจและให้ข้อมูลมากกว่าเมื่อเร็วขึ้นหรือช้าลง

Curve จะใช้ฟังก์ชันการเปลี่ยนผ่าน ซึ่งกำหนดอัตราการเปลี่ยนแปลงของพารามิเตอร์เมื่อเวลาผ่านไป Flutter มาพร้อมกับชุดเส้นโค้งการชะลอความเร็วที่สร้างไว้ล่วงหน้าในคลาส Curves เช่น easeIn หรือ easeOut

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

แผนภาพเหล่านี้ (มีอยู่ในCurvesหน้าเอกสารประกอบ API) จะช่วยให้ทราบวิธีการทำงานของเส้นโค้ง เส้นโค้งจะแปลงค่าอินพุตระหว่าง 0.0 ถึง 1.0 (แสดงบนแกน x) เป็นค่าเอาต์พุตระหว่าง 0.0 ถึง 1.0 (แสดงบนแกน y) นอกจากนี้ แผนภาพเหล่านี้ยังแสดงตัวอย่างลักษณะของเอฟเฟกต์ภาพเคลื่อนไหวต่างๆ เมื่อใช้เส้นโค้งการเปลี่ยนอีกด้วย

สร้างฟิลด์ใหม่ใน AnimatedStar ชื่อ _curve แล้วส่งเป็นพารามิเตอร์ไปยังวิดเจ็ต AnimatedScale และ TweenAnimationBuilder

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(Icons.star, size: 50, color: value);
        },
      ),
    );
  }
}

ในตัวอย่างนี้ เส้นโค้ง elasticOut จะให้เอฟเฟกต์สปริงที่เกินจริงซึ่งเริ่มต้นด้วยการเคลื่อนไหวแบบสปริงและสมดุลไปจนถึงตอนท้าย

8f84142bff312373.gif

โหลดแอปซ้ำอย่างรวดเร็วเพื่อดูว่าเส้นโค้งนี้มีผลกับ AnimatedSize และ TweenAnimationBuilder อย่างไร

206dd8d9c1fae95.gif

ใช้เครื่องมือสำหรับนักพัฒนาเว็บเพื่อเปิดใช้ภาพเคลื่อนไหวช้า

หากต้องการแก้ไขข้อบกพร่องของเอฟเฟกต์ภาพเคลื่อนไหว Flutter DevTools มีวิธีชะลอภาพเคลื่อนไหวทั้งหมดในแอปเพื่อให้คุณเห็นภาพเคลื่อนไหวได้ชัดเจนยิ่งขึ้น

หากต้องการเปิด DevTools ให้ตรวจสอบว่าแอปกำลังทำงานในโหมดแก้ไขข้อบกพร่อง แล้วเปิด Widget Inspector โดยเลือกในแถบเครื่องมือแก้ไขข้อบกพร่องใน VSCode หรือโดยเลือกปุ่มเปิด Flutter DevTools ในหน้าต่างเครื่องมือแก้ไขข้อบกพร่องใน IntelliJ / Android Studio

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

เมื่อเปิดเครื่องมือตรวจสอบวิดเจ็ตแล้ว ให้คลิกปุ่มภาพเคลื่อนไหวช้าในแถบเครื่องมือ

adea0a16d01127ad.png

5. ใช้เอฟเฟกต์ภาพเคลื่อนไหวที่ชัดเจน

เช่นเดียวกับภาพเคลื่อนไหวโดยนัย ภาพเคลื่อนไหวโดยชัดแจ้งคือเอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้า แต่จะใช้Animationออบเจ็กต์เป็นพารามิเตอร์แทนที่จะใช้ค่าเป้าหมาย ซึ่งทำให้มีประโยชน์ในสถานการณ์ที่การเปลี่ยนภาพเคลื่อนไหวถูกกำหนดไว้แล้วโดยการเปลี่ยนการนำทาง AnimatedSwitcher หรือ AnimationController เป็นต้น

ใช้เอฟเฟกต์ภาพเคลื่อนไหวที่ชัดเจน

หากต้องการเริ่มต้นใช้งานเอฟเฟกต์ภาพเคลื่อนไหวที่ชัดเจน ให้ห่อวิดเจ็ต Card ด้วย AnimatedSwitcher

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

AnimatedSwitcher ใช้เอฟเฟกต์ครอสเฟดโดยค่าเริ่มต้น แต่คุณลบล้างค่านี้ได้โดยใช้พารามิเตอร์ transitionBuilder เครื่องมือสร้างการเปลี่ยนฉากจะระบุวิดเจ็ตย่อยที่ส่งไปยัง AnimatedSwitcher และออบเจ็กต์ Animation นี่เป็นโอกาสที่ดีในการใช้ภาพเคลื่อนไหวที่ชัดเจน

สำหรับ Codelab นี้ แอนิเมชันที่ชัดเจนแรกที่เราจะใช้คือ SlideTransition ซึ่งใช้ Animation<Offset> ที่กำหนดออฟเซ็ตเริ่มต้นและสิ้นสุดที่วิดเจ็ตขาเข้าและขาออกจะเคลื่อนที่ระหว่างกัน

Tween มีฟังก์ชันตัวช่วย animate() ที่แปลง Animation ใดๆ ให้เป็น Animation อื่นๆ โดยใช้ Tween ซึ่งหมายความว่าสามารถใช้ Tween เพื่อแปลง Animation ที่ AnimatedSwitcher ให้เป็น Animation เพื่อส่งไปยังวิดเจ็ต SlideTransition

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation = CurveTween(
          curve: Curves.easeInCubic,
        ).animate(animation);
        final offsetAnimation = Tween<Offset>(
          begin: Offset(-0.1, 0.0),
          end: Offset.zero,
        ).animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

โปรดทราบว่าฟังก์ชันนี้ใช้ Tween.animate เพื่อใช้ Curve กับ Animation จากนั้นจึงแปลงจาก Tween ที่มีค่าตั้งแต่ 0.0 ถึง 1.0 เป็น Tween ที่เปลี่ยนจาก -0.1 เป็น 0.0 บนแกน x

หรือคลาส Animation มีฟังก์ชัน drive() ที่รับ Tween (หรือ Animatable) และแปลงเป็น Animation ใหม่ ซึ่งช่วยให้ "เชื่อมโยง" ทวีตได้ ทำให้โค้ดที่ได้กระชับมากขึ้น

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

ข้อดีอีกอย่างของการใช้ภาพเคลื่อนไหวที่ชัดเจนคือสามารถนำมาประกอบกันได้ เพิ่มภาพเคลื่อนไหวที่ชัดเจนอีกรายการFadeTransitionซึ่งใช้ภาพเคลื่อนไหวแบบโค้งเดียวกันโดยการห่อวิดเจ็ต SlideTransition

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation = CurveTween(
      curve: Curves.easeInCubic,
    ).animate(animation);
    final offsetAnimation = Tween<Offset>(
      begin: Offset(-0.1, 0.0),
      end: Offset.zero,
    ).animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

ปรับแต่ง LayoutBuilder

คุณอาจพบปัญหาเล็กน้อยเกี่ยวกับ AnimationSwitcher เมื่อQuestionCardเปลี่ยนไปใช้คำถามใหม่ วิดเจ็ตจะจัดวางคำถามไว้ตรงกลางพื้นที่ที่มีอยู่ขณะที่ภาพเคลื่อนไหวทำงาน แต่เมื่อหยุดภาพเคลื่อนไหว วิดเจ็ตจะเลื่อนไปที่ด้านบนของหน้าจอ ซึ่งจะทำให้ภาพเคลื่อนไหวไม่ราบรื่นเนื่องจากตำแหน่งสุดท้ายของการ์ดคำถามไม่ตรงกับตำแหน่งขณะที่ภาพเคลื่อนไหวทำงานอยู่

d77de181bdde58f7.gif

หากต้องการแก้ไขปัญหานี้ AnimatedSwitcher ยังมีพารามิเตอร์ layoutBuilder ซึ่งใช้กำหนดเลย์เอาต์ได้ด้วย ใช้ฟังก์ชันนี้เพื่อกำหนดค่าเครื่องมือสร้างเลย์เอาต์ให้จัดแนวการ์ดไปที่ด้านบนของหน้าจอ

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

โค้ดนี้เป็นเวอร์ชันที่แก้ไขแล้วของ defaultLayoutBuilder จากคลาส AnimatedSwitcher แต่ใช้ Alignment.topCenter แทน Alignment.center

สรุป

  • ภาพเคลื่อนไหวที่ชัดเจนคือเอฟเฟกต์ภาพเคลื่อนไหวที่ใช้ออบเจ็กต์ Animation (ตรงกันข้ามกับ ImplicitlyAnimatedWidgets ซึ่งใช้เป้าหมาย value และ duration)
  • คลาส Animation แสดงถึงภาพเคลื่อนไหวที่กำลังทำงาน แต่ไม่ได้กำหนดเอฟเฟกต์ที่เฉพาะเจาะจง
  • ใช้ Tween().animate หรือ Animation.drive() เพื่อใช้ Tweens และ Curves (ใช้ CurveTween) กับภาพเคลื่อนไหว
  • ใช้พารามิเตอร์ AnimatedSwitcher's layoutBuilder เพื่อปรับวิธีวางเลย์เอาต์ขององค์ประกอบย่อย

6. ควบคุมสถานะของภาพเคลื่อนไหว

ที่ผ่านมา เฟรมเวิร์กจะเรียกใช้ภาพเคลื่อนไหวทุกรายการโดยอัตโนมัติ ภาพเคลื่อนไหวโดยนัยจะทำงานโดยอัตโนมัติ และเอฟเฟกต์ภาพเคลื่อนไหวโดยชัดแจ้งต้องใช้ Animation จึงจะทำงานได้อย่างถูกต้อง ในส่วนนี้ คุณจะได้เรียนรู้วิธีสร้างออบเจ็กต์ Animation ของคุณเองโดยใช้ AnimationController และใช้ TweenSequence เพื่อรวม Tween เข้าด้วยกัน

เรียกใช้ภาพเคลื่อนไหวโดยใช้ AnimationController

หากต้องการสร้างภาพเคลื่อนไหวโดยใช้ AnimationController คุณจะต้องทำตามขั้นตอนต่อไปนี้

  1. สร้าง StatefulWidget
  2. ใช้ SingleTickerProviderStateMixin mixin ในคลาส State เพื่อระบุ Ticker ให้กับ AnimationController
  3. เริ่มต้น AnimationController ในเมธอดวงจร initState โดยระบุออบเจ็กต์ State ปัจจุบันไปยังพารามิเตอร์ vsync (TickerProvider)
  4. ตรวจสอบว่าวิดเจ็ตสร้างใหม่ทุกครั้งที่ AnimationController แจ้งเตือนผู้ฟัง ไม่ว่าจะโดยใช้ AnimatedBuilder หรือโดยการเรียก listen() และ setState ด้วยตนเอง

สร้างไฟล์ใหม่ flip_effect.dart แล้วคัดลอกและวางโค้ดต่อไปนี้

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                    ? _previousChild
                    : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

คลาสนี้จะตั้งค่า AnimationController และเรียกใช้ภาพเคลื่อนไหวอีกครั้งเมื่อใดก็ตามที่เฟรมเวิร์กเรียกใช้ didUpdateWidget เพื่อแจ้งให้ทราบว่าการกำหนดค่าวิดเจ็ตมีการเปลี่ยนแปลง และอาจมีวิดเจ็ตย่อยใหม่

AnimatedBuilder ช่วยให้มั่นใจได้ว่าระบบจะสร้างแผนผังวิดเจ็ตใหม่ทุกครั้งที่ AnimationController แจ้งเตือนผู้ฟัง และใช้วิดเจ็ต Transform เพื่อใช้เอฟเฟกต์การหมุนแบบ 3 มิติเพื่อจำลองการพลิกบัตร

หากต้องการใช้วิดเจ็ตนี้ ให้ใส่การ์ดคำตอบแต่ละใบไว้ใน CardFlipEffect widget โปรดระบุ key ให้กับวิดเจ็ต Card ดังนี้

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

ตอนนี้ให้โหลดแอปซ้ำอย่างรวดเร็วเพื่อดูการ์ดคำตอบพลิกกลับโดยใช้วิดเจ็ต CardFlipEffect

5455def725b866f6.gif

คุณอาจสังเกตเห็นว่าคลาสนี้มีลักษณะคล้ายกับเอฟเฟกต์ภาพเคลื่อนไหวที่ชัดเจนมาก ในความเป็นจริงแล้ว การขยายAnimatedWidgetคลาสโดยตรงเพื่อใช้เวอร์ชันของคุณเองมักจะเป็นความคิดที่ดี แต่เนื่องจากคลาสนี้ต้องจัดเก็บวิดเจ็ตก่อนหน้าใน State จึงต้องใช้ StatefulWidget ดูข้อมูลเพิ่มเติมเกี่ยวกับการสร้างเอฟเฟกต์ภาพเคลื่อนไหวที่ชัดเจนของคุณเองได้ในเอกสารประกอบ API สำหรับ AnimatedWidget

เพิ่มการหน่วงเวลาโดยใช้ TweenSequence

ในส่วนนี้ คุณจะเพิ่มการหน่วงเวลาให้กับวิดเจ็ต CardFlipEffect เพื่อให้การ์ดแต่ละใบพลิกทีละใบ หากต้องการเริ่มต้นใช้งาน ให้เพิ่มฟิลด์ใหม่ชื่อ delayAmount

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

จากนั้นเพิ่ม delayAmount ไปยังเมธอดบิลด์ AnswerCards

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

จากนั้นใน _CardFlipEffectState ให้สร้าง Animation ใหม่ที่ใช้การหน่วงเวลาโดยใช้ TweenSequence โปรดทราบว่าโค้ดนี้ไม่ได้ใช้ยูทิลิตีใดๆ จากไลบรารี dart:async เช่น Future.delayed เนื่องจากความล่าช้าเป็นส่วนหนึ่งของภาพเคลื่อนไหว และไม่ใช่สิ่งที่วิดเจ็ตควบคุมอย่างชัดเจนเมื่อใช้ AnimationController ซึ่งจะช่วยให้แก้ไขข้อบกพร่องของเอฟเฟกต์ภาพเคลื่อนไหวได้ง่ายขึ้นเมื่อเปิดใช้ภาพเคลื่อนไหวช้าในเครื่องมือสำหรับนักพัฒนาเว็บ เนื่องจากใช้TickerProviderเดียวกัน

หากต้องการใช้ TweenSequence ให้สร้าง TweenSequenceItem 2 รายการ โดยรายการหนึ่งมี ConstantTween ที่ทำให้ภาพเคลื่อนไหวอยู่ที่ 0 สำหรับระยะเวลาที่สัมพันธ์กัน และอีกรายการหนึ่งเป็น Tween ปกติที่เปลี่ยนจาก 0.0 เป็น 1.0

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay;            // NEW

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration * (widget.delayAmount + 1),
    );

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

สุดท้าย ให้แทนที่ภาพเคลื่อนไหวของ AnimationController ด้วยภาพเคลื่อนไหวที่หน่วงเวลาใหม่ในเมธอด build

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

ตอนนี้ให้โหลดแอปซ้ำอย่างรวดเร็ว แล้วดูการ์ดพลิกทีละใบ หากต้องการความท้าทาย ลองเปลี่ยนมุมมองของเอฟเฟกต์ 3 มิติที่วิดเจ็ต Transform มีให้

28b5291de9b3f55f.gif

7. ใช้การเปลี่ยนฉากการนำทางที่กำหนดเอง

ที่ผ่านมาเราได้เห็นวิธีปรับแต่งเอฟเฟกต์บนหน้าจอเดียวไปแล้ว แต่การใช้ภาพเคลื่อนไหวอีกวิธีหนึ่งคือการใช้เพื่อเปลี่ยนผ่านระหว่างหน้าจอ ในส่วนนี้ คุณจะได้เรียนรู้วิธีใช้เอฟเฟกต์ภาพเคลื่อนไหวกับการเปลี่ยนหน้าจอโดยใช้เอฟเฟกต์ภาพเคลื่อนไหวในตัวและเอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าอย่างสวยงามซึ่งมีให้ในแพ็กเกจ animations อย่างเป็นทางการบน pub.dev

สร้างภาพเคลื่อนไหวการเปลี่ยนการนำทาง

PageRouteBuilder คลาสคือ Route ที่ช่วยให้คุณปรับแต่งภาพเคลื่อนไหวการเปลี่ยนได้ ซึ่งจะช่วยให้คุณลบล้างtransitionBuilderการเรียกกลับของฟังก์ชันนี้ ซึ่งจะให้ออบเจ็กต์ Animation 2 รายการที่แสดงถึงภาพเคลื่อนไหวขาเข้าและขาออกที่ Navigator เรียกใช้

หากต้องการปรับแต่งภาพเคลื่อนไหวของการเปลี่ยน ให้แทนที่ MaterialPageRoute ด้วย PageRouteBuilder และหากต้องการปรับแต่งภาพเคลื่อนไหวของการเปลี่ยนเมื่อผู้ใช้ไปยังจาก HomeScreen ไปยัง QuestionScreen ใช้ FadeTransition (วิดเจ็ตแบบเคลื่อนไหวอย่างชัดเจน) เพื่อให้หน้าจอใหม่ค่อยๆ ปรากฏขึ้นเหนือหน้าจอก่อนหน้า

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // Add from here...
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeTransition(
                opacity: animation,
                child: child,
              );
            },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

แพ็กเกจภาพเคลื่อนไหวมีเอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าอย่างสวยงาม เช่น FadeThroughTransition นำเข้าแพ็กเกจภาพเคลื่อนไหวและแทนที่ FadeTransition ด้วยวิดเจ็ต FadeThroughTransition โดยทำดังนี้

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeThroughTransition(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  child: Text('New Game'),
),

ปรับแต่งภาพเคลื่อนไหวของการย้อนกลับที่คาดการณ์ได้

1c0558ffa3b76439.gif

การย้อนกลับแบบคาดการณ์เป็นฟีเจอร์ใหม่ของ Android ที่ช่วยให้ผู้ใช้มองเห็นเส้นทางหรือแอปที่อยู่เบื้องหลังเส้นทางหรือแอปปัจจุบันก่อนที่จะไปยังส่วนอื่น ภาพเคลื่อนไหวการแอบดูจะขับเคลื่อนด้วยตำแหน่งนิ้วของผู้ใช้ขณะที่ลากกลับไปบนหน้าจอ

Flutter รองรับการย้อนกลับแบบคาดการณ์ของระบบโดยการเปิดใช้ฟีเจอร์ที่ระดับระบบเมื่อ Flutter ไม่มีเส้นทางที่จะแสดงในสแต็กการนำทาง หรือกล่าวอีกนัยหนึ่งคือเมื่อการย้อนกลับจะออกจากแอป ระบบจะเป็นผู้จัดการภาพเคลื่อนไหวนี้ ไม่ใช่ Flutter เอง

Flutter ยังรองรับการย้อนกลับที่คาดการณ์ได้เมื่อไปยังส่วนต่างๆ ระหว่างเส้นทางภายในแอป Flutter โดยมีPageTransitionsBuilderพิเศษที่เรียกว่า PredictiveBackPageTransitionsBuilder ซึ่งจะคอยฟังท่าทางสัมผัสสำหรับย้อนกลับที่คาดการณ์ได้ของระบบและขับเคลื่อนการเปลี่ยนหน้าด้วยความคืบหน้าของท่าทางสัมผัส

การย้อนกลับแบบคาดการณ์ใช้ได้เฉพาะใน Android U ขึ้นไป แต่ Flutter จะกลับไปใช้ลักษณะการทำงานของท่าทางสัมผัสย้อนกลับเดิมและ ZoomPageTransitionBuilder โดยอัตโนมัติ ดูข้อมูลเพิ่มเติมได้ในบล็อกโพสต์ ซึ่งรวมถึงส่วนเกี่ยวกับวิธีตั้งค่าในแอปของคุณเอง

ในการกำหนดค่า ThemeData สำหรับแอป ให้กำหนดค่า PageTransitionsTheme เพื่อใช้ PredictiveBack ใน Android และเอฟเฟกต์การเปลี่ยนฉากแบบจางผ่านจากแพ็กเกจภาพเคลื่อนไหวในแพลตฟอร์มอื่นๆ

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

ตอนนี้คุณเปลี่ยนการโทรกลับของ Navigator.push() เป็น MaterialPageRoute ได้แล้ว

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(                                        // Add from here...
        builder: (context) {
          return const QuestionScreen();
        },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

ใช้ FadeThroughTransition เพื่อเปลี่ยนคำถามปัจจุบัน

วิดเจ็ต AnimatedSwitcher จะมี Animation เพียงรายการเดียวในโค้ดเรียกกลับของเครื่องมือสร้าง แพ็กเกจ animations มี PageTransitionSwitcher เพื่อแก้ไขปัญหานี้

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },                                                        // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

ใช้ OpenContainer

77358e5776eb104c.png

วิดเจ็ต OpenContainer จากแพ็กเกจ animations มีเอฟเฟกต์ภาพเคลื่อนไหวการเปลี่ยนรูปแบบคอนเทนเนอร์ที่ขยายเพื่อสร้างการเชื่อมต่อด้วยภาพระหว่างวิดเจ็ต 2 รายการ

วิดเจ็ตที่ closedBuilder แสดงจะปรากฏขึ้นในตอนแรก และจะขยายเป็นวิดเจ็ตที่ openBuilder แสดงเมื่อมีการแตะคอนเทนเนอร์หรือเมื่อมีการเรียกใช้openContainer Callback

หากต้องการเชื่อมต่อopenContainerการเรียกกลับกับ View-Model ให้เพิ่มviewModelใหม่ลงในวิดเจ็ต QuestionCard และจัดเก็บการเรียกกลับที่จะใช้เพื่อแสดงหน้าจอ "เกมโอเวอร์"

lib/question_screen.dart

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

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel = QuizViewModel(
    onGameOver: _handleGameOver,
  );
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                    ? () {
                        viewModel.getNextQuestion();
                      }
                    : null,
                child: const Text('Next'),
              ),
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

เพิ่มวิดเจ็ตใหม่ GameOverScreen โดยทำดังนี้

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(automaticallyImplyLeading: false),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text('You Win!', style: Theme.of(context).textTheme.displayLarge),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

ในวิดเจ็ต QuestionCard ให้แทนที่ Card ด้วยวิดเจ็ต OpenContainer จากแพ็กเกจ animations โดยเพิ่มฟิลด์ใหม่ 2 รายการสำหรับ viewModel และการเรียกกลับของคอนเทนเนอร์แบบเปิด ดังนี้

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. ขอแสดงความยินดี

ขอแสดงความยินดี คุณเพิ่มเอฟเฟกต์ภาพเคลื่อนไหวลงในแอป Flutter และได้เรียนรู้เกี่ยวกับคอมโพเนนต์หลักของระบบภาพเคลื่อนไหวของ Flutter เรียบร้อยแล้ว โดยเฉพาะอย่างยิ่ง คุณได้เรียนรู้สิ่งต่อไปนี้

  • วิธีใช้ ImplicitlyAnimatedWidget
  • วิธีใช้ ExplicitlyAnimatedWidget
  • วิธีใช้ Curves และ Tweens กับภาพเคลื่อนไหว
  • วิธีใช้วิดเจ็ตการเปลี่ยนฉากที่สร้างไว้ล่วงหน้า เช่น AnimatedSwitcher หรือ PageRouteBuilder
  • วิธีใช้เอฟเฟกต์ภาพเคลื่อนไหวที่สร้างไว้ล่วงหน้าอย่างสวยงามจากแพ็กเกจ animations เช่น FadeThroughTransition และ OpenContainer
  • วิธีกำหนดภาพเคลื่อนไหวการเปลี่ยนเริ่มต้น รวมถึงการเพิ่มการรองรับการย้อนกลับที่คาดการณ์ได้ใน Android

3026390ad413769c.gif

ขั้นตอนต่อไปคืออะไร

ลองใช้ Codelab เหล่านี้

หรือดาวน์โหลดแอปตัวอย่างภาพเคลื่อนไหว ซึ่งแสดงเทคนิคภาพเคลื่อนไหวต่างๆ

อ่านเพิ่มเติม

ดูแหล่งข้อมูลเพิ่มเติมเกี่ยวกับภาพเคลื่อนไหวได้ที่ flutter.dev

หรืออ่านบทความต่อไปนี้ใน Medium

เอกสารอ้างอิง