포스트

flutter_screenutil 기반 반응형 설계

flutter_screenutil 기반 반응형 설계

들어가며

플러터 앱을 개발하며 태블릿·폴더블·가로·세로 등 다양한 화면에 대응하고 있다.
모든 경우의 수를 확인할 순 없는데 확인하지 않은 화면이라고 UI/UX가 크게 달라져선 안 된다. 지금보다 적절한 반응형 세팅이 필요했다.

flutter_screenutil 기반 앱에서 여러 기기 화면에 대응하며 정리한 레이어 구조와 패턴, 그리고 거기서 만난 문제들을 적는다.

반응형의 두 갈래

화면이 커졌을 때 할 수 있는 건 두 가지이다.

 스케일(scale)적응(adapt)
방식하나의 디자인을 비율로 늘림레이아웃 구조 자체를 바꿈
결과더 크게더 많이 (2단, 그리드, master-detail)
대표flutter_screenutilLayoutBuilder, breakpoint, flutter_adaptive_scaffold
장점단일 디자인을 픽셀 퍼펙트로 재현, 싸다화면을 제대로 활용, 네이티브 정석
한계큰 화면에서 그냥 커지기만 함디자인·구현 비용

둘은 배타적이지 않다. 보통 레이아웃은 적응, 그 안의 연속 변화는 스케일로 나눠 맡긴다.

레이어

전역 반응형은 대략 이런 레이어로 쌓인다.

1
2
3
4
5
레이어 0. 기기 분류        - 지금 어떤 기기/방향인가 (프로필)
레이어 1. 스케일 기준      - 무엇을 기준으로 몇 배 할 것인가 (designSize)
레이어 2. 컨텐츠 폭        - 얼마나 넓어지게 둘 것인가 (maxWidth cap)
레이어 3. 스케일 ↔ 폭 연결 - cap 안에서 스케일이 cap을 존중하게 (.wc)
레이어 4. 텍스트           - 어디까지 방어할 것인가

레이어 0이 적응 축(어떤 디자인을 쓸지 고르는 것)이고, 레이어 1~4는 그렇게 고른 디자인을 스케일로 펼치며 생기는 문제들을 잡는다.

레이어 0. 기기 분류

축을 분해하고 판정은 한 곳에서

기기 분류는 기기 등급과 방향, 두 축을 곱해 프로필을 만든다.

1
device class (폰/태블릿)  ×  orientation (세로/가로)  =  4 프로필
1
2
3
4
5
6
7
8
9
10
11
12
13
enum AppDesignProfile { phonePortrait, phoneLandscape, tabletPortrait, tabletLandscape }

static AppDesignProfile designProfileForWindow(Size size) {
  final isTablet = size.shortestSide >= 600;   // ① 기기 등급
  if (!isTablet) {
    return size.width > size.height           // ② 방향
        ? AppDesignProfile.phoneLandscape
        : AppDesignProfile.phonePortrait;
  }
  return size.width > size.height
      ? AppDesignProfile.tabletLandscape
      : AppDesignProfile.tabletPortrait;
}
  • 600은 안드로이드 sw600dp와 Material window size class가 쓰는 기준선이다.
  • shortestSide(짧은 변) 기준이라 기기를 돌려도 폰↔태블릿이 안 바뀌고 방향만 바뀐다.

판정은 한 곳에만 둔다. 앱 전체가 AppBreakpoints.designProfile(context) 하나만 부르고, 나머지 코드는 프로필 enum만 받아서 분기한다.

프로필은 4개로 시작하고, 실제로 어색한 기기가 나타날 때 중간 tier(예: Material의 medium 600–840)를 추가하면 된다. 프로필을 늘리면 분기 switch를 전부 손봐야 한다.

레이어 1. 스케일 기준

프로필별 baseline

ScreenUtil은 “디자인 기준 대비 실제 화면 비율”로 모든 값을 스케일한다.

단위배율용도
.wscreenWidth / designWidth너비, 가로 거리
.hscreenHeight / designHeight높이, 세로 거리
.rmin(scaleW, scaleH)정사각·아이콘·radius
.spminTextAdapt: true면 min(scaleW, scaleH)폰트

기준을 프로필별로 둬서 각 프로필의 표준 기기에서 배율이 1 근처가 되게 한다.

1
2
3
4
phonePortrait   = Size(360, 738)
phoneLandscape  = Size(738, 360)
tabletPortrait  = Size(680, 1024)
tabletLandscape = Size(1024, 680)

창 비율이 baseline과 다를 때

태블릿 PC 모드에서 창을 다양하게 조절해보니 UI가 깨진다.

1
2
세로 창 592×765 → scaleW 1.65 / scaleH 1.04   → 가로만 늘어남
가로 창 575×556 → scaleW 0.78 / scaleH 1.55   → 세로만 늘어남 + 글자 작아짐

baseline은 0.49(홀쭉) 또는 2.05(납작)인데 리사이즈 창은 1:1 근처라 두 기준 어디에도 안 맞는다. 실기기(폰 0.46, 태블릿 1.6대)는 기준과 가까워 안 드러나던 문제가 임의 창 크기에서 드러난 것이다.

리사이즈 창만의 얘기도 아니다. 갤럭시 Z 폴드·플립은 접고 펴는 상태에 따라(폴드 내부는 정사각, 플립 커버는 홀쭉) 다양한 aspect가 나온다.

designSize를 조정해 배율을 가둔다

.w에 clamp를 걸 순 없다. 전역 싱글턴인 데다 num 확장이기 때문이다. 대신 배율의 분모인 designSize를 조정해서 결과 배율을 밴드에 가둔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const double minDesignScale = 1.0;   // 디자인 기준보다 작게는 안 줄임
static const double maxDesignScale = 1.25;  // 최대 1.25배까지만

static Size designSizeForWindow(Size size) {
  final base = baseDesignSizeForWindow(size);   // 프로필별 고정 baseline (phonePortrait, tabletPortrait ...)
  return Size(
    _clampDesignDim(size.width, base.width),
    _clampDesignDim(size.height, base.height),
  );
}

static double _clampDesignDim(double window, double baseDesign) {
  if (window <= 0 || baseDesign <= 0) return baseDesign;
  final scale = (window / baseDesign).clamp(minDesignScale, maxDesignScale).toDouble();
  return window / scale;   // 배율이 밴드를 벗어나면 designSize를 조정해 상쇄
}
창전후 (밴드 1.0~1.25)
세로 592×765design 360×738 / scaleW 1.65 / scaleH 1.04design 473.6×738 / scaleW 1.25 / scaleH 1.04
가로 575×556design 738×360 / scaleW 0.78 / scaleH 1.55design 575×444.8 / scaleW 1.00 / scaleH 1.25
일반 폰·태블릿배율 ≈ 1.0~1.25무변화

.w/.h/.r/.sp가 전부 designSize에서 파생되므로, 분모 하나만 조정하면 모든 스케일 값이 밴드 안에 들어온다. 호출부는 안 고친다.

하한 1.0의 의미

상한 1.25는 너무 커지지 않게 하는 미관 문제지만 하한은 다르다. 배율이 1 아래라는 건 창이 캔버스보다 좁다는 뜻이고, 이때는 줄이거나(글자·터치 타깃이 작아짐) 버티거나(컨텐츠가 넘침) 둘 중 하나다. 넘치는 건 Flexible·wrap·스크롤로 흡수할 수 있지만, 작아진 본문과 40dp 밑의 터치 타깃은 되돌릴 수 없다. 그래서 하한을 1.0으로 두고 넘치는 쪽을 택했다. 밴드가 [0.8, 1.2] 같은 대칭이 아닌 이유다.

이 결정은 baseline과 묶여 있다. phonePortrait baseline 360은 현역 폰의 사실상 최소폭이라, 하한에 걸리는 창은 분할화면 조각이나 리사이즈 창 같은 예외뿐이다. baseline을 414(큰 폰)로 잡았다면 360 폰 전부가 하한에 걸려 일상적으로 넘쳤을 것이다. 하한 1.0은 baseline을 프로필 범위의 하단에 뒀을 때만 부담이 없다.

레이어 2. 컨텐츠 폭

왜 필요한가

스케일만으로 가면 태블릿에서 버튼 하나가 800dp가 된다. 레이어 1의 clamp로 배율을 묶어도 소용없다. double.infinity나 Expanded로 깔린 풀폭 요소는 스케일과 무관하게 창 폭을 그대로 따라가기 때문이다. 그래서 컨텐츠 폭을 제한하고 가운데 정렬하는 max-width 컨테이너를 둔다.

1
2
3
4
5
6
double get contentMaxWidth => switch (this) {
      AppDesignProfile.phonePortrait   => double.infinity,  // 폰은 cap 무의미
      AppDesignProfile.phoneLandscape  => 600,
      AppDesignProfile.tabletPortrait  => 800,
      AppDesignProfile.tabletLandscape => 800,
    };

cap 없음은 null 대신 double.infinity로 둔다. min(screen, cap) 같은 계산식이 그대로 통과한다.

cap은 공용 스캐폴드 한 곳에서만 건다. 페이지마다 걸지 않는다.

  • cap이 유한하면 appbar까지 포함한 스캐폴드 전체를 Align(topCenter) + ConstrainedBox(maxWidth)로 중앙정렬한다. body만 cap하고 appbar를 full-width로 두면 좌우 라인이 어긋난다.
  • 스캐폴드 배경은 투명으로 두고 그 뒤에 전체 화면 배경 레이어를 깐다. 안 그러면 좁아진 양옆 gutter가 허옇게 빈다.
  • cap이 ∞면 래핑을 씌우지 않고 기존 Scaffold를 그대로 반환한다. 레이아웃이 바뀌지 않는다.

drawer 위치

스캐폴드 전체를 cap하면 그 안의 endDrawer도 갇힌다. 해결은 바깥에 전체화면 Scaffold를 하나 더 두고 거기에 drawer를 옮기는 것이다. 안쪽 스캐폴드는 투명 + drawer 없음으로 두고, appbar 버튼은 GlobalKey<ScaffoldState>로 바깥 drawer를 연다.

레이어 3. 스케일 ↔ 폭 연결

.w는 cap을 모른다

1
2
3
4
// flutter_screenutil 내부
extension SizeExtension on num {
  double get w => ScreenUtil().setWidth(this); // 전역 싱글턴, BuildContext 없음
}

.w는 전역 싱글턴에서 기기 전체 창 폭을 읽는다. 위젯 트리의 ConstrainedBox를 알 방법이 없다. 그래서 컨텐츠를 600으로 묶어도 이렇게 된다.

기기 폭컨텐츠 박스scaleW (raw → clamp)20.w 실제값
1024600 (cap)1.0 → 1.020
1280600 (cap)1.25 → 1.25≈ 25
1600600 (cap)1.56 → 1.25≈ 25 (clamp 없으면 ≈31)

박스는 600에서 멈췄는데 그 안의 여백은 화면을 따라 자란다. 레이어 1의 clamp가 폭주는 막아주지만(1600에서도 25에서 멈춤), 600 박스 입장에선 여전히 25% 어긋난 값이다. clamp는 화면 대비 배율을 지키는 장치일 뿐 cap을 모른다.

cap 기준으로 다시 재는 .wc

라이브러리를 못 고치니 cap 영역 전용 가로 스케일을 하나 더 만든다. .w가 실제 화면 폭을 시안 화면 폭으로 나누듯, .wc는 실제 컨텐츠 박스 폭을 시안의 컨텐츠 박스 폭으로 나눈다.

1
2
3
4
5
6
7
8
.w  = value × screenWidth / designWidth
      screenWidth  실제 화면 폭
      designWidth  시안을 그린 화면 폭 (프로필 baseline, 레이어 1에서 clamp된 값)

.wc = value × clamp(contentWidth / contentDesignWidth, 1.0, 1.25)
      contentWidth       = min(screenWidth, cap)     실제 컨텐츠 박스 폭
      contentDesignWidth = min(baselineWidth, cap)   시안에서의 컨텐츠 박스 폭
      clamp              레이어 1과 같은 밴드. 이유는 뒤 "`.wc`와 clamp 밴드"에서

주의할 점이 있다. 배율은 같은 물건의 실제 크기를 시안 크기로 나눈 값이다. designWidth를 분모로 두면 실제 박스(800)를 시안 화면(1024)과 비교하는 셈이 된다. 예를 들어 tabletLandscape에서 1280 화면의 박스는 시안과 똑같이 800인데도 20 × 800 / 1024 = 15.6으로 20% 넘게 줄어든다. 박스끼리 비교하면 800 / 800 = 1이라 20이 20으로 나온다.

숫자로 보면 이렇다. baseline 1024, cap 600 기준.

화면 폭scaleW20.wcontentWidth / contentDesignWidth20.wc
10241.020600 / 60020
12801.2525600 / 60020

.w는 화면 폭을 따라가고, .wc는 박스 폭을 따라간다. cap을 넘는 순간부터 박스가 600에 고정되니 .wc도 20에 멈춘다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ContentWidthScope extends InheritedWidget {
  final double widthFactor; // .w 에 곱할 보정 계수

  static double factorFor({
    required double screenWidth,   // ScreenUtil().screenWidth
    required double maxWidth,      // profile.contentMaxWidth
    required double baselineWidth, // 프로필 baseline 폭 (clamp 전)
    required double scaleWidth,    // ScreenUtil().scaleWidth
  }) {
    final contentWidth = math.min(screenWidth, maxWidth);
    final contentDesignWidth = math.min(baselineWidth, maxWidth);
    final contentScale = (contentWidth / contentDesignWidth)
        .clamp(AppBreakpoints.minDesignScale, AppBreakpoints.maxDesignScale); // 1.0 ~ 1.25
    return contentScale / scaleWidth; // .w 의 scaleWidth 를 되나눈다
  }

  static double factorOf(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<ContentWidthScope>()?.widthFactor ?? 1.0;
}

extension CappedWidthExt on num {
  double wc(BuildContext context) => w * ContentWidthScope.factorOf(context);
}

.w에 곱하는 형태를 유지한 건 기존 .w 확장을 건드리지 않고 그 위에 얹기 위해서다. 안에서 scaleWidth를 되나누니 결과는 .w의 배율과 무관한 value × clamp(contentWidth / contentDesignWidth)다.

수식보다 중요한 건 factorOf의 ?? 1.0이다.

  • 스코프 밖에서 부르면 factor = 1 → .wc == .w
  • 스코프 안이라도 cap이 없으면(∞) contentWidth / contentDesignWidth = screen / baseline이고, 이걸 밴드로 자른 값이 곧 레이어 1의 scaleWidth라 factor = 1 → .wc == .w. phonePortrait가 여기 해당한다.

기본값이 기존 .w와 같으니 전역에 깔아도 아무것도 안 바뀌고, 공용 위젯에서도 그대로 쓸 수 있다. 전면 마이그레이션 없이 cap이 걸리는 페이지부터 점진적으로 바꿀 수 있다.

호출 위치와 context

.wc(context)는 넘겨받은 context 위치에서 부모 방향으로만 올라가며 ContentWidthScope를 찾는다. 자식 쪽은 보지 않는다. 그런데 스코프를 까는 건 공용 스캐폴드고, 페이지는 그 스캐폴드를 감싸는 쪽이다. 트리로 보면 스코프는 페이지보다 아래에 있다.

1
2
3
4
HomePage                       ← build 의 context 는 여기
 └ CommonScaffoldScrollBool
     └ ContentWidthScope       ← 스코프
         └ Padding(child)      ← 페이지가 넘긴 child

그래서 페이지 build 맨 위에서 .wc를 부르면 스코프를 못 찾는다.

1
2
3
4
5
Widget build(BuildContext context) {
  // ❌ 이 context 는 HomePage 위치. 위로 올라가도 스코프가 없다 → factor 1 → .w 와 같은 값
  final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
  return CommonScaffoldScrollBool(child: Padding(padding: padding, ...));
}

factorOf가 ?? 1.0으로 1을 돌려주니 cap 보정이 빠진 채 그려지는데, 에러는 안 나 “왜 cap이 안 먹지?”로 헤맨다.

고치는 방법은 .wc를 부르는 context가 스코프 아래에 있게 하는 것이다. Builder는 자기가 놓인 자리의 context를 새로 만들어 주므로, child 안에 Builder를 두고 그 안에서 부르면 된다.

1
2
3
4
5
6
7
// ✅ Builder 의 context 는 스코프 아래
return CommonScaffoldScrollBool(
  child: Builder(builder: (context) {
    final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
    return Padding(padding: padding, ...);
  }),
);

값은 raw 숫자로 넘기고 실제로 그리는 위젯 안에서 .wc를 붙이는 습관을 지키면 호출 위치가 항상 스코프 아래가 되어 피해간다.

.hc는 없다. cap은 가로에만 걸리기 때문이다.

두 장치는 서로 독립이다

여기까지 오면 배율을 조정하는 장치가 둘이다. 각각 다른 문제를 푼다.

 ① designSize clamp② .wc
푸는 문제창 aspect가 baseline과 멀 때 배율 폭주컨텐츠 maxWidth cap을 가로가 안 따름
기준값window / designBaseline 을 밴드로min(screen, cap) / min(baseline, cap) 을 같은 밴드로
적용 축가로·세로 둘 다가로만
영향 범위앱 전역 (.w/.h/.r/.sp 전부)스코프 하위, .wc 쓴 곳만
구현designSize(분모)를 조정.w의 배율을 되나누고 자기 배율을 곱함

①은 배율이 폭주하지 않게 하는 전역 안전장치, ②는 cap 안에서 가로가 cap을 따르게 하는 국소 보정이다. 하나만으론 다른 문제가 안 풀린다.

②가 .w 안의 배율을 되나누기 때문에 .wc의 최종값에는 ①이 남지 않는다.

1
2
3
4
① designSize clamp →  scaleW 가 [1.0, 1.25] 로 묶임
       ↓
  .w  = value × scaleW                                           ← ①만 적용
  .wc = value × clamp(min(screen, cap) / min(baseline, cap))     ← ②만 적용, scaleW 는 소거

.wc와 clamp 밴드

.wc에 ①이 남지 않으니 ①의 하한·상한도 없다. 그래서 .wc의 비율에도 같은 밴드 [1.0, 1.25]를 건다.
cap이 없으면 비율은 screen / baseline이고, 이걸 밴드로 자른 값이 곧 ①의 scaleWidth다. .wc가 .w와 정확히 같아진다. cap이 있으면 cap 위 구간은 그대로고 cap 아래에서만 하한이 생긴다.
좁은 화면에서 값이 박스에 비례해 줄지 않는 대가는 .w에 하한 1.0을 두며 이미 택한 것(줄이지 않고 넘치는 쪽)과 같다.

상황scaleW20.w20.wc 밴드 없음20.wc 밴드 적용
phonePortrait 360 (표준 기기)1.0202020
phonePortrait 592 리사이즈 창 (넓은 화면, cap 없음)1.25 (상한)2532.925
tabletPortrait 600 (좁은 화면, baseline 680)1.0 (하한)2017.620
tabletLandscape 1280 (cap 800 위)1.25 (상한)252020

레이어 4. 텍스트

.sp도 .w처럼 cap을 모른다. 그렇다고 .spc까지 만들지는 않았다. 이유는 셋이다.

  1. 글자 크기는 박스가 아니라 화면을 따라야 한다. 1280 태블릿에서 16.sp는 20으로 커지지만 .spc를 만들면 16에서 멈춘다. 큰 화면에 작은 글자는 답답하다.
  2. 이미 안전한 쪽이다. 레이어 1 clamp로 .sp도 최대 1.25배이고, minTextAdapt: true면 가로·세로 배율 중 작은 쪽을 써서 한 축만 늘어난 창에서는 그만큼도 안 커진다.
  3. 고칠 곳이 너무 많다. .sp는 보통 baseStyle(fontSize: 16.sp)처럼 context가 없는 스타일 함수 안에 있다. .spc(context)를 쓰려면 그 함수들과 그걸 부르는 곳 전부에 context를 넘기도록 고쳐야 한다. 로직은 그대로인데 값을 전달하는 통로만 수백 곳 손대는 일이다.

텍스트가 깨지는 경우

상황결과
줄바꿈 되는 텍스트가 커짐줄 수만 늘어남 → 스크롤이 흡수, 안 깨짐
고정 높이 박스 안 텍스트세로 잘림 가능
경직된 Row(Flexible 없음)가로 오버플로

전역 장치는 깨지는 게 구조적으로 불가피할 때만 둔다. 텍스트는 깨지는 유형이 위처럼 특정되니, 거기만 Flexible이나 ellipsis로 막으면 된다.

이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.