Flutter로 Cupertino 앱 빌드

Flutter Cupertino Codelab에 오신 것을 환영합니다.

이 Codelab에서는 Flutter를 사용하여 Cupertino(iOS 스타일) 앱을 만듭니다. Flutter SDK는 기본적인 위젯 라이브러리 외에도 다음과 같은 두 스타일의 위젯 라이브러리와 함께 제공됩니다.

  • 머티리얼 위젯 - iOS, Android, 웹, 데스크톱용 머티리얼 디자인 언어를 구현합니다.
  • Cupertino 위젯 - Apple의 인간 인터페이스 가이드라인에 따라 현재 iOS 디자인 언어를 구현합니다.

Cupertino 앱을 작성하는 이유는 무엇인가요? 머티리얼 디자인 언어는 Android뿐만 아니라 모든 플랫폼을 위해 제작되었습니다. Flutter로 머티리얼 앱을 작성하면 앱이 모든 기기에서, 즉 iOS에서도 머티리얼 디자인의 스타일과 느낌을 갖게 됩니다. 앱이 표준적인 iOS 스타일 앱처럼 보이도록 하려면 Cupertino 라이브러리를 사용하면 됩니다.

기술적으로는 Android 또는 iOS에서 Cupertino 앱을 실행할 수 있지만 (라이선스 문제로 인해) Cupertino는 Android에서 올바른 글꼴을 구현할 수 없습니다. 이러한 이유로 Cupertino 앱 작성 시 iOS 전용 기기를 사용해야 합니다.

3개의 탭, 즉 제품 목록 탭, 제품 검색 탭, 장바구니 탭이 포함된 Cupertino 스타일 쇼핑 앱을 구현합니다.

f104a94356854c24.png 6f345bfa17663f9a.png

daf61aa9d823646a.png

이 Codelab에서 학습할 내용

  • iOS 스타일의 디자인과 느낌이 있는 Flutter 앱을 빌드하는 방법
  • 여러 탭을 만들고 탭 간에 이동하는 방법
  • provider 패키지를 사용하여 화면 간 상태를 관리하는 방법

이 Codelab에서 학습하고 싶은 내용은 무엇인가요?

이 주제를 처음 접하므로 개요를 파악하고 싶습니다. 이 주제에 관해 약간 알고 있지만 한 번 더 확인하고 싶습니다. 프로젝트에 사용할 코드 예를 찾고 있습니다. 특정 항목에 관한 설명을 찾고 있습니다.

이 실습을 완료하려면 Flutter SDK편집기라는 두 가지 소프트웨어가 필요합니다. Flutter 및 Dart 플러그인이 설치된 Android 스튜디오 또는 IntelliJ와 같은 선호하는 편집기나 Dart 코드 및 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의 코드를 모두 삭제합니다. 그리고 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를 만듭니다. lib 디렉터리에 styles.dart라는 파일을 추가합니다. 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 파일에서 스타일 마크업을 중앙집중식으로 처리하는 것과 유사한 방식으로 스타일 정의를 중앙집중식으로 처리할 수 있습니다. 이렇게 하면 앱 전체에서 스타일을 매우 쉽게 재사용하고 재정의할 수 있습니다.

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 유용한 정보

  • 서비스 라이브러리를 가져옵니다. 그러면 앱에서 기기 방향 설정 및 클립보드와 같은 플랫폼 서비스를 사용할 수 있습니다.
  • iOS 사용자가 기대하는 앱을 만드는 데 필요한 테마 설정, 탐색, 텍스트 방향, 기타 기본값을 제공하는 CupertinoApp을 인스턴스화합니다.
  • 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 유용한 정보

  • 이렇게 파일을 업데이트하면 매장을 채우는 제품이 포함된 shrine_images를 비롯한 여러 패키지를 가져옵니다.
  • provider 패키지는 화면 간에 상태를 관리하는 간단한 방법을 제공합니다.
  • intl 패키지는 국제화 및 현지화 기능을 제공합니다.
  • cupertino_icons 패키지에는 Cupertino 위젯의 아이콘 애셋이 포함되어 있습니다.

b2f84ff91b0e1396.png 앱을 실행합니다. 다음과 같이 Cupertino 탐색 메뉴 및 제목이 포함된 흰색 화면이 표시됩니다.

5705e4da178665a5.png

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 정상으로 돌려놓을 수 있습니다.

최종 앱에는 다음과 같은 3개의 탭이 있습니다.

  • 제품 목록
  • 제품 검색
  • 장바구니

이 단계에서는 CupertinoTabScaffold를 사용하여 3개의 탭이 있는 홈페이지를 업데이트합니다. 또한 판매할 항목의 목록을 사진 및 가격과 함께 제공하는 데이터 소스를 추가합니다.

이전 단계에서는 CupertinoPageScaffold를 사용하여 CupertinoStoreHomePage 클래스를 만들었습니다. 탭이 없는 페이지에 이 Scaffold를 사용합니다. 최종 앱에는 3개의 탭이 있습니다. 따라서 CupertinoPageScaffoldCupertinoTabScaffold로 바꿉니다.

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에 2개 이상의 항목이 필요합니다. 그러지 않으면 런타임 시 오류가 표시됩니다.
  • 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 유용한 정보

  • 제품 목록 탭은 스테이트리스(Stateless) 위젯입니다.
  • 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 유용한 정보

  • 사용자가 검색을 실행함에 따라 결과 목록이 변경되므로 검색 탭은 스테이트풀(Stateful) 위젯입니다.

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 유용한 정보

  • 장바구니 탭은 구매 목록과 고객 정보를 유지하므로 스테이트풀(Stateful) 위젯입니다.
  • 이 페이지도 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은 애플리케이션 상태를 중앙집중식으로 처리하고 애플리케이션 전체에서 상태를 사용할 수 있게 하는 방법을 보여줍니다. 이후 단계에서 이 상태를 사용하여 Search 및 Shopping Cart 기능을 구동합니다.

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) 핫 리로드를 활용하여 거의 실시간으로 변경사항을 확인할 수 있다는 점입니다.

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() 메서드에서 새로운 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로 설정되며, sliver가 끝까지 스크롤할 때 방해가 되지 않게 하단 홈 바를 지나 스크롤할 수 있도록 여전히 bottom을 처리합니다.
  • 앱이 세로 모드로만 제한되기 때문에 여기에서 이 로직이 특별히 필요하지는 않지만, 이 로직을 포함하면 가로 표시를 처리하는 앱에서 이 코드를 안전하게 재사용할 수 있습니다.

b2f84ff91b0e1396.png 앱을 실행합니다. Products 탭에 이미지, 가격 그리고 제품을 장바구니에 추가하는 더하기 기호가 있는 버튼과 함께 제품 목록이 표시됩니다. 버튼은 나중에 장바구니를 빌드하는 단계에서 구현됩니다.

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 앱을 실행합니다. Search 탭을 선택하고 텍스트 입력란에 'shirt'를 입력합니다. 이름에 'shirt'가 포함된 제품 5개의 목록이 표시됩니다.

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 앱을 실행합니다. Shopping Cart 탭을 선택합니다. 다음과 같이 고객 정보를 수집하기 위한 텍스트 입력란 3개가 표시됩니다.

bcb97c1aff65d3d7.png

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 정상으로 돌려놓을 수 있습니다.

이 단계에서는 사용자가 원하는 배송일을 선택할 수 있도록 장바구니에 CupertinoDatePicker를 추가합니다.

b2f84ff91b0e1396.png lib/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 함수에 날짜 및 시간 UI를 빌드하는 호출을 추가합니다. 다음과 같이 새 코드를 추가합니다.

  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 앱을 실행합니다. Shopping Cart 탭을 선택합니다. 다음과 같이 고객 정보를 수집하기 위한 텍스트 입력란 아래에 iOS 스타일 날짜 선택 도구가 표시됩니다.

ecd9ef206f1e86c7.png

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 정상으로 돌려놓을 수 있습니다.

이 단계에서는 선택된 항목을 장바구니에 추가하여 앱을 완성합니다.

b2f84ff91b0e1396.png shopping_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 앱을 실행합니다. Products 탭에서 각 항목의 오른쪽에 있는 더하기 기호 버튼을 사용하여 구매할 항목을 몇 개 선택합니다. Shopping Cart 탭을 선택합니다. 다음과 같이 날짜 선택 도구 아래의 장바구니에 방금 선택한 항목이 표시되는 것을 확인할 수 있습니다.

28201e6fa0dc3102.png

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 정상으로 돌려놓을 수 있습니다.

축하합니다.

Codelab을 완료하고 Cupertino 스타일과 느낌의 Flutter 앱을 빌드했습니다. 또한 provider 패키지를 사용하여 화면 간 앱 상태를 관리했습니다. 시간적인 여유가 있을 때 상태 관리에 관해 자세히 알아보려는 경우 상태 관리 문서를 참고하세요.

기타 다음 단계

이 Codelab에서는 쇼핑 환경을 위한 프런트엔드를 빌드했는데, 이 환경을 실제로 구현하기 위한 다음 단계는 사용자 계정, 제품, 장바구니 등을 처리하는 백엔드를 만드는 것입니다. 다음과 같은 여러 방법으로 이 목표를 달성할 수 있습니다.

자세히 알아보기

다음 링크에서 더 자세히 알아볼 수 있습니다.