반응형 UI — LayoutBuilder, MediaQuery, 적응형 설계

Flutter는 모바일, 태블릿, 웹, 데스크톱을 하나의 코드로 지원합니다. 다양한 화면 크기에 대응하는 반응형 UI 구현 방법을 정리해보겠습니다.


MediaQuery — 화면 정보 조회

DART
@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)을 기반으로 레이아웃을 결정합니다.

DART
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 900) {
      return _buildDesktopLayout();
    } else if (constraints.maxWidth > 600) {
      return _buildTabletLayout();
    } else {
      return _buildMobileLayout();
    }
  },
)

MediaQuery vs LayoutBuilder

구분MediaQueryLayoutBuilder
기준화면 전체 크기부모 위젯의 제약
용도화면 크기 기반 분기위젯 크기 기반 분기
재사용성낮음높음

LayoutBuilder가 더 유연합니다. 위젯이 어디에 배치되든 부모 크기에 따라 반응합니다.


브레이크포인트 시스템

DART
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단 레이아웃

DART
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();
      },
    );
  }
}

그리드 열 수 자동 조절

DART
GridView.builder(
  gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 200,  // 최대 너비 기준으로 열 수 자동 결정
    crossAxisSpacing: 8,
    mainAxisSpacing: 8,
  ),
  itemCount: items.length,
  itemBuilder: (context, index) => ItemCard(item: items[index]),
)

OrientationBuilder — 가로/세로 전환

DART
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 — 비율 기반 크기

DART
// 부모 너비의 80%, 높이의 50%
FractionallySizedBox(
  widthFactor: 0.8,
  heightFactor: 0.5,
  child: Container(color: Colors.blue),
)

적응형 위젯 패턴

플랫폼에 따라 다른 위젯을 사용하는 패턴입니다.

DART
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 영역을 피합니다.

DART
SafeArea(
  // 개별 방향 제어
  top: true,
  bottom: true,
  left: true,
  right: true,
  child: Scaffold(
    body: Text('안전 영역 내의 콘텐츠'),
  ),
)

실전: 마스터-디테일 패턴

DART
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로 부모 제약을 확인합니다
  • 재사용 가능한 위젯에서는 LayoutBuilderMediaQuery보다 적합합니다
  • 브레이크포인트를 상수로 관리하면 일관성을 유지할 수 있습니다
  • SafeArea로 시스템 UI 영역을 안전하게 피하세요
  • 마스터-디테일 패턴은 태블릿/데스크톱에서 자주 사용되는 반응형 패턴입니다
댓글 로딩 중...