데브콘 활동 후기

[GoToLearn] Flutter CodeLab - First Flutter App

K-DEVCON 2024. 5. 13. 07:40
해당 글은 Go to Learn 1기 박원용님께서 작성하신 후기이며, 허락하에 게시하였습니다.
원글 :  https://oneyong.tistory.com/5

 

Go To Leran이란?

K-DevCon에서 주최하는 멘토링 프로그램으로 Flutter, BackEnd 등 각 분야의 전문가들의 멘토링을 통해 성장할 수 있도록 도움을 주는 프로그램입니다. 현업에서 Flutter를 사용하고 있기 때문에 Flutter로 지원을 하였고, 운이좋게 멘토링에 참가할 수 있게 되어 감사하게 생각하고 있습니다.

 

Flutter 멘토링 방식은 1주일에 1개의 Flutter CodeLab을 과제로하고 과제를 하면서 막힌 부분이나 이외 궁금한것 무엇이든 자유롭게 질문하는 방식입니다. 5주동안 총 5개의 코드랩을 진행하는데 짧은 시간 같지만 주어진 시간동안 많이 질문하고 배워가야겠습니다.


First Flutter App

1. 소개

First flutter App의 주제는 Next 버튼을 통해 랜덤 단어쌍을 생성하고, 맘에드는 단어쌍이 생성되면 좋아요 버튼을 눌러 저장할 수 있도록 하는 앱입니다.

완성 결과

학습 내용

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

2. Flutter 환경 설정

편집기/Flutter SDK 설치

코드랩에서는 편집기로 Visual Studio Code를 선택하지만 저는 Android Studio가 더 익숙해서 따로 설치는 진행하지 않았습니다. 또한 이미 환경이 구성되어 있어서 환결 설정은 따로 진행하지 않았습니다.

 

환경 설정 관련해서는 공식문서를 따라한다면 충분합니다 !

https://docs.flutter.dev/get-started/install

 

Choose your development platform to get started

Install Flutter and get started. Downloads available for Windows, macOS, Linux, and ChromeOS operating systems.

docs.flutter.dev

개발 타겟 선택

Flutter는 다중 플랫폼 도구라 앱이 여러 운영체제(iOS, Android, Windows, macOS, Linux, Web)에서 실행 될 수 있습니다.

주로 개발할 단일 운영 체제를 선택하는게 일반적이며, 개발 타겟이란 개발 중에 앱이 실행되는 운영체제를 뜻한다고 합니다.

 

또한 처음 알게 된 사실로는 Web은 핫 리로드를 지원하지 않는다고 합니다. 궁금해서 찾아보니 공식 문서에 웹 FAQ도 핫 리로드는 지원하지 않는다고 나와있으며 실제로 돌려봤을때 Hot Reload를 선택해도 `Performing hot restart...`라는 표시와 함께 핫 리스타드가 진행되는것을 확인했습니다.

https://docs.flutter.dev/platform-integration/web/faq

 

Web FAQ

Some gotchas and differences when writing or running web apps in Flutter.

docs.flutter.dev


3. 프로젝트 만들기

첫 번째 Flutter 프로젝트 만들기

CodeLab에서는 VisualStudio로 생성하지만 저는 익숙한 AndroidStudio를 통해 만들어줬습니다.

pubspec.yaml

코드랩에서 제공해주는 pubspec.yaml을 확인해보니 처음보는 패키지인 english_words와 상태관리 패키지인 provider가 추가됨을 확인할 수 있었습니다. english_word를 검색하니 영어 단어와 일부 유틸리티 기능을 포함해주는 패키지라고하네요.

name: flutter_first_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

analysis_options.yaml

analysis_options.yaml은 코드를 분석할 때 Flutter의 엄격성 정도 조정하는 파일로 lints에 걸리는 부분을 무시해도 된다는 설정입니다.

 

linter 규칙은 아래 링크에 자세히 나와 있습니다.

https://dart.dev/tools/linter-rules

 

Linter rules

Details about the Dart linter and its style rules you can choose.

dart.dev

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

4. 버튼 추가

main.dart 동작 방식

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

/// 1. main 함수 실행
void main() {
  /// 2.runApp 함수를 통한 MyApp() 실행
  runApp(MyApp());
}

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

  /// 3. MyApp이 생성되면서 build 메서드 호출
  @override
  Widget build(BuildContext context) {
    /// 4. ChangeNotifierProvider의 create를 통해 MyAppState()를 생성하고
    /// 5. MaterialApp,MyHomePage()를 생성
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

/// 4번 동작에서 MyAppState 생성
class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
}

/// 5번 동작에서 생성
class MyHomePage extends StatelessWidget {

  // 6. MyHomePage 생성과 동시에 build 메서드 호출
  @override
  Widget build(BuildContext context) {
    // 7. 4번에서 생성된 MyAppState 상태를 변수 appState에 초기화
    var appState = context.watch<MyAppState>();

   	// 8. 아래 구조를 가진 위젯 생성
    return Scaffold(
      body: Column(
        children: [
          Text('A random idea:'),
          Text(appState.current.asLowerCase), /// 7번에서 생성된 앱의 현재 상태의 단어 쌍 표출
        ],
      ),
    );
  }
}

버튼 추가

class MyAppState extends ChangeNotifier {
  // Random 단어쌍 생성
  var current = WordPair.random();

  void getNext() {
    // Random 단어쌍 생성
    current = WordPair.random();
    // 변경사항 알림
    notifyListeners();
  }
}
ElevatedButton(
  onPressed: () {
    // MyAppState의 상태 변경
    appState.getNext();
  },
  child: Text('Next'),
),

5. 더 멋진 앱 만들기

위젯 추출

위젯을 추출하기 appState를 참조하지 않고 변수를 넘기는 방식으로 변경합니다.

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current; // 단어쌍 변수 선언

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),
          Text(pair.asLowerCase), // 변수 사용
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('Next'),
          ),
        ],
      ),
    );
  }
}

 

해당 Text 위젯을 추출하여 새로운 위젯을 생성합니다. ('Extract Flutter Widget')

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

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),
          BigCard(pair: pair), // 위젯 추출
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('Next'),
          ),
        ],
      ),
    );
  }
}

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

  final WordPair pair;

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

카드 추가

단어쌍을 큼지막하게 만들기 위해 패딩과 카드를 추가해줍니다.

  @override
  Widget build(BuildContext context) {
    // 카드 위젯 사용
    return Card(
      // 패딩 위젯 사용
      child: Padding(
        // 상하좌우 20의 패딩
        padding: const EdgeInsets.all(20),
        child: Text(pair.asLowerCase),
      ),
    );
  }

테마와 스타일

카드를 눈에 띄게 만들기 위해 강렬한 색상을 칠합니다. 또한 색 구성표는 일관되게 유지되는게 좋아 Theme를 사용합니다.

ColorScheme의 색을 변경하기 위해서는 MyApp의 ColorScheme를 변경하면 됩니다.

 

너무 작은 글자 크기로 색상을 알아보기 힘들어 Text의 style을 정의하고 사용합니다.

  @override
  Widget build(BuildContext context) {
    // 앱의 테마 요청
    final theme = Theme.of(context);
    // theme.textTheme에 접근한 뒤 큰 스타일인 displayMedium을 사용
    final style = theme.textTheme.displayMedium!.copyWith(
      color: theme.colorScheme.onPrimary,
    );

    return Card(
      // 앱의 테마의 컬러 스킴중 가장 두드러진 색깔 사용
      color: theme.colorScheme.primary,
      child: Padding(
        padding: const EdgeInsets.all(20),
        // style 사용
        child: Text(pair.asLowerCase, style: style),
      ),
    );
  }

접근성 개선

Flutter의 모든 앱은 TalkBack, VoiceOver 같은 스크린 리더에 앱의 모든 텍스트와 대화형 요소를 올바르게 표시합니다. 현재 예제의 단어 쌍의 경우 단어가 붙어 있어 스크린 리더가 잘못 발음할 수 있는 경우가 있습니다. 따라서 semanticsLabel를 사용하여 적절히 식별하고 스크린 리더가 잘 발음할 수 있도록 설정해줄 수 있습니다.

child: Text(
  pair.asLowerCase,
  style: style,
  semanticsLabel: "${pair.first} ${pair.second}",
),

UI 중앙 배치

Column위젯과 Center위젯을 사용하여 위젯을 중앙 배치 시킵니다.

또한 아름다운 배치를 위해 중간에 SizeBox를 추가해줍니다.

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

    return Scaffold(
      // Center 사용
      body: Center(
        // Column-중앙정렬
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10), // 사이 공간 지정
            ElevatedButton(
              onPressed: () {
                appState.getNext();
              },
              child: Text('Next'),
            ),
          ],
        ),
      ),
    );
  }
}

6. 기능 추가

좋아요 기능을 추가하여 단어를 저장하는 기능을 추가합니다.

비즈니스 로직 추가

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

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

  // 사용자가 좋아요 버튼을 누른 단어 쌍을 담는 변수
  var favorites = <WordPair>[];

  void toggleFavorite() {
    // 해당 단어쌍이 포함되어 있으면 삭제
    if (favorites.contains(current)) {
      favorites.remove(current);
    } 
    // 단어쌍이 포함되어 있지 않으면 추가
    else {
      favorites.add(current);
    }
    notifyListeners();
  }
}

버튼 추가

 

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

    IconData icon;
    // favorites에 포함된 경우 icon에 색칠된 favorite
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    }
    // favorites에 포함된 경우 icon에 색칠되지 않은 favorite
    else {
      icon = Icons.favorite_border;
    }

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            // Row 위젯으로 가로 배치
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                // ElvatedButton.icon을 통한 아이콘과 버튼 추가
                ElevatedButton.icon(
                  onPressed: () {
                    appState.toggleFavorite();
                  },
                  icon: Icon(icon),
                  label: Text('Like'),
                ),
                SizedBox(width: 10),

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

7. 탐색 레일 추가

대부분의 앱은 한 화면에 모든 내용을 담을 수 없습니다. 따라서 탐색 레일을 사용해서 화면 전환이 가능하도록 변경합니다.

 

기존에 MyHomePage 위젯을 삭제하고 아래 코드를 추가합니다.

 

새로 추가된 코드를 보니 한 Scffold 내에 가로로 배치된 NavigationRail과 GeneratorPage를 배치한 것을 확인할 수 있습니다.

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          // SafeArea - 하위 요소가 하드웨어 노치나 상태 표시줄에 가리지 않도록 함
          SafeArea(
            child: NavigationRail(
              extended: false, // 확장하지 않음
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: 0, // 선택된 Index
              // 탐색 레일 중 하나를 선택할 때 호출되는 함수
              onDestinationSelected: (value) {
                print('selected: $value');
              },
            ),
          ),
          // Expanded - 남은 공간을 전체 사용하는 Widget
          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'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

Stateless Widget, Stateful Widget

현재까지는 모든 위젯을 Stateless 위젯으로 작성하였습니다. 왜냐하면 MyAppState만 가지고 모든 상태 변경 요구사항을 처리할 수 있었기 때문이었는데, 탐색 레일을 사용하는 Widget의 경우에는 변경해야할 상태인 selectedIndex 변수를 다룰 상태가 필요하게 됩니다. MyAppState에 모든 상태를 넣어놔도 되지만, 그렇게 한다면 MyAppState에 너무많은 상태들이 들어갈 수 있고 일부 상태에서는 단일 위젯에만 관련되어 있기 때문에 해당 위젯과 같이 있어야 합니다.

 

따라서 MyHomePage 위젯을 Stateless 위젯에서 Stateful 위젯으로 변경합니다. Stateless 위젯을 Stateful 위젯을 변경할때는 Option + Enter(Android Studio + MAC 환경 기준)을 통해 Convert to StatefulWidget을 사용해서 변경합니다.

 

이후 _MyHomePageState 내에 현재 선택된 Index값을 가지는 변수를 선언하고 새로운 탐색 레일이 선택된 경우 선택된 index값을 해당 변수에 넣어주고 화면을 다시 그리는 setState 함수를 호출합니다.

class _MyHomePageState extends State<MyHomePage> {
  // 선택된 인덱스 속성 추가
  var selectedIndex = 0;

  @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, // 변수 사용
              onDestinationSelected: (value) {
                // 화면 다시 그리기
                setState(() {
                  // 상태 대입
                  selectedIndex = value;
                });
              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

selectedIndex 사용

현재 다시 그리는 함수는 있지만 선택된 Index 값에 따라 페이지를 변경해주는 코드는 존재하지 않아 추가해줍니다.

 

fail-fast 원칙을 사용해서 0과1이 아닌 경우에는 에러가 떨어지도록 추가해줍니다. 에러를 발생시킴으로써 index값이 0,1 이외에 값이 들어오면 어디서 잘못됐는지 확인할 수 있게 됩니다.

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

반응성

탐색레일을 반응형으로 바꿔봅니다. 공간이 충분하다면 탐색 레일을 우측으로 열 수 있도록 변경해줍니다.

이번 예제에서는 너비가 600이상인 경우에만 탐색레일을 펼칠수 있도록 정의해보겠습니다.

제약 조건을 사용하기 위해 LayoutBuilder를 사용해서 600이상인 경우 탐색 레일을 펼치고, 아닌 경우에는 펼치지 않는 식으로 진행합니다.

 

참고: Flutter는 논리 픽셀을 길이 단위로 사용합니다. 기기 독립형 픽셀이라고도 합니다. 8 픽셀 패딩은 앱이 저해상도의 이전 휴대전화에서 실행되든 최신 '레티나' 기기에서 실행되든 동일하게 표시됩니다. 실제 디스플레이에는 논리 픽셀이 센티미터당 약 38개 또는 인치당 약 96개가 있습니다.

 

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(
                // maxWidth가 600이상인 경우에는 NavigationRail을 펼친다.
                extended: constraints.maxWidth >= 600,
                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,
              ),
            ),
          ],
        ),
      );
    });
  }
}

8. 새 페이지 추가

좋아요를 선택한 단어들을 표시하는 페이지를 만들어 봅니다.

// 좋아요를 선택한 단어 페이지
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.'),
      );
    }
    
    // 이외에는 ListView와 ListTile을 사용하여 좋아요 목록 및 개수를 표시합니다.
    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),
          ),
      ],
    );
  }
}

 

이로써 첫 번째 코드랩인 First Flutter App을 완성이 됩니다.


코드랩 후기

Flutter를 2년 넘게 하면서 코드랩 자체는 처음 해봤습니다. 설명이 엄청 자세하게 되어 있어 막히는 부분은 없었는데 평소에 잘 쓰지 않은 위젯이나 옵션, 개념에 대해서 알 수 있는 의미있는 시간을 가진 것 같습니다. 앞으로 남은 코드랩도 열심히 참여해봐야겠습니다 !

실습 코드

https://github.com/wonyong-park/flutter_codelabs/tree/main/flutter_first_app

 

flutter_codelabs/flutter_first_app at main · wonyong-park/flutter_codelabs

flutter_codelabs_repo. Contribute to wonyong-park/flutter_codelabs development by creating an account on GitHub.

github.com