Android/Compose

[Android Compose] Material 3의 ModalNavigationDrawer 사용법

참깨빵위에참깨빵_ 2023. 8. 16. 22:20
728x90
반응형

Compose로 앱을 만들 때 NavigationDrawer를 구현하는 방법에 대해 포스팅한다.

먼저 NavigationDrawer가 열렸을 때 표시할 제목, 아이콘들을 갖고 있을 data class를 만든다.

 

import androidx.compose.ui.graphics.vector.ImageVector

data class NavigationItem(
    val title: String,
    val selectedIcon: ImageVector,
    val unselectedIcon: ImageVector,
    val badgeCount: Int? = null
)

 

그리고 액티비티를 구성한다.

 

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.composeprac.navigation_drawer.ui.theme.ComposePracTheme
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
class NavigationDrawerTestActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposePracTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    val items = listOf(
                        NavigationItem(
                            title = "홈",
                            selectedIcon = Icons.Filled.Home,
                            unselectedIcon = Icons.Outlined.Home
                        ),
                        NavigationItem(
                            title = "알림",
                            selectedIcon = Icons.Filled.Notifications,
                            unselectedIcon = Icons.Outlined.Notifications,
                            badgeCount = 10
                        ),
                        NavigationItem(
                            title = "설정",
                            selectedIcon = Icons.Filled.Settings,
                            unselectedIcon = Icons.Outlined.Settings
                        )
                    )

                    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
                    val scope = rememberCoroutineScope()
                    var selectedItemIndex by rememberSaveable {
                        mutableStateOf(0)
                    }
                    // PermanentNavigationDrawer vs ModalNavigationDrawer vs DismissibleNavigationDrawer (좌우 스와이프 가능)
                    ModalNavigationDrawer(
                        drawerContent = {
                            ModalDrawerSheet {
                                Spacer(modifier = Modifier.height(16.dp))
                                items.forEachIndexed { index, item ->
                                    NavigationDrawerItem(
                                        label = {
                                            Text(text = item.title)
                                        },
                                        selected = (index == selectedItemIndex),
                                        onClick = {
//                                            navController.navigate(item.route)
                                            selectedItemIndex = index
                                            scope.launch {
                                                drawerState.close()
                                            }
                                        },
                                        icon = {
                                            Icon(
                                                imageVector = if (index == selectedItemIndex) {
                                                    item.selectedIcon
                                                } else {
                                                    item.unselectedIcon
                                                },
                                                contentDescription = item.title
                                            )
                                        },
                                        badge = {
                                            item.badgeCount?.let {
                                                Text(text = it.toString())
                                            }
                                        },
                                        modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
                                    )
                                }
                            }
                        },
                        drawerState = drawerState
                    ) {
                        Scaffold(
                            topBar = {
                                TopAppBar(
                                    title = {
                                        Text("NavigationDrawer Test")
                                    },
                                    navigationIcon = {
                                        IconButton(onClick = {
                                            scope.launch {
                                                drawerState.open()
                                            }
                                        }) {
                                            Icon(
                                                imageVector = Icons.Default.Menu,
                                                contentDescription = "menu"
                                            )
                                        }
                                    }
                                )
                            }
                        ) {
                            // paddingValue를 쓰지 않아서 컴파일 에러가 발생하지만 무시해도 작동한다
                        }
                    }
                }
            }
        }
    }
}

 

실행하면 아래 화면이 표시될 것이다.

 

 

햄버거 버튼을 누르면 NavigationDrawer가 열리면서 하드코딩해둔 홈, 알림, 설정 탭이 각각 표시된다.

 

 

이제 코드를 분석해 본다. 먼저 onCreate에서 data class를 사용해 items라는 불변 리스트를 만들었다.

 

val items = listOf(
    NavigationItem(
        title = "홈",
        selectedIcon = Icons.Filled.Home,
        unselectedIcon = Icons.Outlined.Home
    ),
    NavigationItem(
        title = "알림",
        selectedIcon = Icons.Filled.Notifications,
        unselectedIcon = Icons.Outlined.Notifications,
        badgeCount = 10
    ),
    NavigationItem(
        title = "설정",
        selectedIcon = Icons.Filled.Settings,
        unselectedIcon = Icons.Outlined.Settings
    )
)

 

badgeCount는 2번째 사진에서 보았듯 현재 알림이 몇 개 왔는지 사용자에게 표시할 때 활용할 수 있다.

지금은 10으로 하드코딩했지만 API를 호출해서 실제로 사용자가 안 읽은 알림 개수를 받아올 수 있다면 그 개수를 badgeCount에 넣어서 보여줄 수 있을 것이다.

 

val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
var selectedItemIndex by rememberSaveable {
    mutableStateOf(0)
}

 

그 밑으로는 홈 버튼을 눌러서 앱이 백그라운드로 이동했다가 다시 포그라운드로 돌아와도 컴포넌트의 상태를 유지할 수 있도록 rememberDrawerState 등의 remember 접두어가 붙은 함수를 사용하고 있다. 만약 이게 무슨 말인지 모르겠고 저 코드 자체가 이해가 안 된다면 아래 디벨로퍼 문서를 한 번 읽고 다른 곳에서 설명하는 remember류 함수들에 대해 읽고 온다.

단, rememberCoroutineScope()는 상태를 저장하기 위해 사용한다기보단 컴포저블 안에서 안전하게 코루틴을 실행할 수 있도록 도와주는 Compose API다. 어떤 컴포저블의 생명주기가 종료됐을 때, 해당 컴포저블에서 시작된 코루틴도 같이 취소되어야 하기 때문이다. 사이드 이펙트와 메모리 누수 방지를 막기 위함이다.

 

https://developer.android.com/jetpack/compose/state-saving?hl=ko 

 

Compose에 UI 상태 저장  |  Jetpack Compose  |  Android Developers

Compose에 UI 상태 저장 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 상태가 호이스팅된 위치와 필요한 로직에 따라 서로 다른 API를 사용하여 UI 상태를 저장

developer.android.com

 

그리고 현재 액티비티가 launchActivity라면 뒤로가기를 눌렀을 경우 앱이 백그라운드로 이동한다. 이 때 안드로이드 12 이상을 타겟팅하고 기기의 안드로이드 OS가 안드로이드 12라면, 앱을 재실행했을 경우 마지막으로 봤던 화면이 다시 표시된다. 왜 이렇게 되는지는 아래의 안드로이드 12 동작 변경사항 관련 디벨로퍼 공식문서를 확인한다.

 

https://developer.android.com/about/versions/12/behavior-changes-all?hl=ko#back-press 

 

동작 변경사항: 모든 앱  |  Android 개발자  |  Android Developers

모든 앱에 영향을 주는 Android 12의 변경사항을 알아봅니다.

developer.android.com

 

다음 코드는 아래와 같다.

 

ModalNavigationDrawer(
    drawerContent = {
        ModalDrawerSheet {
            Spacer(modifier = Modifier.height(16.dp))
            items.forEachIndexed { index, item ->
                NavigationDrawerItem(
                    label = {
                        Text(text = item.title)
                    },
                    selected = (index == selectedItemIndex),
                    onClick = {
//                        navController.navigate(item.route)
                        selectedItemIndex = index
                        scope.launch {
                            drawerState.close()
                        }
                    },
                    icon = {
                        Icon(
                            imageVector = if (index == selectedItemIndex) {
                                item.selectedIcon
                            } else {
                                item.unselectedIcon
                            },
                            contentDescription = item.title
                        )
                    },
                    badge = {
                        item.badgeCount?.let {
                            Text(text = it.toString())
                        }
                    },
                    modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
                )
            }
        }
    },
    drawerState = drawerState
) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text("NavigationDrawer Test")
                },
                navigationIcon = {
                    IconButton(onClick = {
                        scope.launch {
                            drawerState.open()
                        }
                    }) {
                        Icon(
                            imageVector = Icons.Default.Menu,
                            contentDescription = "menu"
                        )
                    }
                }
            )
        }
    ) {
        // paddingValue를 쓰지 않아서 컴파일 에러가 발생하지만 무시해도 작동한다
    }

 

ModalNavigationDrawer 컴포저블 함수를 써서 NavigationDrawer의 큰 틀을 만들고, drawerContent 매개변수로 ModalDrawerSheet 컴포저블 함수를 넘겨 NavigationDrawer 안에 표시할 탭들을 forEachIndexed{}로 처음에 만든 items 불변 리스트를 순회하며 만들고 있다.

ModalNavigationDrawer 컴포저블 함수는 머티리얼 3 api에 포함된 컴포저블 함수라서, 액티비티 이름 위에 @OptIn 어노테이션을 사용해 컴파일 에러가 발생하지 않도록 했다.

ModalDrawerSheet의 modifier로 패딩을 입혔는데, 매개변수로 NavigationDrawerItemDefaults.ItemPadding을 넘기고 있다. 이 변수를 확인해 보면 12dp로 설정돼 있어서, 결과적으로 상하좌우 각각 12dp씩 패딩을 입히는 것이다.

 

그리고 drawerState 매개변수로 처음에 DrawerValue.Closed로 설정한 drawerState 변수를 넘김으로써, 앱을 실행했을 때 NavigationDrawer가 닫혀 있는 상태로 표시되도록 했다. 만약 열린 상태로 실행하고 싶으면 Closed를 Open으로 바꾸면 된다. 그러나 보통 Closed 값을 많이 쓰게 될 것이다.

하단의 Scaffold 컴포저블 함수는 툴바를 만들기 위해 작성한 것이다. 다른 건 그냥 넘기면 되지만, navigationIcon 람다 안을 주의해서 봐야 한다.

 

scope 변수는 rememberCoroutineScope()의 리턴타입인 CoroutineScope를 갖고 있다. 이 변수를 통해 launch {} 코루틴 빌더를 호출하고, 그 안에서 drawerState.open() 함수를 호출한다. 이렇게 함으로써 햄버거 버튼을 클릭했을 때 NavigationDrawer가 열리게 된다.

drawerState.close()도 마찬가지로 scope.launch {} 코루틴 빌더 안에서 호출되는데, items.forEachIndexed {} 안을 확인하면 찾을 수 있다.

 

왜 open(), close() 함수를 코루틴 빌더를 써서 호출해야 하는지는 각 함수의 구현을 보면 알 수 있다. 두 함수는 모두 suspend function으로 구현돼 있다.

 

suspend fun open() = animateTo(DrawerValue.Open, AnimationSpec)
suspend fun close() = animateTo(DrawerValue.Closed, AnimationSpec)

 

suspend fun 키워드가 붙은 함수는 코루틴 빌더 안에서 실행해야 한다. 만약 코루틴이 무엇인지, 왜 저렇게 써야 하는지 잘 이해가 안 된다면 아래의 코루틴의 기본적인 내용을 설명하는 코틀린 공식문서를 확인하거나, 다른 블로그에서 설명하는 코루틴의 개념과 사용법을 간단하게 보고 다시 온다.

 

https://kotlinlang.org/docs/coroutines-basics.html

 

Coroutines basics | Kotlin

 

kotlinlang.org

 

그 외에는 selectedItemIndex와 forEachIndexed {}의 index 매개변수를 갖고 현재 사용자가 어떤 탭을 선택했는지, 그에 따라 아이콘을 어떤 걸 표시해야 할지 정하는 로직과 자잘한 로직들이 있지만 어려운 로직은 아니라서 설명은 생략한다.

 

여기까지 기본적인 ModalNavigationDrawer 구현법을 확인해 봤다. 하지만 Material 3에서 제공하는 NavigationDrawer는 3종류가 있는데, 각각의 차이점에 대해선 다음 포스팅에서 다룬다.

그리고 햄버거 버튼을 누르면 펼쳐지는 NavigationDrawer의 width가 너무 길어서 불편할 수 있다. 이 NavigationDrawer의 가로 길이를 조절하려면 ModalDrawerSheet의 modifier를 조절하면 된다.

 

ModalDrawerSheet {
    Spacer(modifier = Modifier.height(16.dp))
    items.forEachIndexed { index, item ->
        NavigationDrawerItem(
            label = {
                Text(text = item.title)
            },
            selected = (index == selectedItemIndex),
            onClick = {
//                navController.navigate(item.route)
                selectedItemIndex = index
                scope.launch {
                    drawerState.close()
                }
            },
            icon = {
                Icon(
                    imageVector = if (index == selectedItemIndex) {
                        item.selectedIcon
                    } else {
                        item.unselectedIcon
                    },
                    contentDescription = item.title
                )
            },
            badge = {
                item.badgeCount?.let {
                    Text(text = it.toString())
                }
            },
            modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
                .width(200.dp) // <- 여기를 수정한다
        )
    }
}

 

너무 작게 하면 탭의 글자가 안 보이니 적절한 숫자를 넣어서 최적의 가로 길이를 설정하면 된다.

그리고 onClick 안의 navController.navigate()가 주석 처리되어 있는데, 여기에 navigation을 넣어서 각 탭을 누를 때마다 적절한 화면으로 이동하는 처리를 추가하면 된다.

반응형