Compose Multiplatform iPhone Duo 대응 트러블 슈팅

easyhooon·6일 전
post-thumbnail

서두

반다라트는 Kotlin Multiplatform과 Compose Multiplatform(CMP)으로 Android와 iOS를 함께 만드는 만다라트 계획표 앱이다. 2027년 4월 전까지 App Store에 iPhone Duo 스크린샷을 제출해야 해서, 이번 iOS 1.6.0에서 Duo와 iPad 지원을 함께 진행했다.

Duo 대응 자료를 찾아보면 SwiftUI 기준 글은 조금씩 보였지만, CMP로 만든 앱을 Duo에 맞춘 사례는 찾지 못했다. 결론부터 말하면 내가 고친 문제 대부분은 "Duo라서" 생긴 문제가 아니었다. 화면 크기와 safe area inset을 기기 종류에 기대어 다루던 코드가 넓은 화면에서 드러난 것이었다.

이 글에서는 내가 Duo 시뮬레이터에서 마주친 순서대로 현상, 처음 가정, 실제 원인, 수정과 검증을 정리한다.

본론

시뮬레이터 환경부터 맞지 않았다

가장 먼저 막힌 건 앱이 아니라 시뮬레이터였다.

처음에는 Xcode 27.0의 Device Hub로 Duo 시뮬레이터를 열었다. 화면은 나왔지만 접기와 펼치기 컨트롤이 없었다. simctl에도 힌지 자세를 바꾸는 명령은 없었다. Xcode 27.1 베타 번들 안에 있는 Device Hub를 직접 열어야 하단 툴바에 자세 버튼(접힘, 반 펼침, 펼침)이 나왔다.

open /Applications/Xcode-27.1-beta.app/Contents/Applications/DeviceHub.app

그런데 자세를 바꿀 수 있게 된 뒤에도 안쪽 화면에서 앱 창이 이상하게 좁았다. 오른쪽 상태 레일 자리를 시스템이 검은 바탕으로 차지하고 있었다. 비교해 보니 원인은 빌드 SDK였다. 27.0 SDK로 빌드한 앱은 레일 바깥에만 그려졌고, 27.1 SDK로 빌드한 앱은 레일 아래까지 그려지는 대신 그만큼 safe area inset을 받았다.

기본 xcode-select는 다른 프로젝트도 쓰고 있어서 바꾸지 않았다. 대신 Duo 확인용 빌드에만 DEVELOPER_DIR로 27.1을 지정했다.

DEVELOPER_DIR=/Applications/Xcode-27.1-beta.app/Contents/Developer xcodebuild ...

빌드 결과가 어느 SDK로 만들어졌는지는 앱 Info.plist의 DTSDKName으로 확인했다. 이 차이를 모르고 27.0 SDK 화면을 기준으로 레이아웃을 고쳤다면, 실제 기기와 다른 화면을 맞추고 있었을 것이다.

문제 상황 1: 반다라트 표 아래쪽이 잘린다

27.1 SDK로 다시 빌드해 Duo 안쪽 화면(2853 × 2007)을 열었더니, 홈의 반다라트 표 아래쪽이 화면 밖으로 잘려 스크롤해야 했다. 같은 현상이 Galaxy Fold2 펼침 화면에서도 났다.

iPhone Duo 안쪽 화면에서 반다라트 표 아래쪽이 잘린 수정 전 홈 화면

원인

BandalartChart는 maxWidth - 30.dp를 한 변으로 쓰는 정사각형이었다. 세로 휴대폰에서는 너비가 짧은 쪽이라 문제가 없었지만, 너비가 높이보다 긴 화면에서는 너비를 따라 높이까지 커지면서 화면을 넘어갔다.

처음 가정과 기각한 방법

처음에는 표의 최대 너비만 제한하면 된다고 생각했다. 하지만 그렇게 하면 Duo와 휴대폰 가로에서 여전히 스크롤이 생기고 양옆 빈 공간만 늘었다. 표만 남은 높이에 맞춰 줄이는 방법도 봤지만, 휴대폰 가로에서는 헤더가 높이를 차지해 표가 화면 높이의 60% 수준으로 작아졌다.

또 하나 걸린 점은 분기 기준이었다. iOS에서는 Duo도 iPhone이라 기기 종류로 나눌 수 없었다.

해결: 가용 크기로 1단과 2단을 나눈다

그래서 기기 종류나 화면 방향이 아니라 홈 콘텐츠 영역의 가용 크기로 분기했다. BoxWithConstraints로 가용 너비와 높이를 받아 다음 규칙을 적용했다.

항목결정
2단 조건가용 너비 ≥ 가용 높이 × 0.9
2단 배치왼쪽 정보 영역(이모지, 제목, 달성률, 공유 버튼), 오른쪽 표
2단 표 한 변min(가용 높이 − 상단 바, 가용 너비 × 0.6)
1단 배치기존 구조 유지

처음에는 2단 조건을 1.2배로 잡았다. 그런데 Fold2 펼침 홈 영역이 약 589 × 609dp라 1단으로 떨어졌고, 1단에서는 다시 표가 잘렸다. 그래서 0.9배로 낮췄다. 세로 휴대폰은 이 비율이 약 0.55라 영향이 없다.

표 크기가 바뀌면 셀 글자도 같이 맞아야 했다. BandalartChart가 CompositionLocal로 배율을 내려주고, 셀 텍스트가 글자 크기와 줄 높이에 곱하도록 했다. 표 한 변이 400dp를 넘으면 최대 1.5배까지 키우고, 300dp보다 작으면 최소 0.85배까지 줄인다.

고치고 나서 드러난 휴대폰 가로 문제

2단 조건을 넣은 뒤 내부 테스트에서 일반 휴대폰을 가로로 돌렸더니, 이번에는 표가 약 180dp로 작아지고 공유 버튼이 광고 밑으로 잘렸다. 높이 약 384dp 중 상태바, 상단 바, 배너 광고, 여백이 대부분을 가져갔기 때문이다.

홈 전체 높이가 420dp 미만이면 상단 바와 배너 광고를 왼쪽 정보 열로 옮기고, 표가 홈 전체 높이를 쓰게 했다. 표 한 변은 약 180dp에서 약 340dp로 커졌다. Fold2 펼침, Duo 안쪽, 세로 휴대폰 배치는 바뀌지 않는다.

문제 상황 2: 하단 시트 내용이 한쪽으로 치우친다

이 부분이 CMP에서 가장 헷갈렸다.

1차 수정과 그 결과

먼저 발견한 건 Duo 바깥 화면에서 하단 시트 내용이 상태 레일 밑으로 들어가는 문제였다. 그래서 시트 5곳에 WindowInsets.safeDrawing의 가로 inset을 그대로 패딩으로 더했다. Duo 바깥 화면은 이걸로 해결됐다.

그런데 Duo 안쪽 화면에서 시트를 열자 내용이 왼쪽으로 치우치고 오른쪽에 빈 공간이 생겼다.

원인

Material3 ModalBottomSheet는 넓은 화면에서 BottomSheetDefaults.SheetMaxWidth(640dp)로 너비가 제한되어 화면 가운데에 뜬다. 시트가 이미 상태 레일에서 멀리 떨어져 있는데도, 내가 더한 inset은 시트 위치와 상관없이 패딩으로 들어갔다. 그래서 레일 너비(약 70pt)만큼 내용이 밀렸다.

정리하면 1차 수정은 "시트가 창 전체 너비"라는 가정 위에서만 맞았다.

해결: 겹치는 만큼만 패딩을 준다

각 쪽 패딩을 max(0, 가로 inset − (창 너비 − 시트 너비) / 2)로 계산했다. 시트 바깥 여백이 inset을 이미 덮고 있으면 그만큼 패딩을 줄이는 방식이다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun bottomSheetContentWindowInsets(): WindowInsets {
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    val windowWidth = LocalWindowInfo.current.containerSize.width
    val sheetWidth = minOf(windowWidth, with(density) { BottomSheetDefaults.SheetMaxWidth.roundToPx() })
    val sideGap = (windowWidth - sheetWidth) / 2
    val safeDrawing = WindowInsets.safeDrawing
    return BottomSheetDefaults.windowInsets.union(
        WindowInsets(
            left = (safeDrawing.getLeft(density, layoutDirection) - sideGap).coerceAtLeast(0),
            right = (safeDrawing.getRight(density, layoutDirection) - sideGap).coerceAtLeast(0),
        ),
    )
}

시트 5곳에서는 contentWindowInsets로 넘긴다.

ModalBottomSheet(
    contentWindowInsets = { bottomSheetContentWindowInsets() },
    // ...
)
  • 시트가 창 전체 너비일 때(Duo 바깥, 세로 휴대폰)는 1차 수정과 결과가 같다.
  • 가운데 뜬 시트는 바깥 여백만큼 패딩이 줄어 좌우가 대칭이 된다.
  • 세로 inset은 BottomSheetDefaults.windowInsets를 그대로 쓴다.

iPhone Duo 안쪽 화면 가운데에 좌우 여백이 대칭으로 뜬 편집 시트

Modifier.fitInside(WindowInsetsRulers.SafeDrawing.current)처럼 위치 기반으로 처리하는 방법도 검토했다. 위치를 기준으로 하니 더 정확하지만, 가로 inset만 고를 수 없었다. 시트가 아래에서 올라오는 동안 아래쪽 패딩이 바뀌어 내용이 흔들릴 수 있어서 쓰지 않았다.

문제 상황 3: 완료 화면과 온보딩이 가로로 늘어난다

넓은 화면에서 완료 화면의 카드와 버튼, 온보딩 이미지와 시작 버튼이 화면 끝까지 늘어났다.

처음에는 완료 화면을 Column으로 나눠 다시 배치했다. 그랬더니 카드가 Lottie 축하 애니메이션과 겹쳤다. 기존 화면은 애니메이션을 overlay로 겹쳐 두는 구조였는데, 그 전제를 깨뜨린 것이다.

결국 기존 overlay 구조는 그대로 두고 최대 너비만 제한했다.

Box(
    modifier = Modifier
        .widthIn(max = 480.dp)
        .fillMaxSize(),
)

온보딩의 HorizontalPager에도 같은 480dp 제한을 걸었다. 구조를 바꾸기 전에 제약 하나로 끝나는지 먼저 봤어야 했다.

문제 상황 4: 배너 광고 자리에 흰 사각형이 보인다

iOS 배너 광고는 Google Mobile Ads의 GADBannerView를 UIKitView로 감싸 Compose 안에 넣고 있다. 광고가 로드되기 전이나 숨겨졌을 때 그 자리에 흰 사각형이 보였다.

원인

CMP의 UIKitView는 Compose 캔버스에서 해당 영역을 비워 두고 그 뒤에 UIKit 뷰를 둔다. 광고 내용이 없을 때 보이는 건 그 UIKit 뷰의 배경이었고, 기본 배경이 흰색이었다.

해결

placedAsOverlay로 UIKit 뷰를 Compose 위에 올리는 방법도 있었지만, 하단 시트가 열렸을 때 광고가 시트 위에 그려지는 문제가 생겨 쓰지 않았다. 대신 UIKit 뷰 배경을 Compose 테마의 배경색에 맞췄다.

val backgroundColor = MaterialTheme.colorScheme.background
UIKitView(
    factory = { /* GADBannerView 생성 */ },
    update = { view ->
        view.hidden = !visible
        view.backgroundColor = UIColor.colorWithRed(
            red = backgroundColor.red.toDouble(),
            green = backgroundColor.green.toDouble(),
            blue = backgroundColor.blue.toDouble(),
            alpha = backgroundColor.alpha.toDouble(),
        )
    },
)

update에서 매번 맞추기 때문에 라이트와 다크 모드를 바꿔도 따라간다.

함께 처리한 것: iPad 공유 시트

Duo와 함께 iPad도 지원 대상에 넣으면서(TARGETED_DEVICE_FAMILY = "1,2,7") 공유 기능을 다시 봤다. iPad에서 UIActivityViewController는 popover로 뜨기 때문에 sourceView와 sourceRect를 지정하지 않으면 크래시한다.

import platform.UIKit.popoverPresentationController

controller.popoverPresentationController?.apply {
    sourceView = rootView
    sourceRect = rootView.bounds.useContents { CGRectMake(size.width / 2, size.height / 2, 0.0, 0.0) }
    permittedArrowDirections = 0uL
}

여기서 한 번 막혔다. Kotlin/Native에서 popoverPresentationController는 확장 프로퍼티로 노출되어서, import를 명시하지 않으면 unresolved reference가 난다.

검증

화면확인한 결과
세로 휴대폰기존과 동일
휴대폰 가로 (Android, iOS)2단, 스크롤 없이 표 전체와 공유 버튼이 보임
Galaxy Fold2 펼침2단, 표 전체가 보임
iPhone Duo 안쪽2단, 하단 시트 좌우 여백 대칭
iPhone Duo 바깥쪽기존 배치 유지, 시트 내용이 상태 레일 밑으로 들어가지 않음
iPad 13세로와 가로 모두 2단 또는 1단으로 정상 표시, 공유 시트가 popover로 뜸

남은 한계도 있다. Duo 시뮬레이터는 Xcode 27.1 베타 기준이라, 정식 Xcode와 실제 기기에서 레일과 inset 값이 달라지면 다시 확인해야 한다.

결과

Duo에 맞춰 고친 규칙은 기기 이름을 보지 않기 때문에, 다른 넓은 화면에도 그대로 적용됐다. 덕분에 이번 작업으로 아래 환경까지 큰 추가 작업 없이 함께 지원할 수 있었다.

  • 가로 모드: Android 휴대폰의 세로 고정을 풀고, Android와 iOS 모두 가로 화면에서 표 전체가 보이게 했다.
  • 폴더블: Galaxy Fold 펼침 화면에서 2단 배치로 표가 잘리지 않는다.
  • 태블릿: Android 태블릿에서도 같은 크기 규칙으로 1단과 2단이 나뉜다.
  • iPad: iOS 1.6.0에서 iPad를 정식 지원 대상에 넣었다.

iPhone Duo 안쪽 화면에서 정보 영역과 반다라트 표가 2단으로 배치된 홈 화면

iPad 가로 화면에서 2단으로 배치된 반다라트 홈 화면

결론

Duo 대응을 시작할 때는 "Duo 전용 레이아웃"을 만들어야 한다고 생각했다. 실제로 고친 건 기기를 구분하는 코드가 아니었다. 가용 크기를 기준으로 배치를 나누고, inset을 내가 그리는 영역과 실제로 겹치는 만큼만 적용하는 코드였다.

CMP에서는 Material3 컴포넌트의 최대 너비 같은 숨은 제약과 safe area inset이 따로 움직일 수 있다. UIKitView처럼 플랫폼 뷰가 끼어드는 지점은 배경색까지 확인해야 한다. 그리고 레이아웃을 고치기 전에, 지금 보고 있는 화면이 실제 기기와 같은 조건(SDK, 자세)인지부터 확인해야 한다.

Reference

profile
실력은 고통의 총합이다. Android Developer

1개의 댓글

Compose Multiplatform에서 iOS 쪽 트러블슈팅 기록은 아직 자료가 많지 않아서 귀하네요. 원인 찾는 과정을 순서대로 적어 주셔서 비슷한 상황에 참고가 될 것 같습니다.

답글 달기