สร้างเลย์เอาต์แอปที่ปรับเปลี่ยนตามอุปกรณ์และเคลื่อนไหวด้วย Material 3

1. บทนำ

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

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

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

  • ใช้การออกแบบที่ปรับเปลี่ยนได้เพื่อให้ใช้งานได้บนเดสก์ท็อปหรืออุปกรณ์เคลื่อนที่
  • ใช้ภาพเคลื่อนไหวเพื่อสลับระหว่างเลย์เอาต์ต่างๆ อย่างราบรื่น
  • ใช้ Material 3 สําหรับการจัดสไตล์ที่สื่อความหมาย
  • ทำงานบน Android, iOS, เว็บ, Windows, Linux และ macOS

4111f0eb4aba816f.png

Codelab นี้จะมุ่งเน้นที่ Material 3 ด้วย Flutter แนวคิดและบล็อกโค้ดที่ไม่เกี่ยวข้องจะได้รับการอธิบายโดยย่อและพร้อมให้คุณคัดลอกและวาง

2. ตั้งค่าสภาพแวดล้อม Flutter

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

โค้ดแล็บนี้ได้รับการทดสอบเพื่อนำไปใช้งานใน Android, iOS, เว็บ, Windows, Linux และ macOS แล้ว เป้าหมายการติดตั้งใช้งานบางรายการเหล่านี้ต้องติดตั้งซอฟต์แวร์เพิ่มเติมจึงจะติดตั้งใช้งานได้ วิธีที่ดีในการทำความเข้าใจว่าแพลตฟอร์มได้รับการตั้งค่าอย่างถูกต้องหรือไม่คือให้เรียกใช้ flutter doctor

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.32.1, on macOS 15.5 24F74 darwin-arm64, locale en-AU)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.3)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] IntelliJ IDEA Community Edition (version 2024.3.1.1)
[✓] VS Code (version 1.100.3)
[✓] Connected device (3 available)
[✓] Network resources

• No issues found!

หากมีปัญหาแสดงอยู่ในเอาต์พุตที่ส่งผลต่อเป้าหมายการติดตั้งใช้งานที่คุณเลือก ให้เรียกใช้ flutter doctor -v เพื่อดูรายละเอียดเพิ่มเติม หากแก้ปัญหาไม่ได้หลังจากลองทำตามขั้นตอนที่ flutter doctor -v ระบุไว้ ให้ลองติดต่อชุมชน Flutter

3. เริ่มต้นใช้งาน

สร้างแอปพลิเคชัน Flutter เปล่า

นักพัฒนา Flutter ส่วนใหญ่สร้างแอป "การนับการแตะปุ่ม" พื้นฐานด้วย flutter create แล้วใช้เวลา 2-3 นาทีในการนำสิ่งที่ไม่จำเป็นออก คุณสามารถสร้างโปรเจ็กต์ Flutter ที่ว่างเปล่า (โดยใช้พารามิเตอร์ --empty) ที่มีเพียงข้อมูลที่จำเป็นที่สุดเพื่อทำให้แอปทำงานได้

$ flutter create animated_responsive_layout --empty
Creating project animated_responsive_layout...
Resolving dependencies in `animated_responsive_layout`...
Downloading packages...
Got dependencies in `animated_responsive_layout`.
Wrote 129 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your empty application, type:

  $ cd animated_responsive_layout
  $ flutter run

Your empty application code is in animated_responsive_layout/lib/main.dart.

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

$ cd animated_responsive_layout
$ flutter run
Connected devices:
macOS (desktop) • macos  • darwin-arm64   • macOS 15.5 24F74 darwin-arm64
Chrome (web)    • chrome • web-javascript • Google Chrome 137.0.7151.56

[1]: macOS (macos)
[2]: Chrome (chrome)
Please choose one (or "q" to quit): 2
Launching lib/main.dart on Chrome in debug mode...
Waiting for connection from debug service on Chrome...              6.4s
This app is linked to the debug service: ws://127.0.0.1:60848/AM68Aq_ZiB8=/ws
Debug service listening on ws://127.0.0.1:60848/AM68Aq_ZiB8=/ws

Flutter run key commands.
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

A Dart VM Service on Chrome is available at: http://127.0.0.1:60848/AM68Aq_ZiB8=
The Flutter DevTools debugger and profiler on Chrome is available at: http://127.0.0.1:9100?uri=http://127.0.0.1:60848/AM68Aq_ZiB8=
Application finished.

ในกรณีนี้ คุณจะเห็นแอปว่างเปล่าที่ทำงานอยู่ในเว็บเบราว์เซอร์ Chrome นอกจากนี้ คุณยังเลือกที่จะเรียกใช้ใน Android, iOS หรือระบบปฏิบัติการเดสก์ท็อปได้ด้วย

152efb79ce615edb.png

4. สร้างแอปรับส่งข้อความ

สร้างรูปโปรไฟล์

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

$ mkdir assets
$ cd assets
$ for name in avatar_1 avatar_2 avatar_3 avatar_4 \
              avatar_5 avatar_6 avatar_7 thumbnail_1; \
  do wget https://raw.githubusercontent.com/flutter/codelabs/main/animated-responsive-layout/step_04/assets/$name.png ; \
  done

การดำเนินการนี้จะดาวน์โหลดรูปภาพต่อไปนี้ลงในไดเรกทอรี assets ของแอป

avatar_1.png

avatar_2.png

avatar_3.png

avatar_4.png

avatar_5.png

avatar_6.png

avatar_7.png

thumbnail_1.png

เมื่อคุณมีชิ้นงานรูปภาพสำหรับรูปโปรไฟล์แล้ว คุณต้องเพิ่มชิ้นงานดังกล่าวลงในไฟล์ pubspec.yaml ดังนี้

pubspec.yaml

name: animated_responsive_layout
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.0

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

                                        # Add from here...
  assets:
    - assets/avatar_1.png
    - assets/avatar_2.png
    - assets/avatar_3.png
    - assets/avatar_4.png
    - assets/avatar_5.png
    - assets/avatar_6.png
    - assets/avatar_7.png
    - assets/thumbnail_1.png
                                        # ... to here.

แอปพลิเคชันต้องใช้แหล่งข้อมูลสําหรับข้อความที่แสดง สร้างไดเรกทอรีย่อย models ในไดเรกทอรี lib ของโปรเจ็กต์ ซึ่งทำได้ในบรรทัดคำสั่งด้วย mkdir หรือในเครื่องมือแก้ไขข้อความที่คุณเลือก สร้างไฟล์ models.dart ในไดเรกทอรี lib/models ที่มีเนื้อหาต่อไปนี้

lib/models/models.dart

class Attachment {
  const Attachment({required this.url});

  final String url;
}

class Email {
  const Email({
    required this.sender,
    required this.recipients,
    required this.subject,
    required this.content,
    this.replies = 0,
    this.attachments = const [],
  });

  final User sender;
  final List<User> recipients;
  final String subject;
  final String content;
  final List<Attachment> attachments;
  final double replies;
}

class Name {
  const Name({required this.first, required this.last});

  final String first;
  final String last;
  String get fullName => '$first $last';
}

class User {
  const User({
    required this.name,
    required this.avatarUrl,
    required this.lastActive,
  });

  final Name name;
  final String avatarUrl;
  final DateTime lastActive;
}

เมื่อคุณมีคำจำกัดความของรูปแบบข้อมูลแล้ว ให้สร้างไฟล์ data.dart ในไดเรกทอรี lib/models โดยมีเนื้อหาดังต่อไปนี้

lib/models/data.dart

import 'models.dart';

final User user_0 = User(
  name: const Name(first: 'Me', last: ''),
  avatarUrl: 'assets/avatar_1.png',
  lastActive: DateTime.now(),
);
final User user_1 = User(
  name: const Name(first: '老', last: '强'),
  avatarUrl: 'assets/avatar_2.png',
  lastActive: DateTime.now().subtract(const Duration(minutes: 10)),
);
final User user_2 = User(
  name: const Name(first: 'So', last: 'Duri'),
  avatarUrl: 'assets/avatar_3.png',
  lastActive: DateTime.now().subtract(const Duration(minutes: 20)),
);
final User user_3 = User(
  name: const Name(first: 'Lily', last: 'MacDonald'),
  avatarUrl: 'assets/avatar_4.png',
  lastActive: DateTime.now().subtract(const Duration(hours: 2)),
);
final User user_4 = User(
  name: const Name(first: 'Ziad', last: 'Aouad'),
  avatarUrl: 'assets/avatar_5.png',
  lastActive: DateTime.now().subtract(const Duration(hours: 6)),
);

final List<Email> emails = [
  Email(
    sender: user_1,
    recipients: [],
    subject: '豆花鱼',
    content: '最近忙吗?昨晚我去了你最爱的那家饭馆,点了他们的特色豆花鱼,吃着吃着就想你了。',
  ),
  Email(
    sender: user_2,
    recipients: [],
    subject: 'Dinner Club',
    content:
        'I think it's time for us to finally try that new noodle shop downtown that doesn't use menus. Anyone else have other suggestions for dinner club this week? I'm so intrigued by this idea of a noodle restaurant where no one gets to order for themselves - could be fun, or terrible, or both :)\n\nSo',
  ),
  Email(
    sender: user_3,
    recipients: [],
    subject: 'This food show is made for you',
    content:
        'Ping– you'd love this new food show I started watching. It's produced by a Thai drummer who started getting recognized for the amazing vegan food she always brought to shows.',
    attachments: [const Attachment(url: 'assets/thumbnail_1.png')],
  ),
  Email(
    sender: user_4,
    recipients: [],
    subject: 'Volunteer EMT with me?',
    content:
        'What do you think about training to be volunteer EMTs? We could do it together for moral support. Think about it??',
  ),
];

final List<Email> replies = [
  Email(
    sender: user_2,
    recipients: [user_3, user_2],
    subject: 'Dinner Club',
    content:
        'I think it's time for us to finally try that new noodle shop downtown that doesn't use menus. Anyone else have other suggestions for dinner club this week? I'm so intrigued by this idea of a noodle restaurant where no one gets to order for themselves - could be fun, or terrible, or both :)\n\nSo',
  ),
  Email(
    sender: user_0,
    recipients: [user_3, user_2],
    subject: 'Dinner Club',
    content:
        'Yes! I forgot about that place! I'm definitely up for taking a risk this week and handing control over to this mysterious noodle chef. I wonder what happens if you have allergies though? Lucky none of us have any otherwise I'd be a bit concerned.\n\nThis is going to be great. See you all at the usual time?',
  ),
];

เมื่อได้ข้อมูลดังกล่าวแล้ว ก็ถึงเวลากำหนดวิดเจ็ต 2-3 รายการเพื่อแสดงข้อมูล สร้างไดเรกทอรีย่อยใน lib ชื่อ widgets คุณจะต้องสร้างไฟล์ 4 ไฟล์ใน widgets และอาจได้รับคำเตือนบางอย่างจากผู้แก้ไขจนกว่าคุณจะสร้างไฟล์ทั้ง 4 ไฟล์ อย่าลืมว่าจุดประสงค์ของ Codelab นี้คือการจัดสไตล์แอปโดยใช้ Material 3 ดังนั้น ให้เพิ่มไฟล์ 4 ไฟล์ต่อไปนี้พร้อมเนื้อหาที่ระบุ

lib/widgets/email_list_view.dart

import 'package:flutter/material.dart';

import '../models/data.dart' as data;
import '../models/models.dart';
import 'email_widget.dart';
import 'search_bar.dart' as search_bar;

class EmailListView extends StatelessWidget {
  const EmailListView({
    super.key,
    this.selectedIndex,
    this.onSelected,
    required this.currentUser,
  });

  final int? selectedIndex;
  final ValueChanged<int>? onSelected;
  final User currentUser;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      child: ListView(
        children: [
          const SizedBox(height: 8),
          search_bar.SearchBar(currentUser: currentUser),
          const SizedBox(height: 8),
          ...List.generate(data.emails.length, (index) {
            return Padding(
              padding: const EdgeInsets.only(bottom: 8.0),
              child: EmailWidget(
                email: data.emails[index],
                onSelected: onSelected != null
                    ? () {
                        onSelected!(index);
                      }
                    : null,
                isSelected: selectedIndex == index,
              ),
            );
          }),
        ],
      ),
    );
  }
}

การแสดงรายการอีเมลดูเหมือนว่าจะเป็นสิ่งที่แอปรับส่งข้อความควรทำได้ คุณจะได้รับคําร้องเรียน 2-3 รายการจากผู้แก้ไข แต่คุณสามารถแก้ไขได้บางส่วนโดยการเพิ่มไฟล์ถัดไป email_widget.dart

lib/widgets/email_widget.dart

import 'package:flutter/material.dart';
import '../models/models.dart';
import 'star_button.dart';

enum EmailType { preview, threaded, primaryThreaded }

class EmailWidget extends StatefulWidget {
  const EmailWidget({
    super.key,
    required this.email,
    this.isSelected = false,
    this.isPreview = true,
    this.isThreaded = false,
    this.showHeadline = false,
    this.onSelected,
  });

  final bool isSelected;
  final bool isPreview;
  final bool showHeadline;
  final bool isThreaded;
  final void Function()? onSelected;
  final Email email;

  @override
  State<EmailWidget> createState() => _EmailWidgetState();
}

class _EmailWidgetState extends State<EmailWidget> {
  late final ColorScheme _colorScheme = Theme.of(context).colorScheme;
  late Color unselectedColor = Color.alphaBlend(
    _colorScheme.primary.withAlpha(20),
    _colorScheme.surface,
  );

  Color get _surfaceColor => switch (widget) {
    EmailWidget(isPreview: false) => _colorScheme.surface,
    EmailWidget(isSelected: true) => _colorScheme.primaryContainer,
    _ => unselectedColor,
  };

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: widget.onSelected,
      child: Card(
        elevation: 0,
        color: _surfaceColor,
        clipBehavior: Clip.hardEdge,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            if (widget.showHeadline) ...[
              EmailHeadline(email: widget.email, isSelected: widget.isSelected),
            ],
            EmailContent(
              email: widget.email,
              isPreview: widget.isPreview,
              isThreaded: widget.isThreaded,
              isSelected: widget.isSelected,
            ),
          ],
        ),
      ),
    );
  }
}

class EmailContent extends StatefulWidget {
  const EmailContent({
    super.key,
    required this.email,
    required this.isPreview,
    required this.isThreaded,
    required this.isSelected,
  });

  final Email email;
  final bool isPreview;
  final bool isThreaded;
  final bool isSelected;

  @override
  State<EmailContent> createState() => _EmailContentState();
}

class _EmailContentState extends State<EmailContent> {
  late final ColorScheme _colorScheme = Theme.of(context).colorScheme;
  late final TextTheme _textTheme = Theme.of(context).textTheme;

  Widget get contentSpacer => SizedBox(height: widget.isThreaded ? 20 : 2);

  String get lastActiveLabel {
    final DateTime now = DateTime.now();
    if (widget.email.sender.lastActive.isAfter(now)) throw ArgumentError();
    final Duration elapsedTime = widget.email.sender.lastActive
        .difference(now)
        .abs();
    return switch (elapsedTime) {
      Duration(inSeconds: < 60) => '${elapsedTime.inSeconds}s',
      Duration(inMinutes: < 60) => '${elapsedTime.inMinutes}m',
      Duration(inHours: < 24) => '${elapsedTime.inHours}h',
      Duration(inDays: < 365) => '${elapsedTime.inDays}d',
      _ => throw UnimplementedError(),
    };
  }

  TextStyle? get contentTextStyle => switch (widget) {
    EmailContent(isThreaded: true) => _textTheme.bodyLarge,
    EmailContent(isSelected: true) => _textTheme.bodyMedium?.copyWith(
      color: _colorScheme.onPrimaryContainer,
    ),
    _ => _textTheme.bodyMedium?.copyWith(color: _colorScheme.onSurfaceVariant),
  };

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          LayoutBuilder(
            builder: (context, constraints) {
              return Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  if (constraints.maxWidth - 200 > 0) ...[
                    CircleAvatar(
                      backgroundImage: AssetImage(
                        widget.email.sender.avatarUrl,
                      ),
                    ),
                    const Padding(
                      padding: EdgeInsets.symmetric(horizontal: 6.0),
                    ),
                  ],
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          widget.email.sender.name.fullName,
                          overflow: TextOverflow.fade,
                          maxLines: 1,
                          style: widget.isSelected
                              ? _textTheme.labelMedium?.copyWith(
                                  color: _colorScheme.onSecondaryContainer,
                                )
                              : _textTheme.labelMedium?.copyWith(
                                  color: _colorScheme.onSurface,
                                ),
                        ),
                        Text(
                          lastActiveLabel,
                          overflow: TextOverflow.fade,
                          maxLines: 1,
                          style: widget.isSelected
                              ? _textTheme.labelMedium?.copyWith(
                                  color: _colorScheme.onSecondaryContainer,
                                )
                              : _textTheme.labelMedium?.copyWith(
                                  color: _colorScheme.onSurfaceVariant,
                                ),
                        ),
                      ],
                    ),
                  ),
                  if (constraints.maxWidth - 200 > 0) ...[const StarButton()],
                ],
              );
            },
          ),
          const SizedBox(width: 8),
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              if (widget.isPreview) ...[
                Text(
                  widget.email.subject,
                  style: const TextStyle(
                    fontSize: 18,
                  ).copyWith(color: _colorScheme.onSurface),
                ),
              ],
              if (widget.isThreaded) ...[
                contentSpacer,
                Text(
                  "To ${widget.email.recipients.map((recipient) => recipient.name.first).join(", ")}",
                  style: _textTheme.bodyMedium,
                ),
              ],
              contentSpacer,
              Text(
                widget.email.content,
                maxLines: widget.isPreview ? 2 : 100,
                overflow: TextOverflow.ellipsis,
                style: contentTextStyle,
              ),
            ],
          ),
          const SizedBox(width: 12),
          widget.email.attachments.isNotEmpty
              ? Container(
                  height: 96,
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(8.0),
                    image: DecorationImage(
                      fit: BoxFit.cover,
                      image: AssetImage(widget.email.attachments.first.url),
                    ),
                  ),
                )
              : const SizedBox.shrink(),
          if (!widget.isPreview) ...[const EmailReplyOptions()],
        ],
      ),
    );
  }
}

class EmailHeadline extends StatefulWidget {
  const EmailHeadline({
    super.key,
    required this.email,
    required this.isSelected,
  });

  final Email email;
  final bool isSelected;

  @override
  State<EmailHeadline> createState() => _EmailHeadlineState();
}

class _EmailHeadlineState extends State<EmailHeadline> {
  late final TextTheme _textTheme = Theme.of(context).textTheme;
  late final ColorScheme _colorScheme = Theme.of(context).colorScheme;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return Container(
          height: 84,
          color: Color.alphaBlend(
            _colorScheme.primary.withAlpha(12),
            _colorScheme.surface,
          ),
          child: Padding(
            padding: const EdgeInsets.fromLTRB(24, 12, 12, 12),
            child: Row(
              mainAxisSize: MainAxisSize.max,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Expanded(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        widget.email.subject,
                        maxLines: 1,
                        overflow: TextOverflow.fade,
                        style: const TextStyle(
                          fontSize: 18,
                          fontWeight: FontWeight.w400,
                        ),
                      ),
                      Text(
                        '${widget.email.replies.toString()} Messages',
                        maxLines: 1,
                        overflow: TextOverflow.fade,
                        style: _textTheme.labelMedium?.copyWith(
                          fontWeight: FontWeight.w500,
                        ),
                      ),
                    ],
                  ),
                ),
                // Display a "condensed" version if the widget in the row are
                // expected to overflow.
                if (constraints.maxWidth - 200 > 0) ...[
                  SizedBox(
                    height: 40,
                    width: 40,
                    child: FloatingActionButton(
                      onPressed: () {},
                      elevation: 0,
                      backgroundColor: _colorScheme.surface,
                      child: const Icon(Icons.delete_outline),
                    ),
                  ),
                  const Padding(padding: EdgeInsets.only(right: 8.0)),
                  SizedBox(
                    height: 40,
                    width: 40,
                    child: FloatingActionButton(
                      onPressed: () {},
                      elevation: 0,
                      backgroundColor: _colorScheme.surface,
                      child: const Icon(Icons.more_vert),
                    ),
                  ),
                ],
              ],
            ),
          ),
        );
      },
    );
  }
}

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

  @override
  State<EmailReplyOptions> createState() => _EmailReplyOptionsState();
}

class _EmailReplyOptionsState extends State<EmailReplyOptions> {
  late final ColorScheme _colorScheme = Theme.of(context).colorScheme;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 100) {
          return const SizedBox.shrink();
        }
        return Row(
          children: [
            Expanded(
              child: TextButton(
                style: ButtonStyle(
                  backgroundColor: WidgetStateProperty.all(
                    _colorScheme.onInverseSurface,
                  ),
                ),
                onPressed: () {},
                child: Text(
                  'Reply',
                  style: TextStyle(color: _colorScheme.onSurfaceVariant),
                ),
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: TextButton(
                style: ButtonStyle(
                  backgroundColor: WidgetStateProperty.all(
                    _colorScheme.onInverseSurface,
                  ),
                ),
                onPressed: () {},
                child: Text(
                  'Reply All',
                  style: TextStyle(color: _colorScheme.onSurfaceVariant),
                ),
              ),
            ),
          ],
        );
      },
    );
  }
}

ใช่ มีการดำเนินการหลายอย่างในวิดเจ็ตนั้น คุณควรศึกษารายละเอียดบางอย่าง โดยเฉพาะเพื่อดูวิธีใช้สีในวิดเจ็ต การดำเนินการนี้จะกลายเป็นธีมที่กลับมาซ้ำ ถัดไปคือ search_bar.dart

lib/widgets/search_bar.dart

import 'package:flutter/material.dart';

import '../models/models.dart';

class SearchBar extends StatelessWidget {
  const SearchBar({super.key, required this.currentUser});

  final User currentUser;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 56,
      child: Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(100),
          color: Colors.white,
        ),
        padding: const EdgeInsets.fromLTRB(31, 12, 12, 12),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            const Icon(Icons.search),
            const SizedBox(width: 23.5),
            Expanded(
              child: TextField(
                maxLines: 1,
                decoration: InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  hintText: 'Search replies',
                  hintStyle: Theme.of(context).textTheme.bodyMedium,
                ),
              ),
            ),
            CircleAvatar(backgroundImage: AssetImage(currentUser.avatarUrl)),
          ],
        ),
      ),
    );
  }
}

วิดเจ็ตที่ใช้งานง่ายกว่ามากและไม่มีสถานะ ถัดไป ให้เพิ่มวิดเจ็ต star_button.dart ดังนี้

lib/widgets/star_button.dart

impoimport 'package:flutter/material.dart';

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

  @override
  State<StarButton> createState() => _StarButtonState();
}

class _StarButtonState extends State<StarButton> {
  bool state = false;
  late final ColorScheme _colorScheme = Theme.of(context).colorScheme;

  Icon get icon {
    final IconData iconData = state ? Icons.star : Icons.star_outline;

    return Icon(iconData, color: Colors.grey, size: 20);
  }

  void _toggle() {
    setState(() {
      state = !state;
    });
  }

  double get turns => state ? 1 : 0;

  @override
  Widget build(BuildContext context) {
    return AnimatedRotation(
      turns: turns,
      curve: Curves.decelerate,
      duration: const Duration(milliseconds: 300),
      child: FloatingActionButton(
        elevation: 0,
        shape: const CircleBorder(),
        backgroundColor: _colorScheme.surface,
        onPressed: () => _toggle(),
        child: Padding(padding: const EdgeInsets.all(10.0), child: icon),
      ),
    );
  }
}

ถัดไป ให้อัปเดตดาราหลักของรายการ lib/main.dart แทนที่เนื้อหาปัจจุบันของไฟล์นั้นด้วยข้อมูลต่อไปนี้

lib/main.dart

import 'package:flutter/material.dart';

import 'models/data.dart' as data;
import 'models/models.dart';
import 'widgets/email_list_view.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(),
      home: Feed(currentUser: data.user_0),
    );
  }
}

class Feed extends StatefulWidget {
  const Feed({super.key, required this.currentUser});

  final User currentUser;

  @override
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> {
  late final _colorScheme = Theme.of(context).colorScheme;
  late final _backgroundColor = Color.alphaBlend(
    _colorScheme.primary.withAlpha(36),
    _colorScheme.surface,
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: _backgroundColor,
        child: EmailListView(currentUser: widget.currentUser),
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: _colorScheme.tertiaryContainer,
        foregroundColor: _colorScheme.onTertiaryContainer,
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

เรียกใช้แอปเพื่อดูสิ่งที่คุณเริ่มต้นด้วย

43f8d99b09e0f983.png

5. เพิ่ม NavigationBar

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

การมี NavigationBar หมายความว่ามีปลายทางให้ไปยัง สร้างไฟล์ใหม่ในไดเรกทอรี lib ชื่อ destinations.dart แล้วป้อนโค้ดต่อไปนี้

lib/destinations.dart

import 'package:flutter/material.dart';

class Destination {
  const Destination(this.icon, this.label);
  final IconData icon;
  final String label;
}

const List<Destination> destinations = <Destination>[
  Destination(Icons.inbox_rounded, 'Inbox'),
  Destination(Icons.article_outlined, 'Articles'),
  Destination(Icons.messenger_outline_rounded, 'Messages'),
  Destination(Icons.group_outlined, 'Groups'),
];

ซึ่งทำให้แอปพลิเคชันมีปลายทาง 4 แห่งสำหรับ NavigationBar ที่จะแสดง ถัดไป ให้ต่อรายการปลายทางนี้ลงในไฟล์ lib/main.dart ดังนี้

lib/main.dart

import 'package:flutter/material.dart';

import 'destinations.dart';                    // Add this import
import 'models/data.dart' as data;
import 'models/models.dart';
import 'widgets/email_list_view.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(),
      home: Feed(currentUser: data.user_0),
    );
  }
}

class Feed extends StatefulWidget {
  const Feed({super.key, required this.currentUser});

  final User currentUser;

  @override
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> {
  late final _colorScheme = Theme.of(context).colorScheme;
  late final _backgroundColor = Color.alphaBlend(
    _colorScheme.primary.withAlpha(36),
    _colorScheme.surface,
  );

  int selectedIndex = 0;                       // Add this variable

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: _backgroundColor,
        child: EmailListView(
                                              // Add from here...
          selectedIndex: selectedIndex,
          onSelected: (index) {
            setState(() {
              selectedIndex = index;
            });
          },
                                              // ... to here.
          currentUser: widget.currentUser,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        backgroundColor: _colorScheme.tertiaryContainer,
        foregroundColor: _colorScheme.onTertiaryContainer,
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
                                                  // Add from here...
      bottomNavigationBar: NavigationBar(
        elevation: 0,
        backgroundColor: Colors.white,
        destinations: destinations.map<NavigationDestination>((d) {
          return NavigationDestination(icon: Icon(d.icon), label: d.label);
        }).toList(),
        selectedIndex: selectedIndex,
        onDestinationSelected: (index) {
          setState(() {
            selectedIndex = index;
          });
        },
      ),
                                                // ...to here.
    );
  }
}

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

9b3f7628fd32679c.png

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

6. เพิ่ม NavigationRail

ขั้นตอนนี้จะเพิ่ม NavigationRail ลงในใบสมัคร แนวคิดคือแสดงวิดเจ็ตการนําทางเพียง 1 ใน 2 รายการเท่านั้น โดยขึ้นอยู่กับขนาดหน้าจอ ซึ่งหมายความว่าคุณต้องซ่อนหรือแสดง NavigationBar เมื่อจําเป็น ในไดเรกทอรี lib/widgets ให้สร้างไฟล์ disappearing_bottom_navigation_bar.dart แล้วเพิ่มโค้ดต่อไปนี้

lib/widgets/disappearing_bottom_navigation_bar.dart

import 'package:flutter/material.dart';

import '../destinations.dart';

class DisappearingBottomNavigationBar extends StatelessWidget {
  const DisappearingBottomNavigationBar({
    super.key,
    required this.selectedIndex,
    this.onDestinationSelected,
  });

  final int selectedIndex;
  final ValueChanged<int>? onDestinationSelected;

  @override
  Widget build(BuildContext context) {
    return NavigationBar(
      elevation: 0,
      backgroundColor: Colors.white,
      destinations: destinations.map<NavigationDestination>((d) {
        return NavigationDestination(icon: Icon(d.icon), label: d.label);
      }).toList(),
      selectedIndex: selectedIndex,
      onDestinationSelected: onDestinationSelected,
    );
  }
}

ในไดเรกทอรีเดียวกัน ให้เพิ่มไฟล์อีกไฟล์ชื่อ disappearing_navigation_rail.dart ที่มีโค้ดต่อไปนี้

lib/widgets/disappearing_navigation_rail.dart

import 'package:flutter/material.dart';

import '../destinations.dart';

class DisappearingNavigationRail extends StatelessWidget {
  const DisappearingNavigationRail({
    super.key,
    required this.backgroundColor,
    required this.selectedIndex,
    this.onDestinationSelected,
  });

  final Color backgroundColor;
  final int selectedIndex;
  final ValueChanged<int>? onDestinationSelected;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return NavigationRail(
      selectedIndex: selectedIndex,
      backgroundColor: backgroundColor,
      onDestinationSelected: onDestinationSelected,
      leading: Column(
        children: [
          IconButton(onPressed: () {}, icon: const Icon(Icons.menu)),
          const SizedBox(height: 8),
          FloatingActionButton(
            shape: const RoundedRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(15)),
            ),
            backgroundColor: colorScheme.tertiaryContainer,
            foregroundColor: colorScheme.onTertiaryContainer,
            onPressed: () {},
            child: const Icon(Icons.add),
          ),
        ],
      ),
      groupAlignment: -0.85,
      destinations: destinations.map((d) {
        return NavigationRailDestination(
          icon: Icon(d.icon),
          label: Text(d.label),
        );
      }).toList(),
    );
  }
}

เมื่อปรับโฉมรูปแบบการนําทางให้เป็นวิดเจ็ตของตัวเองแล้ว ไฟล์ lib/main.dart จะต้องได้รับการแก้ไขบางอย่าง ดังนี้

lib/main.dart

import 'package:flutter/material.dart';

// Remove the destination.dart import, it's not required
import 'models/data.dart' as data;
import 'models/models.dart';
import 'widgets/disappearing_bottom_navigation_bar.dart';  // Add import
import 'widgets/disappearing_navigation_rail.dart';        // Add import
import 'widgets/email_list_view.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(),
      home: Feed(currentUser: data.user_0),
    );
  }
}

class Feed extends StatefulWidget {
  const Feed({super.key, required this.currentUser});

  final User currentUser;

  @override
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> {
  late final _colorScheme = Theme.of(context).colorScheme;
  late final _backgroundColor = Color.alphaBlend(
    _colorScheme.primary.withAlpha(36),
    _colorScheme.surface,
  );

  int selectedIndex = 0;
                                                  // Add from here...
  bool wideScreen = false;

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

    final double width = MediaQuery.of(context).size.width;
    wideScreen = width > 600;
  }
                                                 // ... to here.

  @override
  Widget build(BuildContext context) {
                                                 // Modify from here...
    return Scaffold(
      body: Row(
        children: [
          if (wideScreen)
            DisappearingNavigationRail(
              selectedIndex: selectedIndex,
              backgroundColor: _backgroundColor,
              onDestinationSelected: (index) {
                setState(() {
                  selectedIndex = index;
                });
              },
            ),
          Expanded(
            child: Container(
              color: _backgroundColor,
              child: EmailListView(
                selectedIndex: selectedIndex,
                onSelected: (index) {
                  setState(() {
                    selectedIndex = index;
                  });
                },
                currentUser: widget.currentUser,
              ),
            ),
          ),
        ],
      ),
      floatingActionButton: wideScreen
          ? null
          : FloatingActionButton(
              backgroundColor: _colorScheme.tertiaryContainer,
              foregroundColor: _colorScheme.onTertiaryContainer,
              onPressed: () {},
              child: const Icon(Icons.add),
            ),
      bottomNavigationBar: wideScreen
          ? null
          : DisappearingBottomNavigationBar(
              selectedIndex: selectedIndex,
              onDestinationSelected: (index) {
                setState(() {
                  selectedIndex = index;
                });
              },
            ),
    );
                                                    // ... to here.
  }
}

การเปลี่ยนแปลงที่สําคัญประการแรกในไฟล์ main.dart คือการเพิ่มสถานะ wideScreen ที่อัปเดตทุกครั้งที่ผู้ใช้ปรับขนาดการแสดงผล ไม่ว่าจะเป็นการปรับขนาดหน้าต่างเบราว์เซอร์หรือการหมุนโทรศัพท์ การเปลี่ยนแปลงถัดไปจะแก้ไข NavigationBar และ FloatingActionButton ให้ขึ้นอยู่กับว่าแอปอยู่ในโหมด wideScreen หรือไม่ สุดท้าย NavigationRail จะแสดงขึ้นทางด้านซ้ายแบบมีเงื่อนไขหากหน้าจอกว้างพอ เรียกใช้แอปพลิเคชันในเว็บหรือเดสก์ท็อป และปรับขนาดหน้าจอเพื่อแสดงเลย์เอาต์ 2 แบบ

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

7. สร้างภาพเคลื่อนไหวของทรานซิชัน

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

lib/animations.dart

import 'package:flutter/animation.dart';

class BarAnimation extends ReverseAnimation {
  BarAnimation({required AnimationController parent})
    : super(
        CurvedAnimation(
          parent: parent,
          curve: const Interval(0, 1 / 5),
          reverseCurve: const Interval(1 / 5, 4 / 5),
        ),
      );
}

class OffsetAnimation extends CurvedAnimation {
  OffsetAnimation({required super.parent})
    : super(
        curve: const Interval(
          2 / 5,
          3 / 5,
          curve: Curves.easeInOutCubicEmphasized,
        ),
        reverseCurve: Interval(
          4 / 5,
          1,
          curve: Curves.easeInOutCubicEmphasized.flipped,
        ),
      );
}

class RailAnimation extends CurvedAnimation {
  RailAnimation({required super.parent})
    : super(
        curve: const Interval(0 / 5, 4 / 5),
        reverseCurve: const Interval(3 / 5, 1),
      );
}

class RailFabAnimation extends CurvedAnimation {
  RailFabAnimation({required super.parent})
    : super(curve: const Interval(3 / 5, 1));
}

class ScaleAnimation extends CurvedAnimation {
  ScaleAnimation({required super.parent})
    : super(
        curve: const Interval(
          3 / 5,
          4 / 5,
          curve: Curves.easeInOutCubicEmphasized,
        ),
        reverseCurve: Interval(
          3 / 5,
          1,
          curve: Curves.easeInOutCubicEmphasized.flipped,
        ),
      );
}

class ShapeAnimation extends CurvedAnimation {
  ShapeAnimation({required super.parent})
    : super(
        curve: const Interval(
          2 / 5,
          3 / 5,
          curve: Curves.easeInOutCubicEmphasized,
        ),
      );
}

class SizeAnimation extends CurvedAnimation {
  SizeAnimation({required super.parent})
    : super(
        curve: const Interval(
          0 / 5,
          3 / 5,
          curve: Curves.easeInOutCubicEmphasized,
        ),
        reverseCurve: Interval(
          2 / 5,
          1,
          curve: Curves.easeInOutCubicEmphasized.flipped,
        ),
      );
}

การพัฒนาเส้นโค้งเหล่านี้ต้องอาศัยการปรับปรุงซ้ำ ซึ่งการโหลดซ้ำขณะทำงานของ Flutter ช่วยให้ดำเนินการได้ง่ายขึ้นมาก หากต้องการใช้ภาพเคลื่อนไหวเหล่านี้ คุณต้องมีทรานซิชัน สร้างไดเรกทอรีย่อยในไดเรกทอรี lib ชื่อ transitions และเพิ่มไฟล์ชื่อ bottom_bar_transition.dart ที่มีโค้ดต่อไปนี้

lib/transitions/bottom_bar_transition.dart

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

class BottomBarTransition extends StatefulWidget {
  const BottomBarTransition({
    super.key,
    required this.animation,
    required this.backgroundColor,
    required this.child,
  });

  final Animation<double> animation;
  final Color backgroundColor;
  final Widget child;

  @override
  State<BottomBarTransition> createState() => _BottomBarTransition();
}

class _BottomBarTransition extends State<BottomBarTransition> {
  late final Animation<Offset> offsetAnimation = Tween<Offset>(
    begin: const Offset(0, 1),
    end: Offset.zero,
  ).animate(OffsetAnimation(parent: widget.animation));

  late final Animation<double> heightAnimation = Tween<double>(
    begin: 0,
    end: 1,
  ).animate(SizeAnimation(parent: widget.animation));

  @override
  Widget build(BuildContext context) {
    return ClipRect(
      child: DecoratedBox(
        decoration: BoxDecoration(color: widget.backgroundColor),
        child: Align(
          alignment: Alignment.topLeft,
          heightFactor: heightAnimation.value,
          child: FractionalTranslation(
            translation: offsetAnimation.value,
            child: widget.child,
          ),
        ),
      ),
    );
  }
}

เพิ่มไฟล์อื่นลงในไดเรกทอรี lib/transitions ชื่อ nav_rail_transition.dart แล้วเพิ่มโค้ดต่อไปนี้

lib/transitions/nav_rail_transition.dart

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

class NavRailTransition extends StatefulWidget {
  const NavRailTransition({
    super.key,
    required this.animation,
    required this.backgroundColor,
    required this.child,
  });

  final Animation<double> animation;
  final Widget child;
  final Color backgroundColor;

  @override
  State<NavRailTransition> createState() => _NavRailTransitionState();
}

class _NavRailTransitionState extends State<NavRailTransition> {
  // The animations are only rebuilt by this method when the text
  // direction changes because this widget only depends on Directionality.
  late final bool ltr = Directionality.of(context) == TextDirection.ltr;
  late final Animation<Offset> offsetAnimation = Tween<Offset>(
    begin: ltr ? const Offset(-1, 0) : const Offset(1, 0),
    end: Offset.zero,
  ).animate(OffsetAnimation(parent: widget.animation));
  late final Animation<double> widthAnimation = Tween<double>(
    begin: 0,
    end: 1,
  ).animate(SizeAnimation(parent: widget.animation));

  @override
  Widget build(BuildContext context) {
    return ClipRect(
      child: DecoratedBox(
        decoration: BoxDecoration(color: widget.backgroundColor),
        child: AnimatedBuilder(
          animation: widthAnimation,
          builder: (context, child) {
            return Align(
              alignment: Alignment.topLeft,
              widthFactor: widthAnimation.value,
              child: FractionalTranslation(
                translation: offsetAnimation.value,
                child: widget.child,
              ),
            );
          },
        ),
      ),
    );
  }
}

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

lib/widgets/disappearing_bottom_navigation_bar.dart

import 'package:flutter/material.dart';

import '../animations.dart';                          // Add this import
import '../destinations.dart';
import '../transitions/bottom_bar_transition.dart';   // Add this import

class DisappearingBottomNavigationBar extends StatelessWidget {
  const DisappearingBottomNavigationBar({
    super.key,
    required this.barAnimation,                       // Add this parameter
    required this.selectedIndex,
    this.onDestinationSelected,
  });

  final BarAnimation barAnimation;                   // Add this variable
  final int selectedIndex;
  final ValueChanged<int>? onDestinationSelected;

  @override
  Widget build(BuildContext context) {
                                                     // Modify from here...
    return BottomBarTransition(
      animation: barAnimation,
      backgroundColor: Colors.white,
      child: NavigationBar(
        elevation: 0,
        backgroundColor: Colors.white,
        destinations: destinations.map<NavigationDestination>((d) {
          return NavigationDestination(icon: Icon(d.icon), label: d.label);
        }).toList(),
        selectedIndex: selectedIndex,
        onDestinationSelected: onDestinationSelected,
      ),
    );
                                                     // ... to here.
  }
}

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

จากนั้นแก้ไข disappearing_navigation_rail.dart ดังนี้

lib/widgets/disappearing_navigation_rail.dart

import 'package:flutter/material.dart';

import '../animations.dart';                          // Add this import
import '../destinations.dart';
import '../transitions/nav_rail_transition.dart';     // Add this import
import 'animated_floating_action_button.dart';        // Add this import

class DisappearingNavigationRail extends StatelessWidget {
  const DisappearingNavigationRail({
    super.key,
    required this.railAnimation,                      // Add this parameter
    required this.railFabAnimation,                   // Add this parameter
    required this.backgroundColor,
    required this.selectedIndex,
    this.onDestinationSelected,
  });

  final RailAnimation railAnimation;                  // Add this variable
  final RailFabAnimation railFabAnimation;            // Add this variable
  final Color backgroundColor;
  final int selectedIndex;
  final ValueChanged<int>? onDestinationSelected;

  @override
  Widget build(BuildContext context) {
    // Delete colorScheme
                                                      // Modify from here ...
    return NavRailTransition(
      animation: railAnimation,
      backgroundColor: backgroundColor,
      child: NavigationRail(
        selectedIndex: selectedIndex,
        backgroundColor: backgroundColor,
        onDestinationSelected: onDestinationSelected,
        leading: Column(
          children: [
            IconButton(onPressed: () {}, icon: const Icon(Icons.menu)),
            const SizedBox(height: 8),
            AnimatedFloatingActionButton(
              animation: railFabAnimation,
              elevation: 0,
              onPressed: () {},
              child: const Icon(Icons.add),
            ),
          ],
        ),
        groupAlignment: -0.85,
        destinations: destinations.map((d) {
          return NavigationRailDestination(
            icon: Icon(d.icon),
            label: Text(d.label),
          );
        }).toList(),
      ),
    );
                                                      // ... to here.
  }
}

ขณะป้อนรหัสก่อนหน้า คุณอาจได้รับชุดคำเตือนข้อผิดพลาดเกี่ยวกับวิดเจ็ตที่ยังไม่ได้กำหนด ซึ่งก็คือ FloatingActionButton วิธีแก้ปัญหานี้คือให้เพิ่มไฟล์ชื่อ animated_floating_action_button.dart ลงใน lib/widgets ด้วยโค้ดต่อไปนี้

lib/widgets/animated_floating_action_button.dart

import 'dart:ui';

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

class AnimatedFloatingActionButton extends StatefulWidget {
  const AnimatedFloatingActionButton({
    super.key,
    required this.animation,
    this.elevation,
    this.onPressed,
    this.child,
  });

  final Animation<double> animation;
  final VoidCallback? onPressed;
  final Widget? child;
  final double? elevation;

  @override
  State<AnimatedFloatingActionButton> createState() =>
      _AnimatedFloatingActionButton();
}

class _AnimatedFloatingActionButton
    extends State<AnimatedFloatingActionButton> {
  late final ColorScheme _colorScheme = Theme.of(context).colorScheme;
  late final Animation<double> _scaleAnimation = ScaleAnimation(
    parent: widget.animation,
  );
  late final Animation<double> _shapeAnimation = ShapeAnimation(
    parent: widget.animation,
  );

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scaleAnimation,
      child: FloatingActionButton(
        elevation: widget.elevation,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.all(
            Radius.circular(lerpDouble(30, 15, _shapeAnimation.value)!),
          ),
        ),
        backgroundColor: _colorScheme.tertiaryContainer,
        foregroundColor: _colorScheme.onTertiaryContainer,
        onPressed: widget.onPressed,
        child: widget.child,
      ),
    );
  }
}

หากต้องการนําการเปลี่ยนแปลงเหล่านี้ไปใช้ในแอปพลิเคชัน ให้อัปเดตไฟล์ main.dart ดังนี้

lib/main.dart

import 'package:flutter/material.dart';

import 'animations.dart';                               // Add this import
import 'models/data.dart' as data;
import 'models/models.dart';
import 'widgets/animated_floating_action_button.dart';  // Add this import
import 'widgets/disappearing_bottom_navigation_bar.dart';
import 'widgets/disappearing_navigation_rail.dart';
import 'widgets/email_list_view.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(),
      home: Feed(currentUser: data.user_0),
    );
  }
}

class Feed extends StatefulWidget {
  const Feed({super.key, required this.currentUser});

  final User currentUser;

  @override
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> with SingleTickerProviderStateMixin {
  late final _colorScheme = Theme.of(context).colorScheme;
  late final _backgroundColor = Color.alphaBlend(
    _colorScheme.primary.withAlpha(36),
    _colorScheme.surface,
  );
                                                        // Add from here...
  late final _controller = AnimationController(
    duration: const Duration(milliseconds: 1000),
    reverseDuration: const Duration(milliseconds: 1250),
    value: 0,
    vsync: this,
  );
  late final _railAnimation = RailAnimation(parent: _controller);
  late final _railFabAnimation = RailFabAnimation(parent: _controller);
  late final _barAnimation = BarAnimation(parent: _controller);
                                                        // ... to here.

  int selectedIndex = 0;
  // Remove wideScreen
  bool controllerInitialized = false;                   // Add this variable

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

    final double width = MediaQuery.of(context).size.width;
    // Remove wideScreen reference
                                                        // Add from here ...
    final AnimationStatus status = _controller.status;
    if (width > 600) {
      if (status != AnimationStatus.forward &&
          status != AnimationStatus.completed) {
        _controller.forward();
      }
    } else {
      if (status != AnimationStatus.reverse &&
          status != AnimationStatus.dismissed) {
        _controller.reverse();
      }
    }
    if (!controllerInitialized) {
      controllerInitialized = true;
      _controller.value = width > 600 ? 1 : 0;
    }
                                                        // ... to here.
  }

                                                        // Add from here ...
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
                                                        // ... to here.

  @override
  Widget build(BuildContext context) {
                                                        // Modify from here ...
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, _) {
        return Scaffold(
          body: Row(
            children: [
              DisappearingNavigationRail(
                railAnimation: _railAnimation,
                railFabAnimation: _railFabAnimation,
                selectedIndex: selectedIndex,
                backgroundColor: _backgroundColor,
                onDestinationSelected: (index) {
                  setState(() {
                    selectedIndex = index;
                  });
                },
              ),
              Expanded(
                child: Container(
                  color: _backgroundColor,
                  child: EmailListView(
                    selectedIndex: selectedIndex,
                    onSelected: (index) {
                      setState(() {
                        selectedIndex = index;
                      });
                    },
                    currentUser: widget.currentUser,
                  ),
                ),
              ),
            ],
          ),
          floatingActionButton: AnimatedFloatingActionButton(
            animation: _barAnimation,
            onPressed: () {},
            child: const Icon(Icons.add),
          ),
          bottomNavigationBar: DisappearingBottomNavigationBar(
            barAnimation: _barAnimation,
            selectedIndex: selectedIndex,
            onDestinationSelected: (index) {
              setState(() {
                selectedIndex = index;
              });
            },
          ),
        );
      },
    );
                                                        // ... to here.
  }
}

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

8. เพิ่มรายการที่มีมุมมองแบบละเอียด

นอกจากนี้ แอปรับส่งข้อความยังเป็นพื้นที่ที่ยอดเยี่ยมในการแสดงรายการที่มีเลย์เอาต์แบบละเอียด ในกรณีที่จอแสดงผลกว้างพอ เริ่มต้นด้วยการเพิ่มไฟล์ใน lib/widgets ชื่อ reply_list_view.dart แล้วกรอกโค้ดต่อไปนี้

lib/widgets/reply_list_view.dart

import 'package:flutter/material.dart';

import '../models/data.dart' as data;
import 'email_widget.dart';

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

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(right: 8.0),
      child: ListView(
        children: [
          const SizedBox(height: 8),
          ...List.generate(data.replies.length, (index) {
            return Padding(
              padding: const EdgeInsets.only(bottom: 8.0),
              child: EmailWidget(
                email: data.replies[index],
                isPreview: false,
                isThreaded: true,
                showHeadline: index == 0,
              ),
            );
          }),
        ],
      ),
    );
  }
}

ถัดไป ให้เพิ่ม list_detail_transition.dart ใน lib/transitions แล้วป้อนรหัสต่อไปนี้

lib/transitions/list_detail_transition.dart

import 'dart:ui';

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

class ListDetailTransition extends StatefulWidget {
  const ListDetailTransition({
    super.key,
    required this.animation,
    required this.one,
    required this.two,
  });

  final Animation<double> animation;
  final Widget one;
  final Widget two;

  @override
  State<ListDetailTransition> createState() => _ListDetailTransitionState();
}

class _ListDetailTransitionState extends State<ListDetailTransition> {
  Animation<double> widthAnimation = const AlwaysStoppedAnimation(0);
  late final Animation<double> sizeAnimation = SizeAnimation(
    parent: widget.animation,
  );
  late final Animation<Offset> offsetAnimation = Tween<Offset>(
    begin: const Offset(1, 0),
    end: Offset.zero,
  ).animate(OffsetAnimation(parent: sizeAnimation));
  double currentFlexFactor = 0;

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

    final double width = MediaQuery.of(context).size.width;
    double nextFlexFactor = switch (width) {
      >= 800 && < 1200 => lerpDouble(1000, 2000, (width - 800) / 400)!,
      >= 1200 && < 1600 => lerpDouble(2000, 3000, (width - 1200) / 400)!,
      >= 1600 => 3000,
      _ => 1000,
    };

    if (nextFlexFactor == currentFlexFactor) {
      return;
    }

    if (currentFlexFactor == 0) {
      widthAnimation = Tween<double>(
        begin: 0,
        end: nextFlexFactor,
      ).animate(sizeAnimation);
    } else {
      final TweenSequence<double> sequence = TweenSequence([
        if (sizeAnimation.value > 0) ...[
          TweenSequenceItem(
            tween: Tween(begin: 0, end: widthAnimation.value),
            weight: sizeAnimation.value,
          ),
        ],
        if (sizeAnimation.value < 1) ...[
          TweenSequenceItem(
            tween: Tween(begin: widthAnimation.value, end: nextFlexFactor),
            weight: 1 - sizeAnimation.value,
          ),
        ],
      ]);

      widthAnimation = sequence.animate(sizeAnimation);
    }

    currentFlexFactor = nextFlexFactor;
  }

  @override
  Widget build(BuildContext context) {
    return widthAnimation.value.toInt() == 0
        ? widget.one
        : Row(
            children: [
              Flexible(flex: 1000, child: widget.one),
              Flexible(
                flex: widthAnimation.value.toInt(),
                child: FractionalTranslation(
                  translation: offsetAnimation.value,
                  child: widget.two,
                ),
              ),
            ],
          );
  }
}

ผสานรวมเนื้อหานี้ลงในแอปโดยอัปเดต lib/main.dart ดังนี้

lib/main.dart

import 'package:flutter/material.dart';

import 'animations.dart';
import 'models/data.dart' as data;
import 'models/models.dart';
import 'transitions/list_detail_transition.dart';          // Add import
import 'widgets/animated_floating_action_button.dart';
import 'widgets/disappearing_bottom_navigation_bar.dart';
import 'widgets/disappearing_navigation_rail.dart';
import 'widgets/email_list_view.dart';
import 'widgets/reply_list_view.dart';                     // Add import

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(),
      home: Feed(currentUser: data.user_0),
    );
  }
}

class Feed extends StatefulWidget {
  const Feed({super.key, required this.currentUser});

  final User currentUser;

  @override
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> with SingleTickerProviderStateMixin {
  late final _colorScheme = Theme.of(context).colorScheme;
  late final _backgroundColor = Color.alphaBlend(
    _colorScheme.primary.withAlpha(36),
    _colorScheme.surface,
  );
  late final _controller = AnimationController(
    duration: const Duration(milliseconds: 1000),
    reverseDuration: const Duration(milliseconds: 1250),
    value: 0,
    vsync: this,
  );
  late final _railAnimation = RailAnimation(parent: _controller);
  late final _railFabAnimation = RailFabAnimation(parent: _controller);
  late final _barAnimation = BarAnimation(parent: _controller);

  int selectedIndex = 0;
  bool controllerInitialized = false;

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

    final double width = MediaQuery.of(context).size.width;
    final AnimationStatus status = _controller.status;
    if (width > 600) {
      if (status != AnimationStatus.forward &&
          status != AnimationStatus.completed) {
        _controller.forward();
      }
    } else {
      if (status != AnimationStatus.reverse &&
          status != AnimationStatus.dismissed) {
        _controller.reverse();
      }
    }
    if (!controllerInitialized) {
      controllerInitialized = true;
      _controller.value = width > 600 ? 1 : 0;
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, _) {
        return Scaffold(
          body: Row(
            children: [
              DisappearingNavigationRail(
                railAnimation: _railAnimation,
                railFabAnimation: _railFabAnimation,
                selectedIndex: selectedIndex,
                backgroundColor: _backgroundColor,
                onDestinationSelected: (index) {
                  setState(() {
                    selectedIndex = index;
                  });
                },
              ),
              Expanded(
                child: Container(
                  color: _backgroundColor,
                                                // Update from here ...
                  child: ListDetailTransition(
                    animation: _railAnimation,
                    one: EmailListView(
                      selectedIndex: selectedIndex,
                      onSelected: (index) {
                        setState(() {
                          selectedIndex = index;
                        });
                      },
                      currentUser: widget.currentUser,
                    ),
                    two: const ReplyListView(),
                  ),
                                                // ... to here.
                ),
              ),
            ],
          ),
          floatingActionButton: AnimatedFloatingActionButton(
            animation: _barAnimation,
            onPressed: () {},
            child: const Icon(Icons.add),
          ),
          bottomNavigationBar: DisappearingBottomNavigationBar(
            barAnimation: _barAnimation,
            selectedIndex: selectedIndex,
            onDestinationSelected: (index) {
              setState(() {
                selectedIndex = index;
              });
            },
          ),
        );
      },
    );
  }
}

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

94b96bd9d65a829.png

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

ยินดีด้วย คุณสร้างแอป Flutter ที่ใช้ Material 3 รายการแรกสำเร็จแล้ว

หากต้องการตรวจสอบทุกขั้นตอนของโค้ดแล็บนี้ในโค้ด ให้ดูที่ที่เก็บ GitHub ของโค้ดแล็บ Flutter

สิ่งต่อไปที่ควรทำ

ลองดู Codelab เหล่านี้...

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