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
, ואז מקדישים כמה דקות להסרת מה שהם לא צריכים. החל מ-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
או בכלי לעריכת טקסט שתבחרו. יוצרים בספרייה 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
, וסביר להניח שתקבלו כמה אזהרות מהעורך עד ליצירת כל הארבעה. חשוב לזכור שהמטרה של Codelab היא לעצב את האפליקציה באמצעות 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),
),
);
}
}
השורה החשובה ביותר בקובץ הזה מנקודת המבט של ה-Codelab הזה, היא הארגומנט theme
של MaterialApp
, שמגדיר את useMaterial3
כ-true
. הארגומנט useMaterial3
קובע אם הווידג'טים באפליקציה יעוצבו בהתאם להנחיות העיצוב של Material 2 או Material 3. אם מגדירים את הארגומנט useMaterial3
לערך true
, מוצגות גם תכונות חדשות כמו IconButtons
שניתן לבחור.
הפעילו את האפליקציה כדי לראות במה אתם מתחילים.
5. הוספת סרגל ניווט
בסוף השלב הקודם, האפליקציה לתחילת העבודה הכילה רשימה של הודעות, אבל לא הרבה דברים אחרים. בשלב הזה, מוסיפים NavigationBar
כדי להוסיף עוד תחום עניין חזותי. כשהאפליקציה משתנה משרטוט של ממשק המשתמש והופך אותו לאפליקציה אמיתית, סרגל הניווט מספק אזורים שונים באפליקציה שבהם המשתמש יכול להשתמש.
כשהערך של 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
לאפליקציה. הרעיון הוא להציג רק אחד משני הווידג'טים של הניווט בהתאם לגודל המסך. כלומר, תצטרכו להסתיר או להציג את סרגל הניווט במקרה הצורך. בספרייה 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,
),
),
),
);
}
}
מוסיפים עוד קובץ לספרייה 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,
),
);
},
),
),
);
}
}
שני ווידג'טים אלה עוקפים את ווידג'טים של מסילת הניווט וסרגל הניווט, כדי להנפיש את המראה וההיעלמות שלהם. כדי להשתמש בשני ווידג'טים אלה, צריך לעדכן שני ווידג'טים, החל מ-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
. כדי לפתור את הבעיה, צריך להוסיף אל lib/widgets
קובץ בשם animated_floating_action_button.dart
עם הקוד הבא:
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.
}
}
מפעילים את האפליקציה. בהתחלה, האתר אמור להיראות כמו קודם. משנים את גודל המסך כדי לראות את המתג של ממשק המשתמש בין רכבת הניווט לסרגל הניווט, בהתאם לגודל ולמידות. עכשיו התנועה של המעברים האלה אמורה להיראות חלקה ושובבה. אפשר להשתמש בטעינה מחדש מתוך הזיכרון כדי לשנות את עקומות האנימציה שבהן נעשה שימוש כדי לראות איך היא משנה את התחושה באפליקציה.
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.
מה השלב הבא?
כדאי לנסות כמה מ-Codelabs האלה...
- בניית משחק עם Flutter and Flame
- הופכים את אפליקציית Flutter משעמם ליפה
- שימוש ב-FFI בפלאגין של Flutter
קריאה נוספת
- מידע נוסף על עדכוני Material 3 ב-Flutter
- אפשר לעיין במסמכי התיעוד בנושאFlutter Material 3
- לעיין בכל מקורות המידע של Material 3