Today I Learned 19회차 - Flutter 영화관 좌석 예매 앱 만들기

6 분 소요

오늘은 Flutter로 영화관 좌석 예매 앱을 만들어보면서 상태 관리와 사용자 상호작용을 구현하는 방법을 배웠습니다. StatefulWidget을 활용한 좌석 선택 기능과 Cupertino 스타일의 다이얼로그를 익혔어요!

🎯 학습 목표

  • StatefulWidget을 활용한 상태 관리
  • 부모-자식 컴포넌트 간 데이터 전달 (콜백 함수)
  • GestureDetector로 사용자 터치 이벤트 처리
  • CupertinoDialog로 iOS 스타일 알림창 구현
  • AspectRatio를 활용한 정사각형 좌석 UI 만들기

📚 학습 내용

1. 앱 구조 설계

영화관 좌석 예매 앱은 크게 3개의 컴포넌트로 구성됩니다.

앱 구조

SeatPage (StatefulWidget)
  ├─ AppBar
  └─ Column
      ├─ SeatSelectBox (좌석 선택 영역)
      │   ├─ Screen 텍스트
      │   ├─ 5개의 행 (각 행당 9개 좌석)
      │   └─ 범례 (Available/Selected)
      └─ SeatBottom (하단 예약 영역)
          ├─ 선택된 좌석 정보
          └─ 예약 버튼

2. StatefulWidget을 활용한 상태 관리

영화관 좌석 예매 앱에서는 선택된 좌석 정보를 관리해야 합니다.

상태 관리가 필요한 이유

  • 사용자가 좌석을 클릭하면 해당 좌석이 선택된 상태로 변경
  • 선택된 좌석의 색상이 회색에서 노란색으로 변경
  • 하단 영역에 선택된 좌석 정보 표시

상태 변수 정의

int? selectedRow;    // 선택된 행 번호 (null이면 선택 안 됨)
int? selectedCol;    // 선택된 열 번호 (null이면 선택 안 됨)

nullable 타입 사용 이유

  • 초기 상태에서는 아무 좌석도 선택되지 않음
  • null로 “선택되지 않음” 상태를 표현
  • 조건문에서 selectedRow == null && selectedCol == null로 체크 가능

3. 콜백 함수를 통한 부모-자식 간 통신

자식 위젯(SeatSelectBox)에서 발생한 이벤트를 부모 위젯(SeatPage)에게 전달하는 방법입니다.

콜백 함수 패턴

사용자가 좌석 클릭
  ↓
SeatSelectBox의 GestureDetector가 onTap 감지
  ↓
onSelected 콜백 함수 호출
  ↓
SeatPage의 onSelected 메서드 실행
  ↓
setState로 selectedRow, selectedCol 업데이트
  ↓
화면 재렌더링 (좌석 색상 변경, 하단 정보 업데이트)

콜백 함수 정의 방법

// 자식 위젯에서 콜백 함수 타입 정의
final void Function(int row, int col) onSelected;

// 부모 위젯에서 콜백 함수 구현
void onSelected(int row, int col) {
  setState(() {
    selectedRow = row;
    selectedCol = col;
  });
}

4. GestureDetector로 터치 이벤트 처리

GestureDetector는 다양한 제스처(탭, 드래그, 스와이프 등)를 감지하는 위젯입니다.

GestureDetector 주요 속성

속성 설명
onTap 한 번 탭했을 때
onDoubleTap 두 번 연속 탭했을 때
onLongPress 길게 눌렀을 때
onPanUpdate 드래그할 때
onHorizontalDragStart 가로 드래그 시작할 때
onVerticalDragStart 세로 드래그 시작할 때

좌석 클릭 감지

GestureDetector(
  onTap: () {
    onSelected(rowNum, colNum);  // 클릭 시 부모에게 알림
  },
  child: AspectRatio(
    aspectRatio: 1,  // 정사각형 좌석
    child: Container(
      decoration: BoxDecoration(
        color: rowNum == selectedRow && colNum == selectedCol
            ? Colors.amber  // 선택된 좌석
            : Colors.grey,  // 선택 안 된 좌석
        borderRadius: BorderRadius.circular(10),
      ),
    ),
  ),
)

5. CupertinoDialog로 iOS 스타일 알림창 구현

CupertinoDialog는 iOS 스타일의 알림창을 구현하는 위젯입니다.

Material vs Cupertino

Material Cupertino
Android 디자인 가이드 iOS 디자인 가이드
AlertDialog CupertinoAlertDialog
showDialog showCupertinoDialog
각진 디자인 둥근 디자인

CupertinoAlertDialog 구조

CupertinoAlertDialog
  ├─ title (제목)
  ├─ content (내용)
  └─ actions (액션 버튼 리스트)
      ├─ CupertinoDialogAction (취소 버튼)
      └─ CupertinoDialogAction (확인 버튼)

다이얼로그 띄우기

showCupertinoDialog(
  context: context,
  builder: (context) {
    return CupertinoAlertDialog(
      title: Text('예약 확인'),
      content: Text('예약 하시겠습니까?'),
      actions: [
        CupertinoDialogAction(
          isDestructiveAction: true,  // 빨간색 텍스트 (취소용)
          onPressed: () {
            Navigator.of(context).pop();  // 다이얼로그 닫기
          },
          child: Text('취소'),
        ),
        CupertinoDialogAction(
          isDestructiveAction: false,  // 파란색 텍스트 (확인용)
          onPressed: () {
            // 예약 로직 실행
          },
          child: Text('확인'),
        ),
      ],
    );
  },
);

CupertinoDialogAction 속성

속성 설명
isDestructiveAction true면 빨간색(삭제/취소), false면 파란색(확인)
isDefaultAction 기본 액션으로 설정 (굵은 글씨)
onPressed 버튼 클릭 시 실행할 함수

6. 조건부 렌더링

선택된 좌석이 있는지 없는지에 따라 다른 UI를 표시합니다.

삼항 연산자 활용

Text(
  selectedRow == null && selectedCol == null
      ? '선택된 좌석 없음'
      : '$selectedRow - $selectedCol',
  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
)

조건

  • selectedRow == null && selectedCol == null : 둘 다 null이면 선택 안 됨
  • 참이면: ‘선택된 좌석 없음’ 표시
  • 거짓이면: ‘행번호 - 열번호’ 표시 (예: ‘3 - 5’)

7. AspectRatio로 정사각형 좌석 만들기

좌석을 정사각형으로 만들기 위해 AspectRatio를 사용합니다.

AspectRatio의 역할

  • 화면 크기에 관계없이 1:1 비율 유지
  • 반응형 UI 구현 가능
  • 다양한 디바이스에서 일관된 모양
AspectRatio(
  aspectRatio: 1,  // 1:1 = 정사각형
  child: Container(
    decoration: BoxDecoration(
      color: Colors.grey,
      borderRadius: BorderRadius.circular(10),
    ),
  ),
)

💻 전체 코드

import 'package:flutter/material.dart';
import 'package:flutter_seat_app/seat_bottom.dart';
import 'package:flutter_seat_app/seat_select_box.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: SeatPage());
  }
}

class SeatPage extends StatefulWidget {
  const SeatPage({super.key});

  @override
  State<SeatPage> createState() => _SeatPageState();
}

class _SeatPageState extends State<SeatPage> {
  int? selectedRow;
  int? selectedCol;

  void onSelected(int row, int col) {
    setState(() {
      selectedRow = row;
      selectedCol = col;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Seats')),
      backgroundColor: Colors.grey[200],
      body: Column(
        children: [
          SeatSelectBox(
            selectedCol: selectedCol,
            selectedRow: selectedRow,
            onSelected: onSelected,
          ),
          SeatBottom(selectedCol: selectedCol, selectedRow: selectedRow),
        ],
      ),
    );
  }
}

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

class SeatBottom extends StatelessWidget {
  final int? selectedCol;
  final int? selectedRow;

  const SeatBottom({super.key, this.selectedCol, this.selectedRow});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200,
      width: double.infinity,
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      child: Column(
        children: [
          SizedBox(height: 20),
          Text(
            selectedRow == null && selectedCol == null
                ? '선택된 좌석 없음'
                : '$selectedRow - $selectedCol',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 20),
          SizedBox(
            width: 200,
            height: 56,
            child: ElevatedButton(
              onPressed: () {
                showCupertinoDialog(
                  context: context,
                  builder: (context) {
                    return CupertinoAlertDialog(
                      title: Text('예약 확인'),
                      content: Text('예약 하시겠습니까?'),
                      actions: [
                        CupertinoDialogAction(
                          isDestructiveAction: true,
                          onPressed: () {
                            Navigator.of(context).pop();
                          },
                          child: Text('취소'),
                        ),
                        CupertinoDialogAction(
                          isDestructiveAction: false,
                          onPressed: () {},
                          child: Text('확인'),
                        ),
                      ],
                    );
                  },
                );
              },
              style: ElevatedButton.styleFrom(backgroundColor: Colors.amber),
              child: Text('Book now'),
            ),
          ),
        ],
      ),
    );
  }
}

SeatSelectBox 위젯

import 'package:flutter/material.dart';

class SeatSelectBox extends StatelessWidget {
  final int? selectedCol;
  final int? selectedRow;
  final void Function(int row, int col) onSelected;

  const SeatSelectBox({
    super.key,
    this.selectedCol,
    this.selectedRow,
    required this.onSelected,
  });

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'Screeen',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 10),
          row(1),
          SizedBox(height: 8),
          row(2),
          SizedBox(height: 8),
          row(3),
          SizedBox(height: 8),
          row(4),
          SizedBox(height: 8),
          row(5),
          SizedBox(height: 8),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              label('Available', Colors.grey),
              SizedBox(width: 4),
              label('Selected', Colors.amber),
            ],
          ),
        ],
      ),
    );
  }

  Row label(String text, Color color) {
    return Row(
      children: [
        Text(text),
        SizedBox(width: 4),
        Container(
          width: 36,
          height: 36,
          decoration: BoxDecoration(
            color: color,
            borderRadius: BorderRadius.circular(10),
          ),
        ),
      ],
    );
  }

  Widget row(int rowNum) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20),
      child: Row(
        children: [
          Expanded(
            child: Center(
              child: Text(
                '$rowNum',
                style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
              ),
            ),
          ),
          seat(rowNum, 1),
          seat(rowNum, 2),
          seat(rowNum, 3),
          seat(rowNum, 4),
          seat(rowNum, 5),
          seat(rowNum, 6),
          seat(rowNum, 7),
          seat(rowNum, 8),
          seat(rowNum, 9),
        ],
      ),
    );
  }

  Widget seat(int rowNum, int colNum) {
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 2),
        child: GestureDetector(
          onTap: () {
            onSelected(rowNum, colNum);
          },
          child: AspectRatio(
            aspectRatio: 1,
            child: Container(
              decoration: BoxDecoration(
                color: rowNum == selectedRow && colNum == selectedCol
                    ? Colors.amber
                    : Colors.grey,
                borderRadius: BorderRadius.circular(10),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

💡 핵심 개념 정리

StatefulWidget 상태 관리 흐름

초기 상태 (selectedRow: null, selectedCol: null)
  ↓
사용자가 좌석 클릭
  ↓
GestureDetector의 onTap 실행
  ↓
콜백 함수 onSelected(row, col) 호출
  ↓
부모 위젯의 setState() 실행
  ↓
selectedRow, selectedCol 값 업데이트
  ↓
build() 메서드 재실행 (화면 재렌더링)
  ↓
선택된 좌석 색상 변경 & 하단 정보 업데이트

콜백 함수 패턴

부모 위젯 (SeatPage)
  ├─ 상태 관리: selectedRow, selectedCol
  ├─ 콜백 함수 정의: onSelected(row, col)
  └─ 자식에게 전달: onSelected 함수 전달
      ↓
자식 위젯 (SeatSelectBox)
  ├─ 콜백 함수 받기: final void Function(int, int) onSelected
  └─ 이벤트 발생 시: onSelected(rowNum, colNum) 호출
      ↓
부모 위젯의 setState() 실행

GestureDetector 활용

GestureDetector (제스처 감지)
  ├─ onTap: 클릭 이벤트
  ├─ onDoubleTap: 더블 클릭
  ├─ onLongPress: 길게 누르기
  └─ child: 실제 UI 위젯

CupertinoDialog 구조

showCupertinoDialog()
  └─ CupertinoAlertDialog
      ├─ title (제목)
      ├─ content (내용)
      └─ actions (버튼 리스트)
          ├─ CupertinoDialogAction (취소)
          │   ├─ isDestructiveAction: true (빨간색)
          │   └─ Navigator.pop() (닫기)
          └─ CupertinoDialogAction (확인)
              ├─ isDestructiveAction: false (파란색)
              └─ 실행 로직

조건부 렌더링 패턴

상태 체크
  ↓
조건: selectedRow == null && selectedCol == null
  ↓
  ├─ true → '선택된 좌석 없음'
  └─ false → '$selectedRow - $selectedCol'

🔍 주요 학습 포인트

1. 상태 관리의 이해

  • StatefulWidget: 변경 가능한 상태를 가진 위젯
  • setState(): 상태 변경 시 화면 재렌더링 트리거
  • nullable 타입: 초기 상태나 선택 안 됨을 표현

2. 부모-자식 컴포넌트 통신

  • Props down: 부모가 자식에게 데이터 전달 (selectedRow, selectedCol)
  • Events up: 자식이 부모에게 이벤트 전달 (onSelected 콜백)
  • 콜백 함수: 자식의 이벤트를 부모가 처리하는 패턴

3. 사용자 인터랙션

  • GestureDetector: 다양한 터치 제스처 감지
  • 조건부 스타일링: 선택 상태에 따른 색상 변경
  • 다이얼로그: 사용자 확인을 받는 UI 패턴

🚀 다음 학습 목표

  1. 예약 완료 기능: 실제 좌석 예약 상태 저장
  2. 이미 예약된 좌석 표시: 선택 불가능한 좌석 구현
  3. 여러 좌석 선택: 다중 선택 기능 추가
  4. 좌석 등급별 가격: VIP/일반석 구분과 가격 표시
  5. 예약 내역 화면: 예약 완료 후 내역 페이지 이동

댓글남기기