티스토리 뷰

Flutter를 처음 접하셨나요? 이번 포스트에서는 Google의 공식 Codelab과 함께 Flutter로 첫 번째 앱을 만들어보는 과정을 상세히 안내해드리겠습니다. 동영상 튜토리얼과 함께하면 더욱 쉽게 따라오실 수 있습니다!

 

Flutter 소개

Flutter는 Google에서 개발한 오픈 소스 UI 소프트웨어 개발 키트(SDK)로, 하나의 코드베이스로 iOS, Android, 웹, 데스크톱 애플리케이션을 개발할 수 있습니다. 빠른 개발 속도와 아름다운 UI를 제공하여 많은 개발자들에게 사랑받고 있습니다.

개발 환경 설정

필요한 도구 설치하기

  • Flutter SDK: 공식 사이트에서 다운로드
  • Visual Studio Code(VS Code): 가벼운 코드 편집기로 Flutter 개발에 적합
  • 플러그인 설치: VS Code에서 Flutter 및 Dart 플러그인 설치

개발 대상 선택하기

  • Windows, macOS, Linux: 데스크톱 애플리케이션 개발
  • Android, iOS: 모바일 애플리케이션 개발
  • Web: 웹 애플리케이션 개발

개발 환경 설정에 따라 필요한 추가 도구(예: Android Studio, Xcode)를 설치해야 할 수 있습니다.

새 프로젝트 생성

  1. VS Code 열기: F1 또는 Ctrl+Shift+P를 눌러 Command Palette 열기
  2. Flutter: New Project 입력 후 선택
  3. 프로젝트 유형으로 Application 선택
  4. 프로젝트 이름 입력(예: namer_app)
  5. 프로젝트 위치 선택

기본 앱 구조 이해하기

프로젝트 파일 살펴보기

  • pubspec.yaml: 프로젝트 메타데이터와 의존성 관리
  • lib/main.dart: 앱의 진입점이며 주요 코드가 위치

코드 분석

  • main() 함수: Flutter 앱의 시작점이며 runApp(MyApp()) 호출
  • MyApp 위젯: 앱의 전반적인 테마와 라우팅 설정
  • MyHomePage 위젯: 메인 화면의 UI 구성

버튼 추가 및 기능 구현

버튼 추가하기

ElevatedButton(
  onPressed: () {
    print('Button pressed');
  },
  child: Text('Next'),
),
  • ElevatedButton: Material Design 스타일의 버튼 위젯
  • onPressed: 버튼이 눌렸을 때 실행될 콜백 함수
  • child: 버튼 내부에 표시될 위젯(예: Text, Icon)

기능 구현

  • 버튼을 눌렀을 때 콘솔에 메시지가 출력되도록 설정
  • 추후 기능 확장을 위해 콜백 함수 내에 로직 추가 예정

UI 개선하기

텍스트 스타일링

  • TextStyle을 사용하여 글꼴, 크기, 색상 변경
Text(
  'Random Idea',
  style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),

위젯 구조 변경

  • Padding, Center, Column 등을 사용하여 레이아웃 조정
  • Card 위젯으로 아이템을 카드 형태로 표현

위젯 추출

  • 반복되는 UI 요소를 별도의 위젯으로 추출하여 코드 재사용성 향상
  • VS Code에서 Extract Widget 기능 활용

상태 관리와 상태 유지

StatefulWidget 사용

  • 상태가 변하는 위젯을 만들기 위해 StatefulWidget으로 변경
  • setState() 함수를 통해 상태 변경 시 UI 업데이트

상태 저장하기

  • 현재 단어 쌍을 저장할 변수 추가
  • 즐겨찾기 목록을 저장할 리스트 변수 추가

네비게이션 추가하기

NavigationRail 사용

  • 좌측에 네비게이션 레일 추가하여 화면 전환 구현
  • selectedIndex를 사용하여 선택된 메뉴 상태 관리
  • onDestinationSelected 콜백으로 메뉴 선택 시 동작 정의

페이지 전환

  • switch 문을 사용하여 선택된 인덱스에 따라 표시할 위젯 결정
  • GeneratorPageFavoritesPage로 화면 분리

즐겨찾기 기능 구현

즐겨찾기 추가 및 제거

  • 하트 아이콘 버튼을 추가하여 단어 쌍을 즐겨찾기에 추가 또는 제거
  • 즐겨찾기 상태에 따라 아이콘 모양 변경
IconButton(
  icon: Icon(isFavorited ? Icons.favorite : Icons.favorite_border),
  onPressed: toggleFavorite,
),

즐겨찾기 목록 표시

  • FavoritesPage에서 즐겨찾기한 단어 쌍을 리스트로 표시
  • ListViewListTile 위젯을 사용하여 스크롤 가능한 목록 구현

마무리 및 다음 단계

앱 실행 및 테스트

  • 다양한 기기에서 앱을 실행하여 UI 및 기능 확인
  • Hot Reload를 활용하여 빠른 개발 사이클 유지

추가 개선 사항

  • 반응형 디자인: 화면 크기에 따라 레이아웃이 적절히 변경되도록 설정
  • 테마 커스터마이징: 색상 및 글꼴을 변경하여 앱의 분위기 조정
  • 접근성 향상: Semantics 위젯을 사용하여 스크린 리더 지원 강화

 

https://codelabs.developers.google.com/codelabs/flutter-codelab-first?hl=ko#0

 

첫 번째 Flutter 앱  |  Google Codelabs

이 Codelab에서는 멋진 이름을 무작위로 생성하는 Flutter 앱을 빌드하는 방법을 알아봅니다.

codelabs.developers.google.com

 

// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables

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();
  var history = <WordPair>[];

  GlobalKey? historyListKey;

  void getNext() {
    history.insert(0, current);
    var animatedList = historyListKey?.currentState as AnimatedListState?;
    animatedList?.insertItem(0);
    current = WordPair.random();
    notifyListeners();
  }

  var favorites = <WordPair>[];

  void toggleFavorite([WordPair? pair]) {
    pair = pair ?? current;
    if (favorites.contains(pair)) {
      favorites.remove(pair);
    } else {
      favorites.add(pair);
    }
    notifyListeners();
  }

  void removeFavorite(WordPair pair) {
    favorites.remove(pair);
    notifyListeners();
  }
}

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

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

  @override
  Widget build(BuildContext context) {
    var colorScheme = Theme.of(context).colorScheme;

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

    // The container for the current page, with its background color
    // and subtle switching animation.
    var mainArea = ColoredBox(
      color: colorScheme.surfaceVariant,
      child: AnimatedSwitcher(
        duration: Duration(milliseconds: 200),
        child: page,
      ),
    );

    return Scaffold(
      body: LayoutBuilder(
        builder: (context, constraints) {
          if (constraints.maxWidth < 450) {
            // Use a more mobile-friendly layout with BottomNavigationBar
            // on narrow screens.
            return Column(
              children: [
                Expanded(child: mainArea),
                SafeArea(
                  child: BottomNavigationBar(
                    items: [
                      BottomNavigationBarItem(
                        icon: Icon(Icons.home),
                        label: 'Home',
                      ),
                      BottomNavigationBarItem(
                        icon: Icon(Icons.favorite),
                        label: 'Favorites',
                      ),
                    ],
                    currentIndex: selectedIndex,
                    onTap: (value) {
                      setState(() {
                        selectedIndex = value;
                      });
                    },
                  ),
                )
              ],
            );
          } else {
            return Row(
              children: [
                SafeArea(
                  child: 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: mainArea),
              ],
            );
          }
        },
      ),
    );
  }
}

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: [
          Expanded(
            flex: 3,
            child: HistoryListView(),
          ),
          SizedBox(height: 10),
          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'),
              ),
            ],
          ),
          Spacer(flex: 2),
        ],
      ),
    );
  }
}

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

  final WordPair pair;

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

    return Card(
      color: theme.colorScheme.primary,
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: AnimatedSize(
          duration: Duration(milliseconds: 200),
          // Make sure that the compound word wraps correctly when the window
          // is too narrow.
          child: MergeSemantics(
            child: Wrap(
              children: [
                Text(
                  pair.first,
                  style: style.copyWith(fontWeight: FontWeight.w200),
                ),
                Text(
                  pair.second,
                  style: style.copyWith(fontWeight: FontWeight.bold),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}

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

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

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.all(30),
          child: Text('You have '
              '${appState.favorites.length} favorites:'),
        ),
        Expanded(
          // Make better use of wide windows with a grid.
          child: GridView(
            gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
              maxCrossAxisExtent: 400,
              childAspectRatio: 400 / 80,
            ),
            children: [
              for (var pair in appState.favorites)
                ListTile(
                  leading: IconButton(
                    icon: Icon(Icons.delete_outline, semanticLabel: 'Delete'),
                    color: theme.colorScheme.primary,
                    onPressed: () {
                      appState.removeFavorite(pair);
                    },
                  ),
                  title: Text(
                    pair.asLowerCase,
                    semanticsLabel: pair.asPascalCase,
                  ),
                ),
            ],
          ),
        ),
      ],
    );
  }
}

class HistoryListView extends StatefulWidget {
  const HistoryListView({Key? key}) : super(key: key);

  @override
  State<HistoryListView> createState() => _HistoryListViewState();
}

class _HistoryListViewState extends State<HistoryListView> {
  /// Needed so that [MyAppState] can tell [AnimatedList] below to animate
  /// new items.
  final _key = GlobalKey();

  /// Used to "fade out" the history items at the top, to suggest continuation.
  static const Gradient _maskingGradient = LinearGradient(
    // This gradient goes from fully transparent to fully opaque black...
    colors: [Colors.transparent, Colors.black],
    // ... from the top (transparent) to half (0.5) of the way to the bottom.
    stops: [0.0, 0.5],
    begin: Alignment.topCenter,
    end: Alignment.bottomCenter,
  );

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

    return ShaderMask(
      shaderCallback: (bounds) => _maskingGradient.createShader(bounds),
      // This blend mode takes the opacity of the shader (i.e. our gradient)
      // and applies it to the destination (i.e. our animated list).
      blendMode: BlendMode.dstIn,
      child: AnimatedList(
        key: _key,
        reverse: true,
        padding: EdgeInsets.only(top: 100),
        initialItemCount: appState.history.length,
        itemBuilder: (context, index, animation) {
          final pair = appState.history[index];
          return SizeTransition(
            sizeFactor: animation,
            child: Center(
              child: TextButton.icon(
                onPressed: () {
                  appState.toggleFavorite(pair);
                },
                icon: appState.favorites.contains(pair)
                    ? Icon(Icons.favorite, size: 12)
                    : SizedBox(),
                label: Text(
                  pair.asLowerCase,
                  semanticsLabel: pair.asPascalCase,
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}