אנימציות ב-Flutter

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 הזה מוסבר איך ליצור משחק חידון עם שאלות אמריקאיות שכולל טכניקות ואפקטים שונים של אנימציה.

3026390ad413769c.gif

במאמר הזה מוסבר איך:

  • איך יוצרים ווידג'ט עם אנימציה של הגודל והצבע
  • איך יוצרים אפקט של היפוך כרטיס בתלת-ממד
  • שימוש באפקטים מיוחדים של אנימציה מוכנה מראש מחבילת האנימציות
  • הוספת תמיכה בחיזוי תנועת החזרה שזמינה בגרסה האחרונה של 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

fbb1e1f7b6c91e21.png

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

4. שימוש באפקטים מרומזים של אנימציה

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

206dd8d9c1fae95.gif

יצירת ווידג'ט של לוח התוצאות ללא אנימציה

יוצרים קובץ חדש, 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.

84aec4776e70b870.gif

שימוש ב-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.
        },
      ),
    );
  }
}

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

8b0911f4af299a60.gif

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

החלת עקומה

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

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

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

הדיאגרמות האלה (שזמינות בדף מאמרי העזרה של 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 מספקת אפקט קפיצי מוגזם שמתחיל בתנועת קפיץ ומתאזן לקראת הסוף.

8f84142bff312373.gif

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

206dd8d9c1fae95.gif

שימוש בכלי הפיתוח להפעלת אנימציות איטיות

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

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

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

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

adea0a16d01127ad.png

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

d77de181bdde58f7.gif

כדי לפתור את הבעיה, ל-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, צריך לבצע את השלבים הבאים:

  1. צור StatefulWidget
  2. משתמשים ב-mixin‏ SingleTickerProviderStateMixin בכיתה State כדי לספק Ticker ל-AnimationController
  3. מאתחלים את AnimationController ב-method של מחזור החיים initState, ומספקים את האובייקט הנוכחי State לפרמטר vsync (TickerProvider).
  4. מוודאים שהווידג'ט נבנה מחדש בכל פעם ש-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.

5455def725b866f6.gif

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

28b5291de9b3f55f.gif

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'),
),

התאמה אישית של האנימציה של חיזוי החזרה

1c0558ffa3b76439.gif

התכונה 'חזרה עם תצוגה מקדימה' היא תכונה חדשה ב-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

77358e5776eb104c.png

הווידג'ט 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
      ),
    );
  }
}

4120f9395857d218.gif

8. מזל טוב

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

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

3026390ad413769c.gif

מה השלב הבא?

כדאי לעיין ב-Codelabs הבאים:

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

קריאה נוספת

באתר flutter.dev יש עוד מקורות מידע על אנימציות:

אפשר גם לעיין במאמרים הבאים ב-Medium:

מסמכי עזר