Today I Learned 19회차 - Flutter 영화관 좌석 예매 앱 만들기
오늘은 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 패턴
🚀 다음 학습 목표
- 예약 완료 기능: 실제 좌석 예약 상태 저장
- 이미 예약된 좌석 표시: 선택 불가능한 좌석 구현
- 여러 좌석 선택: 다중 선택 기능 추가
- 좌석 등급별 가격: VIP/일반석 구분과 가격 표시
- 예약 내역 화면: 예약 완료 후 내역 페이지 이동
댓글남기기