使用 Flutter 构建 Cupertino 应用

欢迎学习 Flutter Cupertino Codelab!

在此 Codelab 中,您将使用 Flutter 创建一个 Cupertino 应用(iOS 样式的应用)。除了基本的微件库,Flutter SDK 还附带两个样式化微件库:

  • Material 微件:用于实现适用于 iOS、Android、Web 和桌面设备的 Material Design 语言。
  • Cupertino 微件:用于根据 Apple 的人机接口指南实现当前的 iOS 设计语言。

为何要编写 Cupertino 应用?Material Design 语言适用于所有平台,而不仅仅是 Android 平台。在 Flutter 中编写的 Material 应用在所有设备(包括 iOS 设备)上都拥有 Material 外观和风格。如果您希望应用的外观类似于 iOS 样式的标准应用,则可以使用 Cupertino 库。

从技术层面来讲,您可以在 Android 或 iOS 上运行 Cupertino 应用,但由于许可问题,Cupertino 在 Android 上没法显示正确的字体。因此,在编写 Cupertino 应用时,请使用 iOS 专用设备。

您将实现一个 Cupertino 样式的购物应用,其中包含三个标签页:一个用于商品列表,一个用于商品搜索,一个用于购物车。

f104a94356854c24.png 6f345bfa17663f9a.png

daf61aa9d823646a.png

您将通过此 Codelab 学到的内容

  • 如何构建具有 iOS 样式的外观和风格的 Flutter 应用。
  • 如何创建多个标签页并在它们之间导航。
  • 如何使用 provider 软件包管理屏幕之间的状态。

您想通过此 Codelab 学习哪些内容?

我不熟悉这个主题,想深入了解一下。 我对这个主题有所了解,但我想重温一下。 我在寻找示例代码以用到我的项目中。 我在寻找有关特定内容的说明。

您需要使用两款软件才能完成此 Codelab:Flutter SDK一款编辑器。您可以使用自己偏好的编辑器,例如包含 Flutter 和 Dart 插件的 Android Studio 或 IntelliJ,或包含 Dart Code 和 Flutter 扩展程序的 Visual Studio Code。

您可以使用以下任意设备运行此 Codelab:

您还需要:

  • 配置了 Xcode 的 Mac。

使用 CupertinoPageScaffold 创建初始应用。

b2f84ff91b0e1396.png 按如下所示创建一个名为 cupertino_store 的 Flutter 项目,并进行迁移,以确保 null 安全。

$ flutter create cupertino_store
$ cd cupertino_store
$ dart migrate --apply-changes

b2f84ff91b0e1396.png 替换 lib/main.dart 的内容。删除 lib/main.dart 中的所有代码,这将创建一个采用 Material 主题的按钮计数应用。替换为以下用于初始化 Cupertino 应用的代码。

lib/main.dart

import 'package:flutter/cupertino.dart';

import 'app.dart';

void main() {
  return runApp(CupertinoStoreApp());
}

cf1e10b838bf60ee.png 观察内容

  • 导入 Cupertino 软件包。这样一来,您的应用便可使用所有 Cupertino 微件和常量。

b2f84ff91b0e1396.png 创建 lib/styles.dart。将一个文件添加到名为 styles.dartlib 目录下。Styles 类用于指定可用来自定义应用的文本和颜色样式。下面是该文件的一个示例,但您可前往 GitHub 获取完整内容:lib/styles.dart

lib/styles.dart

// THIS IS A SAMPLE FILE. Get the full content at the link above.
import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';

abstract class Styles {
  static const TextStyle productRowItemName = TextStyle(
    color: Color.fromRGBO(0, 0, 0, 0.8),
    fontSize: 18,
    fontStyle: FontStyle.normal,
    fontWeight: FontWeight.normal,
  );

  static const TextStyle productRowTotal = TextStyle(
    color: Color.fromRGBO(0, 0, 0, 0.8),
    fontSize: 18,
    fontStyle: FontStyle.normal,
    fontWeight: FontWeight.bold,
  );

 // ...
// THIS IS A SAMPLE FILE. Get the full content at the link above.

cf1e10b838bf60ee.png 观察内容

  • 我们可以集中放置样式定义,将所有定义归入单个文件中,这种方式与 Web 开发者将样式标记集中放置在 CSS 文件中类似。这是在整个应用中重复使用和重新定义样式的最简单方式。

b2f84ff91b0e1396.png 创建 lib/app.dart 并添加 CupertinoStoreApp 类。将以下 CupertinoStoreApp 类添加到 lib/app.dart

lib/app.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';

class CupertinoStoreApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // This app is designed only to work vertically, so we limit
    // orientations to portrait up and down.
    SystemChrome.setPreferredOrientations(
        [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);

    return CupertinoApp(
      theme: const CupertinoThemeData(brightness: Brightness.light),
      home: CupertinoStoreHomePage(),
    );
  }
}

cf1e10b838bf60ee.png 观察内容

  • 导入服务库。这样一来,您的应用便可使用相应平台服务,如剪贴板和设备屏幕方向设置服务。
  • 实例化 CupertinoApp,它提供了创建 iOS 用户期望的应用所需的主题、导航、文本方向和其他默认设置。
  • CupertinoStoreHomePage 实例化为首页。
  • 该应用只能以垂直方向使用,因此设备屏幕方向仅限于纵向。

b2f84ff91b0e1396.png 添加 CupertinoStoreHomePage 类。将以下 CupertinoStoreHomePage 类添加到 lib/app.dart,从而为首页创建布局。

lib/app.dart

class CupertinoStoreHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('Cupertino Store'),
      ),
      child: SizedBox(),
    );
  }
}

cf1e10b838bf60ee.png 观察内容

  • Cupertino 软件包提供两种类型的页面 scaffold。CupertinoPageScaffold 接受 Cupertino 样式的导航栏以及背景色,并可存放页面的微件树。下一步会介绍第二种 scaffold。
  • 此页面包含一个标题,而微件树包含一个空容器。

b2f84ff91b0e1396.png 更新 pubspec.yaml 文件。在项目顶部,修改 pubspec.yaml 文件。添加所需的库以及图片资源列表。下面是该文件的一个示例,您可前往 GitHub 找到完整内容:pubspec.yaml

pubspec.yaml

# THIS IS A SAMPLE OF THE FILE. Get the full file at the link above.
name: cupertino_store
description: Creating a Store in Cupertino widgets
publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  cupertino_icons: ^1.0.2
  flutter:
    sdk: flutter
  intl: ^0.17.0
  provider: ^5.0.0
  shrine_images: ^2.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  pedantic: ^1.11.0

flutter:
  assets:
    - packages/shrine_images/0-0.jpg
# THIS IS A SAMPLE OF THE FILE. Get the full file at the link above.

cf1e10b838bf60ee.png 观察内容

  • 上述代码会提取多个软件包,包括 shine_images,其中包含用于填充商店的商品。
  • provider 软件包提供了一种跨屏幕管理状态的简单方式。
  • intl 软件包提供了国际化和本地化功能。
  • cupertino_icons 软件包包含适用于 Cupertino 微件的图标资源。

b2f84ff91b0e1396.png 运行应用。您应该会看到以下白色屏幕,其中包含 Cupertino 导航栏和一个标题:

5705e4da178665a5.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

最终的应用具备 3 个标签页:

  • 商品列表
  • 商品搜索
  • 购物车

在这一步中,您需要使用 CupertinoTabScaffold 更新首页及三个标签页。此外,您还需要添加一个数据源,用于提供待售商品列表、照片和价格。

在上一步中,您使用 CupertinoPageScaffold 创建了一个 CupertinoStoreHomePage 类。对于没有标签页的页面,请使用这种 scaffold。最终的应用有三个标签页,因此请将 CupertinoPageScaffold 替换为 CupertinoTabScaffold

Cupertino 标签页具有单独的 scaffold,因为在 iOS 上,底部标签页通常一直位于嵌套路由之上(而不是在页面内)。

b2f84ff91b0e1396.png 更新 lib/app.dart。将 CupertinoStoreHomePage 类替换为以下代码,后者设置了一个包含 3 个标签页的 scaffold:

lib/app.dart

class CupertinoStoreHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoTabScaffold(
      tabBar: CupertinoTabBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.home),
            label: 'Products',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.search),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.shopping_cart),
            label: 'Cart',
          ),
        ],
      ),
      tabBuilder: (context, index) {
        late final CupertinoTabView returnValue;
        switch (index) {
          case 0:
            returnValue = CupertinoTabView(builder: (context) {
              return CupertinoPageScaffold(
                child: ProductListTab(),
              );
            });
            break;
          case 1:
            returnValue = CupertinoTabView(builder: (context) {
              return CupertinoPageScaffold(
                child: SearchTab(),
              );
            });
            break;
          case 2:
            returnValue = CupertinoTabView(builder: (context) {
              return CupertinoPageScaffold(
                child: ShoppingCartTab(),
              );
            });
            break;
        }
        return returnValue;
      },
    );
  }
}

cf1e10b838bf60ee.png 观察内容

  • CupertinoTabBar 必须至少包含两项内容,否则您会在运行时看到错误。
  • tabBuilder: 负责确保指定的标签页已构建。在本示例中,它会调用类构造函数来设置各个标签页,同时将所有三个构造函数封装在 CupertinoTabViewCupertinoPageScaffold 中。

b2f84ff91b0e1396.png 为新标签页的内容添加桩类。为可正常编译但仅显示白色屏幕的第一个标签页创建一个 lib/product_list_tab.dart 文件。请使用以下内容:

lib/product_list_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';

class ProductListTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        return const CustomScrollView(
          slivers: <Widget>[
            CupertinoSliverNavigationBar(
              largeTitle: Text('Cupertino Store'),
            ),
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png 观察内容

  • 商品列表标签页是一个无状态微件。
  • provider 软件包中的 Consumer 可协助执行状态管理。我们稍后会详细介绍该模型。
  • iOS 上的导航栏有 2 种变体:自 iOS 1 以来出现的常见的短的静态类型,以及在 iOS 11 中引入的高大的可滚动的大标题类型。此页面使用 CupertinoSliverNavigationBar 微件在 CustomScrollView 内实现了后者。

b2f84ff91b0e1396.png 添加搜索页面桩。创建一个可正常编译但仅显示白色屏幕的 lib/search_tab.dart 文件。请使用以下内容:

lib/search_tab.dart

import 'package:flutter/cupertino.dart';

class SearchTab extends StatefulWidget {
  @override
  _SearchTabState createState() {
    return _SearchTabState();
  }
}

class _SearchTabState extends State<SearchTab> {
  @override
  Widget build(BuildContext context) {
    return const CustomScrollView(
      slivers: <Widget>[
        CupertinoSliverNavigationBar(
          largeTitle: Text('Search'),
        ),
      ],
    );
  }
}

cf1e10b838bf60ee.png 观察内容

  • 搜索标签页是一个有状态微件,因为当用户执行搜索时,结果列表会发生变化。

b2f84ff91b0e1396.png 添加购物车页面桩。创建一个可正常编译但仅显示白色屏幕的 lib/shopping_cart_tab.dart 文件。请使用以下内容:

lib/shopping_cart_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';

class ShoppingCartTab extends StatefulWidget {
  @override
  _ShoppingCartTabState createState() {
    return _ShoppingCartTabState();
  }
}

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        return const CustomScrollView(
          slivers: <Widget>[
            CupertinoSliverNavigationBar(
              largeTitle: Text('Shopping Cart'),
            ),
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png 观察内容

  • 购物车标签页是一个有状态微件,因为它维护着购买交易列表和客户信息。
  • 此页面还使用了 CustomScrollView

b2f84ff91b0e1396.png 更新 lib/app.dart。更新 lib/app.dart 中的 import 语句以提取新标签页微件:

lib/app.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'product_list_tab.dart';   // NEW
import 'search_tab.dart';         // NEW
import 'shopping_cart_tab.dart';  // NEW

在这一步的第二部分,还是在接下来的那个页面上,您需要添加用于跨标签页管理和共享状态的代码。

该应用有一些共用的数据需要在多个屏幕上共享,因此您需要通过一种简单方式将数据传输到需要它们的每个对象中。为此,您可以使用 provider 软件包。在 provider 中,您可以定义数据模型,然后使用 ChangeNotifierProvider 沿微件树向下提供数据模型。

b2f84ff91b0e1396.png 创建数据模型类。在 lib 下创建一个 model 目录。添加一个 lib/model/product.dart 文件,用于定义来自数据源的商品数据:

lib/model/product.dart

enum Category {
  all,
  accessories,
  clothing,
  home,
}

class Product {
  const Product({
    required this.category,
    required this.id,
    required this.isFeatured,
    required this.name,
    required this.price,
  });

  final Category category;
  final int id;
  final bool isFeatured;
  final String name;
  final int price;

  String get assetName => '$id-0.jpg';
  String get assetPackage => 'shrine_images';

  @override
  String toString() => '$name (id=$id)';
}

cf1e10b838bf60ee.png 观察内容

  • Product 类的每个实例都描述了一件待售商品。

ProductsRepository 类包含待售商品的完整列表及其价格、标题文本和类别。我们的应用不会对 isFeatured 属性执行任何操作。该类还包含 loadProducts() 方法,用于返回所有商品,或指定类别中的所有商品。

b2f84ff91b0e1396.png 创建商品存储库。创建 lib/model/products_repository.dart 文件。此文件包含所有待售商品。每个商品都属于一个类别。下面是该文件的一个示例,但您可以前往 GitHub 获取完整内容:products_repository.dart

lib/model/products_repository.dart

// THIS IS A SAMPLE FILE. Get the full content at the link above.

import 'product.dart';

class ProductsRepository {
 static const _allProducts = <Product>[
   Product(
     category: Category.accessories,
     id: 0,
     isFeatured: true,
     name: 'Vagabond sack',
     price: 120,
   ),
   Product(
     category: Category.home,
     id: 9,
     isFeatured: true,
     name: 'Gilt desk trio',
     price: 58,
   ),
   Product(
     category: Category.clothing,
     id: 33,
     isFeatured: true,
     name: 'Cerise scallop tee',
     price: 42,
   ),
   // THIS IS A SAMPLE FILE. Get the full content at the link above.
 ];

 static List<Product> loadProducts(Category category) {
   if (category == Category.all) {
     return _allProducts;
   } else {
     return _allProducts.where((p) => p.category == category).toList();
   }
 }
}

cf1e10b838bf60ee.png Observations

  • 在本示例中,为简化开发,我们将创建一个模拟的商品数据库,但它应作为 API 提供给应用。使用 Cloud Firestore 可以轻松处理手机部分断开连接的实际情况。

现在您可以定义模型了。创建 lib/model/app_state_model.dart 文件。在 AppStateModel 类中,提供用于通过模型访问数据的方法。例如,添加一种用于访问购物车总额的方法,再添加一种用于访问选定的欲购商品的方法,再添加一种用于访问运费的方法,等等。

b2f84ff91b0e1396.png 创建模型类。下面列出了此类提供的方法签名。您可前往 GitHub 获取完整内容:lib/model/app_state_model.dart

lib/model/app_state_model.dart

// THIS IS A SAMPLE FILE ONLY. Get the full content at the link above.

import 'package:flutter/foundation.dart' as foundation;

import 'product.dart';
import 'products_repository.dart';

double _salesTaxRate = 0.06;
double _shippingCostPerItem = 7;

class AppStateModel extends foundation.ChangeNotifier {
 List<Product> _availableProducts = [];
 Category _selectedCategory = Category.all;
 final _productsInCart = <int, int>{};

 Map<int, int> get productsInCart
 int get totalCartQuantity
 Category get selectedCategory
 double get subtotalCost
 double get shippingCost

 double get tax
 double get totalCost
 List<Product> getProducts()
 List<Product> search(String searchTerms)
 void addProductToCart(int productId)
 void removeItemFromCart(int productId)
 Product getProductById(int id)
 void clearCart()
 void loadProducts()
 void setCategory(Category newCategory)
// THIS IS A SAMPLE FILE ONLY. Get the full content at the link above.

cf1e10b838bf60ee.png Observations

  • 我们的 AppStateModel 提供了一种集中放置应用状态以及使状态在整个应用中可用的方式。在后面的步骤中,我们会使用此状态来推动搜索和购物车功能的实现。

b2f84ff91b0e1396.png 更新 lib/main.dart。在 main() 方法中,初始化该模型。添加标记为“NEW”的代码行。

lib/main.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';             // NEW

import 'app.dart';
import 'model/app_state_model.dart';                 // NEW

void main() {
 return runApp(
   ChangeNotifierProvider<AppStateModel>(            // NEW
     create: (_) => AppStateModel()..loadProducts(), // NEW
     child: CupertinoStoreApp(),                     // NEW
   ),
 );
}

cf1e10b838bf60ee.png Observations

  • 我们准备在微件树的顶部连接 AppStateModel,使其在整个应用中均可用。
  • 我们将使用 provider 软件包中的 ChangeNotifierProvider,它会监控 AppStateModel 以获取更改通知。

b2f84ff91b0e1396.png 运行应用。您应该会看到以下白色屏幕,其中包含 Cupertino 导航栏、一个标题,以及一个带 3 个有标签图标(代表三个标签页)的抽屉式导航栏。您可以在标签页之间切换,但目前这三页都是空白的。

35520995039d98a6.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在此步骤中,您需要在商品列表标签页中显示待售商品。

b2f84ff91b0e1396.png 添加 lib/product_row_item.dart 以显示商品。创建 lib/product_row_item.dart file,其中包含以下内容:

lib/product_row_item.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';
import 'model/product.dart';
import 'styles.dart';

class ProductRowItem extends StatelessWidget {
  const ProductRowItem({
    required this.product,
    required this.lastItem,
  });

  final Product product;
  final bool lastItem;

  @override
  Widget build(BuildContext context) {
    final row = SafeArea(
      top: false,
      bottom: false,
      minimum: const EdgeInsets.only(
        left: 16,
        top: 8,
        bottom: 8,
        right: 8,
      ),
      child: Row(
        children: <Widget>[
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Image.asset(
              product.assetName,
              package: product.assetPackage,
              fit: BoxFit.cover,
              width: 76,
              height: 76,
            ),
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 12),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.start,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    product.name,
                    style: Styles.productRowItemName,
                  ),
                  const Padding(padding: EdgeInsets.only(top: 8)),
                  Text(
                    '\$${product.price}',
                    style: Styles.productRowItemPrice,
                  )
                ],
              ),
            ),
          ),
          CupertinoButton(
            padding: EdgeInsets.zero,
            onPressed: () {
              final model = Provider.of<AppStateModel>(context, listen: false);
              model.addProductToCart(product.id);
            },
            child: const Icon(
              CupertinoIcons.plus_circled,
              semanticLabel: 'Add',
            ),
          ),
        ],
      ),
    );

    if (lastItem) {
      return row;
    }

    return Column(
      children: <Widget>[
        row,
        Padding(
          padding: const EdgeInsets.only(
            left: 100,
            right: 16,
          ),
          child: Container(
            height: 1,
            color: Styles.productRowDivider,
          ),
        ),
      ],
    );
  }
}

cf1e10b838bf60ee.png Observations

  • 我们通过 CupertinoSliverNavigationBar 在导航栏中获取 iOS 11 样式的展开式标题。务必要让 iOS 用户自如地使用该应用。
  • 此文件非常复杂,因为我们模拟了 iOS 应用极其精美的外观和风格。Flutter 的优势在于,我们可以在编辑器中进行这些更改,并且通过 Stateful Hot Reload(保留应用状态的热重载)近乎实时地查看这些更改。

b2f84ff91b0e1396.pnglib/product_list_tab.dart 中,导入 product_row_item.dart 文件。

lib/product_list_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';

import 'model/app_state_model.dart';
import 'product_row_item.dart';      // NEW

b2f84ff91b0e1396.pngProductListTabbuild() 方法中,获取商品列表和商品数量。添加如下所示的新代码行:

class ProductListTab extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return CupertinoPageScaffold(
     child: Consumer<AppStateModel>(
       builder: (context, child, model) {
         final products = model.getProducts();  // NEW
         return CustomScrollView(
           semanticChildCount: products.length, // NEW
           slivers: <Widget>[
             CupertinoSliverNavigationBar(
               largeTitle: const Text('Cupertino Store'),
             ),
           ],
         );
       },
     ),
   );
 }
}

b2f84ff91b0e1396.png 同样,在 build() 方法中,向 sliver 微件列表添加新的 sliver,用于存储商品列表。添加如下所示的新代码行:

class ProductListTab extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        final products = model.getProducts();
        return CustomScrollView(
          semanticChildCount: products.length,
          slivers: <Widget>[
            const CupertinoSliverNavigationBar(
              largeTitle: Text('Cupertino Store'),
            ),
            SliverSafeArea(   // BEGINNING OF NEW CONTENT
              top: false,
              minimum: const EdgeInsets.only(top: 8),
              sliver: SliverList(
                delegate: SliverChildBuilderDelegate(
                  (context, index) {
                    if (index < products.length) {
                      return ProductRowItem(
                        product: products[index],
                        lastItem: index == products.length - 1,
                      );
                    }

                    return null;
                  },
                ),
              ),
            )     // END OF NEW CONTENT
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png Observations

  • 凹口由第一个 sliver (CupertinoSliverNavigationBar) 占用。
  • 新的 sliver 和第一个 sliver 是同级关系(不是父级/子级关系),所以第一个 sliver 没有办法传达它已经占用了凹口。因此,第二个 sliver 将 SliverSafeAreatop 属性设置为 false,这样它就会忽略凹口。
  • SliverSafeArealeftright 属性仍然默认设置为 true,以防手机发生旋转,并且它仍然占用 bottom,以便滚动越过底部的主屏幕栏,避免在滚动到最后时受阻。
  • 由于该应用被限制为仅纵向显示,因此这里并不特别需要这个逻辑,但添加这个逻辑意味着这段代码可以在处理横向演示的应用中安全地重复使用。

b2f84ff91b0e1396.png 运行应用。在商品标签页中,您应该会看到一系列商品,它们都有图片、价格,以及一个用于将相应商品加入购物车的加号按钮。稍后,您将在构建购物车的那一步实现该按钮。

f104a94356854c24.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在此步骤中,您将构建搜索标签页,并添加在商品中执行搜索的功能。

b2f84ff91b0e1396.png 更新 lib/search_tab.dart 中的 import 语句。

为供搜索标签页使用的类添加 import 语句:

lib/search_tab.dart

import 'package:flutter/cupertino.dart'
import 'package:provider/provider.dart'
import 'model/app_state_model.dart'
import 'product_row_item.dart'
import 'search_bar.dart'
import 'styles.dart'

b2f84ff91b0e1396.png 更新 _SearchTabState 中的 build() 方法。

初始化该模型,并将 CustomScrollView 替换为各个搜索和列出组件。

class _SearchTabState extends State<SearchTab> {
// ...

  @override
  Widget build(BuildContext context) {
    final model = Provider.of<AppStateModel>(context);
    final results = model.search(_terms);

    return DecoratedBox(
      decoration: const BoxDecoration(
        color: Styles.scaffoldBackground,
      ),
      child: SafeArea(
        child: Column(
          children: [
            _buildSearchBox(),
            Expanded(
              child: ListView.builder(
                itemBuilder: (context, index) => ProductRowItem(
                  product: results[index],
                  lastItem: index == results.length - 1,
                ),
                itemCount: results.length,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

cf1e10b838bf60ee.png Observations

  • 我们将重新设计 iOS 样式的搜索体验,但我们也可以非常灵活地自定义用户体验。

b2f84ff91b0e1396.png_SearchTabState 类添加支持变量、函数和方法。

其中包括 initState()dispose()_onTextChanged()_buildSearchBox(),如下所示:

class _SearchTabState extends State<SearchTab> {
  late final TextEditingController _controller;
  late final FocusNode _focusNode;
  String _terms = '';

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController()..addListener(_onTextChanged);
    _focusNode = FocusNode();
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _controller.dispose();
    super.dispose();
  }

  void _onTextChanged() {
    setState(() {
      _terms = _controller.text;
    });
  }

  Widget _buildSearchBox() {
    return Padding(
      padding: const EdgeInsets.all(8),
      child: SearchBar(
        controller: _controller,
        focusNode: _focusNode,
      ),
    );
  }    // TO HERE

 @override
 Widget build(BuildContext context) {

cf1e10b838bf60ee.png Observations

  • _SearchTabState 供我们保留特定于搜索的状态。在此实现中,我们需要存储搜索字词,并连接到 AppStateModel,以实现搜索功能。在已实现 API 后端的情况下,就可以通过它为搜索进行网络接入。

b2f84ff91b0e1396.png 添加 SearchBar 类。

创建新文件 lib/search_bar.dartSearchBar 类会处理在商品列表中执行的实际搜索。在该文件中添加以下内容:

lib/search_bar.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'styles.dart';

class SearchBar extends StatelessWidget {
  const SearchBar({
    required this.controller,
    required this.focusNode,
  });

  final TextEditingController controller;
  final FocusNode focusNode;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: Styles.searchBackground,
        borderRadius: BorderRadius.circular(10),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(
          horizontal: 4,
          vertical: 8,
        ),
        child: Row(
          children: [
            const Icon(
              CupertinoIcons.search,
              color: Styles.searchIconColor,
            ),
            Expanded(
              child: CupertinoTextField(
                controller: controller,
                focusNode: focusNode,
                style: Styles.searchText,
                cursorColor: Styles.searchCursorColor,
                decoration: null,
              ),
            ),
            GestureDetector(
              onTap: controller.clear,
              child: const Icon(
                CupertinoIcons.clear_thick_circled,
                color: Styles.searchIconColor,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

cf1e10b838bf60ee.png Observations

  • iOS 中的搜索界面非常有趣,因为它们在实现方面存在相当大的差异。通过 Flutter,您可以快速轻松地调整实现的布局和颜色。

b2f84ff91b0e1396.png 运行应用。选择搜索标签页,然后在文本字段中输入“shirt”。您应该会看到页面中列出了 5 款商品,其名称中都含有“shirt”字样。

6f345bfa17663f9a.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在接下来的三步中,您将创建购物车标签页。在第一步中,您需要添加用于采集客户信息的字段。

b2f84ff91b0e1396.png 更新 lib/shopping_cart_tab.dart 文件。

添加用于构建名称、电子邮件地址和位置字段的私有方法。然后添加 _buildSliverChildBuildDelegate() 方法,用于构建部分界面。

lib/shopping_cart_tab.dart

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  String? Name;    // ADD FROM HERE
  String? email;
  String? location;
  String? pin;
  DateTime dateTime = DateTime.now();

  Widget _buildNameField() {
    return CupertinoTextField(
      prefix: const Icon(
        CupertinoIcons.person_solid,
        color: CupertinoColors.lightBackgroundGray,
        size: 28,
      ),
      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
      clearButtonMode: OverlayVisibilityMode.editing,
      textCapitalization: TextCapitalization.words,
      autocorrect: false,
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(
            width: 0,
            color: CupertinoColors.inactiveGray,
          ),
        ),
      ),
      placeholder: 'Name',
      onChanged: (newName) {
        setState(() {
          name = newName;
        });
      },
    );
  }

  Widget _buildEmailField() {
    return const CupertinoTextField(
      prefix: Icon(
        CupertinoIcons.mail_solid,
        color: CupertinoColors.lightBackgroundGray,
        size: 28,
      ),
      padding: EdgeInsets.symmetric(horizontal: 6, vertical: 12),
      clearButtonMode: OverlayVisibilityMode.editing,
      keyboardType: TextInputType.emailAddress,
      autocorrect: false,
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(
            width: 0,
            color: CupertinoColors.inactiveGray,
          ),
        ),
      ),
      placeholder: 'Email',
    );
  }

  Widget _buildLocationField() {
    return const CupertinoTextField(
      prefix: Icon(
        CupertinoIcons.location_solid,
        color: CupertinoColors.lightBackgroundGray,
        size: 28,
      ),
      padding: EdgeInsets.symmetric(horizontal: 6, vertical: 12),
      clearButtonMode: OverlayVisibilityMode.editing,
      textCapitalization: TextCapitalization.words,
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(
            width: 0,
            color: CupertinoColors.inactiveGray,
          ),
        ),
      ),
      placeholder: 'Location',
    );
  }

  SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
      AppStateModel model) {
    return SliverChildBuilderDelegate(
      (context, index) {
        switch (index) {
          case 0:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildNameField(),
            );
          case 1:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildEmailField(),
            );
          case 2:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildLocationField(),
            );
          default:
          // Do nothing. For now.
        }
        return null;
      },
    );
  }    // TO HERE

cf1e10b838bf60ee.png Observations

  • 与更为传统的界面设计环境相比,Flutter 存在一个关键优势:让您可以利用合适编程语言的全部功能引入抽象。您可以通过添加函数对功能进行分组,或者,如果您想方便地重复使用,可以把它变成一个独立的微件。作为程序员,如何布置功能完全由您自行决定。

b2f84ff91b0e1396.png 更新 _ShoppingCartTabState 类中的 build() 方法。

添加用于调用 _buildSliverChildBuilderDelegate 方法的 SliverSafeArea

  @override
  Widget build(BuildContext context) {
    return Consumer<AppStateModel>(
      builder: (context, model, child) {
        return CustomScrollView(
          slivers: <Widget>[
            const CupertinoSliverNavigationBar(
              largeTitle: Text('Shopping Cart'),
            ),
            SliverSafeArea(
              top: false,
              minimum: const EdgeInsets.only(top: 4),
              sliver: SliverList(
                delegate: _buildSliverChildBuilderDelegate(model),
              ),
            )
          ],
        );
      },
    );
  }
}

cf1e10b838bf60ee.png Observations

  • 有了构建器函数中定义的所有界面,build 方法可以相当之精炼。

b2f84ff91b0e1396.png 运行应用。选择购物车标签页。您应该会看到三个用于收集客户信息的文本字段:

bcb97c1aff65d3d7.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在此步骤中,向购物车添加 CupertinoDatePicker,以便用户可以选择首选的送货日期。

b2f84ff91b0e1396.pnglib/shopping_cart_tab.dart 中添加 import 语句和 const

添加新代码行,如下所示:

lib/shopping_cart_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';            // NEW
import 'package:provider/provider.dart';
import 'model/app_state_model.dart';
import 'styles.dart';                       // NEW

const double _kDateTimePickerHeight = 216;  // NEW

b2f84ff91b0e1396.png_buildDateAndTimePicker() 函数添加到 _ShoppingCartTab 微件中。

按如下所示添加该函数:

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  // ...

  Widget _buildDateAndTimePicker(BuildContext context) {
    // NEW FROM HERE
    return Column(
      children: <Widget>[
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.start,
              children: const <Widget>[
                Icon(
                  CupertinoIcons.clock,
                  color: CupertinoColors.lightBackgroundGray,
                  size: 28,
                ),
                SizedBox(width: 6),
                Text(
                  'Delivery time',
                  style: Styles.deliveryTimeLabel,
                ),
              ],
            ),
            Text(
              DateFormat.yMMMd().add_jm().format(dateTime),
              style: Styles.deliveryTime,
            ),
          ],
        ),
        Container(
          height: _kDateTimePickerHeight,
          child: CupertinoDatePicker(
            mode: CupertinoDatePickerMode.dateAndTime,
            initialDateTime: dateTime,
            onDateTimeChanged: (newDateTime) {
              setState(() {
                dateTime = newDateTime;
              });
            },
          ),
        ),
      ],
    );
  }    // TO HERE

SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
   AppStateModel model) {
  // ...

cf1e10b838bf60ee.png Observations

  • 添加 CupertinoDatePicker 的操作很简单,这让 iOS 用户可以直观地输入日期和时间。

b2f84ff91b0e1396.png 将一个用于构建日期和时间界面的调用添加到 _buildSliverChildBuilderDelegate 函数中。按如下所示添加新代码:

  SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
      AppStateModel model) {
    return SliverChildBuilderDelegate(
      (context, index) {
        switch (index) {
          case 0:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildNameField(),
            );
          case 1:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildEmailField(),
            );
          case 2:
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: _buildLocationField(),
            );
          case 3:                // ADD FROM HERE
            return Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
              child: _buildDateAndTimePicker(context),
            );                   // TO HERE
          default:
          // Do nothing. For now.
        }
        return null;
      },
    );
  }

b2f84ff91b0e1396.png 运行应用。选择购物车标签页。您应该会在用于收集客户信息的文本字段下方看到 iOS 样式的日期选择器:

ecd9ef206f1e86c7.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在此步骤中,您需要增添将选定商品加入购物车的功能,以完成该应用的构建。

b2f84ff91b0e1396.pngshopping_cart_tab.dart 中导入商品软件包。

lib/shopping_cart_tab.dart

import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'model/app_state_model.dart';
import 'model/product.dart';              // NEW
import 'styles.dart';

b2f84ff91b0e1396.png_ShoppingCartTabState 类添加货币格式。

添加标记为“NEW”的代码行:

class _ShoppingCartTabState extends State<ShoppingCartTab> {
  String? name;
  String? email;
  String? location;
  String? pin;
  DateTime dateTime = DateTime.now();
  final _currencyFormat = NumberFormat.currency(symbol: '\$'); // NEW

b2f84ff91b0e1396.png 将商品索引添加到 _buildSliverChildBuilderDelegate 函数中。

添加标记为“NEW”的代码行:

SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(
   AppStateModel model) {
 return SliverChildBuilderDelegate(
   (context, index) {
     final productIndex = index - 4;    // NEW
     switch (index) {
  // ...

b2f84ff91b0e1396.png 在同一函数中,显示欲购商品。

将代码添加到 switch 语句的 default: 部分,如下所示:

switch (index) {
 case 0:
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16),
     child: _buildNameField(),
   );
 case 1:
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16),
     child: _buildEmailField(),
   );
 case 2:
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16),
     child: _buildLocationField(),
   );
 case 3:
   return Padding(
     padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
     child: _buildDateAndTimePicker(context),
   );
 default:                      // NEW FROM HERE
   if (model.productsInCart.length > productIndex) {
     return ShoppingCartItem(
       index: index,
       product: model.getProductById(
           model.productsInCart.keys.toList()[productIndex]),
       quantity: model.productsInCart.values.toList()[productIndex],
       lastItem: productIndex == model.productsInCart.length - 1,
       formatter: _currencyFormat,
     );
   } else if (model.productsInCart.keys.length == productIndex &&
       model.productsInCart.isNotEmpty) {
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 20),
       child: Row(
         mainAxisAlignment: MainAxisAlignment.end,
         children: <Widget>[
           Column(
             crossAxisAlignment: CrossAxisAlignment.end,
             children: <Widget>[
               Text(
                 'Shipping '
                 '${_currencyFormat.format(model.shippingCost)}',
                  style: Styles.productRowItemPrice,
               ),
               const SizedBox(height: 6),
               Text(
                 'Tax ${_currencyFormat.format(model.tax)}',
                 style: Styles.productRowItemPrice,
                ),
                const SizedBox(height: 6),
                Text(
                  'Total ${_currencyFormat.format(model.totalCost)}',
                  style: Styles.productRowTotal,
                ),
              ],
            )
          ],
        ),
      );
    }
}                       // TO HERE

b2f84ff91b0e1396.png 在该文件的底部添加新的 ShoppingCartItem 类:

class ShoppingCartItem extends StatelessWidget {
  const ShoppingCartItem({
    required this.index,
    required this.product,
    required this.lastItem,
    required this.quantity,
    required this.formatter,
  });

  final Product product;
  final int index;
  final bool lastItem;
  final int quantity;
  final NumberFormat formatter;

  @override
  Widget build(BuildContext context) {
    final row = SafeArea(
      top: false,
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.only(
          left: 16,
          top: 8,
          bottom: 8,
          right: 8,
        ),
        child: Row(
          children: <Widget>[
            ClipRRect(
              borderRadius: BorderRadius.circular(4),
              child: Image.asset(
                product.assetName,
                package: product.assetPackage,
                fit: BoxFit.cover,
                width: 40,
                height: 40,
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 12),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: <Widget>[
                        Text(
                          product.name,
                          style: Styles.productRowItemName,
                        ),
                        Text(
                          '${formatter.format(quantity * product.price)}',
                          style: Styles.productRowItemName,
                        ),
                      ],
                    ),
                    const SizedBox(
                      height: 4,
                    ),
                    Text(
                      '${quantity > 1 ? '$quantity x ' : ''}'
                      '${formatter.format(product.price)}',
                      style: Styles.productRowItemPrice,
                    )
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );

    return row;
  }
}

b2f84ff91b0e1396.png 运行应用。在商品标签页中,使用商品右侧的加号按钮选择一些欲购商品。选择购物车标签页。您应该会在日期选择器下方看到购物车中所列的商品:

28201e6fa0dc3102.png

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

恭喜!

您已完成此 Codelab,并构建了一款具有 Cupertino 外观和风格的 Flutter 应用!此外,您还使用 provider 软件包跨屏幕管理了应用状态。如果您有时间,不妨通过我们的状态管理文档详细了解如何管理状态。

其他后续步骤

此 Codelab 为购物体验构建了一个前端,但如果需要真正实现它,下一步就是创建一个可处理用户帐号、商品和购物车等的后端。实现此目标的方式有多种:

了解详情

您可以通过以下链接了解详情: