본문 바로가기

Android/Jetpack Compose

[Compose] 다양한 방법으로 Drawer를 구현해보자 - Scaffold, ModalDrawer

728x90

최근 업무를 진행하다, 카카오톡에서 보이는 채팅방 서랍 같은 메뉴를 만들어야 했다.

기존에 Compose Sample을 만들 때 해당 UI인 Drawer를 구현해 보았지만, 좌우가 반대이기도 하고 다른 방식을 사용할 수 있었기 때문에 BottomSheet와 마찬가지로 2가지 방법으로 해당 UI를 구현해보고자 한다.

 

제목에 기술한 Scaffold와 ModalDrawer 말고도 Custom 해서 비슷한 UI를 구현할 수 있지만, 이번에는 해당 케이스는 제외하고 Compose에서 Component로 제공해 주는 2가지 방식만 작성하겠다.


우선 처음으로는 가장 기본적인 Scaffold를 사용하여 구현하는 방법이다.

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    isFloatingActionButtonDocked: Boolean = false,
    drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
    drawerGesturesEnabled: Boolean = true,
    drawerShape: Shape = MaterialTheme.shapes.large,
    drawerElevation: Dp = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color = MaterialTheme.colors.surface,
    drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
    drawerScrimColor: Color = DrawerDefaults.scrimColor,
    backgroundColor: Color = MaterialTheme.colors.background,
    contentColor: Color = contentColorFor(backgroundColor),
    content: @Composable (PaddingValues) -> Unit
)

 

Scaffold는 가장 기본적으로 프로젝트를 만들면 생성해주는 Compose Layout이다.

해당 Component를 사용하면 정말 많은 것들을 쉽게 구현할 수 있지만, 이번에는 Drawer를 구현할 것이기 때문에 해당 부분만 확인해 보면 된다.

 

drawerContent부터 drawerScrimColor까지 총 7개의 파라미터가 drawer를 구현하는데 사용되고 있다.

각각의 파라미터는 정말 직관적인 이름으로 구현되어있기 때문에 별다른 설명은 하지 않고 넘어가도록 하겠다.

 

여기서 우리가 Drawer를 구현하기 위해 사용할 부분은 scaffoldState, drawerContent, Content 이 세 가지만 사용하면 된다.

파라미터 이름 그대로, drawerContent는 drawer에 그려줄 UI, Content는 알다시피 화면에 보여줄 UI라고 생각하면 된다.

scaffoldState는 Scaffold에서 사용할 state로,

이번 drawer를 그릴 때 사용하는 케이스는 content 영역 혹은 Topbar, Bottombar 영역 등에 선언된 특정 item을 눌렀을 때 drawer를 열어주기 위해 사용한다.

Scaffold(
    scaffoldState = scaffoldState,
    content = { paddingValues ->
        // Main UI
    },
    drawerContent = {
        // drawer UI
    }
)

 

정말 아주 간단하게 구현하고자 한다면, 이렇게만 Scaffold를 작성하고 사용하면 된다.

우리는 지금 간단한 샘플을 만들어보면서 어떻게 작성하는지 확인하고 실무에 적용을 할 것이기 때문에, 기본적인 베이스만 작성해 보도록 하겠다.

val scaffoldState = rememberScaffoldState()
val coroutineScope = rememberCoroutineScope()

Scaffold(
    scaffoldState = scaffoldState,
    topBar = {
        TopAppBar(
            title = { Text(text = "scaffold drawerContent") },
            navigationIcon = {
                IconButton(
                    onClick = {
                        coroutineScope.launch {
                            scaffoldState.drawerState.apply {
                                if (isClosed) open() else close()
                            }
                        }
                    }
                ) {
                    Icon(Icons.Filled.Menu, contentDescription = "drawerIcon")
                }
            },
            actions = {
                IconButton(
                    onClick = {
                        onBackButtonClick.invoke()
                    }
                ) {
                    Icon(Icons.Filled.ArrowBack, contentDescription = "")
                }
            }
        )
    },
    content = { paddingValues ->
        // Main UI
    },
    drawerContent = {
        DrawerContainer(
            onClickEvent = {
                coroutineScope.launch {
                    scaffoldState.drawerState.close()
                }
            }
        )
    },
    drawerGesturesEnabled = true
)

 

UI를 그려두었으면 scaffoldState를 사용하는 간단한 방법만 숙지하면 된다.

scaffoldState는 두 가지 데이터를 사용할 수 있다.

@Composable
fun rememberScaffoldState(
    drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
    snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
): ScaffoldState = remember {
    ScaffoldState(drawerState, snackbarHostState)
}

 

drawerState와 snackbarHostState.

이름만 봐도 drawer와 snackbar를 사용하는 데 사용될 것 같은 state들이다.

우리는 이번 예제를 통해 drawer를 사용할 것이기 때문에 drawerState만 사용하면 된다.

 

다시 위의 코드로 돌아가보자.

coroutineScope.launch {
    scaffoldState.drawerState.apply {
        if (isClosed) open() else close()
    }
}

 

open과 close는 suspend 함수이기 때문에 별도의 coroutineScope 내부에서 동작해야 한다는 점을 알고 넘어가자.

위의 코드는 정말 간단하게, 특정 이벤트가 발생했을 때 state가 닫혀있으면 open을 통해 열어주고, 열려있으면 close를 통해 닫아준다

coroutineScope.launch {
    scaffoldState.drawerState.close()
}

 

더욱 간단하게, 조건을 달지 않고 특정 이벤트가 발생하면 drawer를 닫아주기 위해 위와 같이 선언해서 사용할 수 있다.

 

drawer를 열고 싶을 때 open을, drawer를 닫아야 할 때는 close를 호출하고 그 외는 별도의 UI를 그려서 해당 content에 넣는다고 생각하면 된다.

 

필자가 drawerContent에서 사용한 DrawerContainer는 단순하게 TextView를 나열한 것이고, 아이템을 클릭했을 때 drawer를 닫는 역할이라고 생각하면 된다.

 

그 결과는 다음과 같다.

 

 

다음은, ModalDrawer 를 사용하여 drawer를 구현해 보도록 하겠다.

 

ModalDrawer의 Component를 확인하면 다음과 같이 나와있다.

Material Design modal navigation drawer.
Modal navigation drawers block interaction with the rest of an app’s content with a scrim. They are elevated above most of the app’s UI and don’t affect the screen’s layout grid.

 

Material에서 제공해주는 Drawer를 구현하기 위한 Component이다.

@Composable
@OptIn(ExperimentalMaterialApi::class)
fun ModalDrawer(
    drawerContent: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
    gesturesEnabled: Boolean = true,
    drawerShape: Shape = MaterialTheme.shapes.large,
    drawerElevation: Dp = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color = MaterialTheme.colors.surface,
    drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
    scrimColor: Color = DrawerDefaults.scrimColor,
    content: @Composable () -> Unit
)

 

코드를 확인해보면, Scaffold와 동일한 파라미터들이 많은 것을 알 수 있다.

간단하게 생각하면, Scaffold에서 Drawer 부분만 따로 빼내어서 Modal View로 사용할 수 있게 만들어둔 Component라고 생각하면 된다.

 

Drawer를 구현함에 있어서 Scaffold와 완전히 동일한 파라미터들을 사용하기 때문에 관련된 것들의 설명은 넘어가도록 하고, 실 구현 코드를 확인해 보도록 하자.

val coroutineScope = rememberCoroutineScope()
val drawerState = rememberDrawerState(DrawerValue.Closed)

ModalDrawer(
    drawerState = drawerState,
    gesturesEnabled = drawerState.isOpen,
    drawerContent = {
        DrawerContainer(
            onClickEvent = {
                coroutineScope.launch {
                    drawerState.close()
                }
            }
        )
    },
    content = {
        // Main Content
    }
)

 

코드가 훨씬 간결해 보일 것이다.

하지만 Scaffold에서 Topbar에 위치하는 것들을 content 영역에 넣어서 구현한다고 생각했을 때, scaffold와 비슷한 정도의 함수 길이가 나올 것이다. 

 

여기서 state 부분만 다르고 나머지는 동일하게 사용된다.

Scaffold에서 scaffoldState는 drawer와 snackbar 관련한 두 가지 데이터를 사용할 수 있었지만,

ModalDrawer는 drawer를 사용하기 위해 만들어진 Component이기 때문에 drawer에 관련된 데이터 하나만 컨트롤한다.

 

rememberDrawerState는 scaffoldState 내부에 위치하던 DrawerState와 완전히 동일한 변수이기 때문에, 사용하는 방법도 동일하다.

scaffold에서 scaffoldStae.drawerState.open() 과 같이 사용했다면,

modalDrawer에서는 drawerState.open() 과 같이 1 depth를 제거해서 사용이 가능하다.

 

이것을 제외한 사용하는 방법, 코드들은 모두 동일하기 때문에, 상황에 맞춰서 어떤 Component를 사용할 것인지 선택하여 사용하면 된다.

 

그렇다면,

Scaffold와 ModalDrawer 두 가지 사용법의 장단점은 무엇일까?

 

결과부터 말하자면,

Scaffold를 사용하면 단순하고 간편하게, 다양한 Component 통한 기능 개발이 쉬우며

ModalDrawer를 사용하면 독립적인 Component로 사용되기 때문에 layout을 구현하는데 제한이 적다는 것이다.

 

이것을 제외하고, 구글링을 해보거나 GPT를 통해 다른점을 찾아보거나 했을 때 나오는 항목들은 모두 동일하다고 생각이 되었다.

  1. ModalDrawer는 overlay되는 것이고 Scaffold는 같은 layer에서 옆으로 슬라이드 되는 방식으로 구현된다.
  2. ModalDrawer는 Scaffold 하위에 위치하므로, Scaffold 내부에 ModalDrawer를 사용할 수 없다.
  3. ModalDrawer는 제스쳐 기반이고 Scaffold는 정적이다.

등등 여러가지 항목을 찾을 수 있었는데, 모두 내용과 다르게 완전히 동일하게 보였다.

 

이렇게 말이다.

 

물론 정말 디테일하게 들어가면 다른 부분이 많이 존재하겠지만, 실무를 진행하면서 아직 그렇게까지 디테일하게 들어가서 확인하지 않고 이 정도만 알고 있어도 문제가 없었기 때문에 이 이상 자세하게 확인하진 않았다.


여기까지 구현을 했으면, 

마지막으로 카톡의 채팅방에서 볼 수 있는 것과 동일한 오른쪽에서 왼쪽으로 열리는 drawer를 구현할 차례이다.

 

Scaffold나 ModalDrawer의 정보를 아무리 확인해 보아도 왼쪽에서 오른쪽으로 열리는 drawer만 구현이 가능하고, 그 반대는 되지 않았다.

따라서, 필자가 찾은 방법은 레이아웃 방향을 반전시키는 것이다.

 

영어, 한국어 등 우리들이 쉽게 찾아볼 수 있는 언어들은 왼쪽에서 오른쪽 방향으로 읽지만,

아랍어, 히브리어 등의 언어는 오른쪽에서 왼쪽 방향으로 텍스트를 표시하는 게 일반적이다.

 

따라서, 레이아웃의 구조도 해당 언어는 반대로 구현하는 것이 그들에게는 익숙한 것이고 이렇게 레이아웃 방향을 android 내부에서 설정해 줄 수 있다.

CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
    // layout Content
}

 

LocalLayoutDirection은 Android compose에서 사용되는 지역 레이아웃 방향을 나타내는 클래스로,

해당 클래스를 선언한 CompositionLocalProvider 블록 내부에 Compose 함수를 선언하게 되면, Rtl 방식으로 layout이 표현되게 된다.

즉, Right-to-Left 방향으로 우리가 기본으로 사용하는 방향과 반대되는 방향으로 설정되게 된다.

 

위의 ModalDrawer를 해당 Provider 블럭 내부에 작성하고 빌드를 하게 되면 다음과 같게 나오게 된다.

 

설정에 따라서 Header의 좌우가 바뀌는 것을 확일할 수 있고, 동시에 drawerContent의 내용도 좌우가 바뀌는 것을 알 수 있다.

좌, 우만 바뀌었는데 뭔가 drawerContent의 내용을 알아보기가 쉽지 않다.

 

UI만 생각하면 거울에서 본 것처럼 좌우가 반전된 모습으로 생각하기 쉬운데, 내부 데이터를 보면 영어 단어 기준으로 순서가 바뀌어있는 것을 알 수 있다.

따라서, Rtl로 설정을 한 다음 그려주는 UI의 순서를 반대로 하면 되겠다!라는 방식은 사용할 수 없다.

 

그렇다면 어떻게 해야 할까?

의외로 방법은 정말 간단하다. DrawerContent 영역의 아이템을 다시 Ltr로 설정해 주면 된다.

CompositionLocalProvider(
    LocalLayoutDirection provides LayoutDirection.Rtl
) {
    ModalDrawer(
        drawerState = drawerState,
        gesturesEnabled = drawerState.isOpen,
        drawerContent = {
            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
                DrawerContainer(
                    onClickEvent = {
                        coroutineScope.launch {
                            drawerState.close()
                        }
                    }
                )
            }
        },
        content = {
            // ...
        }
    )
}

 

 

이와 같이 설정하게 되면, Content 영역은 반전되지만 DrawerContent 부분은 반전되지 않는 것을 볼 수 있다.

 

이처럼 말이다.

 

물론, Main Content 부분도 반전이 되기 때문에 필자가 첨부한 영상을 보면 버튼의 위치가 변경되는 것을 확인할 수 있다.

실제로 사용하게 되면, 안에 들어가 있는 많은 아이템들이 반전되어 보이게 될 것이다.

 

따라서, 필자가 실무에서 사용할 때는 Main Content를 ModalDrawer 내부의 content 영역에 넣지 않고 별도로 구현을 하였으며, drawer UI가 필요한 경우에는 이벤트를 받아서 drawer를 open 하는 형태로 구현하였다.

이런 식으로 구현하게 되면, Main Content 영역은 정렬 기준에 영향을 받지 않게 되고, ModalDrawer는 Rtl로, drawerContent는 Ltr로 정렬 기준이 설정되어 있기 때문에 오른쪽에서 왼쪽으로 나오면서 원하는 UI를 구현할 수 있다.


이것으로 2가지 방법으로 구현할 수 있는 drawer UI와, 좌우를 반전시켜 drawer를 사용하는 방법을 알아보았다.

사실, scaffold와 ModalDrawer를 사용하여 Drawer를 구현하는 방법은 정말 간단하기도 하고 기본적으로 많은 Compose Sample에 Scaffold를 사용하고 있기 때문에 쉽게 알 수 있고, 구현이 가능하다.

 

하지만, 좌우를 반전시켜서 사용해야 하는 경우가 생각보다 존재했는데, 

LocalLayoutDirection이라는 클래스는 평소에 쓰지 않는 클래스이기 때문에 생각하기가 쉽지 않았던 것 같다.

 

실무를 진행하면서, 카카오톡 채팅방에서 볼 수 있는 drawer와 동일한 UI를 구현해야 했는데,

이 좌우를 반대로 바꾸는 방법에 대하여 떠오르지 않아 AnimatedVisibility를 사용해서 직접 제스처를 Custom 해서 사용하려고 했다.

물론 직접 커스텀해서 구현하면 더 자유도가 높게 많은 것들을 구현할 가능성도 있었지만, 역시 제공해진 것들을 사용하는 게 시간상으로나 안정성으로나 효율이 좋은 것 같다.

 

간단하지만 실무에 사용할 때 유용한 것들을 요즘 작성해보고 있는데,

역시 글을 작성하면서 다시 테스트해 보고, 더 깊게 알아보는 과정에서 많은 것들을 배우는 것 같다.

가장 기본적인 것들부터 다시 하나하나 개념을 잡아나가야겠다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample

Contribute to HeeGyeong/ComposeSample development by creating an account on GitHub.

github.com

 

 

728x90