MDC-104 Flutter: Thành phần Material Advanced

1. Giới thiệu

logo_components_color_2x_web_96dp.png

Thành phần Material (MDC) giúp nhà phát triển triển khai Material Design. Được tạo bởi một nhóm kỹ sư và nhà thiết kế trải nghiệm người dùng tại Google, MDC có hàng chục thành phần giao diện người dùng đẹp mắt và có chức năng, đồng thời có sẵn cho Android, iOS, web và Flutter.material.io/develop

Trong lớp học lập trình MDC-103, bạn đã tuỳ chỉnh màu sắc, độ nâng, kiểu chữ và hình dạng của các Thành phần Material (MDC) để tạo kiểu cho ứng dụng.

Một thành phần trong hệ thống Material Design thực hiện một tập hợp các nhiệm vụ được xác định trước và có một số đặc điểm nhất định, chẳng hạn như nút. Tuy nhiên, nút không chỉ là cách để người dùng thực hiện một hành động, mà còn là một biểu hiện trực quan về hình dạng, kích thước và màu sắc, cho người dùng biết rằng nút đó có tính tương tác và sẽ có điều gì đó xảy ra khi chạm hoặc nhấp vào.

Nguyên tắc của Material Design mô tả các thành phần theo quan điểm của nhà thiết kế. Chúng mô tả nhiều chức năng cơ bản có trên nhiều nền tảng và các phần tử giải phẫu tạo nên từng thành phần. Ví dụ: một phông nền chứa lớp sau và nội dung của lớp đó, lớp trước và nội dung của lớp đó, các quy tắc chuyển động và lựa chọn hiển thị. Bạn có thể tuỳ chỉnh từng thành phần này cho phù hợp với nhu cầu, trường hợp sử dụng và nội dung của từng ứng dụng.

Sản phẩm bạn sẽ tạo ra

Trong lớp học lập trình này, bạn sẽ thay đổi giao diện người dùng trong ứng dụng Shrine thành một bản trình bày gồm 2 cấp độ có tên là "phông nền". Phông nền có một trình đơn liệt kê các danh mục có thể chọn dùng để lọc các sản phẩm xuất hiện trong lưới không đối xứng. Trong lớp học lập trình này, bạn sẽ sử dụng những nội dung sau:

  • Hình dạng
  • Chuyển động
  • Các tiện ích Flutter (mà bạn đã dùng trong các lớp học lập trình trước)

Android

iOS

ứng dụng thương mại điện tử có giao diện màu hồng và nâu, có thanh ứng dụng trên cùng và một lưới không đối xứng, có thể di chuyển theo chiều ngang, chứa đầy sản phẩm

ứng dụng thương mại điện tử có giao diện màu hồng và nâu, có thanh ứng dụng trên cùng và một lưới không đối xứng, có thể di chuyển theo chiều ngang, chứa đầy sản phẩm

4 danh mục trong trang thông tin về thực đơn

4 danh mục trong trang thông tin về thực đơn

Các thành phần và hệ thống con Material Flutter trong lớp học lập trình này

  • Hình dạng

Bạn đánh giá thế nào về mức độ kinh nghiệm của mình trong việc phát triển bằng Flutter?

Người mới bắt đầu Trung cấp Thành thạo

2. Thiết lập môi trường phát triển Flutter

Bạn cần có 2 phần mềm để hoàn thành bài thực hành này: Flutter SDKmột trình chỉnh sửa.

Bạn có thể chạy lớp học lập trình này bằng bất kỳ thiết bị nào sau đây:

  • Một thiết bị Android hoặc iOS thực được kết nối với máy tính và được đặt ở Chế độ nhà phát triển.
  • Trình mô phỏng iOS (bạn cần cài đặt các công cụ Xcode).
  • Trình mô phỏng Android (cần thiết lập trong Android Studio).
  • Một trình duyệt (bạn cần có Chrome để gỡ lỗi).
  • Dưới dạng ứng dụng máy tính cho Windows, Linux hoặc macOS. Bạn phải phát triển trên nền tảng mà bạn dự định triển khai. Vì vậy, nếu muốn phát triển một ứng dụng máy tính cho Windows, bạn phải phát triển trên Windows để truy cập vào chuỗi bản dựng thích hợp. Có những yêu cầu cụ thể theo hệ điều hành được đề cập chi tiết trên docs.flutter.dev/desktop.

3. Tải ứng dụng khởi đầu của lớp học lập trình xuống

Bạn đang tiếp tục từ MDC-103?

Nếu bạn đã hoàn thành MDC-103, thì mã của bạn sẽ sẵn sàng cho lớp học lập trình này. Chuyển đến bước: Thêm trình đơn phông nền.

Bạn mới bắt đầu?

Ứng dụng khởi đầu nằm trong thư mục material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series.

...hoặc sao chép từ GitHub

Để sao chép lớp học lập trình này từ GitHub, hãy chạy các lệnh sau:

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

Mở dự án và chạy ứng dụng

  1. Mở dự án trong trình chỉnh sửa mà bạn chọn.
  2. Làm theo hướng dẫn để "Chạy ứng dụng" trong phần Bắt đầu: Lái thử cho trình chỉnh sửa bạn chọn.

Thành công! Bạn sẽ thấy trang đăng nhập Shrine của các lớp học lập trình trước trên thiết bị của mình.

Android

iOS

Trang đăng nhập của Shrine

Trang đăng nhập của Shrine

4. Thêm trình đơn phông nền

Phông nền xuất hiện phía sau tất cả nội dung và thành phần khác. Thẻ này bao gồm 2 lớp: lớp sau (hiển thị các thao tác và bộ lọc) và lớp trước (hiển thị nội dung). Bạn có thể dùng một phông nền để hiển thị thông tin và các thao tác tương tác, chẳng hạn như thao tác điều hướng hoặc bộ lọc nội dung.

Xoá thanh ứng dụng của nhà

Tiện ích HomePage sẽ là nội dung của lớp trước. Hiện tại, ứng dụng này có một thanh ứng dụng. Chúng ta sẽ di chuyển thanh ứng dụng sang lớp sau và Trang chủ sẽ chỉ bao gồm AsymmetricView.

Trong home.dart, hãy thay đổi hàm build() để chỉ trả về một AsymmetricView:

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

Thêm tiện ích Backdrop

Tạo một tiện ích có tên là Backdrop (Phông nền) bao gồm frontLayerbackLayer.

backLayer bao gồm một trình đơn cho phép bạn chọn một danh mục để lọc danh sách (currentCategory). Vì muốn lựa chọn trình đơn được duy trì, chúng ta sẽ tạo Backdrop thành một tiện ích có trạng thái.

Thêm một tệp mới vào /lib có tên là 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)

Xin lưu ý rằng chúng tôi đánh dấu một số thuộc tính required. Đây là phương pháp hay nhất cho các thuộc tính trong hàm khởi tạo không có giá trị mặc định và không thể là null, do đó, bạn không nên quên.

Trong phần định nghĩa lớp Backdrop, hãy thêm lớp _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(),
    );
  }
}

Hàm build() trả về một Scaffold có thanh ứng dụng giống như HomePage trước đây. Nhưng phần nội dung của Scaffold là một Stack. Các thành phần con của Stack có thể chồng lên nhau. Kích thước và vị trí của mỗi thành phần con được chỉ định tương ứng với thành phần mẹ của Stack.

Bây giờ, hãy thêm một thực thể Phông nền vào ShrineApp.

Trong app.dart, hãy nhập backdrop.dartmodel/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';

Trong app.dart,, hãy sửa đổi tuyến / bằng cách trả về một BackdropHomePage làm 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'),
),

Lưu dự án của bạn, bạn sẽ thấy trang chủ của chúng ta xuất hiện cùng với thanh ứng dụng:

Android

iOS

Trang sản phẩm của Shrine có nền màu hồng

Trang sản phẩm của Shrine có nền màu hồng

backLayer cho thấy vùng màu hồng trong một lớp mới phía sau trang chủ frontLayer.

Bạn có thể sử dụng Flutter Inspector để xác minh rằng Stack thực sự có một Container đằng sau HomePage. Nội dung này sẽ có dạng như sau:

92ed338a15a074bd.png

Giờ đây, bạn có thể điều chỉnh cả thiết kế và nội dung của hai lớp.

5. Thêm hình dạng

Trong bước này, bạn sẽ tạo kiểu cho lớp trước để thêm một vết cắt ở góc trên cùng bên trái.

Material Design gọi loại chế độ tuỳ chỉnh này là hình dạng. Vùng hiển thị của Material có thể có hình dạng tuỳ ý. Hình dạng giúp tăng tính nhấn mạnh và phong cách cho các thành phần, đồng thời có thể được dùng để thể hiện thương hiệu. Bạn có thể tuỳ chỉnh các hình chữ nhật thông thường bằng các góc và cạnh cong hoặc có góc cạnh, cũng như tuỳ ý số lượng cạnh. Chúng có thể đối xứng hoặc không đối xứng.

Thêm một hình dạng vào lớp trên cùng

Logo Shrine có góc cạnh đã truyền cảm hứng cho câu chuyện về hình dạng của ứng dụng Shrine. Câu chuyện về hình dạng là cách sử dụng phổ biến các hình dạng được áp dụng trong toàn bộ ứng dụng. Ví dụ: hình dạng logo được lặp lại trong các phần tử trên trang đăng nhập có hình dạng được áp dụng. Trong bước này, bạn sẽ tạo kiểu cho lớp trước bằng một đường cắt góc ở góc trên bên trái.

Trong backdrop.dart, hãy thêm một lớp mới _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,
          ),
        ],
      ),
    );
  }
}

Sau đó, trong hàm _buildStack() của _BackdropState, hãy gói lớp trước trong một _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),
      ],
    );
  }

Tải lại.

Android

iOS

Trang sản phẩm của đền thờ có hình dạng tuỳ chỉnh

Trang sản phẩm của đền thờ có hình dạng tuỳ chỉnh

Chúng tôi đã tạo hình dạng tuỳ chỉnh cho nền tảng chính của Shrine. Tuy nhiên, chúng ta muốn thành phần này kết nối với thanh ứng dụng về mặt hình ảnh.

Thay đổi màu của thanh ứng dụng

Trong app.dart, hãy thay đổi hàm _buildShrineTheme() thành hàm sau:

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

Khởi động lại nóng. Thanh ứng dụng mới có màu sẽ xuất hiện.

Android

iOS

Trang sản phẩm của Shrine có thanh ứng dụng nhiều màu

Trang sản phẩm của Shrine có thanh ứng dụng nhiều màu

Do thay đổi này, người dùng có thể thấy có một lớp ngay sau lớp màu trắng ở phía trước. Hãy thêm chuyển động để người dùng có thể thấy lớp sau của phông nền.

6. Thêm chuyển động

Chuyển động là một cách để làm cho ứng dụng của bạn trở nên sống động. Bạn có thể chọn kiểu trang điểm đậm và ấn tượng, trang điểm nhẹ nhàng và tối giản, hoặc bất kỳ kiểu nào ở giữa. Nhưng hãy nhớ rằng loại chuyển động bạn sử dụng phải phù hợp với tình huống. Chuyển động được áp dụng cho các thao tác lặp lại, thường xuyên phải nhỏ và tinh tế, để các thao tác không làm người dùng mất tập trung hoặc tốn quá nhiều thời gian một cách thường xuyên. Tuy nhiên, có những trường hợp phù hợp (chẳng hạn như lần đầu tiên người dùng mở ứng dụng) có thể thu hút sự chú ý hơn và một số ảnh động có thể giúp hướng dẫn người dùng cách sử dụng ứng dụng của bạn.

Thêm chuyển động hiển thị vào nút trình đơn

Ở đầu backdrop.dart, bên ngoài phạm vi của bất kỳ lớp hoặc hàm nào, hãy thêm một hằng số để biểu thị tốc độ mà chúng ta muốn ảnh động có:

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

Thêm một tiện ích AnimationController vào _BackdropState, khởi tạo tiện ích này trong hàm initState() và loại bỏ tiện ích này trong hàm dispose() của trạng thái:

  // 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 điều phối các Animation và cung cấp cho bạn API để phát, đảo ngược và dừng ảnh động. Giờ chúng ta cần các hàm giúp nó di chuyển.

Thêm các hàm xác định cũng như thay đổi khả năng hiển thị của lớp trước:

  // 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);
  }

Bọc backLayer trong một tiện ích ExcludeSemantics. Tiện ích này sẽ loại trừ các mục trong trình đơn backLayer khỏi cây ngữ nghĩa khi lớp sau không hiển thị.

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

Thay đổi hàm _buildStack() để lấy BuildContext và BoxConstraints. Ngoài ra, hãy thêm một PositionedTransition lấy RelativeRectTween Animation:

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

Cuối cùng, thay vì gọi hàm _buildStack cho phần nội dung của Scaffold, hãy trả về một tiện ích LayoutBuilder sử dụng _buildStack làm trình tạo:

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

Chúng ta đã trì hoãn việc tạo ngăn xếp lớp trước/sau cho đến thời gian bố cục bằng LayoutBuilder để có thể kết hợp chiều cao tổng thể thực tế của phông nền. LayoutBuilder là một tiện ích đặc biệt mà lệnh gọi lại trình tạo của tiện ích này cung cấp các ràng buộc về kích thước.

Trong hàm build(), hãy chuyển biểu tượng trình đơn ở đầu trong thanh ứng dụng thành IconButton và dùng biểu tượng này để bật/tắt chế độ hiển thị của lớp trên cùng khi người dùng nhấn vào nút.

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

Tải lại rồi nhấn vào nút trình đơn trong trình mô phỏng.

Android

iOS

Trình đơn Shrine trống có 2 lỗi

Trình đơn Shrine trống có 2 lỗi

Lớp trước chuyển động (trượt) xuống. Nhưng nếu bạn nhìn xuống, sẽ có một lỗi màu đỏ và một lỗi tràn. Điều này là do AsymmetricView bị thu hẹp và trở nên nhỏ hơn do hiệu ứng động này, từ đó giảm không gian cho các Cột. Cuối cùng, các Cột không thể tự bố trí với không gian đã cho và dẫn đến lỗi. Nếu chúng ta thay thế Cột bằng ListView, thì kích thước cột sẽ vẫn giữ nguyên khi chúng chuyển động.

Bọc các cột sản phẩm trong ListView

Trong supplemental/product_columns.dart, hãy thay thế Cột trong OneProductCardColumn bằng một 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,
        ),

      ],
    );
  }
}

Cột này bao gồm MainAxisAlignment.end. Để bắt đầu bố cục từ dưới cùng, hãy đánh dấu reverse: true. Đơn đặt hàng của trẻ em sẽ được hoàn tiền để bù đắp cho thay đổi này.

Tải lại rồi nhấn vào nút trình đơn.

Android

iOS

Trình đơn Empty Shrine (Đền thờ trống) có một lỗi

Trình đơn Empty Shrine (Đền thờ trống) có một lỗi

Cảnh báo tràn màu xám trên OneProductCardColumn đã biến mất! Bây giờ, hãy khắc phục vấn đề còn lại.

Trong supplemental/product_columns.dart, hãy thay đổi cách tính imageAspectRatio và thay thế Cột trong TwoProductCardColumn bằng 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,
            ),
          ),
        ],
      );

Chúng tôi cũng đã thêm một số biện pháp an toàn cho imageAspectRatio.

Tải lại. Sau đó, hãy nhấn vào nút trình đơn.

Android

iOS

Trình đơn Shrine trống

Trình đơn Shrine trống

Không còn tràn bộ nhớ.

7. Thêm một trình đơn vào lớp sau

Trình đơn là một danh sách các mục văn bản có thể nhấn vào, thông báo cho các đối tượng tiếp nhận dữ liệu khi các mục văn bản được chạm vào. Ở bước này, bạn sẽ thêm một trình đơn lọc danh mục.

Thêm trình đơn

Thêm trình đơn vào lớp trước và các nút tương tác vào lớp sau.

Tạo một tệp mới có tên 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()),
      ),
    );
  }
}

Đây là một GestureDetector bao bọc một Cột có các phần tử con là tên danh mục. Đường gạch dưới dùng để cho biết danh mục đã chọn.

Trong app.dart, hãy chuyển đổi tiện ích ShrineApp từ không trạng thái sang có trạng thái.

  1. Đánh dấu ShrineApp.
  2. Dựa trên IDE của bạn, hãy cho thấy các thao tác đối với đoạn mã:
  3. Android Studio: Nhấn tổ hợp phím ⌥Enter (macOS) hoặc alt + enter
  4. VS Code: Nhấn ⌘. (macOS) hoặc Ctrl+.
  5. Chọn "Convert to StatefulWidget" (Chuyển đổi thành StatefulWidget).
  6. Thay đổi lớp ShrineAppState thành riêng tư (_ShrineAppState). Nhấp chuột phải vào ShrineAppState, rồi
  7. Android Studio: chọn Refactor > Rename (Tái cấu trúc > Đổi tên)
  8. VS Code: chọn Rename Symbol (Đổi tên biểu tượng)
  9. Nhập _ShrineAppState để đặt lớp ở chế độ riêng tư.

Trong app.dart, hãy thêm một biến vào _ShrineAppState cho Danh mục đã chọn và một lệnh gọi lại khi người dùng nhấn vào danh mục đó:

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

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

Sau đó, hãy thay đổi lớp sau thành CategoryMenuPage.

Trong app.dart, hãy nhập 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';

Trong hàm build(), hãy thay đổi trường backLayer thành CategoryMenuPage và trường currentCategory để lấy biến thực thể.

'/': (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'),
            ),

Tải lại rồi nhấn vào nút Trình đơn.

Android

iOS

Trình đơn đền thờ có 4 danh mục

Trình đơn đền thờ có 4 danh mục

Nếu bạn nhấn vào một lựa chọn trong trình đơn, thì chưa có gì xảy ra. Hãy khắc phục vấn đề đó.

Trong home.dart, hãy thêm một biến cho Danh mục rồi truyền biến đó vào 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),
    );
  }
}

Trong app.dart, hãy truyền _currentCategory cho frontLayer:.

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

Tải lại. Nhấn vào nút trình đơn trong trình mô phỏng rồi chọn một Danh mục.

Android

iOS

Trang sản phẩm được lọc của cửa hàng

Trang sản phẩm được lọc của cửa hàng

Chúng đã được lọc!

Đóng lớp trên cùng sau khi chọn một mục trong trình đơn

Trong backdrop.dart, hãy thêm một phương thức ghi đè cho hàm didUpdateWidget() (được gọi bất cứ khi nào cấu hình tiện ích thay đổi) trong _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);
    }
  }

Lưu dự án để kích hoạt tính năng tải lại nhanh. Nhấn vào biểu tượng trình đơn rồi chọn một danh mục. Trình đơn sẽ tự động đóng và bạn sẽ thấy danh mục của các mục đã chọn. Bây giờ, bạn cũng sẽ thêm chức năng đó vào lớp trên cùng.

Bật/tắt lớp trên cùng

Trong backdrop.dart, hãy thêm một lệnh gọi lại khi nhấn vào lớp phông nền:

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;

Sau đó, hãy thêm GestureDetector vào thành phần con của _FrontLayer: 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,
          ),
        ],
      ),

Sau đó, hãy triển khai thuộc tính onTap mới trên _BackdropState trong hàm _buildStack():

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

Tải lại và nhấn vào phần trên cùng của lớp trước. Lớp này sẽ mở và đóng mỗi khi bạn nhấn vào phần trên cùng của lớp trước.

8. Thêm biểu tượng có thương hiệu

Hệ thống biểu tượng mang thương hiệu cũng mở rộng sang các biểu tượng quen thuộc. Hãy tuỳ chỉnh biểu tượng hiển thị và hợp nhất biểu tượng đó với tiêu đề để tạo ra một diện mạo độc đáo, mang dấu ấn thương hiệu.

Thay đổi biểu tượng nút trình đơn

Android

iOS

Trang sản phẩm của đền thờ có biểu tượng thương hiệu

Trang sản phẩm của đền thờ có biểu tượng thương hiệu

Trong backdrop.dart, hãy tạo một lớp mới _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 là một tiện ích tuỳ chỉnh sẽ thay thế tiện ích Text thông thường cho tham số title của tiện ích AppBar. Ứng dụng này có biểu tượng trình đơn động và hiệu ứng chuyển đổi động giữa tiêu đề trước và sau. Biểu tượng trình đơn động sẽ sử dụng một thành phần mới. Bạn phải thêm thông tin tham chiếu đến slanted_menu.png mới vào pubspec.yaml.

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

Xoá thuộc tính leading trong trình tạo AppBar. Bạn cần xoá để biểu tượng có thương hiệu tuỳ chỉnh được hiển thị thay cho tiện ích leading ban đầu. Ảnh động listenable và trình xử lý onPress cho biểu tượng có thương hiệu được truyền đến _BackdropTitle. frontTitlebackTitle cũng được truyền để có thể hiển thị trong tiêu đề của phông nền. Tham số title của AppBar sẽ có dạng như sau:

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

Biểu tượng có thương hiệu được tạo trong _BackdropTitle.. Biểu tượng này chứa một Stack gồm các biểu tượng động: một trình đơn nghiêng và một viên kim cương được bao bọc trong một IconButton để có thể nhấn vào. Sau đó, IconButton sẽ được bao bọc trong SizedBox để tạo không gian cho chuyển động biểu tượng theo chiều ngang.

Cấu trúc "mọi thứ đều là một tiện ích" của Flutter cho phép thay đổi bố cục của AppBar mặc định mà không cần tạo một tiện ích AppBar tuỳ chỉnh hoàn toàn mới. Tham số title (vốn là một tiện ích Text) có thể được thay thế bằng một _BackdropTitle phức tạp hơn. Vì _BackdropTitle cũng bao gồm biểu tượng tuỳ chỉnh, nên thuộc tính này sẽ thay thế thuộc tính leading và bạn có thể bỏ qua thuộc tính này. Việc thay thế tiện ích đơn giản này được thực hiện mà không cần thay đổi bất kỳ thông số nào khác, chẳng hạn như biểu tượng thao tác, biểu tượng này vẫn tiếp tục hoạt động độc lập.

Thêm lối tắt quay lại màn hình đăng nhập

Trong backdrop.dart,thêm một lối tắt quay lại màn hình đăng nhập từ hai biểu tượng cuối cùng trong thanh ứng dụng: Thay đổi nhãn ngữ nghĩa của các biểu tượng để phản ánh mục đích mới của chúng.

        // 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()),
            );
          },
        ),

Bạn sẽ gặp lỗi nếu thử tải lại. Nhập login.dart để khắc phục lỗi:

import 'login.dart';

Tải lại ứng dụng rồi nhấn vào nút tìm kiếm hoặc nút dò kênh để quay lại màn hình đăng nhập.

9. Xin chúc mừng!

Trong 4 lớp học lập trình này, bạn đã tìm hiểu cách sử dụng Thành phần Material để tạo ra những trải nghiệm người dùng độc đáo, trang nhã, thể hiện cá tính và phong cách của thương hiệu.

Các bước tiếp theo

Lớp học lập trình này (MDC-104) sẽ hoàn tất chuỗi lớp học lập trình này. Bạn có thể khám phá thêm nhiều thành phần trong Material Flutter bằng cách truy cập vào Danh mục tiện ích Thành phần Material.

Để đạt được mục tiêu cao hơn, hãy thử thay thế biểu tượng có thương hiệu bằng một AnimatedIcon tạo ảnh động giữa hai biểu tượng khi phông nền xuất hiện.

Có rất nhiều lớp học lập trình Flutter khác để bạn thử, tuỳ theo sở thích của bạn. Chúng tôi có một lớp học lập trình khác dành riêng cho Material mà bạn có thể quan tâm: Tạo hiệu ứng chuyển đổi đẹp mắt bằng Material Motion cho Flutter.

Tôi đã hoàn thành lớp học lập trình này trong một khoảng thời gian hợp lý và không tốn nhiều công sức

Hoàn toàn đồng ý Đồng ý Trung lập Không đồng ý Hoàn toàn không đồng ý

Tôi muốn tiếp tục sử dụng Thành phần Material trong tương lai

Hoàn toàn đồng ý Đồng ý Không đồng ý cũng không phản đối Không đồng ý Hoàn toàn không đồng ý