개발/안드로이드

적응형 앱, 반응형 UI 탐색

스몰스테핑 2024. 5. 16. 17:29

중단점이란?

적응형 앱을 만들려면 화면 크기에 따라 레이아웃이 변경되도록 해야 한다. 레이아웃 변경이 이루어지는 측정 지점을 중단점이라고 한다. Material Design은 대부분의 Android 화면을 처리하는 체계적인 중단점 범위를 만들었다.

 

 

 

이 중단점 범위 표에서는 앱이 화면 크기가 600dp 미만인 기기에서 실행 중인 경우 모바일 레이아웃을 표시해야 함을 보여준다.

 

 

창 크기 클래스 사용하기

Compose에 도입된 WindowSizeClass API를 사용하면 Material Design 중단점 구현이 간단해진다.

창 크기 클래스는 너비와 높이에 대해 소, 중, 대형의 세 가지 크기 카테고리를 도입한다.

 

 

 

프로젝트에서 WindowSizeClass API를 구현하기 위해 다음 단계를 따라야한다.

...
dependencies {
...
"androidx.compose.material3:material3-window-size-class:$material3_version"
...
  • 모듈 build.gradle 파일에 material3-window-size-class 종속 항목을 추가후 Sync Now, gradle을 동기화한다.

 

...
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass

...

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ReplyTheme {
                val windowSize = calculateWindowSizeClass(this)
                ReplyApp()

...
  • MainActivity.kt의 onCreate() 함수에서 매개변수를 통해 this 컨텍스트를 전달받는 calculateWindowSizeClass 메서드를 windowSize라는 변수에 할당한다.
  • 적절한 calculateWindowSizeClass를 가져온다.

 

  • calculateWindowSizeClass 구문에 빨간색 밑줄이 표시되고 빨간색 전구가 나타났다. windowsize 변수 왼쪽의 빨간 전구를 클릭하고 Opt in for 'ExperimentalMaterial3WindowSizeClassApi' on 'onCreate'를 선택해 onCreate() 메서드 위에 주석을 만든다.

이제 MainActivity.kt의 WindowWidthSizeClass 변수를 사용해 여러 컴포저블에서 표시할 레이아웃을 정할 수 있다.

 

 

다음 코드는 ReplyApp의 한 부분이다.

위에서 말한 과정을 적용한 뒤의 결과이다.

class MainActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContent {
            ReplyTheme {
                val windowSize = calculateWindowSizeClass(activity = this)
                val layoutDirection = LocalLayoutDirection.current
                Surface(
                    modifier = Modifier
                        .padding(
                            start = WindowInsets.safeDrawing.asPaddingValues()
                                .calculateStartPadding(layoutDirection),
                            end = WindowInsets.safeDrawing.asPaddingValues()
                                .calculateEndPadding(layoutDirection)
                        )
                ) {
                    ReplyApp(windowSize.widthSizeClass)
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ReplyAppCompactPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(WindowWidthSizeClass.Compact)
        }
    }
}

 

OptIn 주석을 달고, 화면 표시할 컴포저블인 ReplyApp의 매개변수에 windowSize.widthSizeClass를 추가해두면 위처럼 같이 요구를 할 것이고 값을 전달해주면 된다.

 

 

...

@Composable
fun ReplyApp(
    windowSize: WindowWidthSizeClass,
    modifier: Modifier = Modifier
) {
    val viewModel: ReplyViewModel = viewModel()
    val replyUiState = viewModel.uiState.collectAsState().value

    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
        }
        WindowWidthSizeClass.Medium -> {
        }
        WindowWidthSizeClass.Expanded -> {
        }
        else -> {
        }
    }
...

 

이후 화면에서 windowsize를 레이아웃 카테고리별로 나누어 표시할 방법을 강구하면 된다.

 

 

적응형 UI 탐색 구현하기

Preview UI Check

 

이 Reply App의 하단 탐색 메뉴는 모든 크기에서 사용되고 있다.

이 탐색 요소는 사용자가 대형 화면을 사용하면 할수록 접근이 어려워질 수 있다. 반응형 UI 탐색에는 여러 창 크기 클래스에 따라 권장되는 여러 탐색 요소 패턴이 존재한다. Reply 앱의 경우, 다음 요소를 구현할 수 있다.

 

 

탐색 레일Material Design의 또 다른 탐색 구성요소로, 앱에서 기본 도착 페이지를 위한 소형 탐색 옵션에 액세스할 수 있도록 지원한다.

 

 

마찬가지로, 영구 탐색 창Material Degin에 의해 생성되는 또 다른 옵션으로, 대형 화면을 위한 인체 공학적 액세스를 제공한다.

 

 

탐색 창 구현하기

대형 화면의 탐색 창을 만들려면 navigationType 매개변수를 사용해야한다.

  • 탐색 요소의 여러 유형을 나타내려면 ui 디렉터리 아래의 새 패키지 utils에 새 파일 WindowStateUtils.kt를 만든다.
  • Enum 클래스를 추가해 탐색 요소의 여러 유형을 나타낸다.

WindowStateUtils.kt

package com.example.reply.ui.utils

enum class ReplyNavigationType {
    BOTTOM_NAVIGATION, NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER
}

 

탐색 창을 성공적으로 구현하려면 앱의 창 크기에 따라 탐색 유형을 정해야 한다.

  • ReplyApp 컴포저블에서 navigationType 변수를 만들고 when문의 화면 크기에 따라 적절한 ReplyNavigationType값을 할당한다.

 

ReplyApp.kt

...
import com.example.reply.ui.utils.ReplyNavigationType
...
    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
            navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
        }
        WindowWidthSizeClass.Medium -> {
            navigationType = ReplyNavigationType.NAVIGATION_RAIL
        }
        WindowWidthSizeClass.Expanded -> {
            navigationType = ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        }
        else -> {
            navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
        }
    }
...

 

ReplyHomeScreen 컴포저블의 navigationType 값을 사용한다. 이를 컴포저블용 매개변수로 만든다.

  • ReplyHomeScreen 컴포저블에서 navigationType을 매개변수로 추가한다.

 

ReplyHomeScreen.kt

...
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Email) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
)

...

 

  • navigationType을 ReplyHomeScreen 컴포저블에 전달한다.

 

ReplyApp.kt

...
   ReplyHomeScreen(
        navigationType = navigationType,
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )
...

 

 

다음으로, 사용자가 대형 화면에서 앱을 열고 홈 화면을 표시하는 경우 앱 컨텐츠를 탐색 창과 함께 표시하는 브랜치를 만들 수 있다.

  • ReplyHomeScreen 컴포저블 본문에서 navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER && replyUiState.isShowingHomepage 조건에 대응하는 if문을 추가한다.

 

ReplyHomeScreen.kt

import androidx.compose.material3.PermanentNavigationDrawer
...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Email) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
) {
...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
    }

    if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                replyUiState = replyUiState,
...

 

 

  • 영구 창을 만들려면 if 문 본문에 PermanentNavigationDrawer 컴포저블을 만들고, drawerContent 매개변수의 입력으로 NavigationDrawerContent 컴포저블을 추가한다.
  • ReplyAppContent 컴포저블을 PermanentNavigationDrawer의 마지막 람다 인수로 추가한다.

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
        PermanentNavigationDrawer(
            drawerContent = {
                NavigationDrawerContent(
                    selectedDestination = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        ) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier

            )
        }
    }

...

 

  • 이전 컴포저블 본문을 사용하여 대형이 아닌 화면의 이전 브랜치를 유지하는 else 브랜치를 추가한다.

 

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER) {
        PermanentNavigationDrawer(
            drawerContent = {
                PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
                    NavigationDrawerContent(
                        selectedDestination = replyUiState.currentMailbox,
                        onTabPressed = onTabPressed,
                        navigationItemContentList = navigationItemContentList,
                        modifier = Modifier
                            .wrapContentWidth()
                            .fillMaxHeight()
                            .background(MaterialTheme.colorScheme.inverseOnSurface)
                            .padding(dimensionResource(R.dimen.drawer_padding_content))
                    )
                }
            }
        ) {

...

 

  • 태블릿 모드에서 실행해보면 다음과 같이 화면이 표시된다.

 

 

탐색 레일 구현하기

탐색 창 구현과 마찬가지로 navigationType 매개변수를 사용하여 탐색 요소 간의 전환을 해야한다.

먼저 중형 화면을 위한 탐색 레일을 추가한다.

  • 먼저 navigationType을 매개변수로 추가하여 ReplyAppContent 컴포저블을 준비한다.

 

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
...

 

  • navigationType 값을 양쪽 ReplyAppContent 컴포저블에 전달한다.

 

ReplyHomeScreen.kt

...
            ReplyAppContent(
                navigationType = navigationType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                navigationType = navigationType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
...

 

다음으로, 앱에서 일부 시나리오를 위한 탐색 레일을 표시할 수 있도록 브렌치를 추가한다.

  • ReplyAppContent 컴포저블의 첫번째 줄에서 ReplyNavigationRail 컴포저블을 AnimatedVisibility 컴포저블로 레핑하고 ReplyNavigationType 값이 NavigationRail이면 visibility 매개변수가 true가 되도록 설정한다.

 

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            ReplyNavigationRail(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
navigationItemContentList = navigationItemContentList
            )
        }
        Column(
            modifier = Modifier
                .fillMaxSize()            .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList

            )
        }

}
...

 

컴포저블을 올바르게 정렬하려면 ReplyAppContent 본문에 있는 AnimatedVisibility 컴포저블과 Column 컴포저블을 Row 컴포저블로 레핑한다.

 

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier.fillMaxSize()) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            ReplyNavigationRail(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList
            )
        }
        Column(
            modifier = Modifier
                .fillMaxSize()            .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList

            )
        }
    }
}
...

 

마지막으로, 몇 가지 시나리오에서는 하단 탐색이 표시되는지 확인한다.

  • ReplyListOnlyContent 컴포저블 뒤에서 ReplyBottomNavigationBar 컴포저블을 AnimatedVisibility 컴포저블로 래핑한다.
  • ReplyNavigationType 값이 BOTTOM_NAVIGATION인 경우 visible 매개변수를 설정한다.

 

ReplyHomeScreen.kt

...
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
                ReplyBottomNavigationBar(
                    currentTab = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
...

 

펼쳐진 폴더블 모드로 앱을 실행한다.

 

 

 

 

 

여기서 끝내도 좋으나 위 사진을 보면 공간활용이 잘 되지 않는 모습을 볼 수 있다. (Reply 박스가 옆으로 길게 늘어져 있고 클릭하면 detail 정보가 나오는 방식)

 

이를 수정해보자.

 

 

적응형 콘텐츠 레이아웃 구현

이 레이아웃은 표준 레이아웃 중 하나를 적용하여 개선할 수 있다. 표준 레이아웃은 디자인 및 구현을 위한 시작점으로 기능하는 대형 화면 컴포지션이다. 제공되는 세 가지 레이아웃을 사용하여 앱, 목록보기, 지원 패널, 피드에서 일반적인 요소를 정리하는 방법을 안내할 수 있다. 각 레이아웃은 일반적인 사용 사례와 구성요소를 고려하여 앱이 여러 화면 크기와 중단점에서 어떻게 조정되는지에 관한 기대치와 사용자의 요구를 해결한다.

 

현재 예제로 다루고 있는 Reply 앱에서는 목록 세부정보 뷰로 구현할 수 있다. 콘텐츠를 탐색하고 세부정보를 빠르게 확인하는 데 가장 좋기 때문이다. 목록 세부정보 뷰 레이아웃을 사용하면 이메일 목록 화면 옆에 이메일 세부정보를 표시할 다른 창을 만들 수 있다. 이 레이아웃을 사용하면 제공되는 화면을 사용하여 사용자에게 더 많은 정보를 표시하고 앱의 생산성을 높일 수 있다.

 

  • 다양한 유형 콘텐츠 레이아웃을 표현하려면 WindowStateUtils.kt에서 다양한 콘텐츠 유형에 관한 새 Enum 클래스를 만든다. 확장형 화면을 사용 중인 경우 LIST_AND_DETAIL 값을 사용하고 사용하지 않는 경우 LIST_ONLY 값을 사용한다.

WindowStateUtils.kt

...
enum class ReplyContentType {
    LIST_ONLY, LIST_AND_DETAIL
}
...

 

  • ReplyApp.kt에서 contentType 변수를 선언하고 다양한 창 크기에 맞는 적절한 contentType을 할당하여 화면 크기에 따라 적절한 컨텐츠 유형 선택을 할 수 있다.

ReplyApp.kt

...
import com.example.reply.ui.utils.ReplyContentType
...

    val navigationType: ReplyNavigationType
    val contentType: ReplyContentType

    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
        WindowWidthSizeClass.Medium -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
        WindowWidthSizeClass.Expanded -> {
            ...
            contentType = ReplyContentType.LIST_AND_DETAIL
        }
        else -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
    }
...

 

 

이제 contentType 값을 사용하여 ReplyAppContent 컴포저블에서 레이아웃의 다른 브랜치를 만들 수 있다.

  • ReplyHomeScreen.kt에서 contentType을 매개변수로 ReplyHomeScreen 컴포저블에 추가한다.

ReplyHomeScreen.kt

...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    contentType: ReplyContentType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Email) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {
...

 

 

  • contentType값을 ReplyHomeScreen 컴포저블에 전달한다.

ReplyApp.kt

...
    ReplyHomeScreen(
        navigationType = navigationType,
        contentType = contentType,
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )

...

 

 

  • contentType을 ReplyAppContent 컴포저블의 매개변수로 추가한다.

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    contentType: ReplyContentType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
...

 

 

  • contentType 값을 ReplyAppContent 컴포저블 두 개에 전달한다.

ReplyHomeScreen.kt

...
            ReplyAppContent(
                navigationType = navigationType,
                contentType = contentType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                navigationType = navigationType,
                contentType = contentType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackButtonClicked = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
    }
...

 

 

contentType이 LIST_AND_DETAIL일 때는 전체 목록과 세부정보 화면을 표시하고 contentType이 LIST_ONLY일 때는 목록 전용 이메일 콘텐츠를 표시해 보자.

  • ReplyHomeScreen.kt에서 ReplyAppContent 컴포저블에 if/else 문을 추가하여 contentType 값이 LIST_AND_DETAIL일 때 ReplyListAndDetailContent 컴포저블을 표시하고 else 브랜치에 ReplyListOnlyContent 컴포저블을 표시한다.

ReplyHomeScreen.kt

...
        Column(
            modifier = modifier
                .fillMaxSize()
                .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            if (contentType == ReplyContentType.LIST_AND_DETAIL) {
                ReplyListAndDetailContent(
                    replyUiState = replyUiState,
                    onEmailCardPressed = onEmailCardPressed,
                    modifier = Modifier.weight(1f)
                )
            } else {
                ReplyListOnlyContent(
                    replyUiState = replyUiState,
                    onEmailCardPressed = onEmailCardPressed,
                    modifier = Modifier.weight(1f)
                        .padding(
                            horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                        )
                )
            }
            AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
                ReplyBottomNavigationBar(
                    currentTab = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        }
...

 

 

  • 사용자가 확장형 뷰를 사용하는 경우 세부정보 뷰로 이동할 필요가 없으므로 replyUiState.isShowingHomepage 조건을 삭제하여 영구 탐색 창을 표시한다.

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER) {
        PermanentNavigationDrawer(
            drawerContent = {
                PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
                    NavigationDrawerContent(
                        selectedDestination = replyUiState.currentMailbox,
                        onTabPressed = onTabPressed,
                        navigationItemContentList = navigationItemContentList,
                        modifier = Modifier
                            .wrapContentWidth()
                            .fillMaxHeight()
                            .background(MaterialTheme.colorScheme.inverseOnSurface)
                            .padding(dimensionResource(R.dimen.drawer_padding_content))
                    )
                }
            }
        ) {

...

 

 

목록 - 세부정보 뷰의 UI 요소 개선

현재 앱에서는 홈 화면에 확장형 화면의 세부정보 창을 표시한다.

 

하지만 뒤로 버튼과 제목 헤더, 추가 패딩과 같은 관련 없는 요소가 화면에 포함되어 있다. 독립형 세부정보 화면용으로 설계되었기 때문입니다. 간단한 조정으로 이 부분을 개선할 수 있다.

  • ReplyDetailsScreen.kt에서 isFullScreen 변수를 Boolean 매개변수로 ReplyDetailsScreen 컴포저블에 추가한다.

이렇게 하면 독립형으로 사용할 때와 홈 화면 내에서 사용할 때 컴포저블을 구별할 수 있다.

 

ReplyDetailsScreen.kt

...
@Composable
fun ReplyDetailsScreen(
    replyUiState: ReplyUiState,
    onBackPressed: () -> Unit,
    modifier: Modifier = Modifier,
    isFullScreen: Boolean = false
) {
...

 

 

  • ReplyDetailsScreen 컴포저블 내에서 앱이 전체 화면일 때만 표시되도록 if문으로 ReplyDetailsScreenTopBar 컴포저블을 래핑한다.

ReplyDetailsScreen.kt

...
    LazyColumn(
        modifier = modifier
            .fillMaxSize()
            .background(color = MaterialTheme.colorScheme.inverseOnSurface)
            .padding(top = dimensionResource(R.dimen.detail_card_list_padding_top))
    ) {
        item {
            if (isFullScreen) {
                ReplyDetailsScreenTopBar(
                    onBackPressed,
                    replyUiState,
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = dimensionResource(R.dimen.detail_topbar_padding_bottom))
                    )
                )
            }

...

 

 

이제 패딩을 추가할 수 있다. ReplyEmailDetailsCard 컴포저블에 필요한 패딩은 전체 화면으로 사용하는지에 따라 달라진다. 확장형 화면에서 다른 컴포저블과 함께 ReplyEmailDetailsCard를 사용하면 다른 컴포저블의 추가 패딩이 존재한다.

  • isFullScreen 값을 ReplyEmailDetailsCard 컴포저블에 전달한다. 화면이 전체 화면인 경우 가로 패딩이 R.dimen.detail_card_outer_padding_horizontal인 수정자를 전달하고 전체 화면이 아닌 경우 끝 패딩이 R.dimen.detail_card_outer_padding_horizontal인 수정자를 전달한다.

ReplyDetailsScreen.kt

...
        item {
            if (isFullScreen) {
                ReplyDetailsScreenTopBar(
                    onBackPressed,
                    replyUiState,
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = dimensionResource(R.dimen.detail_topbar_padding_bottom))
                    )
                )
            }
            ReplyEmailDetailsCard(
                email = replyUiState.currentSelectedEmail,
                mailboxType = replyUiState.currentMailbox,
                isFullScreen = isFullScreen,
                modifier = if (isFullScreen) {
                    Modifier.padding(horizontal = dimensionResource(R.dimen.detail_card_outer_padding_horizontal))
                } else {
                    Modifier.padding(end = dimensionResource(R.dimen.detail_card_outer_padding_horizontal))
                }
            )
        }
...

 

 

  • isFullScreen 값을 매개변수로 ReplyEmailDetailsCard 컴포저블에 추가한다.

ReplyDetailsScreen.kt

...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplyEmailDetailsCard(
    email: Email,
    mailboxType: MailboxType,
    modifier: Modifier = Modifier,
    isFullScreen: Boolean = false
) {
...

 

 

  • ReplyEmailDetailsCard 컴포저블 내에서 앱이 전체화면이 아닐때만 이메일 제목 텍스트를 표시한다. 전체 화면 레이아웃에는 이미 이메일 제목 헤더로 표시되어 있기 때문이다. 전체 화면인 경우 높이가 R.dimen.detail_content_padding_top인 스페이서를 추가한다.

ReplyDetailsScreen.kt

...
Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(dimensionResource(R.dimen.detail_card_inner_padding))
) {
    DetailsScreenHeader(
        email,
        Modifier.fillMaxWidth()
    )
    if (isFullScreen) {
        Spacer(modifier = Modifier.height(dimensionResource(R.dimen.detail_content_padding_top)))
    } else {
        Text(
            text = stringResource(email.subject),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.outline,
            modifier = Modifier.padding(
                top = dimensionResource(R.dimen.detail_content_padding_top),
                bottom = dimensionResource(R.dimen.detail_expanded_subject_body_spacing)
            ),
        )
    }
    Text(
        text = stringResource(email.body),
        style = MaterialTheme.typography.bodyLarge,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
    )
    DetailsScreenButtonBar(mailboxType, displayToast)
}

...

 

 

  • ReplyHomeScreen.kt의 ReplyHomeScreen 컴포저블 내에서 ReplyDetailsScreen 컴포저블을 독립형으로 만들 때, isFullScreen 매개변수에 true 값을 전달한다.

ReplyHomeScreen.kt

...
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackPressed = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
...

 

 

세부정보 뷰의 뒤로 처리 조정

확장형 화면을 사용하면 ReplyDetailsScreen으로 이동할 필요가 없다. 대신 사용자가 뒤로 버튼을 선택할 때, 앱이 닫히도록하는 것이 좋다. 따라서 뒤로 핸들러를 조정해야한다.

 

  • ReplyListAndDetailContent 컴포저블 내에서 ReplyDetailsScreen 컴포저블의 onBackPressed 매개변수로 activity.finish() 함수를 전달하여 뒤로 핸들러를 수정한다.

ReplyHomeContent.kt

...
import android.app.Activity
import androidx.compose.ui.platform.LocalContext
...
        val activity = LocalContext.current as Activity
        ReplyDetailsScreen(
            replyUiState = replyUiState,
            modifier = Modifier.weight(1f),
            onBackPressed = { activity.finish() }
        )
...

 

이후 실행해서 시스템 뒤로 버튼을 누르면 앱이 종료되는 것을 볼 수 있다.