MDC-104 Flutter: คอมโพเนนต์ขั้นสูงของวัสดุ

1. บทนำ

logo_components_color_2x_web_96dp.png

Material Components (MDC) ช่วยให้นักพัฒนานำดีไซน์ Material มาใช้ MDC สร้างโดยทีมวิศวกรและนักออกแบบ UX ที่ Google โดยมีคอมโพเนนต์ UI ที่สวยงามและใช้งานได้หลายสิบอย่างและพร้อมใช้งานสำหรับ Android, iOS, เว็บ และ Flutter.material.io/develop

ใน Codelab MDC-103 คุณได้ปรับแต่งสี ระดับความสูง การพิมพ์ และรูปร่างของ Material Components (MDC) เพื่อจัดรูปแบบแอป

คอมโพเนนต์หนึ่งในระบบดีไซน์ Material ทำงานชุดหนึ่งที่กำหนดไว้ล่วงหน้าและมีลักษณะเฉพาะบางอย่าง เช่น ปุ่ม อย่างไรก็ตาม ปุ่มเป็นมากกว่าวิธีสำหรับให้ผู้ใช้ทำงาน แต่ยังเป็นการแสดงออกทางภาพของรูปร่าง ขนาด และสีที่ช่วยให้ผู้ใช้ทราบว่าเป็นการโต้ตอบ และจะมีบางอย่างเกิดขึ้นเมื่อมีการแตะหรือคลิก

หลักเกณฑ์ดีไซน์ Material จะอธิบายส่วนประกอบต่างๆ จากมุมมองของนักออกแบบ โดยจะอธิบายฟังก์ชันพื้นฐานต่างๆ ที่พร้อมให้ใช้งานในแพลตฟอร์มต่างๆ และองค์ประกอบที่เป็นส่วนประกอบของแต่ละส่วน ตัวอย่างเช่น ฉากหลังประกอบด้วยเลเยอร์ย้อนกลับและเนื้อหา เลเยอร์หน้าและเนื้อหา กฎการเคลื่อนไหว และตัวเลือกการแสดงผล องค์ประกอบแต่ละอย่างเหล่านี้สามารถปรับแต่งตามความต้องการ กรณีการใช้งาน และเนื้อหาของแต่ละแอปได้

สิ่งที่คุณจะสร้าง

ใน Codelab นี้ คุณจะเปลี่ยน UI ในแอป Shrine เป็นงานนำเสนอ 2 ระดับที่เรียกว่า "ฉากหลัง" ฉากหลังมีเมนูที่แสดงหมวดหมู่ที่เลือกได้ซึ่งใช้เพื่อกรองผลิตภัณฑ์ที่แสดงในตารางกริดแบบอสมมาตร ใน Codelab นี้ คุณจะใช้สิ่งต่อไปนี้

  • รูปร่าง
  • การเคลื่อนไหว
  • วิดเจ็ต Flutter (ที่คุณเคยใช้ใน Codelab ก่อนหน้านี้)

Android

iOS

แอปอีคอมเมิร์ซธีมสีชมพูและน้ำตาลที่มีแถบแอปด้านบนและตารางกริดแบบอสมมาตรที่เลื่อนได้ในแนวนอนและเต็มไปด้วยผลิตภัณฑ์

แอปอีคอมเมิร์ซธีมสีชมพูและน้ำตาลที่มีแถบแอปด้านบนและตารางกริดแบบอสมมาตรที่เลื่อนได้ในแนวนอนและเต็มไปด้วยผลิตภัณฑ์

รายชื่อเมนู 4 หมวดหมู่

รายชื่อเมนู 4 หมวดหมู่

คอมโพเนนต์และระบบย่อยของ Material Flutter ใน Codelab นี้

  • รูปร่าง

โปรดให้คะแนนประสบการณ์การใช้งานการพัฒนา Flutter ของคุณ

มือใหม่ ระดับกลาง ผู้ชำนาญ

2. ตั้งค่าสภาพแวดล้อมในการพัฒนาซอฟต์แวร์ Flutter

ห้องทดลองนี้ต้องมีซอฟต์แวร์ 2 ประเภท ได้แก่ Flutter SDK และเครื่องมือแก้ไข

คุณเรียกใช้ Codelab ได้โดยใช้อุปกรณ์ต่อไปนี้

  • อุปกรณ์ Android หรือ iOS ที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาซอฟต์แวร์
  • เครื่องมือจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
  • โปรแกรมจำลอง Android (ต้องตั้งค่าใน Android Studio)
  • เบราว์เซอร์ (การแก้ไขข้อบกพร่องต้องใช้ Chrome)
  • เป็นแอปพลิเคชัน Windows, Linux หรือ macOS บนเดสก์ท็อป คุณต้องพัฒนาบนแพลตฟอร์มที่คุณวางแผนจะทำให้ใช้งานได้ ดังนั้นหากต้องการพัฒนาแอป Windows บนเดสก์ท็อป คุณต้องพัฒนาบน Windows เพื่อเข้าถึงเชนบิลด์ที่เหมาะสม มีข้อกำหนดเฉพาะระบบปฏิบัติการที่ครอบคลุมรายละเอียดใน 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 จาก Codelab ก่อนหน้าบนอุปกรณ์ของคุณ

Android

iOS

หน้าเข้าสู่ระบบของศาลเจ้า

หน้าเข้าสู่ระบบของศาลเจ้า

4. เพิ่มเมนูฉากหลัง

ฉากหลังจะปรากฏขึ้นด้านหลังเนื้อหาและคอมโพเนนต์อื่นๆ ทั้งหมด ซึ่งประกอบด้วย 2 เลเยอร์ ได้แก่ เลเยอร์หลัง (ที่แสดงการทำงานและตัวกรอง) และเลเยอร์หน้า (ที่แสดงเนื้อหา) คุณสามารถใช้ฉากหลังเพื่อแสดงข้อมูลและการดำเนินการแบบอินเทอร์แอกทีฟ เช่น การนำทางหรือตัวกรองเนื้อหา

นำแถบแอป Home ออก

วิดเจ็ต HomePage จะเป็นเนื้อหาของเลเยอร์หน้าสุดของเรา ตอนนี้มีแถบแอปอยู่ เราจะย้ายแถบแอปไปที่เลเยอร์หลังและหน้าแรกจะรวมเฉพาะ AsymmetricView เท่านั้น

ใน home.dart ให้เปลี่ยนฟังก์ชัน build() เพื่อแสดงผล AsymmetricView เท่านั้น:

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

เพิ่มวิดเจ็ตฉากหลัง

สร้างวิดเจ็ตชื่อฉากหลัง ซึ่งมี frontLayer และ backLayer

backLayer มีเมนูที่ให้คุณเลือกหมวดหมู่เพื่อกรองรายการได้ (currentCategory) เนื่องจากเราต้องการคงการเลือกเมนูไว้ เราจะทำให้ฉากหลังเป็นวิดเจ็ตเก็บสถานะ

เพิ่มไฟล์ใหม่ใน /lib ชื่อ backdrop.dart:

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 วิธีนี้เป็นแนวทางปฏิบัติแนะนำสำหรับพร็อพเพอร์ตี้ในเครื่องมือสร้างที่ไม่มีค่าเริ่มต้น และเป็น null ไม่ได้ ดังนั้นจึงไม่ควรลืม

ภายใต้คำจำกัดความของคลาสฉากหลัง ให้เพิ่มคลาส _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 ที่มีแถบแอปเหมือนกับที่หน้าแรกเคยเห็น แต่ตัวถังของนั่งร้านเป็นกอง องค์ประกอบย่อยของสแต็กจะทับซ้อนกันได้ ขนาดและตำแหน่งของแต่ละลูกจะถูกระบุโดยสัมพันธ์กับรายการหลักของกลุ่ม

ตอนนี้ให้เพิ่มอินสแตนซ์ฉากหลังไปยัง 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

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีพื้นหลังสีชมพู

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีพื้นหลังสีชมพู

BackLayer แสดงพื้นที่สีชมพูในเลเยอร์ใหม่ด้านหลังหน้าแรกของ FrontLayer

คุณสามารถใช้ Flutter Inspector เพื่อยืนยันว่าสแต็กมีคอนเทนเนอร์อยู่หลังหน้าแรกจริงๆ ซึ่งควรมีลักษณะดังนี้

92ed338a15a074bd.png

ตอนนี้คุณปรับทั้ง 2 เลเยอร์ได้แล้ว ด้านการออกแบบและเนื้อหา

5. เพิ่มรูปร่าง

ในขั้นตอนนี้ คุณจะต้องจัดรูปแบบเลเยอร์หน้าเพื่อเพิ่มการตัดที่มุมซ้ายบน

ดีไซน์ Material หมายถึงการปรับแต่งประเภทนี้ในรูปแบบรูปร่าง พื้นผิววัสดุมีรูปร่างที่กำหนดเองได้ รูปทรงช่วยเพิ่มความโดดเด่นและสไตล์ให้กับพื้นผิวต่างๆ และใช้เพื่อแสดงการสร้างแบรนด์ได้ รูปสี่เหลี่ยมผืนผ้าธรรมดาสามารถปรับแต่งด้วยมุมและขอบแบบโค้งหรือมุม และจำนวนด้านเท่าใดก็ได้ อาจเป็นแบบสมมาตรหรือไม่สม่ำเสมอ

เพิ่มรูปร่างให้กับเลเยอร์หน้า

โลโก้ Shrine แบบเอียงเป็นแรงบันดาลใจในการสร้างเรื่องราวรูปร่างของแอป Shrine เรื่องราวของรูปร่างเป็นการนำรูปทรงต่างๆ ที่พบเห็นได้ทั่วไปไปใช้กับแอปทั้งหมด ตัวอย่างเช่น รูปร่างโลโก้จะสะท้อนอยู่ในองค์ประกอบของหน้าเข้าสู่ระบบที่มีการใช้รูปร่าง ในขั้นตอนนี้ คุณจะต้องจัดรูปแบบเลเยอร์หน้าด้วยการตัดมุมที่มุมบนซ้าย

ใน 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

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีรูปร่างที่กำหนดเอง

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีรูปร่างที่กำหนดเอง

โดยได้เปลี่ยนพื้นผิวหลักของศาลเจ้าเป็นรูปร่างที่กำหนดเอง อย่างไรก็ตาม เราต้องการให้ไอคอนนี้เชื่อมต่อภาพกับแถบแอป

เปลี่ยนสีแถบแอป

ใน 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,
      ),
    ),
  );
}

ฮอตรีสตาร์ท แถบแอปสีใหม่จะปรากฏขึ้น

Android

iOS

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีแถบแอปสี

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีแถบแอปสี

การเปลี่ยนแปลงนี้ทำให้ผู้ใช้เห็นว่ามีบางอย่างอยู่ด้านหลังเลเยอร์สีขาวด้านหน้า ลองเพิ่มการเคลื่อนไหวเพื่อให้ผู้ใช้มองเห็นเลเยอร์ด้านหลังของฉากหลัง

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 นอกจากนี้ยังรวมการเปลี่ยนตำแหน่งที่ใช้ภาพเคลื่อนไหวแบบ 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 เป็นเครื่องมือสร้าง:

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

เราได้เลื่อนการสร้างสแต็กเลเยอร์ด้านหน้า/ด้านหลังจนถึงเวลาของเลย์เอาต์โดยใช้ LayoutBuilder เพื่อให้เรารวมความสูงโดยรวมตามจริงของฉากหลังได้ LayoutBuilder เป็นวิดเจ็ตพิเศษที่ Callback ของเครื่องมือสร้างมีข้อจำกัดด้านขนาด

ในฟังก์ชัน build() ให้เปลี่ยนไอคอนเมนูนำหน้าในแถบแอปเป็น "ปุ่มไอคอน" และใช้เพื่อสลับการมองเห็นเลเยอร์หน้าเมื่อแตะปุ่ม

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

โหลดซ้ำแล้วแตะปุ่มเมนูในเครื่องมือจำลอง

Android

iOS

เมนูศาลเจ้าว่างเปล่าแต่มีข้อผิดพลาด 2 รายการ

เมนูศาลเจ้าว่างเปล่าแต่มีข้อผิดพลาด 2 รายการ

เลเยอร์ด้านหน้าเคลื่อนไหว (เลื่อน) ลง แต่หากมองลงไปจะเห็นข้อผิดพลาดสีแดงและข้อผิดพลาดเพิ่มเติมแสดง เนื่องจาก AsymmetricView ถูกบีบและมีขนาดเล็กลงจากภาพเคลื่อนไหวนี้ ทำให้คอลัมน์ต่างๆ มีพื้นที่น้อยลง ซึ่งท้ายที่สุดแล้ว คอลัมน์จะแสดงพื้นที่ที่กำหนดไว้ไม่ได้และส่งผลให้เกิดข้อผิดพลาด หากเราแทนที่คอลัมน์ด้วย ListView ขนาดคอลัมน์ควรจะยังคงเดิมเมื่อมีการเคลื่อนไหว

ตัดคอลัมน์ผลิตภัณฑ์ใน 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

เมนูศาลเจ้าว่างเปล่าแต่มีข้อผิดพลาด 1 รายการ

เมนูศาลเจ้าว่างเปล่าแต่มีข้อผิดพลาด 1 รายการ

คำเตือนรายการเพิ่มเติมสีเทาใน 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

เมนูศาลเจ้าว่างเปล่า

เมนูศาลเจ้าว่างเปล่า

ไม่มีส่วนเกินอีกต่อไป

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 โค้ด: กด ⌘ (macOS) หรือ Ctrl+
  5. เลือก "แปลงเป็น StatefulWidget"
  6. เปลี่ยนคลาส ShrineAppState เป็นแบบส่วนตัว (_ShrineAppState) คลิกขวาที่ ShrineAppState และ
  7. Android Studio: เลือกเปลี่ยนโครงสร้างภายในโค้ด > เปลี่ยนชื่อ
  8. VS Code: เลือก "เปลี่ยนชื่อสัญลักษณ์"
  9. ป้อน _ShrineAppState เพื่อทำให้ชั้นเรียนเป็นส่วนตัว

ใน app.dart ให้เพิ่มตัวแปรไปยัง _ShrineAppState สำหรับหมวดหมู่ที่เลือกและ Callback เมื่อมีการแตะ

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

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

จากนั้นเปลี่ยนเลเยอร์กลับเป็น CategoryMenuPage

ใน 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 ให้เพิ่มตัวแปรสำหรับหมวดหมู่แล้วส่งไปยัง 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

หน้าผลิตภัณฑ์ที่กรองของศาลเจ้า

หน้าผลิตภัณฑ์ที่กรองของศาลเจ้า

กรองแล้ว

ปิด เลเยอร์หน้าหลังจากเมนูที่เลือก

ใน 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);
    }
  }

บันทึกโปรเจ็กต์เพื่อทริกเกอร์การโหลดซ้ำแบบ Hot แตะไอคอนเมนูและเลือกหมวดหมู่ เมนูควรปิดโดยอัตโนมัติและคุณจะเห็นหมวดหมู่ของรายการที่เลือก ถึงตอนนี้คุณจะต้องเพิ่มฟังก์ชันดังกล่าวไว้ในเลเยอร์หน้าด้วย

สลับเลเยอร์หน้า

ใน 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: องค์ประกอบย่อยของคอลัมน์:

      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

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีไอคอนแบรนด์

หน้าผลิตภัณฑ์ของศาลเจ้าที่มีไอคอนแบรนด์

ใน 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 ในเครื่องมือสร้าง AppBar ออก ต้องนำออกเพื่อให้ไอคอนแบรนด์ที่กำหนดเองแสดงผลในตำแหน่งของวิดเจ็ต leading เดิม ระบบจะส่งภาพเคลื่อนไหว listenable และเครื่องจัดการ 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,ให้เพิ่มทางลัดกลับไปที่หน้าจอการเข้าสู่ระบบจากไอคอน 2 ไอคอนต่อท้ายในแถบแอป โดยเปลี่ยนป้ายกำกับเชิงความหมายของไอคอนเพื่อให้สอดคล้องกับวัตถุประสงค์ใหม่

        // 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. ยินดีด้วย

ในบทเรียนจาก Codelab ทั้ง 4 ครั้งนี้ คุณได้เรียนรู้วิธีใช้คอมโพเนนต์ของ Material เพื่อสร้างประสบการณ์การใช้งานที่สง่างามไม่เหมือนใคร ซึ่งสะท้อนถึงบุคลิกภาพและสไตล์ของแบรนด์

ขั้นตอนถัดไป

Codelab ที่ชื่อว่า MDC-104 นี้ทำให้ลำดับ Codelab นี้เสร็จสมบูรณ์ คุณสำรวจคอมโพเนนต์เพิ่มเติมใน Material Flutter ได้โดยไปที่แคตตาล็อกวิดเจ็ต Material Components

สำหรับเป้าหมายแบบขยาย ให้ลองแทนที่ไอคอนที่มีแบรนด์ด้วย AnimatedIcon ที่เคลื่อนไหวระหว่าง 2 ไอคอนเมื่อทำให้ฉากหลังปรากฏขึ้น

มี Codelab ของ Flutter มากมายให้ลองใช้โดยอิงตามความสนใจของคุณ เรามี Codelab เฉพาะเกี่ยวกับ Material อีกรายการหนึ่งที่คุณอาจสนใจ ซึ่งได้แก่ การสร้างการเปลี่ยนภาพที่สวยงามด้วยการเคลื่อนไหวของ Material สำหรับ Flutter

ฉันทำ Codelab นี้เสร็จได้ โดยใช้เวลาและลงแรงพอสมควร

เห็นด้วยอย่างยิ่ง เห็นด้วย เฉยๆ ไม่เห็นด้วย ไม่เห็นด้วยอย่างยิ่ง

ฉันต้องการใช้คอมโพเนนต์เนื้อหาต่อไปในอนาคต

เห็นด้วยอย่างยิ่ง เห็นด้วย เฉยๆ ไม่เห็นด้วย ไม่เห็นด้วยอย่างยิ่ง