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, độ cao, 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à một 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 giúp 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 nhấn hoặc nhấp vào nút.

Nguyên tắc Material Design mô tả các thành phần từ quan điểm của nhà thiết kế. Các tài liệu này mô tả nhiều hàm cơ bản có sẵn trên các 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ụ: phông nền chứa một lớp sau và nội dung của lớp đó, lớp trước và nội dung của lớp đó, quy tắc chuyển động và các tuỳ chọn hiển thị. Bạn có thể tuỳ chỉnh từng thành phần này cho 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 hai cấp có tên là "phông nền". Phông nền bao gồm một trình đơn liệt kê các danh mục có thể chọn để lọc sản phẩm hiển thị 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:

  • Hình dạng
  • Có chuyển động
  • Tiện ích Flutter (mà 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

trang thông tin thực đơn có 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 làm quen 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ó hai phần mềm để hoàn thành lớp học này – SDK Fluttermộ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 kết nối với máy tính của bạn và đặt ở Chế độ nhà phát triển.
  • Trình mô phỏng iOS (yêu cầu cài đặt các 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 dành cho máy tính. 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

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

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

Bạn muốn bắt đầu 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: Thử nghiệm 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 Shrine

Trang đăng nhập Shrine

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 HomePage sẽ là nội dung của lớp trên cùng. 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 Phông nền

Tạo một tiện ích có tên là Backdrop (Nền sau) 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ì chúng ta muốn lựa chọn trình đơn được duy trì, nên chúng ta sẽ đặt Backdrop làm 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 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 thể là null. Do đó, bạn không được quên phương pháp này.

Trong phần khai báo 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 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 phần tử con được chỉ định tương ứng với phần tử mẹ của Ngăn xếp.

Bây giờ, hãy thêm một thực thể Backdrop 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, 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 Shrine có nền màu hồng

Trang sản phẩm Shrine có 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. Mã sẽ tương tự như sau:

92ed338a15a074bd.png

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

5. Thêm hình dạng

Trong bước này, bạn sẽ tạo kiểu cho lớp phía 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. Vùng hiển thị của Material có thể có hình dạng tuỳ ý. Hình dạng làm nổi bật và tạo kiểu cho các nền tảng, đồng thời có thể 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 cong hoặc góc cạnh, cũng như số cạnh bất kỳ. Các cạnh này có thể đối xứng hoặc không đối xứng.

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

Biểu trưng có góc cạnh của Đền đã 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 trên toàn bộ ứ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. Trong bước này, bạn sẽ tạo kiểu cho lớp mặt 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 _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 Shrine có hình dạng tuỳ chỉnh

Chúng ta đã 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 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 Shrine 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 có một thứ gì đó ngay sau lớp trắng ở phía trước. Hãy thêm chuyển động để người dùng có thể thấy lớp nền sau của phông nền.

6. Thêm chuyển động

Ảnh động là một cách để làm cho ứng dụng của bạn trở nên sống động. Nó có thể lớn và ấn tượng, tinh tế và tối giản hoặc ở bất kỳ đâu trong khoảng 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 áp dụng cho các hành động thường xuyên và lặp lại nên nhỏ và nhỏ để 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 trường hợp thích 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 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 tiện ích AnimationController vào _BackdropState, tạo bản sao 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 Ả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 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 có lệnh gọi lại trình tạo cung cấp các quy tắ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 hàng đầu trong thanh ứng dụng thành IconButton và sử dụng biểu tượng đó để bật/tắt chế độ hiển thị của lớp trên cùng khi 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ó hai 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ự bố trí với không gian được cung cấp và dẫn đến lỗi. Nếu chúng ta thay thế Cột bằng ListView, kích thước cột sẽ vẫn giữ nguyên khi tạo ảnh động.

Gói 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 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 sẽ đả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 Shrine 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 Shrine trống

Trình đơn Shrine trống

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

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 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 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 thành StatefulWidget).
  6. Thay đổi lớp ShrineAppState thành riêng tư (_ShrineAppState). Nhấp chuột phải vào ShrineAppState và
  7. Android Studio: chọn Refactor (Tái cấu trúc) > Rename (Đổ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 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 Shrine có 4 danh mục

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

Nếu bạn nhấn vào một mục 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 Đề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 để kích hoạt tính năng 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ên cùng.

Bật/tắt lớp 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 rồi nhấn vào đầu lớp trên cùng. Lớp này sẽ mở và đóng mỗi khi bạn nhấn vào đầu 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 áp dụng cho 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 Shrine có biểu tượng 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 thương hiệu tuỳ chỉnh 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 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à 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 ban đầu 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 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 tiếp tục 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ẽ 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 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 để tạo ra trải nghiệm người dùng độc đáo, thanh lịch, 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 các 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 khác trong Material Flutter bằng cách truy cập vào 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. Biểu tượng này tạo hiệu ứng động giữa hai biểu tượng khi phông nền hiển thị.

Có nhiều lớp học lập trình khác về Flutter cho 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 ý 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 ý Bình thường Không đồng ý Hoàn toàn không đồng ý