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

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

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

หลักเกณฑ์ดีไซน์ 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 ก็ได้

หากต้องการโคลนโค้ดแล็บนี้จาก 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 เลเยอร์ ได้แก่ เลเยอร์หลัง (ที่แสดงการทำงานและตัวกรอง) และเลเยอร์หน้า (ที่แสดงเนื้อหา) คุณสามารถใช้พื้นหลังเพื่อแสดงข้อมูลและการดําเนินการแบบอินเทอร์แอกทีฟ เช่น การนําทางหรือตัวกรองเนื้อหา

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

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

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

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

เพิ่มวิดเจ็ตพื้นหลัง

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

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

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

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

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

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

สุดท้าย ให้แสดงผลวิดเจ็ต LayoutBuilder ที่ใช้ _buildStack เป็น Builder แทนการเรียกใช้ฟังก์ชัน _buildStack สำหรับเนื้อหาของ Scaffold ดังนี้

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

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

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

      // 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 Code: กด ⌘. (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);
    }
  }

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

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

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

      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 ที่มีไอคอนแบรนด์

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

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

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

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

ฉันทำ Codelab นี้เสร็จภายในระยะเวลาและความพยายามที่เหมาะสม

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

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

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