데브콘 활동 후기

[GoToLearn] First Flutter App chapter 1 (Go To Learn 1기 - 최정인)

K-DEVCON 2024. 5. 9. 21:47
해당 글은 Go to Learn 1기 최정인께서 작성하신 후기이며, 허락하에 게시하였습니다.
원글 :  https://jungin1020.devdojo.com/gotolearn-1-flutter

 

CREATED MAY 6, 2024

이번에 K-DevCon에서 주최하는 멘토링 프로그램 Go-To-Learn에 참여하게 되었습니다.

Google CodeLabs를 클론코딩하며 각자 공부하고 서로 질문하는 방식인데요,

Flutter 계의 유명인사 쿠로곰님에게 직접 배울 수 있는 좋은 기회이기에 이 시간을 잘 활용해야겠습니다.

학습 소개

버튼을 누르면 단어가 바뀌고 마음에 드는 단어를 저장하는 앱을 만들어보겠습니다!

첫번째 앱인데도 배우는 내용이 제법 알찹니다.

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

5번 반응형으로 만들기에서 제가 알고 있는 방식과 차이점이 있는지 살펴봐야겠습니다.

6번 앱의 일관된 디자인과 분위기 달성에서는 머터리얼 디자인 Theme을 유도하는 것인지 아니면 다른 방식일지는 잘 모르겠지만 저는 위 디자인이 별로 마음에 들지 않기 때문에 전체적인 구조는 유지하되 각 객체의 크기, 색상에 변화를 주겠습니다.


Flutter 환경설정

저는 개발 환경이 다 세팅되어 있어서 빠르게 넘어가려다가 깨알 상식이 있어 적어두려합니다.

Flutter는 다중 플랫폼 도구이기에 다양한 운영체제에서 실행될 수 있습니다.

그러나 단일 운영체제를 선택하고 그 운영체제를 대상으로 개발하는 것이 일반적입니다.

이를 개발 타겟이라고 합니다. 즉 개발하는 동안 앱을 실행할 운영체제입니다.

개발 체제를 웹으로 할 때는 핫 리로드를 사용할 수 없습니다


프로젝트 만들기

프로젝트를 만들고 pubspec.yaml 파일에 english_words, provider 패키지를 추가합니다.

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

main.dart에 MaterialApp을 만들고 Provider를 적용시켜 줍니다.

Provider 적용 전

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Namer App',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueAccent),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [Text('A Random Idea:'), Text(WordPair.random().asLowerCase)],
      ),
    );
  }
}

Provider 적용 후

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

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

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider( //Provider로 감싸줍니다
      create: (BuildContext context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueAccent),
          useMaterial3: true,
        ),
        home: const MyHomePage(),
        debugShowCheckedModeBanner: false,
      ),
    );
  }
}

class MyAppState extends ChangeNotifier {
  final current = WordPair.random(); //MyAppState가 가진 변수들는 상태 관리를 받게 됩니다
}

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

  @override
  Widget build(BuildContext context) {
    final appState = context.watch<MyAppState>(); //상태를 지속적으로 감시하고 변경될 때 UI도 자동으로 변경되는 메서드입니다


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



버튼 추가

Next 버튼을 추가해 새 단어 쌍을 생성합니다.

그 전에 이 앱에서 상태를 관리하는 방식에 대한 설명이 있네요.

class MyAppState extends ChangeNotifier {
  final current = WordPair.random(); //MyAppState가 가진 변수들는 상태 관리를 받게 됩니다
}

MyAppState 클래스는 앱의 상태를 정의합니다. 그리고 ChangeNotifier를 확장합니다. 이는 현재 단어 쌍이 변경되면 MyApp에서 이를 알게되고 MyApp의 일부 위젯에 이를 알려줄 수 있게 되었다는 뜻입니다.

class MyAppState extends ChangeNotifier {
  WordPair current = WordPair.random(); //MyAppState가 가진 변수들는 상태 관리를 받게 됩니다
  
  void getNext() { 
    current = WordPair.random(); //새로운 랜덤단어 한 쌍을 만듭니다
    notifyListeners(); //MyApp에게 알림을 보내는 역할을 합니다
  }
}

MyAppState에 getNext 메서드를 추가합니다.

가이드에는 current를 var로 정의했는데 개인적으로 쓰기 꺼려지는 클래스라 제 멋대로 final로 정의했는데 새로운 랜덤단어를 만드는 코드를 쓴 순간 에러가 나서 WordPair 클래스 변수로 변경했습니다. 생각해보니 final은 실행 중에 값이 고정되는 상수이기 때문에 상태관리를 받아 변경될 수가 없는 친구군요.

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

ElevatedButton에서 getNext 메서드를 호출합니다.

위젯 추출

위 디자인을 만들기 전에 코드의 가독성 및 복잡성 관리를 위해 랜덤 단어를 포함한 텍스트 위젯을 추출하겠습니다. 현재는 appState를 참조하고 있어 위젯 추출을 위한 리팩토링을 하게 되면 이 부분이 같이 추출됩니다. 이를 막기 위해 pair라는 변수로 참조를 끊어줍니다. 그리고 약간의 디자인을 더해줍니다.

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

  @override
  Widget build(BuildContext context) {
    final appState =
        context.watch<MyAppState>(); //상태를 지속적으로 감시하고 변경될 때 UI도 자동으로 변경되는 메서드입니다
    final pair = appState.current; //참조를 끊기 위해 새로 만든 파라미터

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair), //위젯 추출한 부분
            const SizedBox(height: 10.0),
            ElevatedButton(
              onPressed: () {
                debugPrint('button pressed!');
                appState.getNext();
              },
              child: const Text('Next'),
            )
          ],
        ),
      ),
    );
  }
}

//추출된 위젯
class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @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.0),
        child: Text(
          pair.asLowerCase,
          style: style,
          semanticsLabel:
              "${pair.first} ${pair.second}", //스크린리더에 적합한 시멘틱 콘텐츠로 재정의
        ),
      ),
    );
  }
}

Text에서 semanticsLabel라는 속성은 처음 알았네요. 시각장애인 등 스크린리더로 읽는 사람들을 고려한 앱이라면 필수로 알아둬야겠습니다.

기존 Next 버튼 옆에 Like 버튼을 추가합니다. 그런데 이 때 MainAxisAlignment 대신에 MainAixsSize를 사용하네요. 존재는 알고 있었는데 사용하긴 처음입니다. MainAixsSize.min은 자식 위젯의 크기만큼만 공간을 차지한다는 뜻입니다.

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

MyHomePage의 build 필드에 위 로직을 추가해줍니다.

ElevatedButton.icon(
    onPressed: () {
        appState.toggleFavorite();
    },
    icon: Icon(icon),
    label: const Text('Like'),
),

아이콘이 있는 버튼이 ElevatedButton.icon이라는 속성으로 구현되어있네요. 이걸 몰랐더라면 버튼 안에 또 Row를 넣어서 코드가 한층 복잡해질 뻔 했습니다.

탐색레일 추가

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        //Column으로 변경했을 때 에러는 안나지만 흰 화면만 뜸
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: const [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorite'),
                ),
              ],
              selectedIndex: 0,
              onDestinationSelected: (value) {
                debugPrint('selected: $value');
              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: const GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

Center 이하 위젯을 GeneratorPage로 추출하고 NavogationRail을 추가해줍니다. 고정 메뉴를 만들어야할 때 굉장히 유용하겠네요~ 이것도 몰랐으면 하나하나 구현할 뻔 했습니다. 그리고 Expanded에 관한 자세한 설명이 있습니다.

Expanded를 사용하면 NavigationRail은 필요한 만큼만 공간을 차지하고 Expanded 위젯은 남은 공간을 최대한 차지하게 됩니다. 그래서 Expanded 위젯은 탐욕스럽다고 합니다

Stateless & Stateful

가이드에서는 MyAppState에 자잘한 상태들을 추가하는 것을 지양하고 단일 위젯에만 관련있는 상태는 setState를 사용하여 그 안에서 해결하는 것을 추천하고 있습니다. 하긴 맞는 말이네요. 다른 위젯에 알려줄 필요가 없으면 굳이... (라고 생각했는데 쿠로곰님의 코멘트가 있어 첨부합니다.)

Widget Tree를 구성 할 때 최하단 (흔히 저는 말단 이라고 해요) 에 위치한 Widget의 UI를 단순하게 바꿀때 혹은 애니메이션을 구동할 때에는 setState를 사용하고는 있습니다. Bloc 패턴에서도 마찬가지인게 Bloc을 주입 한 이후에 하위 UI 레이어에서 간단하게 바뀌는 UI의 경우에는 사용해도 괜찮지 않을까 싶습니다만, 주의하실 점은 위젯의 재사용성 때문에, 해당 위젯의 하위 위젯으로 상태관리 모델이 처리하는 경우가 생길 땐 이 구조를 제거해야할 필요가 있습니다. setState를 했더니 Bloc 객체가 초기화 되거나 그러는 경우가 이런데서 보통 와요

int selectedIndex = 0;

MyHomePage 필드에 selectecIndex 변수를 추가해주고

onDestinationSelected: (value) {
                setState(() {
                  selectedIndex = value;
                });
              },

아이콘을 눌렀을 때 selectedIndex가 업데이트 되도록 해줍니다. (그런데 어떤 변수를 build 밑에 정의하고 어떤 변수를 build 밖에 정의하는지 아직 좀 헷갈리네요...!) 이 부분에 대해서는 박ㅇ용님과 쿠로곰님이 코멘트를 달아주셨습니다.

build 메서드는 여러번 호출 될 수 있기 때문에 값이 변하면 문제가 생기냐 안생기냐에 따라 넣어도 되고 안되고가 판단될것으로 보입니다. 예를 들어 값이 고정되는 상수같은 경우에는 여러번 빌드된다고 하더라도 값에 변화는 없기때문에 상관없을것이라고 보이고요! 뭔가 랜덤값이나 한번 빌드된 뒤 값이 변하면 안되는 변수같은 경우에는 build 메서드 안에 넣으면 안될것 같다고 생각합니다. 그리고 build 메서드 안에 정의하냐 밖에 정의하냐는 context를 사용하지 않는 변수라면 build 메서드자체도 메서드기 때문에 해당 클래스 내에서 전역적으로 사용할 변수냐 아니냐에 따라 구분될 것 같기도합니다.

사실 Stateless라면 크게 차이는 없으나, 가급적 build 시에 할당 해야하는 함수 혹은 build 와는 관련 없는 함수로 분리해서 생각하면 어느정도 보이더라구요

selectedIndex 사용

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

_MyHomePage의 build 메서드 안에 switch문을 추가해줍니다(가독성 좋은 switch문 사랑입니다...) 그런데 코린이 입장으론 디폴트를 GeneratorPage로 해도 되는 거 아닌가 생각이 들기도 합니다만 가이드에서는 0, 1이 아닌 경우 에러를 띄워주는게 디버깅에 좋다고 하네요. 예를 들면 탐색레일에 새 대상을 추가했는데 해당 페이지와 연결하는 코드를 업데이트하지 않았다던가...

반응형으로

공간이 충분하면 자동으로 라벨을 표시하도록 만들어봅시다

Flutter에는 앱이 자동으로 반응하도록 할 수 있는 위젯들이 여러 개 있습니다. 공간이 충분하지 않을 때 자동으로 다음 줄에 래핑하는 Wrap, 사양에 따라 하위 요소를 사용 가능한 공간에 맞춰주는 FittedBox 등등, 이들에 대해선 기회가 될 때 깊게 알아보도록 하고 이번에는 LayoutBuilder를 사용해보겠습니다.

- LayoutBuilder는 사용할 수 있는 공간에 따라 위젯트리를 변경할 수 있습니다.
- 사용자가 앱의 창 크기를 조절하면 콜백이 호출됩니다.
- 세로 모드, 가로 모드로 회전하면 콜백이 호출됩니다.
- MyHomePage 옆에 있는 일부 위젯의 크기가 커져 MyHomePage의 제약 조건이 작아지면 콜백을 호출합니다?
 
NavigationRail(
     extended: constraints.maxWidth >= 600, //화면 크기에 따라 라벨이 보일지 안보일지 결정

NavigationRail의 extended 속성에 조건을 추가합니다.

FavoritePage 만들기

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

  @override
  Widget build(BuildContext context) {
    final appState = context.watch<MyAppState>();

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

    return ListView.builder(
      itemCount: appState.favorites.length,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text(appState.favorites[index].toString()),
          leading: const Icon(Icons.favorite),
        );
      },
    );
  }
}

가이드에서는 for문을 사용했는데 저는 ListView.builder를 사용해서 만들어봤습니다. 다음 버전에서는 애니메이션 목록, 그라디언트, 크로스 페이드 등 재밌는 기능들을 추가한다고 합니다!!

챕터2에서 계속!