관리 메뉴

나만을 위한 블로그

[Android] 폴더블 기기 펼침 여부 확인하는 방법 (Jetpack WindowManager) 본문

Android

[Android] 폴더블 기기 펼침 여부 확인하는 방법 (Jetpack WindowManager)

참깨빵위에참깨빵 2023. 11. 8. 20:47
728x90
반응형

갤럭시 폴드 기기는 접거나 펼친 채로 사용할 수 있다. 이 말은 폴더블 기기가 펼쳐졌는지 아닌지를 구분해야 하는 경우도 생길 수 있단 뜻이다. 이 포스팅의 예시 코드는 아래 코드랩을 바탕으로 구현했다.

 

https://developer.android.com/codelabs/android-window-manager-dual-screen-foldables?hl=ko#0

 

Jetpack WindowManager로 폴더블 및 듀얼 화면 기기 지원  |  Android Developers

Jetpack WindowManager 라이브러리를 사용하여 폴더블 기기 및 듀얼 화면 기기와 같은 새로운 폼 팩터에 맞게 앱을 조정하는 방법을 알아보세요.

developer.android.com

 

위 코드랩에서 폴더블 기기를 펼쳤는지 파악하기 위한 코드만 추출했다.

먼저 라이브러리를 2개 추가한다.

 

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
implementation "androidx.window:window:1.1.0"

 

아래는 액티비티에 추가해야 하는 코드다.

 

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker

class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {

    private lateinit var windowInfoTracker: WindowInfoTracker

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
        lifecycleScope.launch(Dispatchers.Main) {
            windowInfoTracker.windowLayoutInfo(this@MainActivity)
                .collect { value ->
                    if (value.displayFeatures.isNotEmpty()) {
                        val foldingFeature = value.displayFeatures[0] as FoldingFeature
                        Timber.e("## foldingFeature.state : ${foldingFeature.state}")
                        if (foldingFeature.state == FoldingFeature.State.FLAT) {
                            // 폴더블 기기가 펼쳐져 있을 때 사용할 로직
                        }
                    }
                }
        }
    }
}

 

 

Timber는 안드로이드에서 쓸 수 있는 로그 라이브러리인데, 사용 중이지 않다면 이 줄을 지워버리면 된다.

코드만 필요하면 여기까지만 보면 된다. 아래부터는 코드에서 사용된 요소들이 무엇이고 왜 사용했는지 확인한다.

 

제목에 썼듯 처음 추가한 라이브러리는 Jetpack WindowManager를 사용하기 위해 추가했다.

 

https://developer.android.com/jetpack/androidx/releases/window?hl=ko

 

WindowManager  |  Jetpack  |  Android Developers

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. WindowManager Jetpack WindowManager 라이브러리를 사용하면 애플리케이션 개발자가 새로운 기기 폼 팩터와 멀티 윈도

developer.android.com

Jetpack WindowManager 라이브러리를 쓰면 앱 개발자가 새 기기 폼팩터, 멀티 윈도우 환경을 지원할 수 있다. 라이브러리는 API 14 이상에서 공통 API 노출 영역을 제공한다. 최초 버전은 폴더블 기기를 타겟팅하지만 향후 버전에선 더 많은 디스플레이 유형, Window 기능을 지원할 예정이다

 

 

API 14 이상부터 사용 가능한 제트팩 라이브러리고, 폴더블 기기와 멀티 윈도우 환경을 지원한다는 것에 주목하면 될 것 같다.

코드의 첫 시작은 WindowInfoTracker를 lateinit var로 선언하는 것부터 시작한다. WindowInfoTracker는 android.view.Window에 관련된 모든 정보를 제공하는 인터페이스다. WindowInfoTracker의 구현은 아래와 같다. 안드로이드 스튜디오에서 클래스명을 Ctrl + 좌클릭하면 확인할 수 있으니 주석은 제외하고 코드만 남겼다.

 

interface WindowInfoTracker {

    @ExperimentalWindowApi
    fun windowLayoutInfo(@UiContext context: Context): Flow<WindowLayoutInfo> {
        val windowLayoutInfoFlow: Flow<WindowLayoutInfo>? = windowLayoutInfo((context as Activity))
        return windowLayoutInfoFlow
            ?: throw NotImplementedError(
                message = "Must override windowLayoutInfo(context) and provide an implementation.")
    }

    fun windowLayoutInfo(activity: Activity): Flow<WindowLayoutInfo>

    companion object {

        private val DEBUG = false
        private val TAG = WindowInfoTracker::class.simpleName

        @Suppress("MemberVisibilityCanBePrivate")
        internal val extensionBackend: WindowBackend? by lazy {
            try {
                val loader = WindowInfoTracker::class.java.classLoader
                val provider = loader?.let {
                    SafeWindowLayoutComponentProvider(loader, ConsumerAdapter(loader))
                }
                provider?.windowLayoutComponent?.let { component ->
                    ExtensionWindowLayoutInfoBackend(component, ConsumerAdapter(loader))
                }
            } catch (t: Throwable) {
                if (DEBUG) {
                    Log.d(TAG, "Failed to load WindowExtensions")
                }
                null
            }
        }

        private var decorator: WindowInfoTrackerDecorator = EmptyDecorator

        @JvmName("getOrCreate")
        @JvmStatic
        fun getOrCreate(context: Context): WindowInfoTracker {
            val backend = extensionBackend ?: SidecarWindowBackend.getInstance(context)
            val repo = WindowInfoTrackerImpl(WindowMetricsCalculatorCompat, backend)
            return decorator.decorate(repo)
        }

        @JvmStatic
        @RestrictTo(LIBRARY_GROUP)
        fun overrideDecorator(overridingDecorator: WindowInfoTrackerDecorator) {
            decorator = overridingDecorator
        }

        @JvmStatic
        @RestrictTo(LIBRARY_GROUP)
        fun reset() {
            decorator = EmptyDecorator
        }
    }
}

@RestrictTo(LIBRARY_GROUP)
interface WindowInfoTrackerDecorator {
    @RestrictTo(LIBRARY_GROUP)
    fun decorate(tracker: WindowInfoTracker): WindowInfoTracker
}

private object EmptyDecorator : WindowInfoTrackerDecorator {
    override fun decorate(tracker: WindowInfoTracker): WindowInfoTracker {
        return tracker
    }
}

 

이후 onCreate()가 시작하자마자 WindowInfoTracker.getOrCreate()를 통해 초기화한 다음, lifecycleScope 안에서 WindowInfoTracker.windowLayoutInfo()를 호출한다. 이후 값의 방출이 시작되면 collect로 수집한다. 왜 이렇게 작성하냐면 WindowInfoTracker 코드 안의 windowLayoutInfo()의 리턴타입을 보면 Flow<WindowLayoutInfo>기 때문이다.

 

https://developer.android.com/reference/androidx/window/layout/WindowInfoTracker#getOrCreate(android.content.Context)

 

WindowInfoTracker  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

abstract @NonNull Flow<@NonNull WindowLayoutInfo> windowLayoutInfo(@NonNull Activity activity)

 

사용 가능한 모든 기능을 포함하는 WindowLayoutInfo의 Flow다. WindowLayoutInfo에는 연결된 android.view.Window와 교차하는 DisplayFeature 리스트가 포함돼 있다. 첫 번째 WindowLayoutInfo는 Activity.onStart()가 호출될 때까지 방출되지 않는다. 당신이 받는 값, 시기는 기기에 따라 다르다. 하드웨어 구현마다 동작이 다를 수 있으므로 다음 시나리오를 테스트하는 게 좋다

- 이 함수를 구독한 후 즉시 값이 방출된다
- 구독과 첫 번째 값 수신 사이에는 긴 지연이 있다
- 구독 후 값을 받지 못한다

정보가 액티비티와 연관돼 있으므로 액티비티 재생성(recreations) 전반에 걸쳐 Flow를 유지해선 안 된다. 이렇게 하면 메모리 누수, WindowLayoutInfo의 잘못된 값 등 예측할 수 없는 동작이 발생할 수 있다

 

공식문서에 매개변수로 Context를 받는 오버로딩 함수도 존재하지만 예시 코드에서 사용한 것은 매개변수로 액티비티를 넘기는 이 함수기 때문에 확인은 생략한다.

 

다시 예시 코드로 돌아와서, 이제 collect 안의 if문 안을 볼 차례다.

 

if (value.displayFeatures.isNotEmpty()) {
    val foldingFeature = value.displayFeatures[0] as FoldingFeature
    Timber.e("## foldingFeature.state : ${foldingFeature.state}")
    if (foldingFeature.state == FoldingFeature.State.FLAT) {
        // 폴더블 기기가 펼쳐져 있을 때 사용할 로직
    }
}

 

 

collect를 생략했지만 value는 당연히 WindowLayoutInfo 타입의 값인 것을 기존 코드를 통해 알 수 있을 것이다. 이 value 뒤에 온점을 찍으면 displayFeatures라는 DisplayFeature의 리스트를 참조할 수 있다.

 

https://developer.android.com/reference/androidx/window/layout/DisplayFeature

 

DisplayFeature  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

디스플레이의 물리적 특징에 대한 설명. 디스플레이 기능은 장치의 디스플레이 패널 안에 있는 고유한 물리적 특성이다. 이는 앱의 Window 공간에 침입해서(intrude) 시각적 왜곡 또는 터치 불연속성을 생성하거나 일부 영역을 안 보이게 만들기, 화면 공간에 논리적 구분선 또는 분리를 생성할 수 있다

 

쉽게 말해 화면(디스플레이)에 관한 정보를 제공하는 클래스다. 그러나 displayFeatures 리스트 참조에 접근하더라도 내가 원하는 펼침 여부를 확인할 수는 없다. bounds라는 Rect 객체에만 접근할 수 있다.

펼침 여부를 확인하려면 displayFeature의 첫 번째 인덱스를 FoldingFeature로 캐스팅하는 처리가 필요하다. 왜 캐스팅이 필요한가? 코드랩에 왜 변환하는지에 대한 이유가 있지만, 쉽게 알아낸 지식은 그만큼 빨리 휘발되기 마련이다. 조사를 해보자. 아래의 공식문서를 보면 이런 내용이 있다.

 

https://developer.android.com/guide/topics/large-screens/make-apps-fold-aware?hl=ko#features_of_foldable_displays

 

앱에서 접힌 상태 인식  |  Android 개발자  |  Android Developers

앱에서 접힌 상태 인식 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 펼친 상태의 대형 디스플레이와 고유한 접힌 상태는 폴더블 기기에서 새로운 사용자

developer.android.com

Jetpack WindowManager의 WindowLayoutInfo 클래스는 디스플레이 Window의 기능을 DisplayFeature 요소 리스트로 쓸 수 있게 해 준다. FoldingFeature는 아래를 비롯해 폴더블 디스플레이에 대한 정보를 제공하는 DisplayFeature 타입이다

- state : 기기의 접힌 상태 (FLAT 또는 HALF_OPENED)
- orientation : 접힘 또는 힌지의 방향 (HORIZONTAL 또는 VERTICAL)
- occlusionType : 접힘 또는 힌지가 디스플레이 일부를 가리는지 여부 (NONE 또는 FULL)
- isSeparating : 접힘 또는 힌지가 2개의 논리 디스플레이 영역을 생성하는지 여부 (T/F)

 

그리고 예시 코드에선 foldingFeature.state로 표현됐지만, 이 state는 프로퍼티가 아니라 getState()라는 함수다.

공식문서에서 이 함수를 보면 리턴타입이 FoldingFeature.State다.

 

abstract @NonNull FoldingFeature.State getState()

 

getState()를 호출하려면 getState()를 호출하는 객체의 타입이 FoldingFeature여야 한다. 그래야 정상적으로 함수를 호출할 수 있으며 if 조건에서 폴더블 기기가 펼쳐져 있음을 뜻하는 FLAT과 getState()의 값이 일치하는지 체크할 수 있다.

하지만 왜 value.displayFeatures 리스트의 0번 인덱스, 즉 1번째 값을 FoldingFeature로 캐스팅해야 하는가? 왜 그런지 찾는 방법은 간단하다. collect의 안에 있는 변수 value를 로그로 찍어서 0번 인덱스 위치에 뭐가 있는지 보면 된다.

 

value : WindowLayoutInfo{ DisplayFeatures[HardwareFoldingFeature { Bounds { [906,0,906,2176] }, type=FOLD, state=FLAT }] }

 

WindowLayoutInfo 안에 DisplayFeatures를 비롯한 여러 값들이 보인다.

이제 value.displayFeatures[0]을 로그로 출력하면 아래와 같이 단순해진다.

 

value.displayFeatures[0] : HardwareFoldingFeature { Bounds { [906,0,906,2176] }, type=FOLD, state=FLAT }

 

자질구레한 감싸고 있던 것들을 걷어내니 내가 원하는 값인 state가 보인다. 하지만 HardwareFoldingFeature 타입의 값이기 때문에 캐스팅을 통해 FoldingFeature로 바꿔서 getState()로 state를 가져온다. HardwareFoldingFeature 클래스의 코드가 궁금하다면 아래 링크를 참고한다.

 

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:window/window/src/main/java/androidx/window/layout/HardwareFoldingFeature.kt

 

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:window/window/src/main/java/androidx/window/layout/HardwareFoldingFeature.kt

 

cs.android.com

 

아래는 코드랩에서 말하는 displayFeatures를 FoldingFeature로 캐스팅하는 이유다.

 

https://developer.android.com/codelabs/android-window-manager-dual-screen-foldables?hl=ko#6

 

Jetpack WindowManager로 폴더블 및 듀얼 화면 기기 지원  |  Android Developers

Jetpack WindowManager 라이브러리를 사용하여 폴더블 기기 및 듀얼 화면 기기와 같은 새로운 폼 팩터에 맞게 앱을 조정하는 방법을 알아보세요.

developer.android.com

(중략)...그런 다음 WindowLayoutInfo를 사용해서 디스플레이 기능 경계를 가져온다. WindowLayoutInfo는 인터페이스일 뿐인 DisplayFeature 리스트를 리턴하므로, 모든 정보에 접근하려면 FoldingFeature로 변환한다

 

이유가 심플하다. 하지만 무슨 이유로 변환해야 하는지는 안 나와 있어서 찾아보고 추리해 봤다.

아무튼 이런저런 처리를 다 마쳤다면 state를 통해 화면이 펼쳐졌는지를 확인할 수 있다. 이제 펼쳐진 경우와 아닌 경우를 if-else로 구분할 수 있게 됐으니, 각 상태일 때 필요한 로직들을 각자 알아서 넣고 사용하면 될 것이다.

반응형
Comments