10. 폴더블 반응형 UI 만들기 by Compose

개요

XML로 폴더블 반응형 UI 만들기

이전에 XML로 ZFlip 등 폴더블 스마트폰에 대한 반응형 UI를 만드는 방법을 소개한 적이 있다.

다만 XML로 구현한 것을 Activity, Fragment 내부에서 UI를 수정하는 것은 까막눈으로 UI를 작업하는 것 같아서 어려운 일이었다.

이를 Compose로 한다면 어떻게 바뀌는지 알아보고자 한다.

실습 코드

기본 레이아웃

기본 레이아웃은 이전과 동일하게 설정할 것이다.

기본 화면

구현

반응형 UI 구현의 큰 틀은 동일하다.

Jetpack Window Manager를 이용해 폴더블 값을 Flow로 받으면서, 그에 따른 UI 로직 처리를 해주는 것이다.

Jetpack Compose의 경우 선언형 UI이기 때문에 Composable 함수로 UI를 구현하기 때문에, XML 방식보다 UI 구현에 있어 편할 것이라 예상된다.

상황에 따라 분기처리를 할 겸 sealed interface를 만들어보았다.

// File: "Event.kt"
sealed interface UIState {
    data object Unfolded : UIState
    data object FoldFolded : UIState
    data object FlipFolded : UIState
    // 폴더블이 아닐 때
    data object Unknown : UIState
}

그래서 window로 들어오는 값에 따라서 Fold일 경우 폴드일 때의 UI가 들어간 Composable 함수와, 아닐 때의 Composable 함수를 각각 만들어서 분기처리를 하면 일단 UI는 아주 간단하게 만들어진다.

// File: "MainActivity.kt"

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        val windowInfoTracker = WindowInfoTracker.getOrCreate(this)
        val uiState = mutableStateOf<UIState>(UIState.Unknown)

        setContent {
            FlexModeComposeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                // Flex Mode 값에 따라 UI를 변경하는 Composable 함수
                // uiState의 state 값이 변경될 때마다 FlexMode에 Recomposition이 일어날 것이다.
                    FlexMode(
                        uiState.value,
                        modifier = Modifier
                            .fillMaxHeight()
                            .padding(innerPadding)
                    )
                }
            }
        }

        lifecycleScope.launch {
            windowInfoTracker.windowLayoutInfo(this@MainActivity)
                .collect { newLayoutInfo ->
                    val foldingFeature =
                        newLayoutInfo.displayFeatures.filterIsInstance<FoldingFeature>()
                            .firstOrNull()

                    foldingFeature?.let {
                        /**
                         * 만약 접힌 상태라면
                         */
                        if (it.state == FoldingFeature.State.HALF_OPENED) {
                            val hingeBounds = it.bounds

                            /**
                             * 만약 힌지가 세로로 있는 거라면 => 폴드라면
                             */
                            val isVerticalHinge = hingeBounds.height() >= hingeBounds.width()
                            if (isVerticalHinge) {
                                // 폴드면 UI state를 폴드 접힘 상태로
                                uiState.value = UIState.FoldFolded
                            }
                            /**
                             * 만약 힌지가 가로로 있는 거라면 => 플립이라면
                             */
                            else {                            
                                // 플립이면 UI state를 플립 접힘 상태로
                                uiState.value = UIState.FlipFolded
                            }
                        }
                        /**
                         * 만약 아니라면 => 다시 폴드의 다 접었을 떄 쓰는 화면 or 펼쳐진 상태라면 펼쳐짐 상태로 복구
                         */
                        else {
                            uiState.value = UIState.Unfolded
                        }
                    }
                }
        }
        
    }
}

들어오는 UIState에 따라 보여줄 Composable 함수를 분기처리한다.

// File: "MainActivity.kt"

@Composable
fun FlexMode(
    uiState: UIState,
    modifier: Modifier = Modifier
) {
    AnimatedContent(
        targetState = uiState,
        label = "FlexMode",
        modifier = Modifier.animateContentSize()
    ) { currentUiState ->
    // 들어온 UI 상태에 따라 접힘이면 각각 폴드, 플립에 따른 @Composable UI를,
    // 펼쳐짐이면 기본 UI를 표현한다.
        when (currentUiState) {
            UIState.Unknown -> {
                Unfolded(
                    modifier = modifier
                )
            }

            UIState.Unfolded -> {
                Unfolded(
                    modifier = modifier
                )
            }

            UIState.FlipFolded -> {

            }

            UIState.FoldFolded -> {
                Folded(
                    modifier = modifier
                )
            }
        }
    }
}

UI State 값에 따라 보여줄 각각의 Composable 함수이다.

// File: "MainActivity.kt"
// Folded와 Unfolded의 차이는 weight밖에 없다.

@Composable
fun Unfolded(modifier: Modifier = Modifier) {
    Row(modifier = modifier) {
        Text(
            text = "Hello",
            color = Color.White,
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxHeight()
                .background(
                    color = Color.Blue
                )
                .wrapContentHeight()
                .weight(8F,),
            // 글자의 정렬 기준이 여러가지가 있는데 아무 설정 없이 사용하면 단순 Top, Bottom으로 적용이 된다고 함
            // 우리가 아는 가운데 정렬로 하려면 아래를 설정하여 폰트의 패딩을 없애야 함
            style = TextStyle(
                platformStyle = PlatformTextStyle(
                    includeFontPadding = false,
                )
            )
        )
        Text(
            text = "World!",
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxHeight()
                .background(
                    color = Color.Green
                )
                .wrapContentHeight()
                .weight(2F),
            style = TextStyle(
                platformStyle = PlatformTextStyle(
                    includeFontPadding = false,
                )
            )
        )
    }
}

@Composable
fun Folded(modifier: Modifier = Modifier) {
    Row(modifier = modifier) {
        Text(
            text = "Hello",
            color = Color.White,
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxHeight()
                .background(
                    color = Color.Blue
                )
                .wrapContentHeight()
                .weight(5F),
            // 글자의 정렬 기준이 여러가지가 있는데 아무 설정 없이 사용하면 단순 Top, Bottom으로 적용이 된다고 함
            // 우리가 아는 가운데 정렬로 하려면 아래를 설정하여 폰트의 패딩을 없애야 함
            style = TextStyle(
                platformStyle = PlatformTextStyle(
                    includeFontPadding = false,
                )
            )
        )
        Text(
            text = "World!",
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxHeight()
                .background(
                    color = Color.Green
                )
                .wrapContentHeight()
                .weight(5F),
            style = TextStyle(
                platformStyle = PlatformTextStyle(
                    includeFontPadding = false,
                )
            )
        )
    }
}

이렇게 하면 우선 UI 상태에 따라 구현은 다 끝났다. 값에 따라 @Composable 함수를 분기처리하면 되니 아주 간단하다

조금 더 나아가 AnimatedContent로 애니메이션 효과를 주었으니, 자연스럽게 변경되지 않을까 기대되는 부분이다.

하지만 결과적으로 AnimatedContent는 기대한대로 적용이 되지 않는다.

바뀔 때마다 번쩍

이유는 XML은 XML로 만들어진 계층적인 View 상태를 기준으로 변경점에 대해서 반영하는 반면, Compose는 그냥 다른 @Composable 함수를 렌더링 할 뿐이기 때문에, 우리 입장에서는 weight만 바뀐 코드이지만 전혀 다른 Composable 함수를 불러오는 것이라 자연스러운 애니메이션이 아니라 사라졌다가 등장하는 애니메이션으로 적용되는 것이다.

결론적으로 분기처리를 통한 각각 다른 Composable 함수 호출은 구현에는 편하지만 좋은 UX 경험을 가져다 주기 어렵다.

결국 하나의 Composable 함수 내에서 로직을 통해서 이동시켜줘야 한다.

본래 FlexMode 함수에서 uiState의 값에 따라 실행시킬 Composable 함수를 분기처리 하였지만, 아래와 같이 수정한다.

updateTransition에 UI상태를 넣어서 변경될 때마다 적용할 애니메이션을 설정한다. 본 예시에서는 weight 값을 변경할 것이다.

주석으로도 적어놓았지만 그냥 Float 값이 아니라, transition.animatedFloat으로 Float 값과 애니메이션을 설정해야 한다.

// File: "UI.kt"
@Composable
fun FlexMode(
    uiState: UIState,
    modifier: Modifier = Modifier
) {
    // updateTransition을 사용해 UI 상태 변화에 따른 애니메이션을 정의
    val transition = updateTransition(targetState = uiState, label = "FlexTransition")

    // 접힘이나 아니냐에 따라서 weight에 적용할 Float 값을 조절하고, tween 애니메이션 적용
    val blueWeight by transition.animateFloat(
        transitionSpec = { tween(durationMillis = 300) },
        label = "BlueWeight"
    ) { state ->
        when (state) {
            UIState.FoldFolded -> 0.5f
            else -> 0.8f
        }
    }

    // green의 weight 애니메이션: Folded이면 0.5, Unfolded이면 0.2
    val greenWeight by transition.animateFloat(
        transitionSpec = { tween(durationMillis = 300) },
        label = "GreenWeight"
    ) { state ->
        when (state) {
            UIState.FoldFolded -> 0.5f
            else -> 0.2f
        }
    }

    // 가중치를 받아서 그리는 FoldableUI함수 실행
    FoldableUI(blueWeight, greenWeight, modifier = modifier)

// 이렇게 생으로 Float 값만 넘기면 적용되지 않는다.
//    val blueWeight = if(uiState == UIState.FoldFolded){
//        0.5F
//    } else {
//        0.8F
//    }
//
//    val greenWeight = if(uiState == UIState.FoldFolded){
//        0.5F
//    } else {
//        0.2F
//    }
}

이렇게 넘겨받은 가중치로 적용할 Float 값을 넘겨받아서 설정하면 된다.

// File: "FoldableUI.kt"
@Composable
fun FoldableUI(blueWeight: Float, greenWeight: Float, modifier: Modifier = Modifier) {
    // blue의 weight 애니메이션: Folded이면 0.5, Unfolded이면 0.8
    Row(modifier = modifier) {
        Text(
            text = "Hello",
            color = Color.White,
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxHeight()
                .background(
                    color = Color.Blue
                )
                .wrapContentHeight()
                .weight(blueWeight),
            // 글자의 정렬 기준이 여러가지가 있는데 아무 설정 없이 사용하면 단순 Top, Bottom으로 적용이 된다고 함
            // 우리가 아는 가운데 정렬로 하려면 아래를 설정하여 폰트의 패딩을 없애야 함
            style = TextStyle(
                platformStyle = PlatformTextStyle(
                    includeFontPadding = false,
                )
            )
        )
        Text(
            text = "World!",
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxHeight()
                .background(
                    color = Color.Green
                )
                .wrapContentHeight()
                .weight(greenWeight),
            style = TextStyle(
                platformStyle = PlatformTextStyle(
                    includeFontPadding = false,
                )
            )
        )
    }
}

최종 결과

애니메이션도 자연스럽게 적용된 것을 확인할 수 있다!

마무리

Compose를 이용한 반응형 UI를 만들어보았다.

선언형 UI인만큼 @Composable를 각각 만들고 분기 처리를 한다면 굳이 머리 아프게 레이아웃 수정을 할 필요 없이 간단하게 할 수 있을 것이라 기대하였으나, 아쉽게 애니메이션이 부자연스러워 이러한 방식으로 적용하는 것은 어려울 것 같다.

하지만 그래도 Compose의 경우 Preview를 이용하여 UI를 미리 볼 수 있다는 압도적인 장점 때문에, 그럼에도 XML 기반 방식보다는 반응형 UI를 구현하는데 있어 장점이 있을 것으로 보인다.


© 2025. Na2te All rights reserved.