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

1. บทนำ

logo_components_color_2x_web_96dp.png

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

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

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

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

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

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

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

Android

iOS

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

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

เมนูที่แสดง 4 หมวดหมู่

เมนูที่แสดง 4 หมวดหมู่

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

  • รูปร่าง

คุณจะให้คะแนนระดับประสบการณ์ในการพัฒนาแอปด้วย Flutter เท่าไร

ผู้ฝึกหัด ขั้นกลาง ผู้ชำนาญ

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

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

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

  • อุปกรณ์ Android หรือ iOS จริงที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาแอป
  • โปรแกรมจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
  • Android Emulator (ต้องตั้งค่าใน 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

หากต้องการโคลนโค้ดแล็บนี้จาก 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

หน้าเข้าสู่ระบบ Shrine

หน้าเข้าสู่ระบบ Shrine

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

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

นำแถบแอปหน้าแรกออก

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

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

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

เพิ่มวิดเจ็ต Backdrop

สร้างวิดเจ็ตชื่อ Backdrop ที่มี frontLayer และ backLayer

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

วิธีเพิ่มไฟล์ใหม่ไปยัง /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 ในส่วนคำจำกัดความของคลาส Backdrop ดังนี้

// 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 คือ Stack องค์ประกอบย่อยของ Stack จะทับซ้อนกันได้ ขนาดและตำแหน่งของแต่ละองค์ประกอบย่อยจะระบุโดยอิงตามองค์ประกอบหลักของกอง

ตอนนี้ให้เพิ่มอินสแตนซ์ 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 ที่มีพื้นหลังสีชมพู

หน้าผลิตภัณฑ์ Shrine ที่มีพื้นหลังสีชมพู

ส่วน backLayer จะแสดงพื้นที่สีชมพูในเลเยอร์ใหม่ที่อยู่ด้านหลังหน้าแรกของ frontLayer

คุณใช้ Flutter Inspector เพื่อยืนยันว่า Stack มี Container อยู่หลัง HomePage จริงๆ ซึ่งควรมีลักษณะดังนี้

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

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

หน้าผลิตภัณฑ์ของ 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,
      ),
    ),
  );
}

รีสตาร์ทแบบร้อน ตอนนี้แถบแอปสีใหม่ควรปรากฏขึ้นแล้ว

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 จะประสานงาน Animation และให้ 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 นอกจากนี้ ให้ใส่ PositionedTransition ที่ใช้แอนิเมชัน 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 เป็นวิดเจ็ตพิเศษที่การเรียกกลับของ Builder จะให้ข้อจำกัดด้านขนาด

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

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

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

Android

iOS

เมนู Shrine ว่างเปล่าที่มีข้อผิดพลาด 2 รายการ

เมนู Shrine ว่างเปล่าที่มีข้อผิดพลาด 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

เมนู Shrine ว่างเปล่าที่มีข้อผิดพลาด 1 รายการ

เมนู Shrine ว่างเปล่าที่มีข้อผิดพลาด 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

เมนู Shrine ว่าง

เมนู Shrine ว่าง

ไม่มีการล้นอีกต่อไป

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 ที่ครอบคลุม Column ซึ่งมีชื่อหมวดหมู่เป็นองค์ประกอบย่อย ระบบจะใช้ขีดเส้นใต้เพื่อระบุหมวดหมู่ที่เลือก

ใน app.dart ให้แปลงวิดเจ็ต ShrineApp จากแบบไม่มีสถานะเป็นแบบมีสถานะ

  1. ไฮไลต์ShrineApp.
  2. แสดงการดำเนินการเกี่ยวกับโค้ดตาม IDE ของคุณ
  3. Android Studio: กด ⌥Enter (macOS) หรือ Alt + Enter
  4. VS Code: กด ⌘. (macOS) หรือ Ctrl+.
  5. เลือก "แปลงเป็น StatefulWidget"
  6. เปลี่ยนคลาส ShrineAppState เป็น private (_ShrineAppState) คลิกขวาที่ ShrineAppState แล้ว
  7. Android Studio: เลือก Refactor > Rename
  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

หน้าผลิตภัณฑ์ที่กรองแล้วของ Shrine

หน้าผลิตภัณฑ์ที่กรองแล้วของ 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 ให้เพิ่มการเรียกกลับเมื่อแตะไปยังเลเยอร์ฉากหลัง

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's child: Column's children:

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

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

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

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

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

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

ฉันทำ Codelab นี้เสร็จได้โดยใช้เวลาและความพยายามที่สมเหตุสมผล

เห็นด้วยอย่างยิ่ง เห็นด้วย เป็นกลาง ไม่เห็นด้วย ไม่เห็นด้วยอย่างยิ่ง

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

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