반응형 UI — LayoutBuilder, MediaQuery, 적응형 설계
반응형 UI — LayoutBuilder, MediaQuery, 적응형 설계
Flutter는 모바일, 태블릿, 웹, 데스크톱을 하나의 코드로 지원합니다. 다양한 화면 크기에 대응하는 반응형 UI 구현 방법을 정리해보겠습니다.
MediaQuery — 화면 정보 조회
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final screenHeight = MediaQuery.sizeOf(context).height;
final padding = MediaQuery.paddingOf(context); // Safe Area
final orientation = MediaQuery.orientationOf(context);
final textScale = MediaQuery.textScaleFactorOf(context);
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
return Scaffold(
body: SafeArea(
child: Text('화면 크기: $screenWidth x $screenHeight'),
),
);
}
면접 포인트: MediaQuery.of(context)보다 MediaQuery.sizeOf(context)를 사용하는 것이 성능에 좋습니다. of()는 모든 MediaQuery 값이 변경될 때 리빌드되지만, sizeOf()는 크기가 변경될 때만 리빌드됩니다.
LayoutBuilder — 부모 제약 기반
LayoutBuilder는 부모가 제공하는 제약 조건(constraints)을 기반으로 레이아웃을 결정합니다.
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 900) {
return _buildDesktopLayout();
} else if (constraints.maxWidth > 600) {
return _buildTabletLayout();
} else {
return _buildMobileLayout();
}
},
)
MediaQuery vs LayoutBuilder
| 구분 | MediaQuery | LayoutBuilder |
|---|---|---|
| 기준 | 화면 전체 크기 | 부모 위젯의 제약 |
| 용도 | 화면 크기 기반 분기 | 위젯 크기 기반 분기 |
| 재사용성 | 낮음 | 높음 |
LayoutBuilder가 더 유연합니다. 위젯이 어디에 배치되든 부모 크기에 따라 반응합니다.
브레이크포인트 시스템
abstract class Breakpoints {
static const double mobile = 600;
static const double tablet = 900;
static const double desktop = 1200;
static bool isMobile(BuildContext context) =>
MediaQuery.sizeOf(context).width < mobile;
static bool isTablet(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return width >= mobile && width < desktop;
}
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= desktop;
}
반응형 레이아웃 패턴
2단 → 1단 레이아웃
class ResponsiveLayout extends StatelessWidget {
const ResponsiveLayout({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 800) {
// 데스크톱: 사이드바 + 콘텐츠
return Row(
children: [
SizedBox(
width: 280,
child: _buildSidebar(),
),
const VerticalDivider(width: 1),
Expanded(child: _buildContent()),
],
);
}
// 모바일: 콘텐츠만
return _buildContent();
},
);
}
}
그리드 열 수 자동 조절
GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, // 최대 너비 기준으로 열 수 자동 결정
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
)
OrientationBuilder — 가로/세로 전환
OrientationBuilder(
builder: (context, orientation) {
if (orientation == Orientation.landscape) {
return Row(
children: [
Expanded(child: _buildImage()),
Expanded(child: _buildInfo()),
],
);
}
return Column(
children: [
_buildImage(),
Expanded(child: _buildInfo()),
],
);
},
)
FractionallySizedBox — 비율 기반 크기
// 부모 너비의 80%, 높이의 50%
FractionallySizedBox(
widthFactor: 0.8,
heightFactor: 0.5,
child: Container(color: Colors.blue),
)
적응형 위젯 패턴
플랫폼에 따라 다른 위젯을 사용하는 패턴입니다.
import 'dart:io' show Platform;
class AdaptiveButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
const AdaptiveButton({
super.key,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
// iOS에서는 Cupertino 스타일
if (Platform.isIOS) {
return CupertinoButton(
onPressed: onPressed,
child: Text(label),
);
}
// 그 외에는 Material 스타일
return ElevatedButton(
onPressed: onPressed,
child: Text(label),
);
}
}
SafeArea — 안전 영역
노치, 상태바, 하단 내비게이션 바 등 시스템 UI 영역을 피합니다.
SafeArea(
// 개별 방향 제어
top: true,
bottom: true,
left: true,
right: true,
child: Scaffold(
body: Text('안전 영역 내의 콘텐츠'),
),
)
실전: 마스터-디테일 패턴
class MasterDetailScreen extends StatefulWidget {
const MasterDetailScreen({super.key});
@override
State<MasterDetailScreen> createState() => _MasterDetailScreenState();
}
class _MasterDetailScreenState extends State<MasterDetailScreen> {
int? _selectedIndex;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 700;
if (isWide) {
// 넓은 화면: 좌우 분할
return Row(
children: [
SizedBox(
width: 300,
child: _buildList(isWide: true),
),
const VerticalDivider(width: 1),
Expanded(
child: _selectedIndex != null
? _buildDetail(_selectedIndex!)
: const Center(child: Text('항목을 선택하세요')),
),
],
);
}
// 좁은 화면: 네비게이션 방식
return _buildList(isWide: false);
},
);
}
Widget _buildList({required bool isWide}) {
return ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ListTile(
selected: _selectedIndex == index,
title: Text('항목 $index'),
onTap: () {
if (isWide) {
setState(() => _selectedIndex = index);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(title: Text('항목 $index')),
body: _buildDetail(index),
),
),
);
}
},
);
},
);
}
Widget _buildDetail(int index) {
return Center(
child: Text('항목 $index의 상세 정보'),
);
}
}
정리
MediaQuery.sizeOf(context)로 화면 크기를,LayoutBuilder로 부모 제약을 확인합니다- 재사용 가능한 위젯에서는
LayoutBuilder가MediaQuery보다 적합합니다 - 브레이크포인트를 상수로 관리하면 일관성을 유지할 수 있습니다
SafeArea로 시스템 UI 영역을 안전하게 피하세요- 마스터-디테일 패턴은 태블릿/데스크톱에서 자주 사용되는 반응형 패턴입니다
댓글 로딩 중...