티스토리 뷰
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)를 설치해야 할 수 있습니다.
새 프로젝트 생성
- VS Code 열기:
F1
또는Ctrl+Shift+P
를 눌러 Command Palette 열기 - Flutter: New Project 입력 후 선택
- 프로젝트 유형으로 Application 선택
- 프로젝트 이름 입력(예:
namer_app
) - 프로젝트 위치 선택
기본 앱 구조 이해하기
프로젝트 파일 살펴보기
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 문을 사용하여 선택된 인덱스에 따라 표시할 위젯 결정
- GeneratorPage와 FavoritesPage로 화면 분리
즐겨찾기 기능 구현
즐겨찾기 추가 및 제거
- 하트 아이콘 버튼을 추가하여 단어 쌍을 즐겨찾기에 추가 또는 제거
- 즐겨찾기 상태에 따라 아이콘 모양 변경
IconButton(
icon: Icon(isFavorited ? Icons.favorite : Icons.favorite_border),
onPressed: toggleFavorite,
),
즐겨찾기 목록 표시
- FavoritesPage에서 즐겨찾기한 단어 쌍을 리스트로 표시
- ListView와 ListTile 위젯을 사용하여 스크롤 가능한 목록 구현
마무리 및 다음 단계
앱 실행 및 테스트
- 다양한 기기에서 앱을 실행하여 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,
),
),
),
);
},
),
);
}
}