$ cat ./posts/dev-log/*.mdrss ↗

> Dev_Log

갤럭시 폴드를 펼쳐도 구현한 구성 변경에 따른 UI 갱신이 발생하지 않던 문제

앱의 전체 화면을 대상으로 특정 커스텀 이미지를 넣는 기능을 구현 중이었습니다. 세로 화면용과 가로 화면용 이미지를 따로 받아서 기기 방향에 맞는 쪽을 골라 보여주는 식입니다. 그런데 갤럭시 폴드를 펼친 상태에서 이 이미지가 위아래로 눌린 것처럼 보인다는 QA 결과가 접수되었습니다. 11df8557-2fd5-4178-aa9c-f8992c7bddf2.png

1. 뭐가 문제였나

이미지를 그려주는 뷰 자체는 centerCrop으로 비율을 유지한 채 잘라 채우는 방식이라, 뷰 크기만 맞으면 이론적으로 찌그러질 이유가 없습니다. 그런데 화면 안의 캘린더 컨텐츠(주/월 단위 행 높이)를 계산하는 코드 쪽을 보니, 화면 크기를 뷰 생성 시점에 한 번 읽어서 프로퍼티에 박아두는 부분이 있었습니다. 그 뒤로 화면 크기가 바뀌어도 이 값은 갱신되지 않고, 액티비티가 다시 만들어질 때만 새로 계산됩니다. 결국 화면 구성 변경에 따라 UI 처리에 대한 계산이 다시 이루어질 것으로 생각했는데, 그게 아니었던 것입니다.

그래서 질문은 “액티비티가 언제 다시 만들어지는가”로 좁혀졌습니다. 코드를 보니 화면 회전이 바뀔 때만 리프레시를 태우고 있었습니다.

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    val current = resources.configuration.orientation
    if (AppState.orientation == current) return   // 방향이 그대로면 아무것도 안 함
    AppState.orientation = current
    refresh()
}

일반 폰에서는 이 로직이 문제없이 동작합니다. 세로에서 가로로 돌리면 Configuration.orientation 값이 확실히 바뀌니까요. 문제는 폴더블입니다. 갤럭시 폴드나 플립은 접었다 펼쳐도 Configuration.orientation 자체는 그대로 PORTRAIT로 유지되는 경우가 많습니다. 화면 크기(smallestScreenWidthDp, screenWidthDp)만 크게 바뀌는 거죠. 그러니 위 코드의 if (같으면 return) 조건에 걸려서 리프레시가 아예 안 일어나고 캘린더 행 높이는 접힌 채로 계산된 값을 계속 씁니다. 좁은 폭 기준으로 계산된 행 안에 배경 이미지가 크롭되어 들어가니, 펼친 넓은 화면에서 보면 눌린 것처럼 보이는 거였습니다.

2. 폴더블을 위한 구성 변경 선언

여기서 한 가지 더 걸리는 부분이 있었습니다. 매니페스트에 선언한 configChangesorientation|screenSize 두 개뿐이었습니다.

안드로이드는 configChanges에 선언 안 한 카테고리의 변화가 생기면 액티비티를 통째로 recreate 합니다. 문제는 폴더블의 접힘/펼침이 orientation, screenSize 말고도 smallestScreenSize, screenLayout 카테고리까지 같이 건드린다는 점입니다.

공식 문서도 폴더블을 다루려면 이 네 가지를 전부 선언하라고 안내합니다.

<activity
    android:name=".MainActivity"
    android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout" />

이걸 다 선언하지 않으면 두 가지 애매한 상황이 겹칩니다. 어떤 폴더블에서는 시스템이 강제로 액티비티를 재생성해버리고(그러면 앞서 본 onConfigurationChanged도 안 타고 그냥 onCreate부터 다시 돕니다), 또 어떤 폴더블에서는 재생성도 안 되고 onConfigurationChanged도 무시당하는 경우가 생깁니다. 어느 쪽이든 화면 크기 관련 캐시가 갱신될 기회를 놓치는 건 마찬가지입니다.

3. 접힘 상태는 방향이 아니라 별도로 감지해야 한다

configChanges를 다 선언하고 나면 이제 “접혔는지 펼쳤는지”를 우리 코드가 직접 판단해야 합니다. 그런데 1번에서 봤듯이 이걸 Configuration.orientation 비교로 유추하는 건 애초에 신뢰할 수 없는 신호였습니다. 방향은 그대로인데 크기만 바뀌는 게 폴더블의 기본 동작이니까요.

문제를 해결할 수 있는 결정적인 도구는 Jetpack의 WindowManager 라이브러리입니다. WindowInfoTracker를 구독하면 FoldingFeature라는 형태로 접힘/펼침 상태(FLAT, HALF_OPENED)를 직접 받을 수 있습니다. 방향이나 크기로 추론하는 게 아니라, “지금 힌지가 접혀 있다/펼쳐져 있다”는 사실 자체를 이벤트로 주는 형식입니다.

implementation 'androidx.window:window:1.5.1'

(현재 안정 버전 확인은 여기서)

lifecycleScope.launch {
    lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        WindowInfoTracker.getOrCreate(this@MainActivity)
            .windowLayoutInfo(this@MainActivity)
            .collect { layoutInfo ->
                val folding = layoutInfo.displayFeatures
                    .filterIsInstance<FoldingFeature>()
                    .firstOrNull()
                // folding?.state == FoldingFeature.State.FLAT / HALF_OPENED
            }
    }
}

일반 폰에서는 displayFeatures가 비어 있어서 folding이 그냥 null로 나옵니다. 별도 분기 없이도 폴더블이 아닌 기기에서는 자연스럽게 무시되는 구조입니다.

4. 최종 코드

여기까지 합치면 접힘 상태가 실제로 바뀌었을 때만 기존 리프레시 함수를 태우는 구조로 정리됩니다. 처음 구독을 시작한 시점에는 이전 상태가 없으니 바로 리프레시하지 않고, 그다음부터 값이 바뀔 때만 반응하도록 이전 상태를 변수 하나로 들고 있습니다.

private var lastFoldState: FoldingFeature.State? = null

private fun observeFoldingFeature() {
    lifecycleScope.launch {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            WindowInfoTracker.getOrCreate(this@MainActivity)
                .windowLayoutInfo(this@MainActivity)
                .collect { layoutInfo ->
                    val folding = layoutInfo.displayFeatures
                        .filterIsInstance<FoldingFeature>()
                        .firstOrNull() ?: return@collect

                    if (lastFoldState != null && lastFoldState != folding.state) {
                        refresh()   // 기존 recreate() 기반 리프레시 재사용
                    }
                    lastFoldState = folding.state
                }
        }
    }
}

기존 방향 감지 로직은 그대로 남겨뒀습니다. 일반 회전은 Configuration.orientation 비교로 충분히 잡히니, 폴더블 힌지 감지는 별도 경로로 추가하는 쪽이 기존 동작을 건드리지 않아 안전합니다.

Comments