1. מבוא
אנימציות הן דרך מצוינת לשפר את חוויית המשתמש באפליקציה, להעביר למשתמש מידע חשוב ולשפר את המראה של האפליקציה כדי שהשימוש בה יהיה מהנה יותר.
סקירה כללית של מסגרת האנימציה של Flutter
Flutter מציג אפקטים של אנימציה על ידי בנייה מחדש של חלק מעץ הווידג'טים בכל פריים. הוא מספק אפקטים של אנימציה מוכנים מראש וממשקי API אחרים כדי להקל על יצירה והרכבה של אנימציות.
- אנימציות מרומזות הן אפקטים של אנימציה מוכנים מראש שמופעלים באופן אוטומטי. כשערך היעד של האנימציה משתנה, האנימציה מופעלת מהערך הנוכחי לערך היעד, וכל ערך שביניהם מוצג כדי שהווידג'ט יציג אנימציה חלקה. דוגמאות לאנימציות משתמעות כוללות את
AnimatedSize,AnimatedScaleו-AnimatedPositioned. - אנימציות מפורשות הן גם אפקטים מוכנים מראש של אנימציה, אבל הן דורשות אובייקט
Animationכדי לפעול. לדוגמה:SizeTransition, ScaleTransitionאוPositionedTransition. - Animation היא מחלקה שמייצגת אנימציה שפועלת או שהופסקה, והיא מורכבת מערך שמייצג את ערך היעד שאליו האנימציה פועלת, ומסטטוס שמייצג את הערך הנוכחי שהאנימציה מציגה על המסך בכל רגע נתון. זו מחלקת משנה של
Listenable, והיא שולחת הודעה למאזינים שלה כשהסטטוס משתנה בזמן שהאנימציה פועלת. - AnimationController היא דרך ליצור אנימציה ולשלוט במצב שלה. אפשר להשתמש בשיטות שלו, כמו
forward(), reset(), stop()ו-repeat(), כדי לשלוט באנימציה בלי להגדיר את אפקט האנימציה שמוצג, כמו קנה המידה, הגודל או המיקום. - Tweens משמשים לאינטרפולציה של ערכים בין ערך התחלתי לערך סופי, ויכולים לייצג כל סוג, כמו double,
OffsetאוColor. - עקומות משמשות להתאמת קצב השינוי של פרמטר לאורך זמן. כשמפעילים אנימציה, נהוג להחיל עקומת שיכוך כדי להגביר או להקטין את קצב השינוי בתחילת האנימציה או בסופה. הפונקציה Curves מקבלת ערך קלט בין 0.0 ל-1.0 ומחזירה ערך פלט בין 0.0 ל-1.0.
מה תפַתחו
ב-Codelab הזה מוסבר איך ליצור משחק חידון עם שאלות אמריקאיות שכולל טכניקות ואפקטים שונים של אנימציה.

במאמר הזה מוסבר איך:
- איך יוצרים ווידג'ט עם אנימציה של הגודל והצבע
- איך יוצרים אפקט של היפוך כרטיס בתלת-ממד
- שימוש באפקטים מיוחדים של אנימציה מוכנה מראש מחבילת האנימציות
- הוספת תמיכה בחיזוי תנועת החזרה שזמינה בגרסה האחרונה של Android
מה תלמדו
ב-codelab הזה תלמדו:
- איך משתמשים באפקטים עם אנימציה מרומזת כדי ליצור אנימציות שנראות מצוין בלי לכתוב הרבה קוד.
- איך משתמשים באפקטים מונפשים מוגדרים כדי להגדיר אפקטים משלכם באמצעות ווידג'טים מונפשים מוכנים מראש, כמו
AnimatedSwitcherאוAnimationController. - איך משתמשים ב-
AnimationControllerכדי להגדיר ווידג'ט משלכם שמציג אפקט תלת-ממדי. - איך משתמשים בחבילת
animationsכדי להציג אפקטים של אנימציה מיוחדת עם הגדרה מינימלית.
הדרישות
- Flutter SDK
- סביבת פיתוח משולבת (IDE), כמו VSCode או Android Studio / IntelliJ
2. הגדרת סביבת הפיתוח של Flutter
כדי להשלים את שיעור ה-Lab הזה, תצטרכו שני סוגי תוכנה: Flutter SDK ועורך.
אפשר להריץ את ה-codelab באמצעות כל אחד מהמכשירים הבאים:
- מכשיר Android פיזי (מומלץ להטמעה של תכונת החזרה החזויה בשלב 7) או מכשיר iOS שמחובר למחשב ומוגדר למצב פיתוח.
- הסימולטור של iOS (נדרשת התקנה של כלי Xcode).
- אמולטור Android (נדרשת הגדרה ב-Android Studio).
- דפדפן (חובה להשתמש ב-Chrome לצורך ניפוי באגים).
- מחשב עם Windows, Linux או macOS. אתם צריכים לפתח בפלטפורמה שבה אתם מתכננים לבצע פריסה. לכן, אם רוצים לפתח אפליקציה למחשב שולחני עם Windows, צריך לפתח ב-Windows כדי לגשת לשרשרת הבנייה המתאימה. יש דרישות ספציפיות למערכות הפעלה שמוסברות בפירוט בכתובת docs.flutter.dev/desktop.
אימות ההתקנה
כדי לוודא ש-Flutter SDK מוגדר בצורה נכונה ושיש לכם לפחות אחת מפלטפורמות היעד שלמעלה, משתמשים בכלי Flutter Doctor:
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources
• No issues found!
3. הפעלת האפליקציה לתחילת הדרך
הורדת האפליקציה לתחילת הדרך
משתמשים ב-git כדי לשכפל את אפליקציית ההתחלה מהמאגר flutter/samples ב-GitHub.
git clone https://github.com/flutter/codelabs.git cd codelabs/animations/step_01/
אפשר גם להוריד את קוד המקור כקובץ ZIP.
הפעלת האפליקציה
כדי להריץ את האפליקציה, משתמשים בפקודה flutter run ומציינים מכשיר יעד, כמו android, ios או chrome. הרשימה המלאה של הפלטפורמות הנתמכות מופיעה בדף פלטפורמות נתמכות.
flutter run -d android
אפשר גם להריץ את האפליקציה ולנפות בה באגים באמצעות סביבת הפיתוח המשולבת (IDE) שתבחרו. מידע נוסף זמין במסמכי התיעוד הרשמיים של Flutter.
סיור בקוד
האפליקציה לתחילת הדרך היא משחק חידון עם שאלות רבות ברירה, שמורכב משני מסכים לפי דפוס העיצוב model-view-view-model, או MVVM. ה-QuestionScreen (תצוגה) משתמש במחלקה QuizViewModel (מודל תצוגה) כדי להציג למשתמש שאלות אמריקאיות מהמחלקה QuestionBank (מודל).
- home_screen.dart – הצגת מסך עם לחצן New Game
- main.dart – קובץ שמגדיר את
MaterialAppלשימוש ב-Material 3 ולהצגת מסך הבית - model.dart – הגדרת מחלקות הליבה שמשמשות בכל האפליקציה
- question_screen.dart – הצגת ממשק המשתמש של משחק החידון
- view_model.dart – מכיל את הסטטוס והלוגיקה של משחק החידון, שמוצגים על ידי
QuestionScreen

האפליקציה לא תומכת באפקטים של אנימציה, למעט מעבר התצוגה שמוצג כברירת מחדל על ידי המחלקה Navigator של Flutter כשלוחצים על הלחצן משחק חדש.
4. שימוש באפקטים מרומזים של אנימציה
אנימציות מרומזות הן בחירה מצוינת במצבים רבים, כי לא צריך להגדיר אותן באופן מיוחד. בקטע הזה נסביר איך לעדכן את הווידג'ט StatusBar כך שיוצג בו לוח תוצאות מונפש. כדי למצוא אפקטים נפוצים של אנימציה מרומזת, אפשר לעיין בתיעוד ה-API של ImplicitlyAnimatedWidget.

יצירת ווידג'ט של לוח התוצאות ללא אנימציה
יוצרים קובץ חדש, lib/scoreboard.dart, עם הקוד הבא:
lib/scoreboard.dart
import 'package:flutter/material.dart';
class Scoreboard extends StatelessWidget {
final int score;
final int totalQuestions;
const Scoreboard({
super.key,
required this.score,
required this.totalQuestions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < totalQuestions; i++)
Icon(
Icons.star,
size: 50,
color: score < i + 1
? Colors.grey.shade400
: Colors.yellow.shade700,
),
],
),
);
}
}
לאחר מכן מוסיפים את הווידג'ט Scoreboard כרכיב צאצא של הווידג'ט StatusBar, במקום הווידג'טים Text שהציגו קודם את הניקוד ואת המספר הכולל של השאלות. הכלי לעריכה אמור להוסיף אוטומטית את התג import "scoreboard.dart" הנדרש בחלק העליון של הקובץ.
lib/question_screen.dart
class StatusBar extends StatelessWidget {
final QuizViewModel viewModel;
const StatusBar({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Scoreboard( // NEW
score: viewModel.score, // NEW
totalQuestions: viewModel.totalQuestions, // NEW
),
],
),
),
);
}
}
בווידג'ט הזה מוצג סמל כוכב לכל שאלה. כשעונים על שאלה בצורה נכונה, כוכב נוסף נדלק באופן מיידי בלי אנימציה. בשלבים הבאים תעזרו ליידע את המשתמש שהציון שלו השתנה על ידי אנימציה של הגודל והצבע שלו.
שימוש באפקט אנימציה מרומז
יוצרים ווידג'ט חדש בשם AnimatedStar שמשתמש בווידג'ט AnimatedScale כדי לשנות את הסכום scale מ-0.5 ל-1.0 כשהכוכב הופך לפעיל:
lib/scoreboard.dart
import 'package:flutter/material.dart';
class Scoreboard extends StatelessWidget {
final int score;
final int totalQuestions;
const Scoreboard({
super.key,
required this.score,
required this.totalQuestions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < totalQuestions; i++)
AnimatedStar(isActive: score > i), // Edit this line.
],
),
);
}
}
class AnimatedStar extends StatelessWidget { // Add from here...
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
duration: _duration,
child: Icon(
Icons.star,
size: 50,
color: isActive ? _activatedColor : _deactivatedColor,
),
);
}
} // To here.
עכשיו, כשהמשתמש עונה על שאלה בצורה נכונה, הווידג'ט AnimatedStar מעדכן את הגודל שלו באמצעות אנימציה מרומזת. האנימציה של Icon color לא מוצגת כאן, רק האנימציה של scale, שמוצגת באמצעות הווידג'ט AnimatedScale.

שימוש ב-Tween כדי לבצע אינטרפולציה בין שני ערכים
שימו לב שהצבע של הווידג'ט AnimatedStar משתנה מיד אחרי שהשדה isActive משתנה ל-true.
כדי ליצור אפקט צבע מונפש, אפשר לנסות להשתמש בווידג'ט AnimatedContainer (שהוא עוד מחלקת משנה של ImplicitlyAnimatedWidget), כי הוא יכול להנפיש באופן אוטומטי את כל המאפיינים שלו, כולל הצבע. לצערנו, בווידג'ט שלנו צריך להופיע סמל, ולא מאגר.
אפשר גם לנסות את AnimatedIcon, שכולל אפקטים של מעברים בין צורות הסמלים. אבל אין הטמעה של סמל כוכב כברירת מחדל במחלקה AnimatedIcons.
במקום זאת, נשתמש במחלקת משנה אחרת של ImplicitlyAnimatedWidget שנקראת TweenAnimationBuilder, שמקבלת Tween כפרמטר. Tween היא מחלקה שמקבלת שני ערכים (begin ו-end) ומחשבת את הערכים שביניהם, כדי שאפשר יהיה להציג אותם באנימציה. בדוגמה הזו נשתמש ב-ColorTween, שמקיים את דרישות הממשק Tween שנדרש ליצירת אפקט האנימציה.
בוחרים את הווידג'ט Icon ומשתמשים בפעולה המהירה Wrap with Builder (הוספה ל-Builder) בסביבת הפיתוח המשולבת (IDE), משנים את השם ל-TweenAnimationBuilder. אחר כך מציינים את משך הזמן ולוחצים על ColorTween.
lib/scoreboard.dart
class AnimatedStar extends StatelessWidget {
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
duration: _duration,
child: TweenAnimationBuilder( // Add from here...
duration: _duration,
tween: ColorTween(
begin: _deactivatedColor,
end: isActive ? _activatedColor : _deactivatedColor,
),
builder: (context, value, child) { // To here.
return Icon(Icons.star, size: 50, color: value); // And modify this line.
},
),
);
}
}
עכשיו, מפעילים מחדש את האפליקציה כדי לראות את האנימציה החדשה.

שימו לב שהערך end של ColorTween משתנה בהתאם לערך של הפרמטר isActive. הסיבה לכך היא שהאנימציה של TweenAnimationBuilder מופעלת מחדש בכל פעם שהערך של Tween.end משתנה. במקרה כזה, האנימציה החדשה תפעל מהערך הנוכחי של האנימציה עד לערך הסיום החדש, כך שתוכלו לשנות את הצבע בכל שלב (גם בזמן שהאנימציה פועלת) ולהציג אפקט אנימציה חלק עם הערכים הנכונים שביניהם.
החלת עקומה
שני אפקטי האנימציה האלה פועלים בקצב קבוע, אבל לרוב האנימציות מעניינות יותר מבחינה ויזואלית ומספקות יותר מידע כשהן מאיצות או מאטות.
Curve מחיל פונקציית מעבר, שמגדירה את קצב השינוי של פרמטר לאורך זמן. Flutter מגיעה עם אוסף של עקומות החלקה מוכנות מראש במחלקה Curves, כמו easeIn או easeOut.


הדיאגרמות האלה (שזמינות בדף מאמרי העזרה של Curves API) מספקות רמז לאופן הפעולה של העקומות. העקומות ממירות ערך קלט בין 0.0 ל-1.0 (מוצג בציר ה-x) לערך פלט בין 0.0 ל-1.0 (מוצג בציר ה-y). בתרשימים האלה מוצגת גם תצוגה מקדימה של האופן שבו נראים אפקטים שונים של אנימציה כשמשתמשים בעקומת האצה.
יוצרים שדה חדש ב-AnimatedStar בשם _curve ומעבירים אותו כפרמטר לווידג'טים AnimatedScale ו-TweenAnimationBuilder.
lib/scoreboard.dart
class AnimatedStar extends StatelessWidget {
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
final Curve _curve = Curves.elasticOut; // NEW
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
curve: _curve, // NEW
duration: _duration,
child: TweenAnimationBuilder(
curve: _curve, // NEW
duration: _duration,
tween: ColorTween(
begin: _deactivatedColor,
end: isActive ? _activatedColor : _deactivatedColor,
),
builder: (context, value, child) {
return Icon(Icons.star, size: 50, color: value);
},
),
);
}
}
בדוגמה הזו, עקומת elasticOut מספקת אפקט קפיצי מוגזם שמתחיל בתנועת קפיץ ומתאזן לקראת הסוף.

מבצעים Hot Reload של האפליקציה כדי לראות את העקומה הזו מוחלת על AnimatedSize ועל TweenAnimationBuilder.

שימוש בכלי הפיתוח להפעלת אנימציות איטיות
כדי לנפות באגים באפקט אנימציה, כלי הפיתוח של Flutter מספקים דרך להאט את כל האנימציות באפליקציה, כך שתוכלו לראות את האנימציה בצורה ברורה יותר.
כדי לפתוח את כלי הפיתוח, מוודאים שהאפליקציה פועלת במצב ניפוי באגים, ופותחים את הכלי לבדיקת הווידג'טים על ידי בחירה בו בסרגל הכלים לניפוי באגים ב-VSCode או על ידי לחיצה על הלחצן פתיחת כלי הפיתוח של Flutter בחלון של הכלי לניפוי באגים ב-IntelliJ או ב-Android Studio.


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

5. שימוש באפקטים של אנימציה מפורשת
בדומה לאנימציות מרומזות, אנימציות מפורשות הן אפקטים של אנימציה מוכנים מראש, אבל במקום לקבל ערך יעד, הן מקבלות אובייקט Animation כפרמטר. לכן הם שימושיים במצבים שבהם האנימציה כבר מוגדרת על ידי מעבר ניווט, למשל AnimatedSwitcher או AnimationController.
שימוש באפקט אנימציה מפורש
כדי להתחיל להשתמש באפקט אנימציה מפורש, עוטפים את הווידג'ט Card בתג AnimatedSwitcher.
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({required this.question, super.key});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher( // NEW
duration: const Duration(milliseconds: 300), // NEW
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
), // NEW
);
}
}
כברירת מחדל, AnimatedSwitcher משתמש באפקט של מעבר הדרגתי, אבל אפשר לשנות את זה באמצעות הפרמטר transitionBuilder. ה-builder של המעבר מספק את הווידג'ט הצאצא שהועבר אל AnimatedSwitcher, ואובייקט Animation. זו הזדמנות מצוינת להשתמש באנימציה ברורה.
ב-codelab הזה, האנימציה המפורשת הראשונה שנשתמש בה היא SlideTransition, שמקבלת Animation<Offset> שמגדיר את ההיסט של ההתחלה והסוף שביניהם הווידג'טים הנכנסים והיוצאים ינועו.
ל-Tweens יש פונקציית עזר, animate(), שממירה כל Animation ל-Animation אחר עם ה-Tween שהוחל. כלומר, אפשר להשתמש ב-Tween כדי להמיר את Animation שסופק על ידי AnimatedSwitcher ל-Animation, כדי לספק אותו לווידג'ט SlideTransition.
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({required this.question, super.key});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
transitionBuilder: (child, animation) { // Add from here...
final curveAnimation = CurveTween(
curve: Curves.easeInCubic,
).animate(animation);
final offsetAnimation = Tween<Offset>(
begin: Offset(-0.1, 0.0),
end: Offset.zero,
).animate(curveAnimation);
return SlideTransition(position: offsetAnimation, child: child);
}, // To here.
duration: const Duration(milliseconds: 300),
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
),
);
}
}
שימו לב: נעשה כאן שימוש ב-Tween.animate כדי להחיל Curve על Animation, ואז להמיר אותו מ-Tween בטווח 0.0 עד 1.0 ל-Tween בטווח -0.1 עד 0.0 בציר ה-x.
לחלופין, למחלקה Animation יש פונקציה drive() שמקבלת כל Tween (או Animatable) וממירה אותו ל-Animation חדש. כך אפשר 'לשרשר' בין האנימציות, והקוד שמתקבל תמציתי יותר:
lib/question_screen.dart
transitionBuilder: (child, animation) {
var offsetAnimation = animation
.drive(CurveTween(curve: Curves.easeInCubic))
.drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
return SlideTransition(position: offsetAnimation, child: child);
},
יתרון נוסף בשימוש באנימציות מפורשות הוא שאפשר ליצור מהן קומפוזיציה. מוסיפים עוד אנימציה מפורשת, FadeTransition שמשתמשת באותה אנימציה מעוקלת על ידי עטיפת הווידג'ט SlideTransition.
lib/question_screen.dart
return AnimatedSwitcher(
transitionBuilder: (child, animation) {
final curveAnimation = CurveTween(
curve: Curves.easeInCubic,
).animate(animation);
final offsetAnimation = Tween<Offset>(
begin: Offset(-0.1, 0.0),
end: Offset.zero,
).animate(curveAnimation);
final fadeInAnimation = curveAnimation; // NEW
return FadeTransition( // NEW
opacity: fadeInAnimation, // NEW
child: SlideTransition(position: offsetAnimation, child: child), // NEW
); // NEW
},
התאמה אישית של layoutBuilder
יכול להיות שתבחינו בבעיה קלה ב-AnimationSwitcher. כשQuestionCard עובר לשאלה חדשה, הוא מציג אותה במרכז השטח הזמין בזמן שהאנימציה פועלת, אבל כשהאנימציה נעצרת, הווידג'ט קופץ לחלק העליון של המסך. התוצאה היא אנימציה מקוטעת, כי המיקום הסופי של כרטיס השאלה לא תואם למיקום שלו בזמן שהאנימציה פועלת.

כדי לפתור את הבעיה, ל-AnimatedSwitcher יש גם פרמטר layoutBuilder, שאפשר להשתמש בו כדי להגדיר את הפריסה. משתמשים בפונקציה הזו כדי להגדיר את הכלי ליצירת פריסות כך שהכרטיס ייושר לחלק העליון של המסך:
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
הקוד הזה הוא גרסה שעברה שינוי של defaultLayoutBuilder מהמחלקה AnimatedSwitcher, אבל הוא משתמש ב-Alignment.topCenter במקום ב-Alignment.center.
סיכום
- אנימציות מפורשות הן אפקטים של אנימציה שמופעלים על אובייקט
Animation(בניגוד ל-ImplicitlyAnimatedWidgets, שמופעלים על יעדvalueו-duration) - הסיווג
Animationמייצג אנימציה שפועלת, אבל לא מגדיר אפקט ספציפי. - משתמשים ב-
Tween().animateאו ב-Animation.drive()כדי להחילTweensו-Curves(באמצעותCurveTween) על אנימציה. - משתמשים בפרמטר
AnimatedSwitcherשלlayoutBuilderכדי לשנות את אופן הפריסה של רכיבי הצאצא שלו.
6. שליטה במצב של אנימציה
עד עכשיו, כל האנימציות הופעלו אוטומטית על ידי המסגרת. אנימציות מרומזות מופעלות באופן אוטומטי, ואפקטים של אנימציות מפורשות דורשים Animation כדי לפעול בצורה תקינה. בקטע הזה נסביר איך ליצור אובייקטים של Animation באמצעות AnimationController, ואיך להשתמש ב-TweenSequence כדי לשלב בין Tween.
הפעלת אנימציה באמצעות AnimationController
כדי ליצור אנימציה באמצעות AnimationController, צריך לבצע את השלבים הבאים:
- צור
StatefulWidget - משתמשים ב-mixin
SingleTickerProviderStateMixinבכיתהStateכדי לספקTickerל-AnimationController - מאתחלים את
AnimationControllerב-method של מחזור החייםinitState, ומספקים את האובייקט הנוכחיStateלפרמטרvsync(TickerProvider). - מוודאים שהווידג'ט נבנה מחדש בכל פעם ש-
AnimationControllerשולח הודעה למאזינים שלו, באמצעותAnimatedBuilderאו באמצעות קריאה ל-listen()ול-setStateבאופן ידני.
יוצרים קובץ חדש, flip_effect.dart ומעתיקים ומדביקים את הקוד הבא:
lib/flip_effect.dart
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
class CardFlipEffect extends StatefulWidget {
final Widget child;
final Duration duration;
const CardFlipEffect({
super.key,
required this.child,
required this.duration,
});
@override
State<CardFlipEffect> createState() => _CardFlipEffectState();
}
class _CardFlipEffectState extends State<CardFlipEffect>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
Widget? _previousChild;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: widget.duration,
);
_animationController.addListener(() {
if (_animationController.value == 1) {
_animationController.reset();
}
});
}
@override
void didUpdateWidget(covariant CardFlipEffect oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.child.key != oldWidget.child.key) {
_handleChildChanged(widget.child, oldWidget.child);
}
}
void _handleChildChanged(Widget newChild, Widget previousChild) {
_previousChild = previousChild;
_animationController.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateX(_animationController.value * math.pi),
child: _animationController.isAnimating
? _animationController.value < 0.5
? _previousChild
: Transform.flip(flipY: true, child: child)
: child,
);
},
child: widget.child,
);
}
}
המחלקות האלה מגדירות AnimationController ומריצות מחדש את האנימציה בכל פעם שהמסגרת קוראת ל-didUpdateWidget כדי להודיע לה שההגדרה של הווידג'ט השתנתה, ויכול להיות שיש ווידג'ט צאצא חדש.
ה-AnimatedBuilder מוודא שעץ הווידג'טים נבנה מחדש בכל פעם שה-AnimationController שולח הודעה למאזינים שלו, והווידג'ט Transform משמש להחלת אפקט סיבוב תלת-ממדי כדי לדמות כרטיס שהופכים אותו.
כדי להשתמש בווידג'ט הזה, צריך להוסיף את הווידג'ט CardFlipEffect לכל כרטיס תשובה. מקפידים לספק key לווידג'ט Card:
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 5 / 2,
children: List.generate(answers.length, (index) {
var color = Theme.of(context).colorScheme.primaryContainer;
if (correctAnswer == index) {
color = Theme.of(context).colorScheme.tertiaryContainer;
}
return CardFlipEffect( // NEW
duration: const Duration(milliseconds: 300), // NEW
child: Card.filled( // NEW
key: ValueKey(answers[index]), // NEW
color: color,
elevation: 2,
margin: EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => onTapped(index),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
answers.length > index ? answers[index] : '',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.clip,
),
),
),
),
), // NEW
);
}),
);
}
עכשיו מבצעים טעינה מחדש של האפליקציה כדי לראות את כרטיסי התשובות מתהפכים באמצעות הווידג'ט CardFlipEffect.

יכול להיות שתשימו לב שהסיווג הזה דומה מאוד לאפקט אנימציה מפורש. למעשה, לעיתים קרובות כדאי להרחיב את המחלקה AnimatedWidget ישירות כדי להטמיע גרסה משלכם. לצערנו, מכיוון שהמחלקות האלה צריכות לאחסן את הווידג'ט הקודם ב-State שלהן, הן צריכות להשתמש ב-StatefulWidget. מידע נוסף על יצירת אפקטים של אנימציה מפורשים משלכם זמין במסמכי התיעוד של ה-API בנושא AnimatedWidget.
הוספת השהיה באמצעות TweenSequence
בקטע הזה, מוסיפים השהיה לווידג'ט CardFlipEffect כדי שכל כרטיס יתהפך בנפרד. כדי להתחיל, מוסיפים שדה חדש בשם delayAmount.
lib/flip_effect.dart
class CardFlipEffect extends StatefulWidget {
final Widget child;
final Duration duration;
final double delayAmount; // NEW
const CardFlipEffect({
super.key,
required this.child,
required this.duration,
required this.delayAmount, // NEW
});
@override
State<CardFlipEffect> createState() => _CardFlipEffectState();
}
לאחר מכן מוסיפים את delayAmount לשיטת ה-build של AnswerCards.
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 5 / 2,
children: List.generate(answers.length, (index) {
var color = Theme.of(context).colorScheme.primaryContainer;
if (correctAnswer == index) {
color = Theme.of(context).colorScheme.tertiaryContainer;
}
return CardFlipEffect(
delayAmount: index.toDouble() / 2, // NEW
duration: const Duration(milliseconds: 300),
child: Card.filled(
key: ValueKey(answers[index]),
אחר כך, ב-_CardFlipEffectState, יוצרים Animation חדש שמחיל את העיכוב באמצעות TweenSequence. שימו לב שלא נעשה כאן שימוש בכלי עזר כלשהו מהספרייה dart:async, כמו Future.delayed. הסיבה לכך היא שההשהיה היא חלק מהאנימציה ולא משהו שהווידג'ט שולט בו באופן מפורש כשהוא משתמש ב-AnimationController. כך קל יותר לנפות באגים באפקט האנימציה כשמפעילים אנימציות איטיות בכלי הפיתוח, כי נעשה שימוש באותו TickerProvider.
כדי להשתמש ב-TweenSequence, יוצרים שני TweenSequenceItem, אחד שמכיל ConstantTween ששומר על האנימציה ב-0 למשך זמן יחסי, ואחד רגיל Tween שעובר מ-0.0 ל-1.0.
lib/flip_effect.dart
class _CardFlipEffectState extends State<CardFlipEffect>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
Widget? _previousChild;
late final Animation<double> _animationWithDelay; // NEW
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: widget.duration * (widget.delayAmount + 1),
);
_animationController.addListener(() {
if (_animationController.value == 1) {
_animationController.reset();
}
});
_animationWithDelay = TweenSequence<double>([ // Add from here...
if (widget.delayAmount > 0)
TweenSequenceItem(
tween: ConstantTween<double>(0.0),
weight: widget.delayAmount,
),
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
]).animate(_animationController); // To here.
}
לבסוף, מחליפים את האנימציה של AnimationController באנימציה החדשה עם ההשהיה בשיטה build.
lib/flip_effect.dart
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationWithDelay, // Modify this line
builder: (context, child) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateX(_animationWithDelay.value * math.pi), // And this line
child: _animationController.isAnimating
? _animationWithDelay.value < 0.5 // And this one.
? _previousChild
: Transform.flip(flipY: true, child: child)
: child,
);
},
child: widget.child,
);
}
עכשיו מבצעים Hot Reload של האפליקציה וצופים בכרטיסים מתהפכים אחד אחרי השני. כדי להתמודד עם אתגר, אפשר לנסות לשנות את נקודת המבט של אפקט התלת-ממד שמופיע בווידג'ט Transform.

7. שימוש במעברים מותאמים אישית בניווט
עד עכשיו ראינו איך להתאים אישית אפקטים במסך אחד, אבל אפשר להשתמש באנימציות גם כדי ליצור מעברים בין מסכים. בקטע הזה נסביר איך להחיל אפקטים של אנימציה על מעברים בין מסכים באמצעות אפקטים מובנים של אנימציה ואפקטים מיוחדים של אנימציה שנוצרו מראש, שזמינים בחבילת האנימציות הרשמית ב-pub.dev.
יצירת אנימציה למעבר בין דפים
הכיתה PageRouteBuilder היא Route שמאפשרת להתאים אישית את אנימציית המעבר. אפשר להגדיר במקומה את הקריאה החוזרת (callback) של transitionBuilder, שמספקת שני אובייקטים של Animation שמייצגים את האנימציה הנכנסת והיוצאת שמופעלת על ידי Navigator.
כדי להתאים אישית את אנימציית המעבר, מחליפים את MaterialPageRoute ב-PageRouteBuilder, וכדי להתאים אישית את אנימציית המעבר כשהמשתמש עובר מ-HomeScreen ל-QuestionScreen. משתמשים ב-FadeTransition (ווידג'ט מונפש במפורש) כדי שהמסך החדש יופיע בהדרגה מעל המסך הקודם.
lib/home_screen.dart
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
PageRouteBuilder( // Add from here...
pageBuilder: (context, animation, secondaryAnimation) {
return const QuestionScreen();
},
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
), // To here.
);
},
child: Text('New Game'),
),
חבילת האנימציות מספקת אפקטים מובנים של אנימציה, כמו FadeThroughTransition. מייבאים את חבילת האנימציות ומחליפים את הווידג'ט FadeTransition בווידג'ט FadeThroughTransition:
lib/home_screen.dart
import 'package;animations/animations.dart';
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) {
return const QuestionScreen();
},
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition( // Add from here...
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
); // To here.
},
),
);
},
child: Text('New Game'),
),
התאמה אישית של האנימציה של חיזוי החזרה

התכונה 'חזרה עם תצוגה מקדימה' היא תכונה חדשה ב-Android שמאפשרת למשתמש להציץ אל מאחורי המסלול או האפליקציה הנוכחיים כדי לראות מה יש מאחוריהם לפני שהוא עובר אליהם. האנימציה של הצצה מופעלת לפי המיקום של האצבע של המשתמש בזמן שהוא גורר אותה חזרה לרוחב המסך.
Flutter תומך בתכונה 'חזרה חזויה' של המערכת על ידי הפעלת התכונה ברמת המערכת, כשאין ל-Flutter מסלולים להצגה בערימה של הניווט, או במילים אחרות, כשפעולת חזרה תוביל ליציאה מהאפליקציה. האנימציה הזו מטופלת על ידי המערכת ולא על ידי Flutter עצמה.
בנוסף, Flutter תומך בחיזוי החזרה כשמנווטים בין מסלולים באפליקציית Flutter. יש PageTransitionsBuilder מיוחד שנקרא PredictiveBackPageTransitionsBuilder, שמזהה תנועות של חיזוי החזרה במערכת ומבצע את מעבר הדף בהתאם להתקדמות התנועה.
התכונה 'חזרה אחורה עם חיזוי' נתמכת רק ב-Android U ומעלה, אבל Flutter תחזור באופן אוטומטי להתנהגות המקורית של תנועת החזרה אחורה ול-ZoomPageTransitionBuilder. מידע נוסף זמין בפוסט בבלוג שלנו, כולל קטע על הגדרת התכונה באפליקציה שלכם.
בהגדרות של ThemeData באפליקציה, מגדירים את PageTransitionsTheme לשימוש ב-PredictiveBack ב-Android, ואת אפקט המעבר של fade-through מחבילת האנימציות בפלטפורמות אחרות:
lib/main.dart
import 'package:animations/animations.dart'; // NEW
import 'package:flutter/material.dart';
import 'home_screen.dart';
void main() {
runApp(MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), // NEW
TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.windows: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.linux: FadeThroughPageTransitionsBuilder(), // NEW
},
),
),
home: HomeScreen(),
);
}
}
עכשיו אפשר לשנות את Navigator.push() השיחה חזרה לMaterialPageRoute.
lib/home_screen.dart
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
MaterialPageRoute( // Add from here...
builder: (context) {
return const QuestionScreen();
},
), // To here.
);
},
child: Text('New Game'),
),
שימוש ב-FadeThroughTransition כדי לשנות את השאלה הנוכחית
ווידג'ט AnimatedSwitcher מספק רק Animation אחד בקריאה החוזרת (callback) של ה-builder. כדי לפתור את הבעיה, חבילת animations מספקת PageTransitionSwitcher.
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({required this.question, super.key});
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher( // Add from here...
layoutBuilder: (entries) {
return Stack(alignment: Alignment.topCenter, children: entries);
},
transitionBuilder: (child, animation, secondaryAnimation) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
}, // To here.
duration: const Duration(milliseconds: 300),
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
),
);
}
}
שימוש ב-OpenContainer

הווידג'ט OpenContainer מהחבילה animations מספק אפקט אנימציה של טרנספורמציה של קונטיינר, שמתרחב כדי ליצור חיבור חזותי בין שני ווידג'טים.
הווידג'ט שמוחזר על ידי closedBuilder מוצג בהתחלה, ומתרחב לווידג'ט שמוחזר על ידי openBuilder כשמקישים על הקונטיינר או כשמתקשרים אל הקריאה החוזרת openContainer.
כדי לחבר את הקריאה החוזרת openContainer לתצוגת המודל, מוסיפים העברה חדשה של viewModel לווידג'ט QuestionCard ומאחסנים קריאה חוזרת שתשמש להצגת המסך 'המשחק הסתיים':
lib/question_screen.dart
class QuestionScreen extends StatefulWidget {
const QuestionScreen({super.key});
@override
State<QuestionScreen> createState() => _QuestionScreenState();
}
class _QuestionScreenState extends State<QuestionScreen> {
late final QuizViewModel viewModel = QuizViewModel(
onGameOver: _handleGameOver,
);
VoidCallback? _showGameOverScreen; // NEW
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Scaffold(
appBar: AppBar(
actions: [
TextButton(
onPressed:
viewModel.hasNextQuestion && viewModel.didAnswerQuestion
? () {
viewModel.getNextQuestion();
}
: null,
child: const Text('Next'),
),
],
),
body: Center(
child: Column(
children: [
QuestionCard( // NEW
onChangeOpenContainer: _handleChangeOpenContainer, // NEW
question: viewModel.currentQuestion?.question, // NEW
viewModel: viewModel, // NEW
), // NEW
Spacer(),
AnswerCards(
onTapped: (index) {
viewModel.checkAnswer(index);
},
answers: viewModel.currentQuestion?.possibleAnswers ?? [],
correctAnswer: viewModel.didAnswerQuestion
? viewModel.currentQuestion?.correctAnswer
: null,
),
StatusBar(viewModel: viewModel),
],
),
),
);
},
);
}
void _handleChangeOpenContainer(VoidCallback openContainer) { // NEW
_showGameOverScreen = openContainer; // NEW
} // NEW
void _handleGameOver() { // NEW
if (_showGameOverScreen != null) { // NEW
_showGameOverScreen!(); // NEW
} // NEW
} // NEW
}
מוסיפים ווידג'ט חדש, GameOverScreen:
lib/question_screen.dart
class GameOverScreen extends StatelessWidget {
final QuizViewModel viewModel;
const GameOverScreen({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Scoreboard(
score: viewModel.score,
totalQuestions: viewModel.totalQuestions,
),
Text('You Win!', style: Theme.of(context).textTheme.displayLarge),
Text(
'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
style: Theme.of(context).textTheme.displaySmall,
),
ElevatedButton(
child: Text('OK'),
onPressed: () {
Navigator.popUntil(context, (route) => route.isFirst);
},
),
],
),
),
);
}
}
בווידג'ט QuestionCard, מחליפים את Card בווידג'ט OpenContainer מהחבילה animations, ומוסיפים שני שדות חדשים ל-viewModel ולקריאה החוזרת של מאגר התגים הפתוח:
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.onChangeOpenContainer,
required this.question,
required this.viewModel,
super.key,
});
final ValueChanged<VoidCallback> onChangeOpenContainer;
final QuizViewModel viewModel;
static const _backgroundColor = Color(0xfff2f3fa);
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation, secondaryAnimation) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
child: OpenContainer( // NEW
key: ValueKey(question), // NEW
tappable: false, // NEW
closedColor: _backgroundColor, // NEW
closedShape: const RoundedRectangleBorder( // NEW
borderRadius: BorderRadius.all(Radius.circular(12.0)), // NEW
), // NEW
closedElevation: 4, // NEW
closedBuilder: (context, openContainer) { // NEW
onChangeOpenContainer(openContainer); // NEW
return ColoredBox( // NEW
color: _backgroundColor, // NEW
child: Padding( // NEW
padding: const EdgeInsets.all(16.0), // NEW
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
);
},
openBuilder: (context, closeContainer) { // NEW
return GameOverScreen(viewModel: viewModel); // NEW
}, // NEW
),
);
}
}

8. מזל טוב
הצלחתם להוסיף אפקטים של אנימציה לאפליקציית Flutter, ולמדתם על רכיבי הליבה של מערכת האנימציה של Flutter. בפרט, למדתם:
- איך משתמשים ב-
ImplicitlyAnimatedWidget - איך משתמשים ב-
ExplicitlyAnimatedWidget - איך משתמשים ב-
Curvesוב-Tweensבאנימציה - איך משתמשים בווידג'טים מוכנים מראש למעבר כמו
AnimatedSwitcherאוPageRouteBuilder - איך משתמשים באפקטים של אנימציה מובנית מתוך חבילת
animations, כמוFadeThroughTransitionו-OpenContainer - איך מתאימים אישית את אנימציית המעבר שמוגדרת כברירת מחדל, כולל הוספת תמיכה בתכונה 'חיזוי החזרה' ב-Android.

מה השלב הבא?
כדאי לעיין ב-Codelabs הבאים:
- יצירת פריסת אפליקציה רספונסיבית עם אנימציה באמצעות Material 3
- יצירת מעברים יפים באמצעות Material Motion ל-Flutter
- איך משדרגים את האפליקציה שנוצרה ב-Flutter מאפליקציה משעממת לאפליקציה יפה
אפשר גם להוריד את אפליקציית הדוגמה של אנימציות, שבה מוצגות טכניקות שונות של אנימציות.
קריאה נוספת
באתר flutter.dev יש עוד מקורות מידע על אנימציות:
- מבוא לאנימציות
- מדריך לאנימציות (מדריך)
- אנימציות מרומזות (מדריך)
- הנפשת המאפיינים של קונטיינר (ספר מתכונים)
- הוספה של אפקט הדהייה לווידג'ט (ספר מתכונים)
- אנימציות של רכיב Hero
- הוספת אנימציה למעבר בין נתיבי דפים (ספר מתכונים)
- יצירת אנימציה לווידג'ט באמצעות סימולציה של פיזיקה (ספר מתכונים)
- אנימציות מדורגות
- ווידג'טים של אנימציה ותנועה (קטלוג הווידג'טים)
אפשר גם לעיין במאמרים הבאים ב-Medium:
- מידע מעמיק על אנימציות
- Custom implicit animations in Flutter
- ניהול אנימציות באמצעות Flutter ו-Flux / Redux
- איך בוחרים את ווידג'ט האנימציה של Flutter שמתאים לכם?
- אנימציות כיווניות עם אנימציות מובְנות וברורות
- מידע בסיסי על אנימציות ב-Flutter עם אנימציות מרומזות
- מתי כדאי להשתמש ב-AnimatedBuilder או ב-AnimatedWidget?