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 các kỹ sư và nhà thiết kế trải nghiệm người dùng tại Google, MDC có nhiều thành phần giao diện người dùng đẹp mắt, dễ sử dụng và 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 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 nhóm các nhiệm vụ định sẵn và có một số đặc điểm nhất định, chẳng hạn như một nút. Tuy nhiên, nút không chỉ là cách để người dùng thực hiện hành động mà còn là 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à điều gì đó sẽ xảy ra khi chạm hoặc nhấp.

Nguyên tắc Material Design mô tả các thành phần từ quan điểm của nhà thiết kế. Bộ nguyên tắc này mô tả một loạt các chức năng cơ bản có sẵn trên các nền tảng và các nguyên tố giải phẫu tạo nên mỗi thành phần. Ví dụ: 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, quy tắc chuyển động và tuỳ chọn hiển thị của lớp này. Bạn có thể tuỳ chỉnh từng thành phần 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 sang bản trình bày 2 cấp được gọi 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 được dùng để lọc sản phẩm xuất hiện trong lưới bất đối xứng. Trong lớp học lập trình này, bạn sẽ sử dụng:

  • Hình dạng
  • Có chuyển động
  • Các tiện ích Flutter (bạn đã sử 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ử theo chủ đề màu hồng và nâu, có thanh ứng dụng ở trên cùng và một lưới bất đối xứng có thể cuộn theo chiều ngang với đầy đủ các sản phẩm

ứng dụng thương mại điện tử theo chủ đề màu hồng và nâu, có thanh ứng dụng ở trên cùng và một lưới bất đối xứng có thể cuộn theo chiều ngang với đầy đủ các sản phẩm

trình đơn liệt kê 4 danh mục

trình đơn liệt kê 4 danh mục

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 khi phát triển Flutter?

Người mới tập 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 phòng thí nghiệm này – Flutter SDKtrình chỉnh sửa.

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

  • Một thiết bị Android hoặc iOS thực kết nối với máy tính của bạn và được đặt ở Chế độ nhà phát triển.
  • Trình mô phỏng iOS (yêu cầu cài đặt công cụ Xcode).
  • Trình mô phỏng Android (yêu cầu thiết lập trong Android Studio).
  • Trình duyệt (cần có Chrome để gỡ lỗi).
  • Dưới dạng ứng dụng 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 Windows dành cho máy tính, bạn phải phát triển trên Windows để truy cập vào chuỗi bản dựng phù hợp. Có các 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

Tiếp tục từ MDC-103?

Nếu bạn đã hoàn thành MDC-103, mã của bạn đã 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.

Tạo 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ệp trên 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: Dùng thử cho trình chỉnh sửa mà bạn đã chọn.

Thành công! Bạn sẽ thấy trang đăng nhập Đền từ 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 vào Đền thờ

Trang đăng nhập vào Đền thờ

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

Phông nền sẽ xuất hiện phía sau tất cả nội dung và thành phần khác. Nó gồm hai 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ể sử dụng phông nền để hiển thị các hành động và thông tin tương tác, chẳng hạn như điều hướng hoặc bộ lọc nội dung.

Xoá thanh ứng dụng trên màn hình chính

Tiện ích Trang chủ sẽ là nội dung của lớp trước. Hiện tại, giao diện này có một thanh ứng dụng. Chúng ta sẽ di chuyển thanh ứng dụng vào 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 Phông nền

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

backLayer có 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 duy trì lựa chọn trình đơn, chúng ta sẽ đặt Phông nền thành một tiện ích trạng thái.

Thêm 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 ta đánh dấu một số thuộc tính là 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 được là null, do đó bạn không nên quên các thuộc tính đó.

Trong phần định nghĩa lớp Phông nền, 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 từng dùng. Tuy nhiên, phần nội dung của Scaffold là một Stack (Ngăn xếp). Phần tử con của Ngăn xếp có thể chồng chéo 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 Ngăn xếp.

Bây giờ, hãy thêm một bản sao 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 BackdropHomePagefrontLayer:

// 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ủ đang xuất hiện và thanh ứng dụng cũng xuất hiện:

Android

iOS

Trang sản phẩm của đền thờ với nền màu hồng

Trang sản phẩm của đền thờ với nền màu hồng

BackLayer hiển thị 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 (Trình kiểm tra Flutter) để xác minh rằng Ngăn xếp thực sự có một Vùng chứa phía sau HomePage. Đoạn mã sẽ có dạng như sau:

92ed338a15a074bd.pngS

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

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 đường cắt ở góc trên bên trái.

Material Design đề cập đến loại tuỳ chỉnh này dưới dạng hình dạng. Bề mặt của Material có thể có các hình dạng tuỳ ý. Hình dạng làm nổi bật và tạo phong cách cho các bề mặt và có thể được dùng để thể hiện thương hiệu. Bạn có thể tuỳ chỉnh hình chữ nhật thông thường bằng các góc và cạnh bo tròn hoặc góc cạnh cũng như số lượng cạnh bất kỳ. Chúng có thể đối xứng hoặc không đều.

Thêm hình dạng vào lớp phía trước

Biểu trưng Đền thờ góc cạnh đã truyền cảm hứng cho câu chuyện hình dạng cho ứng dụng Đền. Câu chuyện hình dạng là cách sử dụng phổ biến các hình dạng được áp dụng xuyên suốt ứng dụng. Ví dụ: hình dạng biểu trưng được lặp lại trong các phần tử trang đăng nhập đã áp dụng hình dạng cho biểu trưng. Trong bước này, bạn sẽ tạo kiểu cho lớp phía 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 phía 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 có hình dạng tuỳ chỉnh

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

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

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

Trong app.dart, hãy thay đổi hàm _buildShrineTheme() thành như 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àu mới sẽ xuất hiện.

Android

iOS

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

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

Do thay đổi này, người dùng có thể thấy rằng có gì đó ngay phía 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. Âm thanh có thể to và ấn tượng, tinh tế và rất nhỏ, hoặc bất cứ vị trí 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 dùng cho các hành động thường xuyên và lặp lại nên nhỏ và nhẹ nhàng để người dùng không bị phân tâm hoặc thường xuyên chiếm quá nhiều thời gian. Tuy nhiên, có những tình huống thích hợp, chẳng hạn như lần đầu tiên người dùng mở ứng dụng, có thể trông bắt mắt 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.

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ố để thể hiện tốc độ mà chúng ta muốn ảnh động đạt được:

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

Thêm một tiện ích AnimationController vào _BackdropState, tạo thực thể cho tiện ích đó trong hàm initState() và loại bỏ tiện ích đó 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 Ảnh động và cung cấp cho bạn API để phát, đảo ngược và dừng ảnh động. Bây 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 chế độ 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);
  }

Gói backLayer trong tiện íchExcludeSemantics. Tiện ích này sẽ loại trừ các mục trong trình đơn của backLayer khỏi cây ngữ nghĩa khi không nhìn thấy lớp sau.

    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 bao gồm một PositionedTransition lấy Ảnh động tương đốiittween:

  // 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 thân 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 tôi đã 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 cách sử dụ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, lệnh gọi lại của trình tạo cung cấp các giới hạn về kích thước.

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

      // 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 Đền trống có 2 lỗi

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

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

Gói cột sản phẩm trong ListView

Trong supplemental/product_columns.dart, hãy thay thế Cột trong OneProductCardColumn bằng 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 bao gồm MainAxisAlignment.end. Để bắt đầu bố cục từ dưới cùng, hãy đánh dấu reverse: true. Thứ tự của các phần tử con bị đảo ngược để bù cho sự thay đổi.

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

Android

iOS

Trình đơn Đền trống có một lỗi

Trình đơn Đền trống có một lỗi

Cảnh báo tràn màu xám trên OneProductCardColumn đã biến mất! Giờ hãy khắc phục phầ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 vào imageAspectRatio.

Tải lại. Tiếp theo, hãy nhấn vào nút trình đơn.

Android

iOS

Trình đơn Đền trống

Trình đơn Đền trống

Không còn tình trạng tràn.

7. Thêm trình đơn ở 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 người nghe khi các mục văn bản đó được chạm vào. Trong bước này, bạn sẽ thêm một trình đơn lọc danh mục.

Thêm thực đơ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 là 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, trong đó các cột con là tên danh mục. Dấu gạch dưới được dùng để biểu thị danh mục đã chọn.

Trong app.dart, hãy chuyển đổi tiện ích ShrineApp từ không có 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 hiện các thao tác đối với mã:
  3. Android Studio: Nhấn tổ hợp phím ⌥Enter (macOS) hoặc alt + enter
  4. Mã VS: Nhấn ⌘. (macOS) hoặc Ctrl+.
  5. Chọn "Convert to StatefulWidget" (Chuyển đổi sang 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 > (Tái cấu trúc) > Đổi tên
  8. VS Code: chọn Đổ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 biến đó:

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

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

Sau đó, thay đổi lớp phía 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 và nhấn vào nút Trình đơn.

Android

iOS

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

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

Nếu bạn nhấn vào một tuỳ chọn trong trình đơn thì sẽ 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 Đền

Trang sản phẩm được lọc của Đền

Đã lọc các từ khóa!

Đóng lớp phía trước sau khi chọn một trình đơn

Trong backdrop.dart, hãy thêm cơ chế 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 của bạn để kích hoạt quá trình tải lại nóng. 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 lại và bạn sẽ thấy danh mục các mục được chọn. Bây giờ, bạn cũng sẽ thêm chức năng đó vào lớp trước.

Bật/tắt lớp phía trước

Trong backdrop.dart, hãy thêm 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 đó, thêm Cử chỉ phát hiện vào phần tử con của _FrontLayer: Phần tử con của cột:.

      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 đó, 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

Biểu tượng thương hiệu cũng xuất hiện trên các biểu tượng quen thuộc. Hãy để biểu tượng hiển thị trở thành tuỳ chỉnh và hợp nhất biểu tượng đó với tiêu đề của chúng ta để tạo nên một diện mạo độc đáo và có 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 có biểu tượng có thương hiệu

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

Trong backdrop.dart, hãy tạo một lớp mới là _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 thuần tuý cho tham số title của tiện ích AppBar. Ứng dụng này có biểu tượng trình đơn dạng ảnh động và hiệu ứng chuyển đổi dạng ảnh độ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 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 tuỳ chỉnh mang thương hiệu để hiển thị ở vị trí của 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 chuyển đến _BackdropTitle. frontTitlebackTitle cũng được truyền để có thể hiển thị trong tiêu đề 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 Stack gồm các biểu tượng động: trình đơn nghiêng và hình thoi, được bao bọc trong IconButton để người dùng có thể nhấn vào. Sau đó, IconButton được gói trong một SizedBox để tạo không gian cho chuyển động của biểu tượng theo chiều ngang.

"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 phải tạo một tiện ích AppBar tuỳ chỉnh hoàn toàn mới. Tham số title, ban đầu là tiện ích Text, có thể được thay thế bằng _BackdropTitle phức tạp hơn. Vì _BackdropTitle cũng bao gồm biểu tượng tuỳ chỉnh, nên nó sẽ thay thế thuộc tính leading (bạn hiện có thể bỏ qua thuộc tính này). Quá trình thay thế tiện ích đơn giản này được thực hiện mà không thay đổi bất kỳ tham số nào khác, chẳng hạn như biểu tượng hành động, vốn vẫn tự hoạt động.

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

Trong backdrop.dart,, hãy thêm lối tắt trở lại màn hình đăng nhập từ hai biểu tượng theo sau trên 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.

        // 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ẽ thấy thông báo 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 chỉ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 để xây dựng trải nghiệm người dùng trang nhã, độc đáo, 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 MDC-104 này sẽ hoàn thành trình tự các lớp học lập trình này. Bạn có thể khám phá nhiều thành phần khác trong Material Flutter bằng cách truy cập danh mục tiện ích Thành phần Material.

Đối với mục tiêu kéo dài, hãy thử thay thế biểu tượng có thương hiệu bằng AnimatedIcon để tạo hiệu ứng chuyển động giữa hai biểu tượng khi phông nền xuất hiện.

Có nhiều lớp học lập trình Flutter khác để bạn thử theo mối quan tâm 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 đã có thể hoàn thành lớp học lập trình này với khá nhiều thời gian và công sức

Hoàn toàn đồng ý Đồng ý Bình thường 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 ý Bình thường Không đồng ý Hoàn toàn không đồng ý