위젯으로 시작하는 Flutter UI

위젯으로 시작하는 Flutter UI

Flutter에서 화면을 만든다는 건 곧 위젯을 쌓는 일이에요. React에서 아이디어를 얻은 이 프레임워크는, 위젯이 자신의 설정과 상태에 따라 화면을 어떻게 그릴지 스스로 '설명'하고, 상태가 바뀌면 그 설명을 다시 그려 프레임워크가 이전 그림과 비교해서 최소한의 변화만 적용하는 구조로 돌아가요. 그래서 플러터를 처음 접할 때 가장 먼저 익숙해져야 할 말이 '모든 것이 위젯'이라는 것이죠.

출처: https://docs.flutter.dev/ui

가장 작은 Flutter 앱

플러터에서 가장 단순한 앱은 runApp() 함수에 위젯 하나를 넘기는 것부터 시작해요. runApp()은 전달받은 위젯을 위젯 트리의 루트로 만들고, 프레임워크가 이 루트 위젯이 화면 전체를 덮도록 강제해요.

import 'package:flutter/material.dart';

void main() {
  runApp(
    const Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
        style: TextStyle(color: Colors.blue),
      ),
    ),
  );
}

이 예시의 위젯 트리는 두 개의 위젯으로 이뤄져요. 가운데 정렬을 담당하는 Center와 그 자식인 Text죠. 여기서 textDirection을 직접 지정해야 하는 이유는 MaterialApp을 쓰기 전이라 글 방향이 정해지지 않았기 때문이에요. 나중에 MaterialApp을 쓰면 이 부분은 알아서 처리돼요.

앱을 만들다 보면 대부분 직접 만드는 위젯은 StatelessWidget이나 StatefulWidget의 서브클래스가 돼요. 어떤 위젯이냐는 상태를 관리하느냐에 따라 갈리죠. 위젯의 핵심 일은 build() 함수를 구현하는 건데, 이 함수가 위젯을 더 낮은 수준의 위젯들로 설명해요. 프레임워크는 이 과정을 반복해 결국 RenderObject라는, 위젯의 기하학을 계산·표현하는 단계까지 내려가요.

자주 쓰는 기본 위젯

플러터는 풍부한 기본 위젯을 제공하는데, 그중 특히 자주 쓰는 것들을 몇 가지 훑어볼게요.

  • Text — 앱 안에서 스타일이 입혀진 텍스트를 만들어요.
  • Row, Column — 가로(Row)·세로(Column) 방향으로 자식 위젯을 유연하게 배치하는 위젯이에요. 웹의 flexbox 레이아웃 모델에서 아이디어를 가져왔어요.
  • Stack — 선형으로 늘어놓는 대신, 위젯들을 겹쳐서 그리는 순서대로 쌓아요. 자식에 Positioned를 쓰면 스택의 상·하·좌·우 가장자리에 상대적으로 배치할 수 있어요. 웹의 절대 위치(absolute positioning) 레이아웃 모델을 기반으로 해요.
  • Container — 사각형 시각 요소를 만드는 위젯이에요. BoxDecoration으로 배경·테두리·그림자를 입힐 수 있고, 마진·패딩·크기 제약도 걸 수 있어요. 행렬로 3D 변환까지 가능하죠.

이런 위젯들을 조합해 만든 예시를 하나 볼게요. 아래 코드는 상단에 앱 바(app bar)를 두고, 그 아래 본문을 배치하는 구조예요.

import 'package:flutter/material.dart';

class MyAppBar extends StatelessWidget {
  const MyAppBar({required this.title, super.key});

  // Widget 서브클래스의 필드는 항상 final로 표시해요.
  final Widget title;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56, // 논리 픽셀 단위
      padding: const EdgeInsets.symmetric(horizontal: 8),
      decoration: BoxDecoration(color: Colors.blue[500]),
      // Row는 가로 방향의 선형 레이아웃이에요.
      child: Row(
        children: [
          const IconButton(
            icon: Icon(Icons.menu),
            tooltip: 'Navigation menu',
            onPressed: null, // null이면 버튼이 비활성화돼요.
          ),
          // Expanded는 자식을 남은 공간만큼 채워요.
          Expanded(child: title),
          const IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Material(
      child: Column(
        children: [
          MyAppBar(
            title: Text(
              'Example title',
              style: Theme.of(context).primaryTextTheme.titleLarge,
            ),
          ),
          const Expanded(child: Center(child: Text('Hello, world!'))),
        ],
      ),
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      title: 'My app', // OS 작업 전환기에 표시돼요.
      home: SafeArea(child: MyScaffold()),
    ),
  );
}

MyAppBar는 높이 56, 좌우 패딩 8의 Container를 만들고 그 안에서 Row로 자식들을 정리해요. 가운데 title 위젯은 Expanded로 표시돼서 나머지 공간을 꽉 채우죠. Expanded는 여러 개를 쓸 수 있고, flex 인자로 공간을 나누는 비율을 조절할 수 있어요.

MyScaffold는 자식을 세로 Column으로 정렬하는데, 맨 위에 MyAppBar를 두고 제목으로 쓸 Text 위젯을 넘겨요. 위젯을 다른 위젯의 인자로 넘기는 이 패턴이 플러터의 강력한 기법 중 하나예요. 이렇게 하면 다양한 상황에서 재사용할 수 있는 제네릭한 위젯을 만들 수 있죠. 마지막으로 Expanded로 나머지 공간을 본문(가운데 정렬된 메시지)으로 채워요.

Material 아이콘을 쓰려면 pubspec.yamlflutter 섹션에 uses-material-design: true를 넣어야 해요.

name: my_app
flutter:
  uses-material-design: true

Material 위젯 대부분은 테마 데이터를 물려받기 위해 MaterialApp 안에 있어야 제대로 표시돼요. 그래서 앱을 MaterialApp으로 실행하는 편을 권장해요.

제스처 처리하기

사용자 상호작용의 첫 단계는 입력 제스처를 감지하는 거예요. GestureDetector로 간단한 버튼을 만들 수 있어요.

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        print('MyButton was tapped!');
      },
      child: Container(
        height: 50,
        padding: const EdgeInsets.all(8),
        margin: const EdgeInsets.symmetric(horizontal: 8),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(5),
          color: Colors.lightGreen[500],
        ),
        child: const Center(child: Text('Engage')),
      ),
    );
  }
}

여기서 잠깐 짚고 갈 점이 있어요. GestureDetector로 만든 위젯은 build() 함수가 호출될 때마다 새로 만들어지는 탭 콜백이에요. 두 번 빌드되면 서로 다른 클로저(closure)가 생기죠. 상태를 관리하는 StatefulWidget이라면 initState()에서 콜백을 만들어 필드로 저장해서 두 빌드가 같은 콜백을 공유하게 만들어야 해요. 상태가 있는 위젯이라 이 부분이 헷갈리기 쉬우니 꼭 기억해 두세요.

상태 있는 위젯: StatefulWidget과 키

화면이 사용자 입력에 반응하거나 시간에 따라 변하려면 상태를 가져야 해요. StatefulWidget은 스스로는 불변이지만, 분리된 State 객체를 만들어 그 객체가 수명 동안 변하는 상태를 들고 있어요.

StatefulWidgetcreateState()를 호출하면 프레임워크가 새 상태 객체를 트리에 넣고 initState()를 부르죠. initState는 딱 한 번만 하면 되는 작업(애니메이션 설정, 플랫폼 서비스 구독 등)을 할 때 오버라이드해요. 반대로 상태 객체가 더 필요 없어지면 dispose()가 불리고, 타이머 취소나 서비스 구독 해제 같은 정리 작업을 여기서 해요. initStatesuper.initState로, disposesuper.dispose로 시작·끝내는 규칙이 있어요.

위젯이 다시 빌드될 때 프레임워크가 어떤 위젯끼리 짝지을지 정할 때 키(key) 를 써요. 기본적으로 프레임워크는 runtimeType과 등장 순서로 짝지지만, 키를 달면 runtimeTypekey가 모두 같아야 짝지어져요. 같은 타입 위젯을 여러 개 만드는 화면(예: 스크롤되는 리스트)에서는 키가 아주 유용해요. 리스트 항목마다 의미 있는 키를 달면, 뷰포트 밖으로 나간 항목이 순서가 아니라 의미로 짝지어져 상태 유지가 정확해져요.

전체 위젯 계층에서 유일하게 식별해야 할 때는 전역 키(GlobalKey) 를 써요. 전역 키는 형제끼리만 유일하면 되는 지역 키와 달리 계층 전체에서 유일해야 하고, 이 덕분에 위젯에 연결된 상태를 꺼내 쓸 수도 있어요.

더 알아보기