첫 번째 Flutter 앱

1. 소개

Flutter는 하나의 코드베이스를 사용해 모바일, 웹, 데스크톱을 대상으로 애플리케이션을 빌드하는 Google의 UI 도구 모음입니다. 이 Codelab에서는 다음과 같은 Flutter 애플리케이션을 빌드합니다.

1d26af443561f39c.gif

이 애플리케이션에서는 'newstay'나 'lightstream', 'mainbrake', 'graypine'과 같은 멋진 이름을 생성합니다. 사용자는 다음 이름을 요청하거나 현재 이름을 즐겨찾기에 추가하거나 별도의 페이지에서 즐겨찾기에 추가한 이름 목록을 검토할 수 있습니다. 앱은 다양한 화면 크기에 반응합니다.

학습할 내용

  • Flutter 작동 방식의 기본사항
  • Flutter에서 레이아웃 만들기
  • 버튼 누르기 등 사용자 상호작용을 앱 동작에 연결
  • Flutter 코드 체계적으로 유지
  • 앱을 반응형으로 만들기(다양한 화면에 맞게)
  • 앱의 일관된 디자인과 분위기 달성

바로 흥미로운 부분으로 들어갈 수 있도록 먼저 기본 스캐폴드부터 시작합니다.

d6e3d5f736411f13.png

필립이 전체 Codelab을 안내합니다.

실습을 시작하려면 Next를 클릭하세요.

2. Flutter 환경 설정

편집기

이 Codelab을 최대한 간단하게 만들기 위해 Google에서는 개발자가 Visual Studio Code(VS Code)를 개발 환경으로 사용한다고 가정합니다. 이 편집기는 무료이며 모든 주요 플랫폼에서 작동합니다.

물론 Android 스튜디오, 기타 IntelliJ IDE, Emacs, Vim, Notepad++ 등 원하는 다른 편집기를 사용해도 됩니다. 모두 Flutter와 호환됩니다.

이 Codelab에 VS Code 사용을 권장하는 이유는 안내에 VS Code 관련 단축키가 기본적으로 설정되어 있기 때문입니다. 'X를 실행하려면 편집기에서 적절한 작업을 하세요'라고 하는 것보다는 '여기를 클릭하세요'나 '이 키를 누르세요'라고 하는 것이 더 쉽습니다.

15961a28a4500ac1.png

개발 타겟 선택

Flutter는 다중 플랫폼 도구 모음입니다. 앱이 다음 운영체제 어디서든 실행될 수 있습니다.

  • iOS
  • Android
  • Windows
  • macOS
  • Linux

그러나 단일 운영체제를 선택하고 주로 그 운영체제를 대상으로 개발하는 것이 일반적입니다. 이를 '개발 타겟'이라고 합니다. 즉, 개발하는 동안 앱을 실행할 운영체제입니다.

d105428cb3aae7d5.png

예를 들어 Windows 노트북을 사용하여 Flutter 앱을 개발한다고 가정해 보겠습니다. Android를 개발 타겟으로 선택하면 일반적으로 USB 케이블을 통해 Android 기기를 Windows 노트북에 연결하고 개발 중인 앱은 연결된 Android 기기에서 실행됩니다. 그러나 개발 타겟으로 Windows를 선택할 수도 있습니다. 즉, 개발 중인 앱이 편집기와 함께 Windows 앱으로 실행됩니다.

개발 타겟으로 웹을 선택하고 싶을 수 있습니다. 웹을 선택하면 Flutter의 가장 유용한 개발 기능 중 하나(스테이트풀(Stateful) 핫 리로드)를 사용할 수 없다는 단점이 있습니다. Flutter는 웹 애플리케이션을 핫 리로드할 수 없습니다.

이제 선택하세요. 나중에 언제든지 다른 운영체제에서 앱을 실행할 수 있습니다. 개발 타겟을 분명하게 정해 두면 다음 단계를 원활하게 진행할 수 있습니다.

Flutter 설치

Flutter SDK 설치 방법에 관한 가장 최신 안내는 docs.flutter.dev에서 항상 확인할 수 있습니다.

Flutter 웹사이트에서는 SDK 자체의 설치뿐 아니라 개발 타겟 관련 도구와 편집기 플러그인에 관해서도 안내합니다. 이 Codelab의 경우 다음 항목만 설치하면 됩니다.

  1. Flutter SDK
  2. Flutter 플러그인이 있는 Visual Studio Code
  3. 선택한 개발 타겟에 필요한 소프트웨어(예: Windows를 타겟팅하는 Visual Studio, macOS를 타겟팅하는 Xcode)

다음 섹션에서는 첫 번째 Flutter 프로젝트를 만들어 봅니다.

문제가 있는 경우 문제 해결에 다음과 같은 질문과 답변(StackOverflow에서 제공)이 도움이 될 수 있습니다.

자주 묻는 질문(FAQ)

3. 프로젝트 만들기

첫 번째 Flutter 프로젝트 만들기

Visual Studio Code를 실행하고 명령어 팔레트를 엽니다(F1 또는 Ctrl+Shift+P 또는 Shift+Cmd+P 사용). 'flutter new'를 입력합니다. Flutter: New Project 명령어를 선택합니다.

58e8487afebfc1dd.gif

Application을 선택하고 프로젝트를 만들 폴더를 선택합니다. 홈 디렉터리이거나 C:\src\ 같은 것일 수 있습니다.

이제 프로젝트의 이름을 지정합니다. namer_app 또는 my_awesome_namer를 예로 들 수 있습니다.

260a7d97f9678005.png

이제 Flutter에서 프로젝트 폴더를 생성하고 VS Code에서 이 폴더를 엽니다.

이제 앱의 기본 스캐폴드로 3개 파일의 콘텐츠를 덮어씁니다.

초기 앱 복사 및 붙여넣기

VS Code의 왼쪽 창에서 Explorer가 선택되어 있는지 확인하고 pubspec.yaml 파일을 엽니다.

e2a5bab0be07f4f7.png

이 파일의 콘텐츠를 다음으로 바꿉니다.

pubspec.yaml

name: namer_app
description: A new Flutter project.

publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 0.0.1+1

environment:
  sdk: '>=2.19.4 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  english_words: ^4.0.0
  provider: ^6.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^2.0.0

flutter:
  uses-material-design: true

pubspec.yaml 파일은 앱에 관한 기본 정보(예: 현재 버전, 종속 항목, 함께 제공될 애셋)를 지정합니다.

프로젝트에서 또 다른 구성 파일 analysis_options.yaml을 엽니다.

a781f218093be8e0.png

파일의 콘텐츠를 다음으로 바꿉니다.

analysis_options.yaml

include: package:flutter_lints/flutter.yaml

linter:
  rules:
    prefer_const_constructors: false
    prefer_final_fields: false
    use_key_in_widget_constructors: false
    prefer_const_literals_to_create_immutables: false
    prefer_const_constructors_in_immutables: false
    avoid_print: false

이 파일은 코드를 분석할 때 Flutter의 엄격성 정도를 결정합니다. 이번이 Flutter를 처음 사용하는 것이므로 분석 도구에 쉬엄쉬엄하자고 지시하는 것입니다. 이는 나중에 언제든지 조정할 수 있습니다. 사실 실제 프로덕션 앱을 게시할 때가 가까워지면 분명 이보다는 더 엄격하게 분석 도구를 만들게 됩니다.

이제 lib/ 디렉터리 아래의 main.dart 파일을 엽니다.

e54c671c9bb4d23d.png

이 파일의 콘텐츠를 다음으로 바꿉니다.

lib/main.dart

import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    return Scaffold(
      body: Column(
        children: [
          Text('A random idea:'),
          Text(appState.current.asLowerCase),
        ],
      ),
    );
  }
}

이 50줄의 코드가 지금까지 전체 앱입니다.

다음 섹션에서는 디버그 모드로 애플리케이션을 실행하고 개발을 시작합니다.

4. 버튼 추가

이 단계에서는 Next 버튼을 추가하여 새 단어 쌍을 생성합니다.

앱 실행

먼저 lib/main.dart를 열고 대상 기기가 선택되어 있는지 확인합니다. VS Code 오른쪽 하단에 현재 대상 기기를 보여주는 버튼이 있습니다. 클릭하여 변경합니다.

6c4474b4b5e92ffb.gif

lib/main.dart가 열려 있는 동안 VS Code 오른쪽 상단에서 'play' b0a5d0200af5985d.png 버튼을 찾아 클릭합니다.

9b7598a38a6412e6.gif

1분 정도 지나면 앱이 디버그 모드로 실행됩니다. 아직은 간단한 앱입니다.

f96e7dfb0937d7f4.png

첫 번째 핫 리로드

lib/main.dart 하단에서 첫 번째 Text 객체의 문자열에 무언가를 추가하고 Ctrl+S 또는 Cmd+S를 사용하여 파일을 저장합니다. 예를 들면 다음과 같습니다.

lib/main.dart

// ...

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),  // ← Example change.
          Text(appState.current.asLowerCase),
        ],
      ),
    );

// ...

앱이 즉각적으로 변경되지만 random 단어는 동일하게 유지됩니다. 이는 Flutter의 유명한 스테이트풀(Stateful) 핫 리로드가 작동하는 것입니다. 핫 리로드는 소스 파일에 변경사항을 저장할 때 트리거됩니다.

1b05b00515b3ecec.gif

자주 묻는 질문(FAQ)

버튼 추가

이제 두 번째 Text 인스턴스 바로 아래 Column 하단에 버튼을 추가합니다.

lib/main.dart

// ...

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),
          Text(appState.current.asLowerCase),

          // ↓ Add this.
          ElevatedButton(
            onPressed: () {
              print('button pressed!');
            },
            child: Text('Next'),
          ),

        ],
      ),
    );

// ...

변경사항을 저장하면 앱이 다시 업데이트됩니다. 버튼이 표시되고 이 버튼을 클릭하면 VS Code의 Debug Consolebutton pressed! 메시지가 표시됩니다.

8d86426a01e28011.gif

Flutter 5분 집중 과정

Debug Console을 보는 것이 재미있지만 버튼이 좀 더 의미 있는 작업을 했으면 합니다. 이 작업을 하기 전에 먼저 lib/main.dart의 코드를 자세히 살펴보고 작동 방식을 파악하세요.

lib/main.dart

// ...

void main() {
  runApp(MyApp());
}

// ...

파일 최상단에는 main() 함수가 있습니다. 현재 형식으로는 MyApp에서 정의된 앱을 실행하라고 Flutter에 지시할 뿐입니다.

lib/main.dart

// ...

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

// ...

MyApp 클래스는 StatelessWidget을 확장합니다. 위젯은 모든 Flutter 앱을 빌드하는 데 사용되는 요소입니다. 앱 자체도 위젯인 것을 확인할 수 있습니다.

MyApp의 코드는 전체 앱을 설정합니다. 앱 전체 상태를 생성하고(나중에 자세히 설명) 앱의 이름을 지정하고 시각적 테마를 정의하고 '홈' 위젯(앱의 시작점)을 설정합니다.

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
}

// ...

MyAppState 클래스는 앱의 상태를 정의합니다. 이번이 처음으로 Flutter를 사용하는 것이므로 이 Codelab에서는 코드를 간단하고 명확하게 유지합니다. Flutter에는 앱 상태를 관리하는 강력한 방법이 여러 가지 있습니다. 설명하기 가장 쉬운 것 중 하나는 이 앱에서 사용하는 접근 방식인 ChangeNotifier입니다.

  • MyAppState는 앱이 작동하는 데 필요한 데이터를 정의합니다. 지금은 현재 임의의 단어 쌍이 있는 단일 변수만 포함되어 있습니다. 나중에 더 추가합니다.
  • 상태 클래스는 ChangeNotifier를 확장합니다. 즉, 자체 변경사항에 관해 다른 항목에 알릴 수 있습니다. 예를 들어 현재 단어 쌍이 변경되면 앱의 일부 위젯이 알아야 합니다.
  • 상태가 만들어지고 ChangeNotifierProvider를 사용하여 전체 앱에 제공됩니다(위의 MyApp 코드 참고). 이렇게 하면 앱의 위젯이 상태를 알 수 있습니다. d9b6ecac5494a6ff.png

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {           // ← 1
    var appState = context.watch<MyAppState>();  // ← 2

    return Scaffold(                             // ← 3
      body: Column(                              // ← 4
        children: [
          Text('A random AWESOME idea:'),        // ← 5
          Text(appState.current.asLowerCase),    // ← 6
          ElevatedButton(
            onPressed: () {
              print('button pressed!');
            },
            child: Text('Next'),
          ),
        ],                                       // ← 7
      ),
    );
  }
}

// ...

이제 MyHomePage가 있습니다. 이 위젯은 이미 수정했습니다. 아래 번호가 지정된 각 줄은 위 코드의 줄 번호 주석에 매핑됩니다.

  1. 모든 위젯은 위젯이 항상 최신 상태로 유지되도록 위젯의 상황이 변경될 때마다 자동으로 호출되는 build() 메서드를 정의합니다.
  2. MyHomePagewatch 메서드를 사용하여 앱의 현재 상태에 관한 변경사항을 추적합니다.
  3. 모든 build 메서드는 위젯 또는 중첩된 위젯 트리(좀 더 일반적임)를 반환해야 합니다. 여기서 최상위 위젯은 Scaffold입니다. 이 Codelab에서는 Scaffold를 사용하지 않지만 유용한 위젯이며 대부분의 실제 Flutter 앱에서 찾을 수 있습니다.
  4. Column은 Flutter에서 가장 기본적인 레이아웃 위젯 중 하나입니다. 하위 요소를 원하는 대로 사용하고 이를 위에서 아래로 열에 배치합니다. 기본적으로 열은 시각적으로 하위 요소를 상단에 배치합니다. 열이 중앙에 위치하도록 이를 곧 변경합니다.
  5. 첫 번째 단계에서 이 Text 위젯을 변경했습니다.
  6. 이 두 번째 Text 위젯은 appState를 사용하고 해당 클래스의 유일한 멤버인 current(즉, WordPair)에 액세스합니다. WordPairasPascalCase 또는 asSnakeCase 등 여러 유용한 getter를 제공합니다. 여기서는 asLowerCase를 사용하지만 대안 중 하나가 더 좋다면 지금 변경해도 됩니다.
  7. Flutter 코드에서는 후행 쉼표를 많이 사용합니다. 이 특정 쉼표는 여기 없어도 됩니다. children이 이 특정 Column 매개변수 목록의 마지막 멤버이자 유일한 멤버이기 때문입니다. 그러나 일반적으로 후행 쉼표를 사용하는 것이 좋습니다. 멤버를 더 추가하는 작업이 쉬워지고 Dart의 자동 형식 지정 도구에서 줄바꿈을 추가하도록 힌트 역할을 합니다. 자세한 내용은 코드 형식 지정을 참고하세요.

이제 버튼을 상태에 연결해 봅니다.

첫 번째 동작

MyAppState로 스크롤하여 getNext 메서드를 추가합니다.

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();

  // ↓ Add this.
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }
}

// ...

getNext() 메서드는 임의의 새 WordPaircurrent에 재할당합니다. 또한 MyAppState를 보고 있는 사람에게 알림을 보내는 notifyListeners()(ChangeNotifier)의 메서드)를 호출합니다.

이제 남은 작업은 버튼의 콜백에서 getNext 메서드를 호출하는 것입니다.

lib/main.dart

// ...

    ElevatedButton(
      onPressed: () {
        appState.getNext();  // ← This instead of print().
      },
      child: Text('Next'),
    ),

// ...

저장하고 앱을 실행해 봅니다. Next 버튼을 누를 때마다 임의의 새 단어 쌍이 생성되어야 합니다.

다음 섹션에서는 사용자 인터페이스를 더 멋지게 만듭니다.

5. 더 멋진 앱 만들기

지금 앱의 모습은 다음과 같습니다.

3dd8a9d8653bdc56.png

잘 보이지 않습니다. 앱의 핵심인 임의로 생성된 단어 쌍이 더 잘 보여야 합니다. 사용자가 이 앱을 사용하는 주된 이유이기 때문입니다. 앱 콘텐츠도 너무 한쪽으로 치우쳐 있고 전체 앱도 흰색과 검은색으로 이루어져 따분합니다.

이 섹션에서는 앱의 디자인을 작업하여 이러한 문제를 해결합니다. 이 섹션의 최종 목표는 다음과 같습니다.

2bbee054d81a3127.png

위젯 추출

현재 단어 쌍을 표시하는 줄은 Text(appState.current.asLowerCase)와 같습니다. 좀 더 복잡하게 변경하려면 이 줄을 별도의 위젯으로 추출하는 것이 좋습니다. UI의 개별 논리적 부분을 위한 별도의 위젯을 갖는 것은 Flutter에서 복잡성을 관리하는 중요한 방법입니다.

Flutter는 위젯 추출을 위한 리팩터링 도우미를 제공하지만 이를 사용하기 전에 추출되는 줄이 필요한 항목에만 액세스하는지 확인하세요. 지금은 이 줄이 appState에 액세스하지만 실제로는 현재 단어 쌍이 무엇인지만 알면 됩니다.

따라서 다음과 같이 MyHomePage 위젯을 다시 작성합니다.

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;                 // ← Add this.

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),
          Text(pair.asLowerCase),                // ← Change to this.
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('Next'),
          ),
        ],
      ),
    );
  }
}

// ...

좋습니다. Text 위젯이 더 이상 전체 appState를 참조하지 않습니다.

이제 Refactor 메뉴를 불러옵니다. VS Code에서는 두 가지 방법 중 하나로 이를 실행합니다.

  1. 리팩터링하려는 코드 부분(여기서는 Text)을 마우스 오른쪽 버튼으로 클릭하고 드롭다운 메뉴에서 Refactor...를 선택합니다.

또는

  1. 리팩터링하려는 코드 부분(여기서는 Text)으로 커서를 이동하고 Ctrl+.(Win/Linux) 또는 Cmd+.(Mac)를 누릅니다.

9e18590d82a6900.gif

Refactor 메뉴에서 Extract Widget을 선택합니다. 이름(예: BigCard)을 할당하고 Enter를 클릭합니다.

그러면 자동으로 현재 파일의 끝부분에 새 클래스 BigCard가 생성됩니다. 이 클래스는 다음과 같이 표시됩니다.

lib/main.dart

// ...

class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @override
  Widget build(BuildContext context) {
    return Text(pair.asLowerCase);
  }
}

// ...

이 리팩터링을 실행하는 동안에도 앱은 계속 작동합니다.

카드 추가

이제 이 새 위젯을 이 섹션을 시작할 때 목표로 한 굵게 표시된 UI로 만들어 보겠습니다.

BigCard 클래스와 클래스 내에 있는 build() 메서드를 찾습니다. 이전처럼 Text 위젯에서 Refactor 메뉴를 불러옵니다. 하지만 이번에는 위젯을 추출하지 않습니다.

대신 Wrap with Padding을 선택합니다. 이렇게 하면 Text 위젯 주위에 Padding이라는 새 상위 위젯이 만들어집니다. 저장하면 임의의 단어 주위에 이미 공간이 많이 생긴 것을 확인할 수 있습니다.

6b585b43e4037c65.gif

패딩을 기본값 8.0에서 늘립니다. 예를 들어 20 정도 값을 사용하여 패딩을 널찍하게 만듭니다.

이제 한 단계 올라갑니다. Padding 위젯에 커서를 놓고 Refactor 메뉴를 불러온 다음 Wrap with widget...을 선택합니다.

이렇게 하면 상위 위젯을 지정할 수 있습니다. 'Card'를 입력하고 Enter를 누릅니다.

523425642904374.gif

이렇게 하면 Padding 위젯과 Text 위젯이 Card 위젯으로 래핑됩니다.

6031adbc0a11e16b.png

테마와 스타일

카드를 좀 더 눈에 띄게 만들려면 좀 더 강렬한 색상으로 칠하세요. 항상 색 구성표는 일관되게 유지하는 것이 좋으므로 앱의 Theme을 사용하여 색상을 선택합니다.

BigCardbuild() 메서드를 다음과 같이 변경합니다.

lib/main.dart

// ...

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);       // ← Add this.

    return Card(
      color: theme.colorScheme.primary,    // ← And also this.
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Text(pair.asLowerCase),
      ),
    );
  }

// ...

새로 추가된 이 두 줄은 많은 일을 합니다.

  • 먼저 코드는 Theme.of(context)로 앱의 현재 테마를 요청합니다.
  • 그런 다음, 코드는 테마의 colorScheme 속성과 동일하도록 카드의 색상을 정의합니다. 색 구성표에는 여러 색상이 포함되어 있으며 primary가 앱을 정의하는 가장 두드러진 색상입니다.

카드가 이제 앱의 기본 색상으로 칠해졌습니다.

a136f7682c204ea1.png

MyApp으로 스크롤하고 거기에서 ColorScheme의 시드 색상을 변경하여 이 색상과 전체 앱의 색 구성표를 변경할 수 있습니다.

5bd5a50b5d08f5fb.gif

색상이 매끄럽게 애니메이션 처리됩니다. 이를 암시적 애니메이션이라고 합니다. 많은 Flutter 위젯은 값 간에 부드럽게 보간하므로 UI가 상태 간에 '건너뛰지' 않습니다.

카드 아래 돌출 버튼도 색상이 변경됩니다. 이는 하드 코딩 값이 아닌 앱 전체 Theme을 사용하면 얻게 되는 이점입니다.

TextTheme

카드에는 여전히 문제가 있습니다. 텍스트가 너무 작아 색상을 알아보기 힘듭니다. 이 문제를 해결하려면 BigCardbuild() 메서드를 다음과 같이 변경합니다.

lib/main.dart

// ...

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    // ↓ Add this.
    final style = theme.textTheme.displayMedium!.copyWith(
      color: theme.colorScheme.onPrimary,
    );

    return Card(
      color: theme.colorScheme.primary,
      child: Padding(
        padding: const EdgeInsets.all(20),
        // ↓ Change this line.
        child: Text(pair.asLowerCase, style: style),
      ),
    );
  }

// ...

이 변경사항에는 다음이 포함됩니다.

  • theme.textTheme,을 사용하여 앱의 글꼴 테마에 액세스합니다. 이 클래스에는 bodyMedium(중간 크기의 표준 텍스트용) 또는 caption(이미지 설명용), headlineLarge(큰 헤드라인용) 등의 멤버가 포함되어 있습니다.
  • displayMedium 속성은 디스플레이 텍스트를 위한 큰 스타일입니다. 여기서 디스플레이라는 단어는 디스플레이 서체와 같은 인쇄상의 의미로 사용됩니다. displayMedium 문서에는 '디스플레이 스타일은 짧고 중요한 텍스트용으로 예약되어 있습니다'(여기 사용 사례와 정확히 일치함)라고 나옵니다.
  • 테마의 displayMedium 속성은 이론적으로 null일 수 있습니다. 이 앱을 작성하는 데 사용하는 Dart라는 프로그래밍 언어는 null에 안전하므로 null이 될 수 있는 객체의 메서드를 개발자가 호출할 수 없습니다. 하지만 이 경우 ! 연산자('bang 연산자')를 사용하여 개발자가 잘 알고 하는 작업임을 Dart에 알릴 수 있습니다. displayMedium은 이 경우 null이 아닌 것이 분명하지만 그 이유에 관한 내용은 이 Codelab에서 다루지 않습니다.
  • displayMedium에서 copyWith()를 호출하면 정의된 변경사항이 포함된 텍스트 스타일의 사본이 반환됩니다. 여기서는 텍스트의 색상만 변경합니다.
  • 새로운 색상을 가져오려면 앱의 테마에 다시 액세스해야 합니다. 색 구성표의 onPrimary 속성은 앱의 기본 색상으로 사용하기 적합한 색상을 정의합니다.

이제 앱이 다음과 같이 표시됩니다.

2405e9342d28c193.png

원한다면 카드를 더 변경해 보세요. 다음은 참고할 수 있는 팁입니다.

  • copyWith()를 사용하면 텍스트 스타일에 관해 색상만이 아니라 여러 가지를 변경할 수 있습니다. 변경할 수 있는 전체 속성 목록을 가져오려면 copyWith()의 괄호 안 아무 데나 커서를 두고 Ctrl+Shift+Space(Win/Linux) 또는 Cmd+Shift+Space(Mac)를 누릅니다.
  • 마찬가지로 Card 위젯에 관해서도 여러 가지를 더 변경할 수 있습니다. 예를 들어 elevation 매개변수의 값을 늘려 카드의 그림자를 확대할 수 있습니다.
  • 색상을 여러 가지로 실험해 봅니다. theme.colorScheme.primary 외에도 .secondary, .surface 등 다양하게 많습니다. 이러한 모든 색상에는 상응하는 onPrimary가 있습니다.

접근성 개선

Flutter는 기본적으로 앱의 접근성을 개선합니다. 예를 들어 모든 Flutter 앱은 TalkBack, VoiceOver와 같은 스크린 리더에 앱의 모든 텍스트와 대화형 요소를 올바르게 표시합니다.

96e3f6d9d36615dd.png

하지만 작업이 필요한 경우도 있습니다. 이 앱의 경우 생성된 일부 단어 쌍을 스크린 리더에서 잘 발음하지 못할 수 있습니다. 사람은 cheaphead를 구성하는 두 단어를 쉽게 식별할 수 있지만 스크린 리더는 가운데 phf로 발음할 수 있습니다.

이 문제는 pair.asLowerCase"${pair.first} ${pair.second}"로 바꾸면 간단하게 해결됩니다. 후자는 문자열 보간 유형을 사용하여 pair에 포함된 두 단어에서 문자열(예: "cheap head")을 만듭니다. 복합어 대신 개별 두 단어를 사용하면 스크린 리더에서 두 단어를 적절히 식별할 수 있고 시각장애인 사용자에게 더 나은 환경을 제공할 수 있습니다.

그러나 pair.asLowerCase는 시각적으로 단순하게 유지하는 것이 좋습니다. TextsemanticsLabel 속성을 사용하여 텍스트 위젯의 시각적 콘텐츠를 스크린 리더에 더 적합한 시맨틱 콘텐츠로 재정의합니다.

lib/main.dart

// ...

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final style = theme.textTheme.displayMedium!.copyWith(
      color: theme.colorScheme.onPrimary,
    );

    return Card(
      color: theme.colorScheme.primary,
      child: Padding(
        padding: const EdgeInsets.all(20),

        // ↓ Make the following change.
        child: Text(
          pair.asLowerCase,
          style: style,
          semanticsLabel: "${pair.first} ${pair.second}",
        ),
      ),
    );
  }

// ...

이제 스크린 리더가 생성된 각 단어 쌍을 올바르게 발음하지만 UI는 동일하게 유지됩니다. 기기에서 스크린 리더를 사용하여 실제로 이를 시도해 보세요.

UI 중앙 배치

이제 임의의 단어 쌍이 시각적으로 충분히 잘 표시되므로 이를 앱의 창/화면 중앙에 배치해 보겠습니다.

먼저 BigCardColumn의 일부라는 점을 고려합니다. 기본적으로 열은 하위 요소를 상단으로 일괄 처리하지만 이는 쉽게 재정의할 수 있습니다. MyHomePagebuild() 메서드로 가서 다음과 같이 변경합니다.

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,  // ← Add this.
        children: [
          Text('A random AWESOME idea:'),
          BigCard(pair: pair),
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('Next'),
          ),
        ],
      ),
    );
  }
}

// ...

이렇게 하면 기본(세로) 축을 따라 Column 내 하위 요소가 중앙에 배치됩니다.

b555d4c7f5000edf.png

하위 요소는 이미 열의 교차 축을 따라 중앙에 배치되어 있습니다(즉, 이미 가로로 중앙에 배치되어 있음). 그러나 Column 자체Scaffold 내에서 중앙에 배치되어 있지 않습니다. Widget Inspector를 사용하여 이를 확인할 수 있습니다.

27c5efd832e40303.gif

Widget Inspector 자체는 이 Codelab의 범위에 포함되지 않지만 Column이 강조 표시될 때 앱의 전체 너비를 차지하지 않는 것을 확인할 수 있습니다. 하위 요소에 필요한 가로 공간만큼만 차지합니다.

열 자체만 중앙에 배치하면 됩니다. Column 위에 커서를 두고 Refactor 메뉴를 불러와(Ctrl+. 또는 Cmd+. 사용) Wrap with Center를 선택합니다.

56418a5f336ac229.gif

이제 앱이 다음과 같이 표시됩니다.

455688d93c30d154.png

원한다면 조금 더 조정할 수 있습니다.

  • BigCard 위의 Text 위젯을 삭제할 수 있습니다. 설명 텍스트('A random AWESOME idea:')가 없어도 UI를 이해할 수 있으므로 이 텍스트가 더 이상 필요하지 않다고 주장할 수 있습니다. 삭제하면 더 깔끔하기도 합니다.
  • BigCardElevatedButton 사이에 SizedBox(height: 10) 위젯을 추가할 수도 있습니다. 이렇게 하면 두 위젯을 조금 더 분명하게 구분할 수 있습니다. SizedBox 위젯은 공간만 차지하고 자체적으로는 아무것도 렌더링하지 않습니다. 시각적 '간격'을 만들 때 흔히 사용됩니다.

선택적 변경사항을 적용하면 MyHomePage에는 다음 코드가 포함됩니다.

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            ElevatedButton(
              onPressed: () {
                appState.getNext();
              },
              child: Text('Next'),
            ),
          ],
        ),
      ),
    );
  }
}

// ...

앱은 다음과 같이 표시됩니다.

3d53d2b071e2f372.png

다음 섹션에서는 생성된 단어를 즐겨찾기(또는 '좋아요')에 추가하는 기능을 추가합니다.

6. 기능 추가

앱이 작동하면서 때로는 흥미로운 단어 쌍을 제공하기도 합니다. 그러나 사용자가 Next를 클릭할 때마다 각 단어 쌍이 영원히 사라집니다. 따라서 최고의 추천 단어를 '기억'하는 방법(예: '좋아요' 버튼)이 있으면 더 좋습니다.

e6b01a8c90df8ffa.png

비즈니스 로직 추가

MyAppState로 스크롤하여 다음 코드를 추가합니다.

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();

  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }

  // ↓ Add the code below.
  var favorites = <WordPair>[];

  void toggleFavorite() {
    if (favorites.contains(current)) {
      favorites.remove(current);
    } else {
      favorites.add(current);
    }
    notifyListeners();
  }
}

// ...

변경사항을 검토합니다.

  • MyAppState에 새 속성 favorites를 추가했습니다. 이 속성은 빈 목록([])으로 초기화됩니다.
  • 또한 제네릭을 사용하여 목록에 <WordPair>[] 단어 쌍만 포함될 수 있다고 지정했습니다. 이렇게 하면 앱이 더 강력해집니다. Dart는 개발자가 WordPair 외 다른 것을 추가하려고 하면 앱을 실행하는 것조차 거부합니다. 결국 숨겨져 있는 원치 않는 객체(예: null)가 있을 수 없음을 알고 favorites 목록을 사용할 수 있습니다.
  • 새 메서드 toggleFavorite()도 추가했습니다. 이 메서드는 즐겨찾기 목록에서 현재 단어 쌍을 삭제하거나(이미 있는 경우) 목록에 추가합니다(아직 없는 경우). 두 경우 모두 코드는 이후 notifyListeners();를 호출합니다.

버튼 추가

'비즈니스 로직'을 처리했으므로 이제 사용자 인터페이스를 다시 작업해 보겠습니다. 'Like' 버튼을 'Next' 버튼 왼쪽에 배치하려면 Row를 사용해야 합니다. Row 위젯은 앞서 살펴본 Column에 가로로 대응하는 것입니다.

먼저 기존 버튼을 Row에 래핑합니다. MyHomePagebuild() 메서드로 가서 ElevatedButton에 커서를 두고 Ctrl+. 또는 Cmd+.Refactor 메뉴를 불러온 다음 Wrap with Row를 선택합니다.

7b9d0ea29e584308.gif

저장하면 RowColumn과 유사하게 작동하는 것을 알 수 있습니다. 기본적으로 하위 요소를 왼쪽으로 일괄 처리합니다. Column은 하위 요소를 상단으로 일괄 처리했습니다. 이를 해결하려면 이전과 같은 접근 방식을 사용하면 되지만 mainAxisAlignment와 함께 사용합니다. 그러나 학습 목적으로는 mainAxisSize를 사용하세요. 사용 가능한 모든 가로 공간을 차지하지 말라고 Row에 지시합니다.

다음과 같이 변경합니다.

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            Row(
              mainAxisSize: MainAxisSize.min,   // ← Add this.
              children: [
                ElevatedButton(
                  onPressed: () {
                    appState.getNext();
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ...

UI가 이전 위치로 돌아갑니다.

3d53d2b071e2f372.png

이제 Like 버튼을 추가하고 toggleFavorite()에 연결합니다. 도전해 보고 싶다면 먼저 아래 코드 블록을 보지 않고 직접 이를 시도해 보세요.

e6b01a8c90df8ffa.png

아래와 정확히 동일하게 실행하지 않아도 괜찮습니다. 진짜 어려운 도전을 원하는 경우가 아니라면 사실 하트 아이콘은 신경 쓰지 않아도 됩니다.

또한 실패해도 괜찮습니다. 이번이 처음 Flutter를 사용해 보는 것이니까요.

252f7c4a212c94d2.png

다음은 MyHomePage에 두 번째 버튼을 추가하는 한 가지 방법입니다. 이번에는 ElevatedButton.icon() 생성자를 사용하여 아이콘이 있는 버튼을 만듭니다. build 메서드 상단에서 현재 단어 쌍이 이미 즐겨찾기에 있는지에 따라 적절한 아이콘을 선택하세요. 또한 SizedBox를 다시 사용하여 두 버튼을 약간 떨어뜨립니다.

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    // ↓ Add this.
    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [

                // ↓ And this.
                ElevatedButton.icon(
                  onPressed: () {
                    appState.toggleFavorite();
                  },
                  icon: Icon(icon),
                  label: Text('Like'),
                ),
                SizedBox(width: 10),

                ElevatedButton(
                  onPressed: () {
                    appState.getNext();
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ...

앱이 다음과 같이 표시됩니다.

11981147e3497c77.gif

안타깝게도 사용자는 즐겨찾기를 확인할 수 없습니다. 이제 앱에 완전히 별도의 화면을 추가해야 합니다. 다음 섹션에서 만나요.

7. 탐색 레일 추가

대부분의 앱은 단일 화면에 모든 내용을 맞출 수 없습니다. 이 특정 앱이라면 그럴 수 있겠지만 학습을 위해 사용자의 즐겨찾기를 위한 별도의 화면을 만들어 보겠습니다. 두 화면 간에 전환하려면 첫 번째 StatefulWidget을 구현합니다.

9320e50cad339e7b.png

이 단계의 요점을 최대한 빨리 알아보기 위해 MyHomePage를 별도의 위젯 2개로 분할합니다.

MyHomePage를 모두 선택하여 삭제하고 다음 코드로 바꿉니다.

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: 0,
              onDestinationSelected: (value) {
                print('selected: $value');
              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

class GeneratorPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }

    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          BigCard(pair: pair),
          SizedBox(height: 10),
          Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              ElevatedButton.icon(
                onPressed: () {
                  appState.toggleFavorite();
                },
                icon: Icon(icon),
                label: Text('Like'),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: () {
                  appState.getNext();
                },
                child: Text('Next'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// ...

저장하면 UI가 시각적으로는 완성되어 있지만 작동하지는 않습니다. 탐색 레일의 ♥︎(하트)를 클릭해도 실행되는 작업은 없습니다.

5a5a8e3a04789ce5.png

변경사항을 검토합니다.

  • 먼저 MyHomePage의 전체 콘텐츠가 새 위젯 GeneratorPage로 추출되었습니다. 이전 MyHomePage 위젯에서 유일하게 추출되지 않은 부분은 Scaffold입니다.
  • MyHomePage에는 하위 요소 두 개가 있는 Row가 포함되어 있습니다. 첫 번째 위젯은 SafeArea이고 두 번째 위젯은 Expanded입니다.
  • SafeArea는 하위 요소가 하드웨어 노치나 상태 표시줄로 가려지지 않도록 합니다. 이 앱에서 이 위젯은 NavigationRail를 래핑하여 탐색 버튼이 휴대기기 상태 표시줄로 가려지지 않도록 합니다.
  • NavigationRail의 extended: false 줄을 true로 변경할 수 있습니다. 이렇게 하면 라벨이 아이콘 옆에 표시됩니다. 이후 단계에서는 앱에 충분한 가로 공간이 있을 때 이를 자동으로 실행하는 방법을 알아봅니다.
  • 탐색 레일에는 두 가지 대상(HomeFavorites)이 있으며 각각 아이콘과 라벨이 있습니다. 탐색 레일은 현재 selectedIndex도 정의합니다. 선택된 색인 0은 첫 번째 대상을 선택하고 선택된 색인 1은 두 번째 대상을 선택하며 이런 방식으로 계속 이어집니다. 지금은 0으로 하드 코딩되어 있습니다.
  • 탐색 레일은 또한 사용자가 onDestinationSelected로 대상 중 하나를 선택할 때 발생하는 작업을 정의합니다. 지금은 앱이 print()를 사용하여 요청된 색인을 출력할 뿐입니다.
  • Row의 두 번째 하위 요소는 Expanded 위젯입니다. Expanded 위젯은 행과 열에서 대단히 유용합니다. 이 위젯을 사용하면 일부 하위 요소는 필요한 만큼만 공간을 차지하고(여기서는 NavigationRail) 다른 위젯은 남은 공간을 최대한 차지해야 하는(여기서는 Expanded) 레이아웃을 표현할 수 있습니다. Expanded 위젯은 '탐욕스럽다'고 생각하면 됩니다. 이 위젯의 역할을 좀 더 파악하려면 NavigationRail 위젯을 또 다른 Expanded 위젯으로 래핑해 보세요. 결과 레이아웃이 다음과 같이 표시됩니다.

d80b73b692fb04c5.png

  • 탐색 레일에는 실제로 왼쪽의 작은 공간만이 필요하더라도 두 Expanded 위젯은 두 위젯 사이에 사용 가능한 모든 가로 공간을 분할합니다.
  • Expanded 위젯 내에는 색상이 지정된 Container가 있고 컨테이너 안에는 GeneratorPage가 있습니다.

스테이트리스(Stateless) 위젯과 스테이트풀(Stateful) 위젯

지금까지 MyAppState는 모든 상태 요구사항을 처리했습니다. 지금까지 작성한 모든 위젯이 스테이트리스(Stateless)인 이유입니다. 변경 가능한 자체 상태를 포함하지 않습니다. 어떤 위젯도 스스로 변경할 수 없으며 MyAppState를 거쳐야 합니다.

이러한 점은 곧 변경됩니다.

탐색 레일의 selectedIndex 값을 보관할 방법이 필요합니다. 또한 onDestinationSelected 콜백 내에서 이 값을 변경하려고 합니다.

selectedIndexMyAppState의 또 다른 속성으로 추가할 수 있고 효과도 있겠지만 앱 상태는 모든 위젯에 해당 값이 저장된 경우 이유 없이 빠르게 커질 수 있습니다.

e52d9c0937cc0823.jpeg

일부 상태는 단일 위젯에만 관련되어 있으므로 해당 위젯과 같이 있어야 합니다.

State가 있는 위젯 유형인 StatefulWidget을 입력합니다. 먼저 MyHomePage을 스테이트풀(Stateful) 위젯으로 변환합니다.

MyHomePage의 첫 번째 줄(class MyHomePage...로 시작하는 줄)에 커서를 두고 Ctrl+. 또는 Cmd+.를 사용하여 Refactor 메뉴를 불러옵니다. Convert to StatefulWidget을 선택합니다.

238f98bceeb0de3a.gif

IDE에서 새 클래스 _MyHomePageState를 만듭니다. 이 클래스는 State를 확장하므로 자체 값을 관리할 수 있습니다. 자체적으로 변경할 수 있습니다. 이전 스테이트리스(Stateless) 위젯의 build 메서드는 위젯에 유지되지 않고 _MyHomePageState로 이동했습니다. 글자 그대로 이동했습니다. build 메서드에서 변경된 내용은 없습니다. 위치만 변경되었을 뿐입니다.

setState

새 스테이트풀(Stateful) 위젯은 하나의 변수 selectedIndex만 추적하면 됩니다. 다음과 같이 _MyHomePageState의 세 가지를 변경하세요.

lib/main.dart

// ...

class _MyHomePageState extends State<MyHomePage> {

  var selectedIndex = 0;     // ← Add this property.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: selectedIndex,    // ← Change to this.
              onDestinationSelected: (value) {

                // ↓ Replace print with this.
                setState(() {
                  selectedIndex = value;
                });

              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

// ...

변경사항을 검토합니다.

  1. 새 변수 selectedIndex를 도입하고 0으로 초기화했습니다.
  2. 지금까지 있던 하드 코딩 0 대신 NavigationRail 정의에서 이 새 변수를 사용했습니다.
  3. onDestinationSelected 콜백이 호출되면 새 값을 콘솔로 인쇄하는 대신 setState() 호출 내 selectedIndex에 할당합니다. 이 호출은 이전에 사용한 notifyListeners() 메서드와 유사합니다. UI가 업데이트되는지 확인합니다.

2b31dd91c5ba6766.gif

탐색 레일이 이제 사용자 상호작용에 반응하지만 오른쪽의 확장된 영역은 동일하게 유지됩니다. 코드가 표시할 화면을 결정하는 데 selectedIndex를 사용하지 않기 때문입니다.

selectedIndex 사용

_MyHomePageStatebuild 메서드 상단, return Scaffold 바로 앞에 다음 코드를 배치합니다.

lib/main.dart

// ...

Widget page;
switch (selectedIndex) {
  case 0:
    page = GeneratorPage();
    break;
  case 1:
    page = Placeholder();
    break;
  default:
    throw UnimplementedError('no widget for $selectedIndex');
}

// ...

이 코드를 검토합니다.

  1. 이 코드는 Widget 유형의 새 변수 page를 선언합니다.
  2. 그런 다음, selectedIndex의 현재 값에 따라 switch 문이 화면을 page에 할당합니다.
  3. 아직 FavoritesPage가 없으므로 배치하는 곳마다 교차 사각형을 그려 UI의 해당 부분이 미완성임을 표시하는 편리한 위젯인 Placeholder를 사용합니다.

5685cf886047f6ec.png

  1. fail-fast 원칙을 적용하면 switch 문이 selectedIndex가 0도 1도 아닌 경우 오류가 발생하도록 합니다. 이렇게 하면 향후 있을 버그를 방지할 수 있습니다. 탐색 레일에 새 대상을 추가하고 이 코드를 업데이트하지 않은 경우 프로그램이 개발 중에 다운됩니다(왜 작동하지 않는지 추측하도록 하거나 버그가 있는 코드를 프로덕션 환경으로 게시하도록 하는 대신).

이제 page에 오른쪽에 표시하려는 위젯이 포함되어 있으므로 다른 필요한 변경사항을 추측할 수 있습니다.

다음은 나머지 하나의 변경사항을 적용한 후의 _MyHomePageState입니다.

lib/main.dart

// ...

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }

    return Scaffold(
      body: Row(
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: selectedIndex,
              onDestinationSelected: (value) {
                setState(() {
                  selectedIndex = value;
                });
              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: page,  // ← Here.
            ),
          ),
        ],
      ),
    );
  }
}

// ...

이제 앱이 GeneratorPage와 곧 Favorites 페이지가 될 자리표시자 간에 전환됩니다.

4122ee1c4830e0eb.gif

반응성

이제 탐색 레일을 반응형으로 만듭니다. 즉, 공간이 충분하면 자동으로 라벨을 표시하도록 만듭니다(extended: true 사용).

bef3378cb73f9a40.png

Flutter는 앱이 자동으로 반응하도록 할 수 있는 위젯을 여러 개 제공합니다. 예를 들어 Wrap은 세로 또는 가로 공간이 충분하지 않으면 하위 요소를 자동으로 다음 '줄'에 래핑하는('run'이라고 함) Row 또는 Column과 유사한 위젯입니다. FittedBox도 있습니다. 이 위젯은 사양에 따라 하위 요소를 사용 가능한 공간에 자동으로 맞춥니다.

그러나 NavigationRail은 공간이 충분히 있을 때 자동으로 라벨을 표시하지 않습니다. 모든 컨텍스트에서 충분한 공간이 무엇인지 알 수 없기 때문입니다. 이 결정은 개발자에게 달려 있습니다.

MyHomePage의 너비가 600픽셀 이상일 때만 라벨을 표시한다고 가정해 보겠습니다.

여기서 사용할 위젯은 LayoutBuilder입니다. 이 위젯을 사용하면 사용할 수 있는 공간의 양에 따라 위젯 트리를 변경할 수 있습니다.

이번에도 VS Code에서 Flutter의 Refactor 메뉴를 사용하여 필요한 변경을 실행합니다. 이번에는 좀 더 복잡합니다.

  1. _MyHomePageStatebuild 메서드에서 Scaffold에 커서를 둡니다.
  2. Ctrl+.(Windows/Linux) 또는 Cmd+.(Mac)를 사용하여 Refactor 메뉴를 불러옵니다.
  3. Wrap with Builder를 선택하고 Enter를 누릅니다.
  4. 새로 추가한 BuilderLayoutBuilder로 이름을 수정합니다.
  5. 콜백 매개변수 목록을 (context)에서 (context, constraints)로 수정합니다.

52d18742c54f1022.gif

LayoutBuilderbuilder 콜백은 제약 조건이 변경될 때마다 호출됩니다. 예를 들어 다음과 같은 경우 발생합니다.

  • 사용자가 앱의 창 크기를 조절합니다.
  • 사용자가 휴대전화를 세로 모드에서 가로 모드로 또는 그 반대로 회전합니다.
  • MyHomePage 옆에 있는 일부 위젯의 크기가 커져 MyHomePage의 제약 조건이 작아집니다.
  • 기타 등등

이제 코드에서 현재 constraints를 쿼리하여 라벨을 표시할지 결정할 수 있습니다. _MyHomePageStatebuild 메서드에서 다음과 같이 한 줄을 변경합니다.

lib/main.dart

// ...

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }

    return LayoutBuilder(builder: (context, constraints) {
      return Scaffold(
        body: Row(
          children: [
            SafeArea(
              child: NavigationRail(
                extended: constraints.maxWidth >= 600,  // ← Here.
                destinations: [
                  NavigationRailDestination(
                    icon: Icon(Icons.home),
                    label: Text('Home'),
                  ),
                  NavigationRailDestination(
                    icon: Icon(Icons.favorite),
                    label: Text('Favorites'),
                  ),
                ],
                selectedIndex: selectedIndex,
                onDestinationSelected: (value) {
                  setState(() {
                    selectedIndex = value;
                  });
                },
              ),
            ),
            Expanded(
              child: Container(
                color: Theme.of(context).colorScheme.primaryContainer,
                child: page,
              ),
            ),
          ],
        ),
      );
    });
  }
}

// ...

이제 앱이 화면 크기, 방향, 플랫폼과 같은 환경에 반응합니다. 즉, 앱이 반응형이 되었습니다.

6223bd3e2dc157eb.gif

이제 남은 작업은 Placeholder를 실제 Favorites 화면으로 대체하는 것입니다. 다음 섹션에서 설명합니다.

8. 새 페이지 추가

Favorites 페이지 대신 사용한 Placeholder 위젯을 기억하나요?

4122ee1c4830e0eb.gif

이제 이 문제를 해결해 보겠습니다.

도전해 보고 싶다면 혼자 힘으로 이 단계를 해 보세요. 새 스테이트리스(Stateless) 위젯 FavoritesPagefavorites 목록을 표시하고 Placeholder 대신 해당 위젯을 표시하면 됩니다.

다음은 몇 가지 도움말입니다.

  • 스크롤되는 Column을 원한다면 ListView 위젯을 사용합니다.
  • context.watch<MyAppState>()를 사용하여 위젯에서 MyAppState 인스턴스에 액세스합니다.
  • 새 위젯을 시도해 보려는 경우 ListTile에는 title(일반적으로 텍스트용), leading(아이콘 또는 아바타용), onTap(상호작용용)과 같은 속성이 있습니다. 하지만 이미 아는 위젯을 사용하여 비슷한 효과를 달성할 수 있습니다.
  • Dart를 사용하면 컬렉션 리터럴 내에서 for 루프를 사용할 수 있습니다. 예를 들어 messages에 문자열 목록이 포함되어 있으면 다음과 같은 코드를 사용할 수 있습니다.

f0444bba08f205aa.png

반면 함수 프로그래밍에 더 익숙한 경우 Dart를 사용해 messages.map((m) => Text(m)).toList()와 같은 코드를 작성할 수도 있습니다. 물론 언제든지 위젯 목록을 만들고 build 메서드 내에서 필수적으로 추가할 수 있습니다.

직접 Favorites 페이지를 추가할 때의 장점은 스스로 결정을 내리면서 더 많이 배우게 된다는 것입니다. 단점은 아직 직접 해결할 수 없는 문제가 발생할 수 있다는 것입니다. 실패해도 괜찮으며 실패는 배움에서 가장 중요한 요소 중 하나라는 점을 기억하세요. 첫 시간부터 Flutter 개발에 성공할 거로 기대하는 사람은 없으며 개발자도 이렇게 생각해야 합니다.

252f7c4a212c94d2.png

다음 내용은 즐겨찾기 페이지를 구현하는 한 가지 방법입니다. 구현 방식을 통해 코드 작성에 관한 영감을 받기를 바랍니다. 즉, UI를 개선하고 나만의 UI로 만들어 보세요.

다음은 새 FavoritesPage 클래스입니다.

lib/main.dart

// ...

class FavoritesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    if (appState.favorites.isEmpty) {
      return Center(
        child: Text('No favorites yet.'),
      );
    }

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsets.all(20),
          child: Text('You have '
              '${appState.favorites.length} favorites:'),
        ),
        for (var pair in appState.favorites)
          ListTile(
            leading: Icon(Icons.favorite),
            title: Text(pair.asLowerCase),
          ),
      ],
    );
  }
}

위젯의 역할은 다음과 같습니다.

  • 앱의 현재 상태를 가져옵니다.
  • 즐겨찾기 목록이 비어 있으면 No favorites yet이라는 메시지를 중앙에 표시합니다*.*
  • 목록이 비어 있지 않으면 스크롤 가능한 목록을 표시합니다.
  • 목록은 요약으로 시작됩니다(예: You have 5 favorites*.*).
  • 그런 다음 코드가 모든 즐겨찾기를 반복하고 각 즐겨찾기의 ListTile 위젯을 구성합니다.

이제 남은 작업은 Placeholder 위젯을 FavoritesPage로 바꾸는 것입니다. 완성되었습니다.

1d26af443561f39c.gif

이 앱의 최종 코드는 GitHub의 Codelab 저장소에서 확인할 수 있습니다.

9. 다음 단계

축하합니다.

성공했습니다. Column 하나와 Text 위젯 두 개가 포함된 작동하지 않는 스캐폴드로 재미있는 반응형의 작은 앱을 만들었습니다.

d6e3d5f736411f13.png

학습한 내용

  • Flutter 작동 방식의 기본사항
  • Flutter에서 레이아웃 만들기
  • 버튼 누르기 등 사용자 상호작용을 앱 동작에 연결
  • Flutter 코드 체계적으로 유지
  • 앱을 반응형으로 만들기
  • 앱의 일관된 디자인과 분위기 달성

다음 단계

  • 이 실습에서 작성한 앱으로 더 많이 실험해 봅니다.
  • 동일한 앱의 이 고급 버전 코드를 살펴보고 애니메이션 목록, 그래디언트, 크로스 페이드 등을 추가할 수 있는 방법을 확인합니다.

d4afd1f43ab976f7.gif