Ch01. Flutter 기초 - 위젯부터 인스타 클론까지

Flutter의 기본 위젯, 레이아웃, 상태관리, 네비게이션, 테마 설정을 익히고, 마지막에 인스타그램 클론 UI를 만들어본 기록

iOS 개발하다가 Flutter 시작한 지 얼마 안 됐는데, 처음부터 정리해보려고 한다. Ch01은 기본 위젯부터 시작해서 최종적으로 인스타그램 클론 UI까지 만드는 과정이다.

Container - 가장 기본적인 박스

SwiftUI에서 .background, .border, .shadow 이런 modifier 조합으로 스타일링하는 것과 비슷하게, Flutter에서는 Container + BoxDecoration으로 처리한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Container(
  width: 300,
  height: 300,
  decoration: BoxDecoration(
    color: Colors.pink.shade50,
    border: Border.all(color: Colors.red, width: 5),
    borderRadius: BorderRadius.circular(10),
    boxShadow: [
      BoxShadow(color: Colors.black, offset: Offset(6, 6))
    ],
  ),
  child: Center(
    child: Container(
      color: Colors.yellow,
      padding: EdgeInsets.symmetric(horizontal: 20),
      margin: EdgeInsets.symmetric(horizontal: 10),
      child: Text('Hello Container'),
    ),
  ),
)

SwiftUI에서는 padding이 modifier 체이닝 순서에 따라 안/밖이 달라지는데, Flutter는 paddingmargin이 명확히 분리돼 있어서 오히려 직관적인 편이다.

레이아웃 - Column, Row, Flexible

SwiftUI의 VStack, HStack이 Flutter에서는 Column, Row다. 거의 1:1 대응이라 어색하지 않았다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Container(width: 100, height: 80, color: Colors.red),
        Container(width: 100, height: 80, color: Colors.blue),
        Container(width: 100, height: 80, color: Colors.green),
      ],
    ),
    Container(width: 300, height: 90, color: Colors.grey),
  ],
)

비율 배치는 Flexibleflex 속성으로 한다. SwiftUI의 .frame(maxHeight: .infinity) 같은 것보다 깔끔함.

1
2
3
4
5
6
7
8
Column(
  children: [
    Flexible(flex: 1, child: Container(color: Colors.red)),
    Flexible(flex: 2, child: Container(color: Colors.blue)),
    Flexible(flex: 3, child: Container(color: Colors.green)),
    Flexible(flex: 4, child: Container(color: Colors.yellow)),
  ],
)

1:2:3:4 비율로 화면을 나눠줌. ExpandedFlexiblefit: FlexFit.tight 버전이라 남은 공간을 무조건 채운다.

Stack - 위젯 겹치기

SwiftUI의 ZStack이 Flutter에서는 Stack이다. 자식 위젯의 위치는 Align이나 Positioned로 잡는다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Stack(
  children: [
    Container(width: 500, height: 500, color: Colors.black),
    Container(width: 400, height: 400, color: Colors.red),
    Container(width: 300, height: 300, color: Colors.blue),
    Align(
      alignment: Alignment.topRight,
      child: Container(width: 200, height: 200, color: Colors.green),
    ),
  ],
)

Positionedtop, left, right, bottom 값으로 절대 위치를 지정하고, Align은 상대 위치를 지정하는 차이가 있다.

StatelessWidget vs StatefulWidget

SwiftUI에서는 @State로 상태를 선언하면 끝인데, Flutter는 StatelessWidgetStatefulWidget이 명확히 나뉜다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 상태 없는 위젯 - 한 번 그리면 끝
class ExampleStateless extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(color: Colors.red);
  }
}

// 상태 있는 위젯 - setState로 업데이트
class ExampleStateful extends StatefulWidget {
  final int index;
  const ExampleStateful({required this.index});

  @override
  State<ExampleStateful> createState() => _ExampleStatefulState();
}

class _ExampleStatefulState extends State<ExampleStateful> {
  late int _index;

  @override
  void initState() {
    super.initState();
    _index = widget.index;
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() {
          _index = _index == 5 ? 0 : _index + 1;
        });
      },
      child: Container(
        color: Colors.blue,
        child: Center(child: Text('index: $_index')),
      ),
    );
  }
}

처음에 setState 쓰는 게 좀 번거로웠는데, SwiftUI의 @State가 내부적으로 해주는 걸 Flutter는 직접 명시하는 거라고 생각하니까 이해됐다. initState는 SwiftUI의 .onAppear, dispose.onDisappear랑 비슷한 라이프사이클이다.

입력 위젯들

Checkbox, Radio, Slider, Switch 같은 기본 입력 위젯들도 전부 StatefulWidget + setState 패턴이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Slider 예제
class TestSlider extends StatefulWidget {
  @override
  State<TestSlider> createState() => _TestSliderState();
}

class _TestSliderState extends State<TestSlider> {
  double value = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$value'),
        Slider(
          value: value,
          onChanged: (newValue) => setState(() => value = newValue),
          divisions: 100,
          activeColor: Colors.red,
        ),
      ],
    );
  }
}

iOS 스타일을 쓰고 싶으면 CupertinoSwitch, CupertinoContextMenu 같은 Cupertino 위젯도 있다. Material이랑 Cupertino를 섞어서 쓸 수 있는 게 Flutter의 장점인듯.

네비게이션 - GoRouter

Flutter의 기본 Navigator도 있지만, go_router 패키지가 훨씬 편하다. SwiftUI의 NavigationStack + .navigationDestination과 비슷한 느낌.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
MaterialApp.router(
  routerConfig: GoRouter(
    initialLocation: '/',
    routes: [
      GoRoute(
        path: '/',
        name: 'home',
        builder: (context, _) => const HomeWidget(),
      ),
      GoRoute(
        path: '/new',
        name: 'new',
        builder: (context, _) => const NewPage(),
      ),
    ],
  ),
)

화면 이동은 context.pushNamed('new'), 뒤로가기는 context.pop(). 직관적이다.

BottomNavigationBar는 SwiftUI의 TabView와 같은 역할인데, currentIndexonTap으로 탭 전환을 직접 관리한다.

1
2
3
4
5
6
7
8
BottomNavigationBar(
  currentIndex: index,
  onTap: (newIndex) => setState(() => index = newIndex),
  items: [
    BottomNavigationBarItem(icon: Icon(Icons.home_filled), label: '홈'),
    BottomNavigationBarItem(icon: Icon(Icons.search), label: '검색'),
  ],
)

ThemeData

앱 전체 스타일을 한 곳에서 관리하는 건 SwiftUI랑 비슷하다. ColorScheme이랑 TextTheme을 설정해두면 Theme.of(context)로 어디서든 가져다 쓸 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
    textTheme: TextTheme(
      bodyMedium: TextStyle(fontWeight: FontWeight.normal, fontSize: 28),
    ),
  ),
)

// 사용할 때
final textTheme = Theme.of(context).textTheme;
Text('Press Count', style: textTheme.bodyMedium);

인스타그램 클론 - 배운 거 총동원

Ch01 마무리로 인스타그램 클론 UI를 만들었다. 위에서 배운 거 거의 다 써먹은 프로젝트다.

구조

1
2
3
4
5
6
lib/
├── main.dart          // 앱 진입점, 테마, 하단 탭
├── body.dart          // 탭별 화면 라우팅
└── screen/
    ├── home_screen.dart   // 피드, 스토리
    └── search_screen.dart // 검색, 그리드

메인 - 테마와 탭 바

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
MaterialApp(
  home: const InstaCloneHome(),
  theme: ThemeData(
    colorScheme: const ColorScheme.light(
      primary: Colors.white,
      secondary: Colors.black,
    ),
    bottomNavigationBarTheme: const BottomNavigationBarThemeData(
      showSelectedLabels: false,
      showUnselectedLabels: false,
      selectedItemColor: Colors.black,
    ),
  ),
)

인스타그램 특유의 흰 배경 + 검정 아이콘 스타일을 ColorScheme으로 잡았다. AppBar에는 google_fonts 패키지로 Lobster Two 폰트를 적용했다.

스토리 영역 - 가로 스크롤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class StoryArea extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: Row(
        children: List.generate(
          10,
          (index) => UserStory(userName: 'User $index'),
        ),
      ),
    );
  }
}

SwiftUI에서 ScrollView(.horizontal) 안에 HStack 넣는 것과 같은 패턴. List.generate로 더미 데이터를 만들어서 넣었다.

피드 리스트

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class FeedData {
  final String userName;
  final int likeCount;
  final String content;
  const FeedData({required this.userName, required this.likeCount, required this.content});
}

class FeedList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      shrinkWrap: true,
      physics: NeverScrollableScrollPhysics(),
      itemBuilder: (context, index) => FeedItem(feedDataList[index]),
      itemCount: feedDataList.length,
    );
  }
}

여기서 shrinkWrap: trueNeverScrollableScrollPhysics()가 핵심이다. 부모가 SingleChildScrollView니까 ListView가 자체 스크롤하면 충돌한다. shrinkWrap으로 콘텐츠 크기만큼만 차지하게 하고, 스크롤은 부모한테 위임하는 거다.

검색 화면 - GridView

1
2
3
4
5
6
7
8
GridView.count(
  crossAxisCount: 3,
  mainAxisSpacing: 4,
  crossAxisSpacing: 4,
  shrinkWrap: true,
  physics: const NeverScrollableScrollPhysics(),
  children: gridItem.map((color) => Container(color: color)).toList(),
)

3열 그리드. SwiftUI의 LazyVGrid와 비슷한데, GridView.count가 더 간결하다.

정리

주제SwiftUIFlutter
박스 스타일링modifier 체이닝Container + BoxDecoration
수직/수평 배치VStack / HStackColumn / Row
비율 배치.frame + GeometryReaderFlexible / Expanded
겹치기ZStackStack + Align/Positioned
상태관리@StateStatefulWidget + setState
화면전환NavigationStackGoRouter
전역 테마.environmentThemeData + Theme.of(context)

전반적으로 SwiftUI보다 보일러플레이트가 좀 더 많긴 한데, 그만큼 명시적이라서 코드를 읽을 때 뭘 하는지 파악이 더 쉬운 것 같다. 특히 레이아웃 시스템은 SwiftUI보다 예측 가능해서 좋았음. GeometryReader 같은 트릭 안 써도 Flexible로 비율 잡으면 깔끔하게 떨어진다.

다음 Ch02에서는 토스 앱 클론 만들면서 좀 더 실전적인 UI를 다룰 예정이다.