MDC-104 Flutter: רכיבים מתקדמים (Material Advanced)

1. מבוא

logo_components_color_2x_web_96dp.png

‏Material Components‏ (MDC) עוזרים למפתחים להטמיע את Material Design. MDC נוצר על ידי צוות של מהנדסים ומעצבי חוויית המשתמש ב-Google, שכולל עשרות רכיבים יפים ופונקציונליים של ממשק המשתמש. זמין ל-Android, ל-iOS, לאינטרנט ול-Flutter.material.io/develop

ב-codelab MDC-103, התאמת אישית את הצבע, הגובה, הטיפוגרפיה והצורה של רכיבי החומר (MDC) כדי לעצב את האפליקציה.

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

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

מה תפַתחו

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

  • צורה
  • תנועה
  • ווידג'טים של Flutter (שבהם השתמשתם בקודלאב הקודם)

Android

iOS

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

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

תפריט עם 4 קטגוריות

תפריט שכולל 4 קטגוריות

רכיבים ומערכות משנה של Material Flutter ב-Codelab הזה

  • צורה

איזה דירוג מגיע לדעתך לרמת הניסיון שלך בפיתוח Flutter?

מתחילים בינוניים מתקדמים

2. הגדרת סביבת הפיתוח של Flutter

כדי להשלים את שיעור ה-Lab הזה אתם צריכים שתי תוכנות: Flutter SDK וכלי עריכה.

אפשר להריץ את Codelab באמצעות כל אחד מהמכשירים הבאים:

  • מכשיר Android או iOS פיזי שמחובר למחשב ומוגדר למצב פיתוח.
  • סימולטור iOS (נדרשת התקנה של כלי Xcode).
  • Android Emulator (נדרשת הגדרה ב-Android Studio).
  • דפדפן (Chrome נדרש לניפוי באגים).
  • בתור אפליקציית Windows , Linux או macOS למחשב. עליכם לפתח בפלטפורמה שבה אתם מתכננים לפרוס. לכן, אם רוצים לפתח אפליקציה למחשב עם Windows, צריך לפתח ב-Windows כדי לגשת לרשת ה-build המתאימה. יש דרישות ספציפיות למערכת ההפעלה שמפורטות בהרחבה בכתובת docs.flutter.dev/desktop.

3. הורדת האפליקציה למתחילים ב-Codelab

ממשיכים מ-MDC-103?

אם השלמתם את הקורס MDC-103, הקוד שלכם אמור להיות מוכן ל-codelab הזה. דלגו לשלב: מוסיפים את התפריט של ׳רקע׳.

מתחילים מאפס?

אפליקציית ההתחלה נמצאת בספרייה material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series.

...או לשכפל אותו מ-GitHub

כדי לשכפל את ה-Codelab הזה מ-GitHub, מריצים את הפקודות הבאות:

git clone https://github.com/material-components/material-components-flutter-codelabs.git
cd material-components-flutter-codelabs/mdc_100_series
git checkout 104-starter_and_103-complete

פתיחת הפרויקט והרצת האפליקציה

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

הצלחת! בדף שנפתח אמורה להופיע דף הכניסה של Shrine מהקודלאב הקודם.

Android

iOS

דף ההתחברות של Shrine

דף ההתחברות למקדש

4. הוספת תפריט הרקע

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

הסרת הסרגל באפליקציית דף הבית

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

ב-home.dart, משנים את הפונקציה build() כך שתחזיר רק את ה-AsymmetricView:

// TODO: Return an AsymmetricView (104)
return AsymmetricView(products: ProductsRepository.loadProducts(Category.all));

הוספת הווידג'ט 'רקע'

יוצרים ווידג'ט בשם רקע שכולל את frontLayer ואת backLayer.

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

מוסיפים קובץ חדש בשם backdrop.dart ל-/lib:

import 'package:flutter/material.dart';

import 'model/product.dart';

// TODO: Add velocity constant (104)

class Backdrop extends StatefulWidget {
  final Category currentCategory;
  final Widget frontLayer;
  final Widget backLayer;
  final Widget frontTitle;
  final Widget backTitle;

  const Backdrop({
    required this.currentCategory,
    required this.frontLayer,
    required this.backLayer,
    required this.frontTitle,
    required this.backTitle,
    Key? key,
  }) : super(key: key);

  @override
  _BackdropState createState() => _BackdropState();
}

// TODO: Add _FrontLayer class (104)
// TODO: Add _BackdropTitle class (104)
// TODO: Add _BackdropState class (104)

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

בהגדרת המחלקה Backdrop, מוסיפים את המחלקה _BackdropState:

// TODO: Add _BackdropState class (104)
class _BackdropState extends State<Backdrop>
    with SingleTickerProviderStateMixin {
  final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');

  // TODO: Add AnimationController widget (104)

  // TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
  Widget _buildStack() {
    return Stack(
    key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        widget.backLayer,
        widget.frontLayer,
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    var appBar = AppBar(
      elevation: 0.0,
      titleSpacing: 0.0,
      // TODO: Replace leading menu icon with IconButton (104)
      // TODO: Remove leading property (104)
      // TODO: Create title with _BackdropTitle parameter (104)
      leading: Icon(Icons.menu),
      title: Text('SHRINE'),
      actions: <Widget>[
        // TODO: Add shortcut to login screen from trailing icons (104)
        IconButton(
          icon: Icon(
            Icons.search,
            semanticLabel: 'search',
          ),
          onPressed: () {
          // TODO: Add open login (104)
          },
        ),
        IconButton(
          icon: Icon(
            Icons.tune,
            semanticLabel: 'filter',
          ),
          onPressed: () {
          // TODO: Add open login (104)
          },
        ),
      ],
    );
    return Scaffold(
      appBar: appBar,
      // TODO: Return a LayoutBuilder widget (104)
      body: _buildStack(),
    );
  }
}

הפונקציה build() מחזירה Scaffold עם סרגל אפליקציות, בדיוק כמו שנעשה שימוש ב-HomePage. אבל הגוף של Scaffold הוא מקבץ. צאצאים של מקבץ יכולים להיות חופפים. הגודל והמיקום של כל ילד או ילדה מצוינים ביחס להורה של המקבץ.

עכשיו מוסיפים מופע Backdrop ל-ShrineApp.

ב-app.dart, מייבאים את backdrop.dart ואת model/product.dart:

import 'backdrop.dart'; // New code
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart'; // New code
import 'supplemental/cut_corners_border.dart';

ב-app.dart, משנים את המסלול / על ידי החזרת Backdrop עם HomePage כ-frontLayer שלו:

// TODO: Change to a Backdrop with a HomePage frontLayer (104)
'/': (BuildContext context) => Backdrop(
     // TODO: Make currentCategory field take _currentCategory (104)
     currentCategory: Category.all,
     // TODO: Pass _currentCategory for frontLayer (104)
     frontLayer: HomePage(),
     // TODO: Change backLayer field value to CategoryMenuPage (104)
     backLayer: Container(color: kShrinePink100),
     frontTitle: Text('SHRINE'),
     backTitle: Text('MENU'),
),

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

Android

iOS

דף מוצר של מקדש עם רקע ורוד

דף המוצר של Shrine עם רקע ורוד

השכבה האחורית מציגה את האזור הוורוד בשכבה חדשה מאחורי דף הבית של ה-FrontLayer.

תוכלו להשתמש בכלי הבדיקה כדי לוודא שבמקבץ אכן יש קונטיינר מאחורי דף הבית. היא אמורה להיראות כך:

92ed338a15a074bd.png

עכשיו אפשר להתאים גם את העיצוב וגם את התוכן של השכבות.

5. הוספת צורה

בשלב הזה, תעצב את השכבה הקדמית כדי להוסיף חיתוך בפינה הימנית העליונה.

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

הוספת צורה לשכבה הקדמית

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

ב-backdrop.dart, מוסיפים את המחלקה החדשה _FrontLayer:

// TODO: Add _FrontLayer class (104)
class _FrontLayer extends StatelessWidget {
  // TODO: Add on-tap callback (104)
  const _FrontLayer({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 16.0,
      shape: const BeveledRectangleBorder(
        borderRadius: BorderRadius.only(topLeft: Radius.circular(46.0)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // TODO: Add a GestureDetector (104)
          Expanded(
            child: child,
          ),
        ],
      ),
    );
  }
}

לאחר מכן, בפונקציה _buildStack() של _BackdropState, עוטפים את השכבה הקדמית ב-_FrontLayer:

  Widget _buildStack() {
    // TODO: Create a RelativeRectTween Animation (104)

    return Stack(
    key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        widget.backLayer,
        // TODO: Add a PositionedTransition (104)
        // TODO: Wrap front layer in _FrontLayer (104)
          _FrontLayer(child: widget.frontLayer),
      ],
    );
  }

טעינה מחדש.

Android

iOS

דף מוצר של Shrine בפורמט מותאם אישית

דף מוצר של Shrine בפורמט מותאם אישית

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

שינוי הצבע של סרגל האפליקציות

ב-app.dart, משנים את הפונקציה _buildShrineTheme() לאפשרות הבאה:

ThemeData _buildShrineTheme() {
  final ThemeData base = ThemeData.light(useMaterial3: true);
  return base.copyWith(
    colorScheme: base.colorScheme.copyWith(
      primary: kShrinePink100,
      onPrimary: kShrineBrown900,
      secondary: kShrineBrown900,
      error: kShrineErrorRed,
    ),
    textTheme: _buildShrineTextTheme(base.textTheme),
    textSelectionTheme: const TextSelectionThemeData(
      selectionColor: kShrinePink100,
    ),
    appBarTheme: const AppBarTheme(
      foregroundColor: kShrineBrown900,
      backgroundColor: kShrinePink100,
    ),
    inputDecorationTheme: const InputDecorationTheme(
      border: CutCornersBorder(),
      focusedBorder: CutCornersBorder(
        borderSide: BorderSide(
          width: 2.0,
          color: kShrineBrown900,
        ),
      ),
      floatingLabelStyle: TextStyle(
        color: kShrineBrown900,
      ),
    ),
  );
}

הפעלה מחדש מתוך הזיכרון (hot restart). עכשיו סרגל האפליקציות החדש בצבע אמור להופיע.

Android

iOS

דף מוצר של Shrine עם סרגל אפליקציות צבעוני

דף מוצר בסגנון מקדש עם סרגל צבעוני באפליקציה

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

6. הוספת תנועה

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

הוספה של תנועת חשיפה ללחצן התפריט

בחלק העליון של backdrop.dart, מחוץ להיקף של כל כיתה או פונקציה, מוסיפים קבוע שמייצג את המהירות שאנחנו רוצים שהאנימציה תהיה לה:

// TODO: Add velocity constant (104)
const double _kFlingVelocity = 2.0;

מוסיפים ווידג'ט של AnimationController ל-_BackdropState, יוצרים מופע שלו בפונקציה initState() ומפנים אותו בפונקציה dispose() של המצב:

  // TODO: Add AnimationController widget (104)
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      value: 1.0,
      vsync: this,
    );
  }

  // TODO: Add override for didUpdateWidget (104)

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

  // TODO: Add functions to get and change front layer visibility (104)

ה-AnimationController מרכז את האנימציות ומספק ממשק API להפעלה, להיפוך ולהפסקה של האנימציה. עכשיו אנחנו צריכים פונקציות שיגרמו לו לזוז.

מוסיפים פונקציות שקובעות את החשיפה של השכבה הקדמית ומשנות אותה:

  // TODO: Add functions to get and change front layer visibility (104)
  bool get _frontLayerVisible {
    final AnimationStatus status = _controller.status;
    return status == AnimationStatus.completed ||
        status == AnimationStatus.forward;
  }

  void _toggleBackdropLayerVisibility() {
    _controller.fling(
        velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity);
  }

עוטפים את backLayer בווידג'ט ExcludeSemantics. הווידג'ט הזה יחרוג את פריטי התפריט של backLayer מעץ הסמנטיקה כששכבת הרקע לא גלויה.

    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
      ...

משנים את הפונקציה _buildStack() כדי להשתמש ב-BuildContext וב-BoxConstraints. בנוסף, צריך לכלול Transition ממוצב שמקבל אנימציה של RelativeRectTween:

  // TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double layerTitleHeight = 48.0;
    final Size layerSize = constraints.biggest;
    final double layerTop = layerSize.height - layerTitleHeight;

    // TODO: Create a RelativeRectTween Animation (104)
    Animation<RelativeRect> layerAnimation = RelativeRectTween(
      begin: RelativeRect.fromLTRB(
          0.0, layerTop, 0.0, layerTop - layerSize.height),
      end: const RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
    ).animate(_controller.view);

    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
        // TODO: Add a PositionedTransition (104)
        PositionedTransition(
          rect: layerAnimation,
          child: _FrontLayer(
            // TODO: Implement onTap property on _BackdropState (104)
            child: widget.frontLayer,
          ),
        ),
      ],
    );
  }

לבסוף, במקום לקרוא לפונקציה ‎_buildStack לגוף של ה-Scaffold, מחזירים ווידג'ט LayoutBuilder שמשתמש ב-‎_buildStack בתור ה-builder שלו:

    return Scaffold(
      appBar: appBar,
      // TODO: Return a LayoutBuilder widget (104)
      body: LayoutBuilder(builder: _buildStack),
    );

דחינו את הבנייה של סטאק השכבה הקדמית/אחורית עד לזמן הפריסה באמצעות LayoutBuilder כדי שנוכל לשלב את הגובה הכולל של הרקע בפועל. LayoutBuilder הוא ווידג'ט מיוחד שקריאה חוזרת (callback) של ה-builder שלו מספקת אילוצים על הגודל.

בפונקציה build(), הופכים את סמל התפריט המוביל בסרגל האפליקציות ל-IconButton ומשתמשים בו כדי להחליף את החשיפה של השכבה הקדמית כשמקישים על הלחצן.

      // TODO: Replace leading menu icon with IconButton (104)
      leading: IconButton(
        icon: const Icon(Icons.menu),
        onPressed: _toggleBackdropLayerVisibility,
      ),

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

Android

iOS

תפריט ריק של Shrine עם שתי שגיאות

תפריט ריק של Shrine עם שתי שגיאות

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

גיבוב עמודות המוצרים ב-ListView

ב-supplemental/product_columns.dart, מחליפים את העמודה OneProductCardColumn ב-ListView:

class OneProductCardColumn extends StatelessWidget {
  const OneProductCardColumn({required this.product, Key? key}) : super(key: key);

  final Product product;

  @override
  Widget build(BuildContext context) {
    // TODO: Replace Column with a ListView (104)
    return ListView(
      physics: const ClampingScrollPhysics(),
      reverse: true,
      children: <Widget>[
        ConstrainedBox(
          constraints: const BoxConstraints(
            maxWidth: 550,
          ),
          child: ProductCard(
            product: product,
          ),
        ),
        const SizedBox(
          height: 40.0,
        ),

      ],
    );
  }
}

העמודה כוללת את MainAxisAlignment.end. כדי להתחיל את הפריסה מתחתית המסך, צריך לסמן reverse: true. סדר הילדים בוטל כדי לפצות על השינוי.

טוענים מחדש ומקישים על לחצן התפריט.

Android

iOS

התפריט &#39;ריקון&#39; ריק עם שגיאה אחת

תפריט ריק של מקדש עם שגיאה אחת

האזהרה על גלישה אפורה ב-OneProductCardColumn בוטלה. עכשיו נתקן את השני.

ב-supplemental/product_columns.dart, משנים את האופן שבו מחושב הערך של imageAspectRatio ומחליפים את העמודה ב-TwoProductCardColumn ב-ListView:

      // TODO: Change imageAspectRatio calculation (104)
      double imageAspectRatio = heightOfImages >= 0.0
          ? constraints.biggest.width / heightOfImages
          : 49.0 / 33.0;
      // TODO: Replace Column with a ListView (104)
      return ListView(
        physics: const ClampingScrollPhysics(),
        children: <Widget>[
          Padding(
            padding: const EdgeInsetsDirectional.only(start: 28.0),
            child: top != null
                ? ProductCard(
                    imageAspectRatio: imageAspectRatio,
                    product: top!,
                  )
                : SizedBox(
                    height: heightOfCards,
                  ),
          ),
          const SizedBox(height: spacerHeight),
          Padding(
            padding: const EdgeInsetsDirectional.only(end: 28.0),
            child: ProductCard(
              imageAspectRatio: imageAspectRatio,
              product: bottom,
            ),
          ),
        ],
      );

הוספנו גם מידה מסוימת של בטיחות אל imageAspectRatio.

טעינה מחדש. אחר כך מקישים על לחצן התפריט.

Android

iOS

התפריט &#39;ריקון המקדש&#39;

התפריט &#39;ריקון המקדש&#39;

אין יותר זליגות.

7. הוספת תפריט בשכבה האחורית

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

מוסיפים את התפריט

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

יוצרים קובץ חדש בשם lib/category_menu_page.dart:

import 'package:flutter/material.dart';

import 'colors.dart';
import 'model/product.dart';

class CategoryMenuPage extends StatelessWidget {
  final Category currentCategory;
  final ValueChanged<Category> onCategoryTap;
  final List<Category> _categories = Category.values;

  const CategoryMenuPage({
    Key? key,
    required this.currentCategory,
    required this.onCategoryTap,
  }) : super(key: key);

  Widget _buildCategory(Category category, BuildContext context) {
    final categoryString =
        category.toString().replaceAll('Category.', '').toUpperCase();
    final ThemeData theme = Theme.of(context);

    return GestureDetector(
      onTap: () => onCategoryTap(category),
      child: category == currentCategory
        ? Column(
            children: <Widget>[
              const SizedBox(height: 16.0),
              Text(
                categoryString,
                style: theme.textTheme.bodyLarge,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 14.0),
              Container(
                width: 70.0,
                height: 2.0,
                color: kShrinePink400,
              ),
            ],
          )
      : Padding(
        padding: const EdgeInsets.symmetric(vertical: 16.0),
        child: Text(
          categoryString,
          style: theme.textTheme.bodyLarge!.copyWith(
              color: kShrineBrown900.withAlpha(153)
            ),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        padding: const EdgeInsets.only(top: 40.0),
        color: kShrinePink100,
        child: ListView(
          children: _categories
            .map((Category c) => _buildCategory(c, context))
            .toList()),
      ),
    );
  }
}

מדובר ב-GestureDetector שעוטף עמודה ששמות הצאצאים שלה הם שמות הקטגוריות. הקטגוריה שנבחרה מסומנת בקו תחתון.

ב-app.dart, ממירים את הווידג'ט של ShrineApp מווידג'ט ללא מצב לווידג'ט עם מצב.

  1. הדגשה של ShrineApp.
  2. הצגת פעולות בקוד בהתאם לסביבת הפיתוח המשולבת (IDE):
  3. Android Studio: מקישים על ‎⌥Enter (ב-macOS) או על alt + enter
  4. VS Code: מקישים על ‎⌘. (macOS) או על Ctrl+.
  5. בוחרים באפשרות 'המרה ל-StatefulWidget'.
  6. משנים את הסיווג של הכיתה ShrineAppState ל'פרטי' (‎_ShrineAppState). לוחצים לחיצה ימנית על ShrineAppState,
  7. ב-Android Studio: בוחרים באפשרות 'ארגון מחדש' > 'שינוי שם'
  8. VS Code: בוחרים באפשרות 'שינוי שם הסמל'
  9. מזינים את _ShrineAppState כדי להפוך את הכיתה לפרטית.

ב-app.dart, מוסיפים משתנה ל-_ShrineAppState עבור הקטגוריה שנבחרה וקריאה חוזרת כשמקישים עליה:

class _ShrineAppState extends State<ShrineApp> {
  Category _currentCategory = Category.all;

  void _onCategoryTap(Category category) {
    setState(() {
      _currentCategory = category;
    });
  }

לאחר מכן משנים את השכבה האחורית ל-CategoryתפריטPage.

ב-app.dart, מייבאים את CategoryMenuPage:

import 'backdrop.dart';
import 'category_menu_page.dart';
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart';
import 'supplemental/cut_corners_border.dart';

בפונקציה build(), משנים את השדה backLayer ל-CategoryMenuPage ואת השדה currentCategory כך שיכלול את משתנה המופע.

'/': (BuildContext context) => Backdrop(
              // TODO: Make currentCategory field take _currentCategory (104)
              currentCategory: _currentCategory,
              // TODO: Pass _currentCategory for frontLayer (104)
              frontLayer: HomePage(),
              // TODO: Change backLayer field value to CategoryMenuPage (104)
              backLayer: CategoryMenuPage(
                currentCategory: _currentCategory,
                onCategoryTap: _onCategoryTap,
              ),
              frontTitle: const Text('SHRINE'),
              backTitle: const Text('MENU'),
            ),

מעלים מחדש את הדף ומקישים על לחצן התפריט.

Android

iOS

תפריט של מקדש עם 4 קטגוריות

תפריט של מקדש עם 4 קטגוריות

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

ב-home.dart, מוסיפים משתנה ל-Category ומעבירים אותו ל-AsymmetricView.

import 'package:flutter/material.dart';

import 'model/product.dart';
import 'model/products_repository.dart';
import 'supplemental/asymmetric_view.dart';

class HomePage extends StatelessWidget {
  // TODO: Add a variable for Category (104)
  final Category category;

  const HomePage({this.category = Category.all, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // TODO: Pass Category variable to AsymmetricView (104)
    return AsymmetricView(
      products: ProductsRepository.loadProducts(category),
    );
  }
}

בapp.dart, מעבירים את _currentCategory של frontLayer:.

// TODO: Pass _currentCategory for frontLayer (104)
frontLayer: HomePage(category: _currentCategory),

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

Android

iOS

דף מוצר שסונן ב-Shrine

דף מוצר שעבר סינון

הם מסוננים!

סגירת השכבה הקדמית אחרי בחירת תפריט

ב-backdrop.dart, מוסיפים שינוי מברירת המחדל לפונקציה didUpdateWidget() (שנקראת בכל פעם שהגדרת הווידג'ט משתנה) ב-_BackdropState:

  // TODO: Add override for didUpdateWidget() (104)
  @override
  void didUpdateWidget(Backdrop old) {
    super.didUpdateWidget(old);

    if (widget.currentCategory != old.currentCategory) {
      _toggleBackdropLayerVisibility();
    } else if (!_frontLayerVisible) {
      _controller.fling(velocity: _kFlingVelocity);
    }
  }

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

החלפת המצב של השכבה הקדמית

ב-backdrop.dart, מוסיפים קריאה חוזרת (callback) להקשה לשכבת הרקע:

class _FrontLayer extends StatelessWidget {
  // TODO: Add on-tap callback (104)
  const _FrontLayer({
    Key? key,
    this.onTap, // New code
    required this.child,
  }) : super(key: key);
 
  final VoidCallback? onTap; // New code
  final Widget child;

לאחר מכן מוסיפים רכיב GestureDetector לצאצא של _FrontLayer: צאצאים של Column:.

      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // TODO: Add a GestureDetector (104)
          GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: onTap,
            child: Container(
              height: 40.0,
              alignment: AlignmentDirectional.centerStart,
            ),
          ),
          Expanded(
            child: child,
          ),
        ],
      ),

לאחר מכן מטמיעים את המאפיין onTap החדש ב- _BackdropState בפונקציה _buildStack():

          PositionedTransition(
            rect: layerAnimation,
            child: _FrontLayer(
              // TODO: Implement onTap property on _BackdropState (104)
              onTap: _toggleBackdropLayerVisibility,
              child: widget.frontLayer,
            ),
          ),

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

8. הוספת סמל ממותג

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

שינוי סמל לחצן התפריט

Android

iOS

דף מוצר של Shrine עם סמל ממותג

דף מוצר של Shrine עם סמל ממותג

בכיתה backdrop.dart, יוצרים כיתה חדשה – _BackdropTitle.

// TODO: Add _BackdropTitle class (104)
class _BackdropTitle extends AnimatedWidget {
  final void Function() onPress;
  final Widget frontTitle;
  final Widget backTitle;

  const _BackdropTitle({
    Key? key,
    required Animation<double> listenable,
    required this.onPress,
    required this.frontTitle,
    required this.backTitle,
  }) : _listenable = listenable, 
       super(key: key, listenable: listenable);

  final Animation<double> _listenable;

  @override
  Widget build(BuildContext context) {
    final Animation<double> animation = _listenable;

    return DefaultTextStyle(
      style: Theme.of(context).textTheme.titleLarge!,
      softWrap: false,
      overflow: TextOverflow.ellipsis,
      child: Row(children: <Widget>[
        // branded icon
        SizedBox(
          width: 72.0,
          child: IconButton(
            padding: const EdgeInsets.only(right: 8.0),
            onPressed: this.onPress,
            icon: Stack(children: <Widget>[
              Opacity(
                opacity: animation.value,
                child: const ImageIcon(AssetImage('assets/slanted_menu.png')),
              ),
              FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: const Offset(1.0, 0.0),
                ).evaluate(animation),
                child: const ImageIcon(AssetImage('assets/diamond.png')),
              )]),
          ),
        ),
        // Here, we do a custom cross fade between backTitle and frontTitle.
        // This makes a smooth animation between the two texts.
        Stack(
          children: <Widget>[
            Opacity(
              opacity: CurvedAnimation(
                parent: ReverseAnimation(animation),
                curve: const Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: const Offset(0.5, 0.0),
                ).evaluate(animation),
                child: backTitle,
              ),
            ),
            Opacity(
              opacity: CurvedAnimation(
                parent: animation,
                curve: const Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: const Offset(-0.25, 0.0),
                  end: Offset.zero,
                ).evaluate(animation),
                child: frontTitle,
              ),
            ),
          ],
        )
      ]),
    );
  }
}

ה-_BackdropTitle הוא ווידג'ט מותאם אישית שיחליף את הווידג'ט הרגיל Text בפרמטר title של הווידג'ט AppBar. הוא כולל סמל תפריט מונפש ואנימציה של מעברים בין הכותרות הקדמיות והאחוריות. נעשה שימוש בנכס חדש בסמל התפריט האנימציה. יש להוסיף את ההפניה אל slanted_menu.png החדש אל pubspec.yaml.

assets:
    - assets/diamond.png
    # TODO: Add slanted menu asset (104)
    - assets/slanted_menu.png
    - packages/shrine_images/0-0.jpg

צריך להסיר את הנכס leading ב-builder של AppBar. ההסרה נדרשת כדי שהסמל המותג המותאם אישית יוצג במקום הווידג'ט המקורי של leading. האנימציה listenable ורכיב ה-handler onPress של הסמל הממותג מועברים אל _BackdropTitle. גם הערכים של frontTitle ו-backTitle מועברים כדי שניתן יהיה ליצור מהם רינדור בתוך כותרת הרקע. הפרמטר title של AppBar אמור להיראות כך:

// TODO: Create title with _BackdropTitle parameter (104)
title: _BackdropTitle(
  listenable: _controller.view,
  onPress: _toggleBackdropLayerVisibility,
  frontTitle: widget.frontTitle,
  backTitle: widget.backTitle,
),

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

הארכיטקטורה של Flutter, שמבוססת על העיקרון "הכול הוא ווידג'ט", מאפשרת לשנות את הפריסה של AppBar שמוגדרת כברירת מחדל בלי צורך ליצור ווידג'ט AppBar מותאם אישית חדש לגמרי. אפשר להחליף את הפרמטר title, שהוא במקור ווידג'ט Text, ב-_BackdropTitle מורכב יותר. מכיוון שהמאפיין _BackdropTitle כולל גם את הסמל המותאם אישית, הוא מחליף את המאפיין leading, שאפשר עכשיו להשמיט. החלפת הווידג'ט הפשוטה הזו מתבצעת בלי לשנות אף אחד מהפרמטרים האחרים, כמו סמלי הפעולות, שממשיכים לפעול בעצמם.

הוספת קיצור דרך חזרה למסך ההתחברות

ב-backdrop.dart,מוסיפים קיצור דרך חזרה למסך ההתחברות משני הסמלים שבסוף סרגל האפליקציות: משנים את התוויות הסמנטיות של הסמלים כך שישקפו את המטרה החדשה שלהם.

        // TODO: Add shortcut to login screen from trailing icons (104)
        IconButton(
          icon: const Icon(
            Icons.search,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
        IconButton(
          icon: const Icon(
            Icons.tune,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext context) => LoginPage()),
            );
          },
        ),

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

import 'login.dart';

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

9. מעולה!

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

השלבים הבאים

Codelab זה, MDC-104, משלים את סדרת ה-codelabs הזו. בקטלוג הווידג'טים של Material Components תוכלו למצוא עוד רכיבים ל-Material Flutter.

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

יש הרבה מדריכי Codelab נוספים בנושא Flutter שאפשר לנסות, בהתאם לתחומי העניין שלכם. יש לנו עוד Codelab ספציפי ל-Material שעשוי לעניין אתכם: יצירת מעברים יפים באמצעות Material Motion ל-Flutter.

הצלחתי להשלים את ה-Codelab הזה תוך השקעה של זמן ומאמץ סבירים

נכון מאוד נכון אין לי דעה לכאן או לכאן לא נכון לא נכון בכלל

אני רוצה להמשיך להשתמש ב-Material Components בעתיד

נכון מאוד נכון ניטרלי לא נכון לא נכון בכלל