1. บทนำ
Material 3 คือระบบการออกแบบโอเพนซอร์สเวอร์ชันล่าสุดของ Google Flutter ได้ขยายการสนับสนุนในการสร้างแอปพลิเคชันที่สวยงามโดยใช้ Material 3 ใน Codelab นี้ คุณจะเริ่มต้นด้วยแอปพลิเคชัน Flutter ที่ว่างเปล่า และสร้างแอปพลิเคชันที่มีสไตล์และภาพเคลื่อนไหวอย่างสมบูรณ์โดยใช้ Material 3 กับ Flutter
สิ่งที่คุณจะสร้าง
ใน Codelab นี้ คุณจะสร้างแอปพลิเคชันรับส่งข้อความจำลอง แอปของคุณจะ
- ใช้การออกแบบที่ปรับขนาดได้เพื่อให้ทำงานได้บนเดสก์ท็อปหรืออุปกรณ์เคลื่อนที่
- ใช้ภาพเคลื่อนไหวเพื่อสลับระหว่างเลย์เอาต์ต่างๆ ได้อย่างง่ายดายและลื่นไหล
- ใช้ Material 3 สำหรับการจัดรูปแบบที่บ่งบอกความเป็นคุณ
- ทำงานบน Android, iOS, เว็บ, Windows, Linux และ macOS
Codelab นี้มุ่งเน้น Material 3 ด้วย Flutter แนวคิดและโค้ดบล็อกที่ไม่เกี่ยวข้องจะปรากฎขึ้นและมีไว้เพื่อให้คุณคัดลอกและวางได้อย่างง่ายดาย
2. ตั้งค่าสภาพแวดล้อม Flutter
สิ่งที่คุณต้องมี
- Flutter SDK เวอร์ชัน 3.10 ขึ้นไป
- เครื่องมือแก้ไข เช่น VS Code หรือ Android Studio
Codelab นี้ได้รับการทดสอบเพื่อทำให้ใช้งานได้ใน Android, iOS, เว็บ, Windows, Linux และ macOS เป้าหมายการทำให้ใช้งานได้เหล่านี้บางรายการจำเป็นต้องมีซอฟต์แวร์เพิ่มเติมที่ติดตั้งเพื่อให้ใช้งานได้ วิธีที่ดีในการทำความเข้าใจว่าแพลตฟอร์มได้รับการตั้งค่าอย่างถูกต้องหรือไม่คือการเรียกใช้ flutter doctor
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 3.10.1, on macOS 13.4 22F5037d darwin-arm64, locale en) [✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0) [✓] Xcode - develop for iOS and macOS (Xcode 14.3) [✓] Chrome - develop for the web [✓] Android Studio (version 2021.2) [✓] IntelliJ IDEA Community Edition (version 2022.2.2) [✓] VS Code (version 1.78.2) [✓] Connected device (2 available) [✓] Network resources • No issues found!
หากมีปัญหาแสดงในผลลัพธ์ซึ่งส่งผลกระทบต่อเป้าหมายการทำให้ใช้งานได้ที่เลือก ให้เรียกใช้ flutter doctor -v
เพื่อรับข้อมูลโดยละเอียดเพิ่มเติม หากยังแก้ปัญหาไม่ได้หลังจากลองทำตามขั้นตอนของ flutter doctor -v
แล้ว โปรดติดต่อชุมชน Flutter
3. เริ่มต้นใช้งาน
การสร้างแอปพลิเคชัน Flutter ที่ว่างเปล่า
นักพัฒนา Flutter ส่วนใหญ่สร้าง "การแตะปุ่มนับ" ขั้นพื้นฐาน แอปกับ flutter create
จากนั้นใช้เวลา 2-3 นาทีในการนำสิ่งที่ไม่ต้องการออก ใน Flutter 3.7 คุณสามารถสร้างโปรเจ็กต์ Flutter ที่ว่างเปล่า (โดยใช้พารามิเตอร์ --empty
) โดยใช้เพียงข้อมูลที่จำเป็นเล็กๆ น้อยๆ เพื่อเริ่มต้นใช้งานแอป
$ flutter create animated_responsive_layout --empty Creating project animated_responsive_layout... Running "flutter pub get" in animated_responsive_layout... Resolving dependencies in animated_responsive_layout... (1.4s) + async 2.10.0 + boolean_selector 2.1.1 + characters 1.2.1 + clock 1.1.1 + collection 1.17.0 + fake_async 1.3.1 + flutter 0.0.0 from sdk flutter + flutter_lints 2.0.1 + flutter_test 0.0.0 from sdk flutter + js 0.6.5 (0.6.6 available) + lints 2.0.1 + matcher 0.12.13 (0.12.14 available) + material_color_utilities 0.2.0 + meta 1.8.0 + path 1.8.2 (1.8.3 available) + sky_engine 0.0.99 from sdk flutter + source_span 1.9.1 + stack_trace 1.11.0 + stream_channel 2.1.1 + string_scanner 1.2.0 + term_glyph 1.2.1 + test_api 0.4.16 (0.4.18 available) + vector_math 2.1.4 Changed 23 dependencies in animated_responsive_layout! Wrote 126 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 Multiple devices found: macOS (desktop) • macos • darwin-arm64 • macOS 13.2 22D5038i darwin-arm64 Chrome (web) • chrome • web-javascript • Google Chrome 108.0.5359.124 [1]: macOS (macos) [2]: Chrome (chrome) Please choose one (To quit, press "q/Q"): 2 Launching lib/main.dart on Chrome in debug mode... Waiting for connection from debug service on Chrome... 10.0s This app is linked to the debug service: ws://127.0.0.1:56599/gxM2gOqxliM=/ws Debug service listening on ws://127.0.0.1:56599/gxM2gOqxliM=/ws 💪 Running with sound null safety 💪 🔥 To hot restart changes while running, press "r" or "R". For a more detailed help message, press "h". To quit, press "q". An Observatory debugger and profiler on Chrome is available at: http://127.0.0.1:56599/gxM2gOqxliM= The Flutter DevTools debugger and profiler on Chrome is available at: http://127.0.0.1:9100?uri=http://127.0.0.1:56599/gxM2gOqxliM=
ในสถานการณ์นี้ คุณจะเห็นแอปที่ว่างเปล่าทำงานในเว็บเบราว์เซอร์ Chrome หรือคุณจะเลือกใช้ใน Android, iOS หรือระบบปฏิบัติการบนเดสก์ท็อปก็ได้
4. สร้างแอปรับส่งข้อความ
กำลังสร้างรูปโปรไฟล์
แอปพลิเคชันการรับส่งข้อความทุกแอปจำเป็นต้องมีรูปภาพของผู้ใช้ รูปภาพเหล่านี้เป็นตัวแทนของผู้ใช้และเรียกว่าอวาตาร์ จากนั้นสร้างไดเรกทอรีเนื้อหาที่ด้านบนของโครงสร้างโปรเจ็กต์ และใส่ชุดรูปภาพจากที่เก็บ Git สำหรับ Codelab นี้ วิธีหนึ่งในการทำเช่นนี้คือการใช้เครื่องมือบรรทัดคำสั่ง 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
ของแอป
|
|
|
|
|
|
|
|
เมื่อมีเนื้อหารูปโปรไฟล์แล้ว คุณต้องเพิ่มเนื้อหาเหล่านั้นลงในไฟล์ pubspec.yaml
โดยทำดังนี้
pubspec.yaml
name: animated_responsive_layout
description: A new Flutter project.
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.0.1 <4.0.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.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.
แอปพลิเคชันต้องการแหล่งข้อมูลสำหรับข้อความที่แสดง ในไดเรกทอรี lib
ของโปรเจ็กต์ ให้สร้างไดเรกทอรีย่อย models
ซึ่งทำได้ในบรรทัดคำสั่งด้วย 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 รายการเพื่อแสดงข้อมูลนั้น สร้างไดเรกทอรีย่อยภายใต้ 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.withOpacity(0.08),
_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.withOpacity(0.05),
_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),
),
],
),
),
);
}
}
วิดเจ็ตที่เรียบง่ายขึ้นและไม่เก็บสถานะ จากนั้น ให้เพิ่มวิดเจ็ตง่ายๆ อีก 1 รายการ star_button.dart
:
lib/widgets/star_button.dart
import '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(useMaterial3: true),
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.withOpacity(0.14), _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),
),
);
}
}
บรรทัดที่สำคัญที่สุดในไฟล์นี้จากมุมมองของ Codelab นี้คืออาร์กิวเมนต์ theme
ของ MaterialApp
ซึ่งตั้งค่า useMaterial3
เป็น true
อาร์กิวเมนต์ useMaterial3
จะกำหนดว่าวิดเจ็ตในแอปมีการจัดรูปแบบตามหลักเกณฑ์การออกแบบของ Material 2 หรือ Material 3 การตั้งค่าอาร์กิวเมนต์ useMaterial3
เป็น true
จะแสดงฟีเจอร์ใหม่ๆ เช่น IconButtons
ที่เลือกได้ด้วย
เรียกใช้แอปเพื่อดูสิ่งที่คุณจะเริ่มต้น
5. เพิ่มแถบนำทาง
ในตอนท้ายของขั้นตอนก่อนหน้า แอปเริ่มต้นมีรายการข้อความอยู่ แต่ข้อความอีกไม่มากที่เกิดขึ้น ในขั้นตอนนี้ คุณต้องเพิ่ม 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'),
];
ซึ่งทำให้แอปพลิเคชันแสดงผล NavigationBar
ปลายทางได้ 4 แห่ง ต่อจากนั้นให้ส่งต่อรายการปลายทางนี้ไปยังไฟล์ 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(useMaterial3: true),
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.withOpacity(0.14), _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
เรียกใช้แอปพลิเคชันเพื่อยืนยันการเปลี่ยนแปลงเหล่านี้
วิธีนี้ดูสมเหตุสมผลในรูปแบบที่แคบ แต่ถ้าคุณทำให้หน้าต่างกว้างขึ้น หรือหมุนเครื่องมือจำลองโทรศัพท์เป็นแนวนอน ก็จะดูแปลกไปเล็กน้อย ในการแก้ไขปัญหานี้ ให้แสดง NavigationRail
ทางด้านซ้ายของหน้าจอเมื่อแอปพลิเคชันกว้างพอ ซึ่งจะมีการจัดการในขั้นตอนถัดไป
6. เพิ่ม NavigationRail
ขั้นตอนนี้จะเพิ่ม NavigationRail
ลงในแอปพลิเคชัน แนวคิดคือการแสดงวิดเจ็ตการนำทางเพียง 1 แบบจาก 2 แบบ ขึ้นอยู่กับขนาดหน้าจอ ซึ่งหมายความว่าคุณต้องซ่อนหรือแสดงแถบนำทางเมื่อจำเป็น ในไดเรกทอรี 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(useMaterial3: true),
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.withOpacity(0.14), _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.
}
}
การแก้ไขก่อนหน้านี้จะเพิ่มภาพเคลื่อนไหว 1 รายการ และรวมการเปลี่ยน ซึ่งจะช่วยให้คุณควบคุมการแสดงและหายไปของแถบนำทางได้
จากนั้น ให้แก้ไข 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(useMaterial3: true),
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();
}
// Add SingleTickerProviderStateMixin to _FeedState
class _FeedState extends State<Feed> with SingleTickerProviderStateMixin {
late final _colorScheme = Theme.of(context).colorScheme;
late final _backgroundColor = Color.alphaBlend(
_colorScheme.primary.withOpacity(0.14), _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,
),
);
}),
],
),
);
}
}
จากนั้นใน lib/transitions
ให้เพิ่ม list_detail_transition.dart
และกรอกรหัสต่อไปนี้
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(useMaterial3: true),
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.withOpacity(0.14), _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 และภาพเคลื่อนไหวระหว่างเลย์เอาต์แบบต่างๆ ในแอปที่แสดงถึงแอปพลิเคชันจริง ซึ่งควรมีลักษณะดังนี้
9. ขอแสดงความยินดี
ยินดีด้วย คุณสร้างแอป Material 3 Flutter แรกสำเร็จแล้ว
หากต้องการดูขั้นตอนทั้งหมดของ Codelab นี้ในโค้ด โปรดดูในที่เก็บ GitHub ของ Flutter codelabs
สิ่งที่ต้องทำต่อไป
ลองดู Codelab เหล่านี้...
- การสร้างเกมด้วย Flutter และ Flame
- เปลี่ยนแอป Flutter แต่ไม่น่าเบื่อให้กลายเป็นความสวยงาม
- การใช้ FFI ในปลั๊กอิน Flutter
อ่านเพิ่มเติม
- อ่านเพิ่มเติมเกี่ยวกับการอัปเดต Material 3 ใน Flutter
- ดูเอกสารประกอบของ Flutter Material 3
- ดูแหล่งข้อมูล Material 3 ทั้งหมด