יצירת פריסה רספונסיבית של אפליקציה עם אנימציה באמצעות Material 3

יצירת פריסה רספונסיבית של אפליקציה עם אנימציה באמצעות Material 3

מידע על Codelab זה

subjectהעדכון האחרון: יוני 6, 2025
account_circleנכתב על ידי Brett Morgan

1.‏ מבוא

Material 3 היא הגרסה האחרונה של מערכת העיצוב של Google בקוד פתוח. התמיכה ב-Flutter התרחבה ליצירת אפליקציות יפות באמצעות Material 3. בקודלאב הזה נתחיל מאפליקציית Flutter ריקה ונבנה אפליקציה עם עיצוב ואנימציה מלאים באמצעות Material 3 עם Flutter.

מה תפַתחו

בקודלאב הזה תלמדו איך ליצור אפליקציית הודעות מדומה. האפליקציה שלכם:

  • שימוש בעיצוב אדפטיבי, כך שהאתר יפעל במחשב או בנייד.
  • להשתמש באנימציה כדי לעבור בצורה חלקה בין פריסות שונות.
  • שימוש ב-Material 3 ליצירת עיצוב דרמטי.
  • תוכנה שפועלת ב-Android, ב-iOS, באינטרנט, ב-Windows, ב-Linux וב-macOS.

4111f0eb4aba816f.png

סדנת הקוד הזו מתמקדת ב-Material 3 עם Flutter. מושגים וחסימות קוד לא רלוונטיים מוצגים בקצרה וניתנים להעתקה והדבקה.

2.‏ הגדרת סביבת Flutter

מה נדרש

הקודלאב הזה נבדק לפריסה ב-Android, ב-iOS, באינטרנט, ב-Windows, ב-Linux וב-macOS. כדי לפרוס לחלק מהיעדים האלה, צריך להתקין תוכנה נוספת. כדי להבין אם הפלטפורמה מוגדרת בצורה נכונה, כדאי להריץ את הפקודה flutter doctor.

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

• No issues found!

אם יש בעיות בפלוט שמשפיעות על יעד הפריסה שבחרתם, מריצים את flutter doctor -v כדי לקבל מידע מפורט יותר. אם הבעיה לא נפתרה אחרי ניסיון השלבים שמפורטים על ידי flutter doctor -v, כדאי לפנות אל קהילת Flutter.

3.‏ תחילת העבודה

יצירת אפליקציית Flutter ריקה

רוב מפתחי Flutter יוצרים אפליקציה בסיסית של 'ספירת הקשות על לחצן' באמצעות flutter create, ולאחר מכן משקיעים כמה דקות בהסרת הקוד שהם לא צריכים. אפשר ליצור פרויקט Flutter ריק (באמצעות הפרמטר --empty), עם רק את הדברים הבסיסיים הנחוצים כדי להפעיל אפליקציה.

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

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

In order to run your empty application, type:

  $ cd animated_responsive_layout
  $ flutter run

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

אפשר להריץ את הקוד הזה דרך עורך הקוד או ישירות משורת הפקודה. בהתאם לערכות הכלים שהותקנו אצלכם, ולפי אם יש לכם סימולטורים או מכונות וירטואליות שפועלות, יכול להיות שתתבקשו להחליט באיזה יעד פריסה להריץ את האפליקציה. בדוגמה הבאה מוסבר איך לבחור להריץ את האפליקציה הריקה בדפדפן אינטרנט על ידי בחירה באפשרות 'Chome'.

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

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

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

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

בתרחיש הזה, האפליקציה הריקה תפעל בדפדפן האינטרנט של Chrome. אפשר גם להפעיל אותו ב-Android, ב-iOS או במערכת ההפעלה של המחשב.

152efb79ce615edb.png

4.‏ יצירת אפליקציית 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 של האפליקציה:

avatar_1.png

avatar_2.png

avatar_3.png

avatar_4.png

avatar_5.png

avatar_6.png

avatar_7.png

thumbnail_1.png

עכשיו, אחרי שיש לכם את נכסי התמונות של הדמות, עליכם להוסיף אותם לקובץ pubspec.yaml באופן הבא:

pubspec.yaml

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

environment:
  sdk: ^3.8.0

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

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

האפליקציה דורשת מקור נתונים להודעות שהיא מציגה. יוצרים ספריית משנה models בספרייה lib של הפרויקט. אפשר לעשות זאת בשורת הפקודה באמצעות mkdir, או בכל עורך טקסט שתבחרו. יוצרים קובץ models.dart בתיקייה lib/models עם התוכן הבא:

lib/models/models.dart

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

  final String url;
}

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

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

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

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

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

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

עכשיו, אחרי שיש לכם הגדרה של צורת הנתונים, יוצרים קובץ data.dart בספרייה lib/models עם התוכן הבא:

lib/models/data.dart

import 'models.dart';

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

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

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

עכשיו, כשהנתונים האלה בידכם, הגיע הזמן להגדיר כמה ווידג'טים להצגת הנתונים האלה. יוצרים ספריית משנה בשם widgets בספרייה lib. תיצורו ארבעה קבצים ב-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.withAlpha(20),
   
_colorScheme.surface,
 
);

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

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

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

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

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

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

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

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

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

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

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

 
final Email email;
 
final bool isSelected;

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

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

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

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

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

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

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

כן, יש הרבה דברים שמתרחשים בווידג'ט הזה. כדאי לקרוא את המאמר הזה בפירוט, במיוחד כדי להבין איך הצבעים חלים על כל הווידג'ט. זה יהפוך לנושא חוזר. השלב הבא הוא search_bar.dart.

lib/widgets/search_bar.dart

import 'package:flutter/material.dart';

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

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

 
final User currentUser;

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

ווידג'ט פשוט יותר ללא מצב. בשלב הבא מוסיפים ווידג'ט נוסף, star_button.dart:

lib/widgets/star_button.dart

impoimport 'package:flutter/material.dart';

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

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

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

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

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

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

 
double get turns => state ? 1 : 0;

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

בשלב הבא, מעדכנים את הכוכב הראשי של התוכנית, lib/main.dart. מחליפים את התוכן הנוכחי של הקובץ בקוד הבא.

lib/main.dart

import 'package:flutter/material.dart';

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

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

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

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

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

 
final User currentUser;

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

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

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

מריצים את האפליקציה כדי לראות מה יש לכם.

43f8d99b09e0f983.png

5.‏ הוספת NavigationBar

בסוף השלב הקודם, באפליקציית ההתחלה הייתה רשימת הודעות, אבל לא הרבה מעבר לזה. בשלב הזה מוסיפים NavigationBar כדי להוסיף עניין חזותי. כשהאפליקציה עוברת מתכנון של ממשק משתמש לאפליקציה אמיתית, סרגל הניווט מספק אזורים שונים באפליקציה שבהם המשתמש יכול להשתמש.

אם יש NavigationBar, סימן שיש יעדים שניתן לנווט אליהם. יוצרים קובץ חדש בשם destinations.dart בספרייה lib וממלאים אותו בקוד הבא.

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(),
     
home: Feed(currentUser: data.user_0),
   
);
 
}
}

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

 
final User currentUser;

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

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

 
int selectedIndex = 0;                       // Add this variable

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

במקום להגדיר תוכן שונה לכל יעד, משנים את המצב של הודעות ספציפיות כך שישקף את היעד שנבחר ב-NavigationBar. כדי לשמור על עקביות, התהליך פועל גם בכיוון ההפוך: בחירה בהודעה תציג את היעד המתאים ב-NavigationBar. מריצים את האפליקציה כדי לוודא שהשינויים בוצעו:

9b3f7628fd32679c.png

זה נראה הגיוני בהגדרה צרה, אבל אם מרחיבים את החלון או מסובבים את הסימולטור של הטלפון למצב אופקי, זה נראה קצת מוזר. כדי לפתור את הבעיה, צריך להוסיף NavigationRail בצד ימין של המסך כשהאפליקציה רחבה מספיק. הטיפול בכך יתבצע בשלב הבא.

6.‏ הוספת פס ניווט

בשלב הזה נוסף NavigationRail לבקשה. הרעיון הוא להציג רק אחד משני ווידג'טים הניווט בהתאם לגודל המסך, כלומר צריך להסתיר או להציג את NavigationBar לפי הצורך. בתיקייה lib/widgets, יוצרים קובץ disappearing_bottom_navigation_bar.dart ומוסיפים את הקוד הבא:

lib/widgets/disappearing_bottom_navigation_bar.dart

import 'package:flutter/material.dart';

import '../destinations.dart';

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

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

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

באותה ספרייה, מוסיפים קובץ נוסף בשם disappearing_navigation_rail.dart עם הקוד הבא:

lib/widgets/disappearing_navigation_rail.dart

import 'package:flutter/material.dart';

import '../destinations.dart';

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

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

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

אחרי ששיניתם את שיטות הניווט לווידג'טים משלהם, צריך לבצע כמה שינויים בקובץ lib/main.dart:

lib/main.dart

import 'package:flutter/material.dart';

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

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

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

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

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

 
final User currentUser;

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

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

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

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

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

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

השינוי החשוב הראשון בקובץ main.dart הוא הוספת המצב wideScreen שמתעדכן בכל פעם שהמשתמש משנה את גודל המסך, בין שבאמצעות שינוי גודל חלון הדפדפן ובין שבאמצעות סיבוב הטלפון. השינוי הבא משנה את NavigationBar ואת FloatingActionButton כך שיהיה תלוי במצב שבו האפליקציה נמצאת – wideScreen. לבסוף, ה-NavigationRail יוצג בצד ימין באופן מותנה אם המסך רחב מספיק. מריצים את האפליקציה באינטרנט או במחשב ומגדילים או מצמצמים את המסך כדי להציג את שתי הפריסות השונות.

שני העיצובים השונים הם דבר טוב, אבל המעבר ביניהם לא מוצלח. החלפת הסרגל בפס (ולהפך) באופן דינמי יותר ישפר באופן משמעותי את האפליקציה הזו. תוסיפו את האנימציה הזו בשלב הבא.

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(),
     
home: Feed(currentUser: data.user_0),
   
);
 
}
}

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

 
final User currentUser;

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

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

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

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

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

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

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

מריצים את האפליקציה. בהתחלה היא אמורה להיראות כמו קודם. משנים את גודל המסך כדי לראות את ממשק המשתמש עובר בין מסילה לניווט לבין סרגל הניווט, בהתאם לגודל ולמידות. עכשיו התנועה של המעברים האלה אמורה להיראות חלקה ומהנה. אפשר להשתמש בטעינה מחדש בזמן ריצה כדי לשנות את עקומות האנימציה שבהן נעשה שימוש, ולראות איך הן משנות את המראה של האפליקציה.

8.‏ הוספת רשימה עם תצוגת פרטים

כתוספת, אפליקציית הודעות היא מקום מצוין להציג רשימה עם פריסה מפורטת, אבל רק אם המסך רחב מספיק. מתחילים בהוספת קובץ בשם reply_list_view.dart ל-lib/widgets וממלאים אותו בקוד הבא:

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(),
     
home: Feed(currentUser: data.user_0),
   
);
 
}
}

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

 
final User currentUser;

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

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

 
int selectedIndex = 0;
 
bool controllerInitialized = false;

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

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

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

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

מריצים את האפליקציה כדי לראות את כל הרכיבים יחד. יש לכם עיצוב של Material 3 ואנימציה בין פריסות שונות באפליקציה שמייצגת אפליקציה אמיתית. הוא אמור להיראות כך:

94b96bd9d65a829.png

9.‏ מזל טוב

כל הכבוד, סיימתם ליצור את אפליקציית Flutter הראשונה שלכם עם Material 3!

כדי לבדוק את כל השלבים של סדנת הקוד הזו בקוד, אפשר לעיין בה במאגר GitHub של סדנאות הקוד של Flutter.

מה השלב הבא?

כדאי לעיין בחלק מהקורסים האלה ב-Codelab…

מקורות מידע נוספים