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 (View-Model) כדי להציג למשתמש שאלות רבות ברירה מתוך המחלקה QuestionBank (Model).
- home_screen.dart – הצגת מסך עם לחצן New Game
- main.dart – מגדיר את
MaterialAppלשימוש ב-Material 3 ולהצגת מסך הבית - model.dart – מגדיר את מחלקות הליבה שמשמשות בכל האפליקציה
- question_screen.dart – הצגת ממשק המשתמש של משחק החידון
- view_model.dart – קובץ שמכיל את המצב והלוגיקה של משחק החידון, שמוצג על ידי
QuestionScreen

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

יצירת ווידג'ט של לוח התוצאות ללא אנימציה
יוצרים קובץ חדש, 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.


הדיאגרמות האלה (שזמינות בדף מאמרי העזרה של ה-API של Curves) מספקות רמז לאופן הפעולה של העקומות. העקומות ממירות ערך קלט בין 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 מספקים דרך להאט את כל האנימציות באפליקציה, כך שאפשר לראות את האנימציה בצורה ברורה יותר.
כדי לפתוח את כלי הפיתוח, מוודאים שהאפליקציה פועלת במצב ניפוי באגים ופותחים את Widget Inspector על ידי בחירה בו בסרגל הכלים לניפוי באגים ב-VSCode או על ידי לחיצה על הלחצן Open Flutter DevTools בחלון של הכלי לניפוי באגים ב-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. הכלי ליצירת מעברים מספק את הווידג'ט הצאצא שהועבר אל 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'slayoutBuilderכדי לשנות את אופן הפריסה של רכיבי הצאצא.
6. שליטה במצב של אנימציה
עד עכשיו, כל האנימציות הופעלו אוטומטית על ידי המסגרת. אנימציות מרומזות מופעלות באופן אוטומטי, ואפקטים של אנימציות מפורשות דורשים Animation כדי לפעול בצורה תקינה. בקטע הזה נסביר איך ליצור אובייקטים של Animation באמצעות AnimationController, ואיך להשתמש ב-TweenSequence כדי לשלב בין Tween.
הפעלת אנימציה באמצעות AnimationController
כדי ליצור אנימציה באמצעות AnimationController, צריך לבצע את השלבים הבאים:
- צור
StatefulWidget - משתמשים ב-mixin
SingleTickerProviderStateMixinבכיתהStateכדי לספקTickerל-AnimationController - מאתחלים את
AnimationControllerב-methodinitStateשל מחזור החיים, ומספקים את האובייקט הנוכחי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, יוצרים שני TweenSequenceItems, אחד שמכיל 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 שמאפשרת להתאים אישית את אנימציית המעבר. אפשר להגדיר מחדש את פונקציית הקריאה החוזרת transitionBuilder שלה, שמספקת שני אובייקטים של אנימציה שמייצגים את האנימציה הנכנסת והיוצאת שמופעלת על ידי 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, ואת אפקט המעבר של דהייה מתוך חבילת האנימציות בפלטפורמות אחרות:
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:
- מידע מעמיק על אנימציות
- אנימציות מרומזות בהתאמה אישית ב-Flutter
- ניהול אנימציות באמצעות Flutter ו-Flux / Redux
- איך בוחרים את ווידג'ט האנימציה של Flutter שמתאים לכם?
- אנימציות כיווניות עם אנימציות מובְנות וברורות
- יסודות האנימציה ב-Flutter עם אנימציות מרומזות
- מתי כדאי להשתמש ב-AnimatedBuilder או ב-AnimatedWidget?