1. 簡介
Material 3 是最新版本的 Google 開放原始碼設計系統。Flutter 持續支援使用 Material 3 建構精美的應用程式。在本程式碼研究室中,您將從空的 Flutter 應用程式開始,接著使用 Material 3 搭配 Flutter 建構一個設定完全樣式的動畫應用程式。
建構項目
在本程式碼研究室中,您會建構模擬訊息應用程式。您的應用程式將會:
- 採用自動調整式設計,以便在電腦或行動裝置上正常運作。
- 利用動畫輕鬆快速切換不同的版面配置。
- 使用 Material 3 設定生動樣式。
- 在 Android、iOS、網頁、Windows、Linux 和 macOS 上執行。
本程式碼研究室著重於包含 Flutter 的 Material 3。我們不會對與本主題無關的概念和程式碼多做介紹,但會事先準備好這些程式碼區塊,屆時您只要複製及貼上即可。
2. 設定 Flutter 環境
軟硬體需求
- Flutter SDK 3.10 以上版本。
- VS Code 或 Android Studio 等編輯器。
本程式碼研究室已經過測試,可部署至 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
自 Flutter 3.7 起,您可以使用 --empty
參數建立空白的 Flutter 專案,只需提供基本要素,應用程式就能開始運作。
$ 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」,在網路瀏覽器中執行空白應用程式如果有需要 SQL 指令的分析工作負載 則 BigQuery 可能是最佳選擇
$ 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. 製作 Messenger 應用程式
正在建立顯示圖片
每個訊息應用程式都需要使用者的圖片。這些圖片代表使用者 ,因此稱為虛擬化圖片。接下來,請在專案樹狀結構頂端建立資產目錄,然後填入本程式碼研究室的 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
目錄:
|
|
|
|
|
|
|
|
現在,您已擁有顯示圖片素材資源,請將其新增至 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
或自選的文字編輯器執行此操作。在含有以下內容的 lib/models
目錄中建立 models.dart
檔案:
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;
}
現在,您已定義資料形狀,請在 lib/models
目錄中建立含有以下內容的 data.dart
檔案:
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?",
),
];
掌握這些資料後,接下來就得定義幾個小工具來顯示相關資料。在 lib
下建立名為 widgets
的子目錄。您會在 widgets
中建立四個檔案。在這四個檔案都建立完成前,編輯器中可能會出現一些警告。提醒您,本程式碼研究室的重點是使用 Material 3 設定應用程式樣式。因此,新增以下四個檔案,其中包含所列的內容:
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,
),
);
},
),
],
),
);
}
}
能夠顯示電子郵件清單,就像是即時通訊應用程式應能做到的事。針對編輯器中的幾個申訴,您可以新增下一個檔案 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),
),
],
),
),
);
}
}
更簡潔的無狀態小工具。接著,請新增另一個簡單的小工具 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),
),
);
}
}
從本程式碼研究室的視角來看,此檔案中最重要的行是 MaterialApp
的 theme
引數,該引數會將 useMaterial3
設為 true
。useMaterial3
引數會決定應用程式中的小工具是否根據 Material 2 或 Material 3 設計指南設定樣式。將 useMaterial3
引數設為 true
也會顯示可選取的 IconButtons
等新功能。
執行應用程式,查看您的起點。
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'),
];
這會讓應用程式顯示四個 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(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
新增至應用程式。這麼做的目的在於根據螢幕大小,只顯示兩個導覽小工具之一,這表示您需要視情況隱藏或顯示 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(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
。在網頁或電腦上執行應用程式,然後調整畫面大小,以顯示兩種不同的版面配置。
擁有兩種不同的版面配置應該很不錯,但是兩者之間的轉換並不好。以更靈活的方式取代軌道 (反之亦然),可大幅改善這個應用程式。您將在下一個步驟中新增此動畫。
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,
),
),
),
);
}
}
將另一個檔案新增至名為 nav_rail_transition.dart
的 lib/transitions
目錄,並新增下列程式碼:
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,
),
);
},
),
),
);
}
}
這兩個轉換小工具會包住導覽邊欄和列小工具,為它們的外觀和消失添加動畫。如要使用這兩個轉換小工具,請從 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(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 應用程式!
如要查看本程式碼研究室的所有步驟,請參閱 Flutter 程式碼研究室 GitHub 存放區。
後續步驟
查看一些程式碼研究室…
其他資訊
- 進一步瞭解 Flutter 的 Material 3 更新
- 請參閱 Flutter Material 3 說明文件
- 查看所有 Material 3 資源