[Flutter] iPad 멀티태스킹에서 AppBar 와 시스템 컨트롤 겹침 수정

2026-05-26 hit count image

iPad Split View · Slide Over · Stage Manager 환경에서 AppBar 좌상단 뒤로가기 버튼이 시스템 grab handle 과 겹치는 문제를 정리합니다. 화면 크기 기반 iPad 판별이 실패하는 이유, 하드웨어 기반 판별, AppBar leading 정렬의 함정까지 다룹니다.

flutter

개요

iPad 의 멀티태스킹(Split View · Slide Over · Stage Manager) 환경에서는 시스템이 윈도우 상단 중앙에 grab handle 을 표시합니다. 사용자가 이 핸들을 잡고 윈도우를 이동하거나 크기를 조절하는 데 쓰는 시스템 컨트롤입니다.

문제는 grab handle 의 좌측 영역이 앱의 AppBar 좌상단 영역과 겹친다는 점입니다. iPhone 의 AppBar 디자인을 그대로 들고 오면 좌상단의 뒤로가기 버튼 이 핸들 영역과 시각적으로 충돌하고, 터치 영역도 부분적으로 가려져 사용자 경험이 깨집니다.

이 글에서는 이 문제를 해결한 과정을 정리합니다.

  • 문제 진단: 왜 기존의 화면 크기 기반 isTablet 판별로는 이 케이스를 잡을 수 없는가
  • 기반 마련: 하드웨어 기반 iPad 판별과 부팅 시 정적 캐시 패턴
  • 적용: AppHeader 에 top offset 을 추가하면서 발견한 AppBar leading 정렬의 미묘한 함정

왜 화면 크기 기반 iPad 판별이 실패하는가

겹침 문제를 해결하려면 “iPad 에서만 안전 오프셋을 적용” 해야 합니다. iPhone 에서는 멀티태스킹이 없으므로 동일한 오프셋을 적용하면 디자인이 깨집니다. 가장 단순한 발상은 화면 크기 기반 판별입니다.

// 화면 크기 기반 판별 (간단하지만 멀티윈도우에서 실패)
bool get isTablet => MediaQuery.of(context).size.shortestSide >= 600;

이 방식은 단일 윈도우 환경에서는 잘 동작하지만, iPad 멀티태스킹에서는 윈도우의 크기가 디바이스의 크기와 다릅니다.

  • Slide Over: 약 320pt 폭의 좁은 오버레이 윈도우. shortestSide 가 320pt 라 iPhone 으로 오판된다.
  • 좁은 Split View(1/3): 약 320~375pt 폭. 동일하게 iPhone 으로 오판된다.
  • Stage Manager: 사용자가 윈도우 크기를 자유 조절 가능. 좁게 줄이면 동일한 오판이 발생한다.

결과적으로 시스템 grab handle 이 가장 잘 보이는 좁은 윈도우 시나리오에서 정작 오프셋이 적용되지 않는 모순이 발생합니다.

또 다른 문제는 윈도우 크기 변경 시 MediaQuery 가 다시 빌드를 트리거한다는 점입니다. isTablet 이 true → false → true 로 흔들리면 매번 AppBar 의 preferredSize 가 변해 레이아웃 점프가 발생할 수 있습니다.

해결의 방향은 명확합니다. “이 단말기가 물리적으로 iPad인가”를 확인하는 방법은 윈도우 크기와 무관해야 합니다.

하드웨어 기반 iPad 판별

device_info_plusIosDeviceInfo 는 화면이 아니라 하드웨어 정보를 반환합니다. 두 필드를 조합하면 iPad 를 안정적으로 식별할 수 있습니다.

final plugin = DeviceInfoPlugin();
final ios = await plugin.iosInfo;

final model = ios.model;            // 예: 'iPad'
final machine = ios.utsname.machine; // 예: 'iPad14,1'

final isIPad =
    model.toLowerCase().contains('ipad') || machine.startsWith('iPad');
  • model 은 “iPad” / “iPhone” 같은 사람이 읽을 수 있는 모델명
  • utsname.machine 은 “iPad14,1” / “iPhone15,2” 같은 하드웨어 식별자

두 필드를 OR 로 결합하는 이유는 향후 iOS 가 model 의 표기를 변경하더라도 utsname.machine 으로 폴백이 가능하기 때문입니다. 둘 중 하나라도 ‘iPad’ 단서를 포함하면 iPad 로 확정합니다.

이 판별은 윈도우 크기와 무관 하므로 Slide Over · 좁은 Split · Stage Manager 어느 시나리오에서도 동일하게 true 를 반환합니다.

부팅 시 1회 캐시 + idempotent init

iosInfo 호출은 비동기이고 플랫폼 채널을 거치므로 매 build 마다 호출하면 안 됩니다. 부팅 시 1회만 호출하고 정적 변수에 캐시합니다.

class DeviceInfo {
  static bool _isIPadCached = false;
  static bool _isIPadInitialized = false;
  static Future<void>? _initializeFuture;

  /// 하드웨어 기반 iPad 판별 결과(부팅 시 1 회 계산된 정적 캐시).
  /// `initialize()` 호출 전에는 false 를 반환한다(안전 디폴트).
  static bool get isIPad => _isIPadCached;

  /// 앱 부팅(`runApp` 직전)에서 1 회 호출하여 iPad 판별 결과를 캐시한다.
  /// 멱등(idempotent): 중복/병렬 호출 시 plugin 은 1 회만 호출된다.
  static Future<void> initialize() {
    if (_isIPadInitialized) {
      return _initializeFuture ?? Future.value();
    }
    return _initializeFuture ??= _doInitialize();
  }

  static Future<void> _doInitialize() async {
    try {
      if (defaultTargetPlatform != TargetPlatform.iOS) {
        _isIPadCached = false;
        return;
      }
      try {
        final plugin = DeviceInfoPlugin();
        final ios = await plugin.iosInfo;
        final model = ios.model;
        final machine = ios.utsname.machine;
        _isIPadCached =
            model.toLowerCase().contains('ipad') ||
            machine.startsWith('iPad');
      } catch (e, s) {
        // 폴백: device_info_plus 실패 시 안전 디폴트(false=iPad 아님).
        _isIPadCached = false;
        try {
          CrashlyticsHelper.recordError(
            e,
            s,
            reason: 'DeviceInfo.initialize iosInfo 조회 실패',
          );
        } catch (_) {
          // 기록 실패는 무시 — iPad 판별 false 폴백 자체는 이미 적용됨.
        }
      }
    } finally {
      _isIPadInitialized = true;
    }
  }
}

설계의 핵심을 정리하면 다음과 같습니다.

  • Future 자체를 캐시 — 중복/병렬 호출 시 plugin 은 1회만 호출됩니다. 동시에 두 곳에서 initialize() 를 await 해도 동일 Future 를 공유하므로 race condition 이 없습니다.
  • iOS 가 아닌 플랫폼은 즉시 종료 — Android / Web 에서 불필요한 plugin 호출을 피합니다.
  • 이중 try/catch 폴백iosInfo 호출 실패 시 false 로 폴백하고 Crashlytics 에 기록합니다. Firebase 미초기화 등으로 recordError 자체가 throw 하는 상황에도 대비해 한 번 더 감쌉니다.
  • 초기화 전 호출은 안전 디폴트_isIPadCached 의 초기값이 false 라, 호출 순서 버그가 있어도 “iPad 아님” 으로 안전하게 동작합니다(오프셋이 적용되지 않을 뿐 시각적 문제는 없음).

호출은 runApp 직전, Firebase 초기화 이후로 둡니다.

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  // iPad 멀티태스킹 안전 오프셋 등 하드웨어 기반 판별에 사용되는
  // 정적 캐시를 부팅 시 1 회 초기화한다.
  // 폴백 경로에서 `CrashlyticsHelper.recordError` 를 호출할 수 있으므로
  // 반드시 `Firebase.initializeApp(...)` 이후에 호출해야 한다.
  await DeviceInfo.initialize();

  runApp(const MyApp());
}

AppHeader 에 안전 오프셋 적용

판별 기반이 갖춰졌으니, iPad 에서만 AppBar 의 상단에 추가 오프셋을 적용합니다. 다른 OS / 단말의 위젯 트리와 외관에는 어떤 영향도 없어야 합니다.

const double _kIPadTopSafeOffset = 32.0;

class AppHeader extends StatelessWidget implements PreferredSizeWidget {
  // ...

  /// iPad 인 경우에만 안전 오프셋을 적용한다.
  /// DeviceInfo.isIPad 는 부팅 시 1 회 캐시된 정적 값이라
  /// 매 build 마다 추가 비용이 없고, 윈도우 크기 변화(슬라이드 오버 등)
  /// 에도 영향받지 않는다.
  double get _topOffset => DeviceInfo.isIPad ? _kIPadTopSafeOffset : 0.0;

  @override
  Size get preferredSize => Size.fromHeight(kToolbarHeight + _topOffset);

  @override
  Widget build(BuildContext context) {
    final offset = _topOffset;
    // iPhone/Android(offset == 0) 에서는 Padding 래퍼 자체를 추가하지 않아
    // 위젯 트리/외관 변화를 코드 레벨에서 명확히 차단한다.
    final hasOffset = offset > 0;
    final topPadding = hasOffset ? EdgeInsets.only(top: offset) : null;
    Widget wrapTop(Widget child) =>
        topPadding == null ? child : Padding(padding: topPadding, child: child);

    // ...
  }
}

여기서 강조하고 싶은 규율 하나는 “오프셋 0일 때는 래퍼 자체를 추가하지 않는다” 는 점입니다.

Padding(padding: EdgeInsets.zero, child: ...) 는 시각적으로는 동일하지만 위젯 트리에 노드를 추가합니다. iPhone / Android 사용자는 99% 이상이 이 변경의 영향권 밖이므로, 위젯 트리 자체가 동일해야 Flutter inspector 에서 변경 사항을 시각화할 때 노이즈가 없습니다. 골든 테스트도 iPhone 측은 기존 골든을 그대로 재사용할 수 있습니다.

if (hasOffset) 분기로 명시적으로 갈라 두면 코드 리뷰에서도 “iPad 가 아니면 아무 변화 없음” 이 한눈에 보입니다.

preferredSize 도 함께 +offset 으로 키워야 합니다. AppBar 의 toolbarHeight 도 동일하게 키워야 하는데, 이 두 값을 같이 키우지 않으면 AppBar 외곽 박스 크기와 부모 Container 박스 크기가 어긋나, 그림자(BoxShadow)가 AppBar 본체와 분리되어 떠 보이는 시각적 버그가 생깁니다.

return Container(
  decoration: getAppBarBoxShadow(),
  child: AppBar(
    // iPad: toolbarHeight 를 32pt 키워 시각 영역과 그림자가 함께 확장된다.
    // iPhone/Android 는 offset == 0 이라 동작/외관이 완전히 동일하다.
    toolbarHeight: kToolbarHeight + offset,
    title: wrapTop(titleWidget ?? _buildDefaultTitle()),
    // ...
  ),
);

함정 — 자동 BackButton 과 명시 leading 의 정렬 차이

여기서부터가 이 fix 의 가장 흥미로운 부분입니다. 위의 오프셋만 적용하고 끝내면 iPad 의 자동 BackButton 만 title 과 baseline 이 어긋나는 문제가 발생합니다.

원인은 Flutter AppBar 의 leading 처리 규칙입니다.

  • 자동 BackButton(leading == null 이고 canPop == true 일 때 AppBar 가 자동 생성) → toolbar의 세로 가운데 정렬
  • 명시적으로 넘긴 leading 위젯 → 기본적으로 top-left 기준 배치

이 차이가 평소에는 보이지 않습니다. toolbarHeight 가 표준값일 때는 BackButton 의 영역이 toolbar 전체와 같아 가운데 정렬이든 top 정렬이든 시각적 결과가 동일하기 때문입니다.

그런데 toolbarHeight 를 +offset 으로 키우는 순간 차이가 드러납니다.

  • title 을 wrapTop(Padding(top: offset, child: ...)) 으로 감싸면 title 은 +offset 만큼 아래로 이동
  • 자동 BackButton 은 가운데 정렬이므로 toolbar 가 32pt 커지면 +offset/2(=16pt)만 아래로 이동
  • 명시 leading 은 top-left 기준이라 그대로 toolbar 상단에 붙음 → wrapTop 으로 감싸야 title 과 같이 이동

즉 동일한 화면에서도 leading 의 종류에 따라 정렬이 달라집니다.

[자동 BackButton 만 16pt 위로 떠 있음]
   ┌─────────────────────────────┐
   │  ← (16pt down)              │ ← 자동 BackButton (중앙 정렬)
   │                             │
   │     [Title]  (32pt down)    │ ← wrapTop 으로 +offset 이동
   └─────────────────────────────┘

해결 방향은 두 가지가 있습니다.

  1. 자동 BackButton 을 비활성화하지 않고 toolbar 상단을 더 끌어내릴 방법을 찾는다(까다로움)
  2. canPop 일 때는 BackButton 을 직접 만들어 title 과 동일한 방식으로 wrapTop 으로 감싼다(명확함)

채택한 것은 2번입니다. 단, canPop == false 인 케이스(첫 페이지, drawer 가 leading 으로 들어가는 케이스 등)에서는 직접 BackButton 을 만들면 안 됩니다. 이 경우 leading 을 null 로 두면 AppBar 가 drawer hamburger 등 기본 leading 을 알아서 처리합니다.

// iPad(hasOffset == true) 에서는 자동 BackButton 도 명시 leading 과 동일하게
// +offset 으로 보정해야 title 과 baseline 이 맞는다.
// 따라서 leading == null 인 경우에도 canPop 일 때는 직접 BackButton 을
// 생성해 wrapTop 으로 감싼다. canPop == false 인 케이스는 null 을 넘겨
// AppBar 의 기본 leading 동작(drawer hamburger 등)을 보존.
// iPhone/Android(offset == 0) 에서는 leading == null 일 때 null 을 그대로
// 넘겨 AppBar 의 자동 BackButton 메커니즘을 유지한다.
Widget? resolvedLeading;
if (leading != null) {
  resolvedLeading = wrapTop(leading!);
} else if (hasOffset) {
  final canPop = ModalRoute.of(context)?.canPop ?? false;
  if (canPop) {
    resolvedLeading = wrapTop(const BackButton());
  }
}

return AppBar(
  toolbarHeight: kToolbarHeight + offset,
  leading: resolvedLeading,
  title: wrapTop(titleWidget ?? _buildDefaultTitle()),
  actions: [
    // offset == 0 (iPhone/Android) 에서는 actions 를 그대로 전달하여
    // 위젯 트리에 Padding 노이즈가 추가되지 않는다.
    if (hasOffset)
      ...actions.map((action) => wrapTop(action))
    else
      ...actions,
    const SizedBox(width: 16),
  ],
);

분기 매트릭스를 정리하면 다음과 같습니다.

iPad?leading 명시?canPop처리
No--원본 그대로(자동 BackButton/null/leading 모두 AppBar 기본 동작)
Yes명시-wrapTop(leading!)
YesnulltruewrapTop(BackButton()) 직접 생성
Yesnullfalsenull (drawer hamburger 등 AppBar 기본 동작 유지)

핵심은 “iPad 에서 leading 이 toolbar 상단에 붙어야 한다는 규칙을, 자동 BackButton 케이스에도 동일하게 적용” 한다는 점입니다. canPop 분기를 잊으면 첫 페이지에서 의도치 않은 BackButton 이 생기는 문제가 발생하므로 반드시 함께 다뤄야 합니다.

정리

iPad 멀티태스킹 환경에서 AppBar 와 시스템 컨트롤 겹침을 해결하기 위해 정리한 내용입니다.

  • 화면 크기 기반 iPad 판별은 멀티윈도우에서 실패 한다. Slide Over · 좁은 Split · Stage Manager 의 좁은 윈도우에서 iPad 가 iPhone 으로 오판된다.
  • 하드웨어 기반 판별 을 사용한다. IosDeviceInfo.modelutsname.machine 을 조합하면 윈도우 크기와 무관한 판별이 가능하다.
  • 부팅 시 1회 캐시 + idempotent init 으로 매 build 비용을 0 으로 만든다. 중복/병렬 호출은 동일 Future 를 공유하여 race 가 없다. 폴백은 안전 디폴트(false=iPad 아님)로 두어 호출 순서 문제에도 시각적 문제가 없도록 한다.
  • 오프셋 적용은 iPad 한정 으로 갈라, iPhone / Android 위젯 트리는 변화 없음을 코드 레벨에서 보장한다. if (hasOffset) 분기로 Padding 자체를 추가하지 않는다.
  • AppBar 의 leading 정렬 함정: 자동 BackButton 은 toolbar center 정렬, 명시 leading 은 top-left 정렬이라 toolbarHeight 를 키우면 baseline 이 어긋난다. canPop 일 때는 BackButton 을 직접 만들어 wrapTop 으로 감싸 title 과 동일한 정렬을 맞춘다. canPop == false 케이스는 null 을 넘겨 AppBar 의 기본 동작을 보존한다.

iPad 멀티태스킹은 사용자가 보기에는 “윈도우 하나가 작아진 것” 뿐이지만, 시스템 grab handle 같은 새로운 시각 요소가 끼어들어 iPhone 디자인을 그대로 들고 오면 충돌이 생깁니다. 핵심은 단말의 정체성(하드웨어)과 화면 상태(윈도우 크기)를 분리하여 다루는 것 입니다. 이 분리가 잘 되어 있어야 멀티태스킹 환경의 다른 문제들도 같은 패턴으로 처리할 수 있습니다.

관련 자료

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS