Flutter で Cupertino アプリを作成する

Flutter Cupertino の Codelab へようこそ。

この Codelab では、Flutter を使用して Cupertino(iOS スタイル)アプリを作成します。Flutter SDK には、基本のウィジェット ライブラリに加えて、以下の 2 つのスタイルのウィジェット ライブラリが付属しています。

  • Material ウィジェットは、iOS、Android、ウェブ、パソコン用のマテリアル デザイン言語を実装しています。
  • Cupertino ウィジェットは、Apple のヒューマン インターフェース ガイドラインに基づいて現在の iOS デザイン言語を実装しています。

Cupertino アプリを作成する理由を説明します。マテリアル デザイン言語は、Android だけでなく、あらゆるプラットフォーム向けに作成されました。Flutter でマテリアル アプリを記述すると、iOS を含め、どのデバイスでもマテリアルの外観になります。外観を標準の iOS スタイルのアプリのようにする場合に、Cupertino ライブラリを使用します。

技術的には Android と iOS のどちらでも Cupertino アプリを実行できますが、Android の Cupertino ではライセンスの問題から適切なフォントが使用されません。そのため、Cupertino アプリを作成する際には iOS 固有のデバイスを使用します。

ここでは、商品リスト用、商品検索用、ショッピング カート用の 3 つのタブがある Cupertino スタイルのショッピング アプリを実装します。

f104a94356854c24.png 6f345bfa17663f9a.png

daf61aa9d823646a.png

学習内容

  • iOS スタイルの外観を持った Flutter アプリを開発する方法
  • 複数のタブを作成してタブ間を移動する方法
  • provider パッケージを使用して画面間で状態を管理する方法

この Codelab で学びたいことは次のどれですか?

このトピックは初めてなので、簡単に概要を知りたい。 このトピックについてある程度は知っているが、復習したい。プロジェクトで使用するサンプルコードを確認したい。特定の項目に関する説明を確認したい。

このラボを完了するには、Flutter SDKエディタの 2 つのソフトウェアが必要です。Flutter プラグインと Dart プラグインがインストールされている Android Studio や IntelliJ、Dart Code と Flutter の拡張機能を備えている Visual Studio Code など、任意のエディタを使用できます。

この Codelab は、次のデバイスのいずれかを使用して実行できます。

上記に加え、以下が必要です。

  • Xcode が構成された Mac

CupertinoPageScaffold を使用して最初のアプリを作成します。

b2f84ff91b0e1396.pngcupertino_store という Flutter プロジェクトを作成し、次のように null 安全に移行します。

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

b2f84ff91b0e1396.png lib/main.dart の内容を置き換えます。マテリアル テーマのボタンカウント アプリを作成するコードすべてを lib/main.dart から削除します。次の 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.dart というファイルを lib ディレクトリに追加します。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 確認内容

  • ウェブ デベロッパーがスタイル マークアップを CSS ファイルで一元管理するのと同じように、すべてのスタイル定義を 1 つのファイルにまとめてグループ化して一元管理できます。これにより、アプリ全体でのスタイルの再利用や再定義が簡単になります。

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 パッケージでは、2 種類のページ スキャフォールドが用意されています。CupertinoPageScaffold は、単一のページをサポートし、Cupertino スタイルのナビゲーション バーと背景色に対応し、ページのウィジェット ツリーを保持します。2 番目のスキャフォールドについては、次のステップで説明します。
  • このページにはタイトルがあり、ウィジェット ツリーには空のコンテナが 1 つ含まれています。

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 確認内容

  • これにより、ショップに陳列する商品が入った shrine_images など、複数のパッケージが取得されます。
  • provider パッケージを使用すると、画面間で状態を簡単に管理できます。
  • intl パッケージは、国際化とローカライズの機能を提供しています。
  • cupertino_icons パッケージには、Cupertino ウィジェットのアイコン アセットが含まれています。

b2f84ff91b0e1396.png アプリを実行します。以下のように、白い画面に、Cupertino のナビゲーション バーとタイトルが表示されます。

5705e4da178665a5.png

トラブルシューティング

アプリが正常に動作しない場合は、入力ミスを探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

完成したアプリには、以下の 3 つのタブがあります。

  • 商品リスト
  • 商品検索
  • ショッピング カート

このステップでは、CupertinoTabScaffold を使用して 3 つのタブを持つホームページを更新します。また、販売する商品のリストを写真と価格とともに提供するデータソースも追加します。

前のステップでは、CupertinoPageScaffold を使用して CupertinoStoreHomePage クラスを作成しました。タブのないページには、このスキャフォールドを使用します。最終的なアプリにはタブが 3 つあるので、CupertinoPageScaffoldCupertinoTabScaffold に差し替えます。

Cupertino のタブには別のスキャフォールドがあり、iOS の場合、下部のタブは通常、ページ内ではなくネストされたルートの上に固定されます。

b2f84ff91b0e1396.png lib/app.dart を更新します。CupertinoStoreHomePage クラスを次のように置き換えて、3 つのタブのスキャフォールドを設定します。

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 には少なくとも 2 つのアイテムが必要です。そうなっていない場合、エラーが表示されます。
  • tabBuilder: には、指定されたタブを作成する役割があります。この場合、クラス コンストラクタを呼び出して各タブをセットアップし、3 つすべてを 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 データモデル クラスを作成します。libmodel ディレクトリを作成します。データソースから届く商品データを定義する 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 を配置しています。
  • AppStateModel を監視して変更を通知する、provider パッケージの ChangeNotifierProvider を使用しています。

b2f84ff91b0e1396.png アプリを実行します。Cupertino のナビゲーション バー、タイトル、3 つのタブを表す 3 つのラベル付きアイコンを含むドロワーが、次のように白い画面に表示されます。タブの切り替えはできますが、今のところ 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 の強みは、ステートフル ホットリロードのおかげで、エディタ内でこうした変更を行うと、ほぼリアルタイムで変更を確認できることです。

b2f84ff91b0e1396.png lib/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.png ProductListTabbuild() メソッドで、商品リストと商品数を取得します。次の新しい行を追加します。

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() メソッドで、商品リストを保持するスライバー ウィジェット リストに新しいスライバーを追加します。次の新しい行を追加します。

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

  • ノッチは最初のスライバー(CupertinoSliverNavigationBar)で考慮されています。
  • 新しいスライバーと最初のスライバーは兄弟であって親子ではないため、最初のスライバーがすでにノッチを使っていることを知らせる方法がありません。そのため、2 番目のスライバーが SliverSafeAreatop プロパティを false に設定して、ノッチを無視するようにしています。
  • SliverSafeArealeftright のプロパティは、スマートフォンが回転してもデフォルトの true に設定されます。また、bottom も考慮して、スライバーが最後までスクロールされたときに邪魔にならないように、下部のホームバーを越えてスクロールできるようにします。
  • アプリが縦向きに限定されているため、このロジックは今回は特に必要ではありません。しかし、このようにすれば、横方向の表示を扱うアプリでも安心して再利用できます。

b2f84ff91b0e1396.png アプリを実行します。商品タブに、画像、価格、プラス記号のボタンが付いた商品のリストが表示されます。ボタンをクリックすると、ショッピング カートに商品が追加されます。このボタンは、ショッピング カートを作成するステップで実装します。

f104a94356854c24.png

トラブルシューティング

アプリが正常に動作しない場合は、入力ミスを探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

このステップでは、検索タブを作成して、商品を検索する機能を追加します。

b2f84ff91b0e1396.png lib/search_tab.dart のインポートを更新します。

検索タブで使用するクラスのインポートを追加します。

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 _SearchTabStatebuild() メソッドを更新します。

モデルを初期化し、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.dart を作成します。SearchBar クラスでは、商品リストからの実際の検索を取り扱います。次の内容のファイルを導入します。

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」と入力します。名前に「shirt」を含む 5 つの商品のリストが表示されます。

6f345bfa17663f9a.png

トラブルシューティング

アプリが正常に動作しない場合は、入力ミスを探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

次の 3 つのステップで、ショッピング カート タブを作成します。最初の手順では、顧客情報を記録するフィールドを追加します。

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 アプリを実行しますショッピング カート タブを選択します。顧客情報を入力する 3 つのテキスト フィールドが表示されます。

bcb97c1aff65d3d7.png

トラブルシューティング

アプリが正常に動作しない場合は、入力ミスを探してください。必要に応じて、次のリンクのコードを確認してから、先に進んでください。

このステップでは、ユーザーが配送日を選択できるように CupertinoDatePicker をショッピング カートに追加します。

b2f84ff91b0e1396.png インポートと constlib/shopping_cart_tab.dart に追加します。

次に示す新しい行を追加します。

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 日時の UI を構築する _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.png shopping_cart_tab.dart に product パッケージをインポートします。

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 では、ショッピング エクスペリエンスのフロントエンドを作成しましたが、本物らしくするには次のステップとして、ユーザー アカウント、商品、ショッピング カートなどを処理するためのバックエンドを作成する必要があります。そのための方法には、次のようなものがあります。

参考資料

より詳しい情報については、次のリンクを参照してください。