프로젝트를 진행하면서 각 화면의 UI를 구현하기 전에, 공통으로 사용될 UI 요소들을 먼저 설계하고자 했다.
그중 하나가 TopAppBar였다.
TopAppBar를 설계하면서 가장 고민이 되었던 부분은 다음 두 가지 접근 방식 중 어떤 것을 선택할지였다.
각 방식을 선택했을 때의 설계 및 구현 방법을 살펴보고, 각각의 장단점을 분석하면서 어떤 선택이 더 적절한지 고민해보고자 한다.
이를 통해 나뿐만 아니라 이 글을 읽는 사람들도 자신만의 기준을 세우는 데 도움이 되었으면 한다.
말 그대로 UI의 최상단 Scaffold에 TopAppBar를 두는 방식이다.
설계를 할 때 고려해야 할 부분은 다음과 같다.
위 사항들을 고려하면서 최종적으로 아래와 같은 화면을 만들어 보고자 한다.
홈 화면 | 상세 화면 |
|---|
Compose Navigation을 설정할 필요가 있다. 하지만 본 글은 TopAppBar가 주제이므로, Navigation 설계는 다루지 않고 동작하도록만 코드를 작성하려 한다.
먼저, Navigation을 사용하기 위해 아래 의존성을 추가하자.
dependencies {
val nav_version = "2.8.6"
implementation("androidx.navigation:navigation-compose:$nav_version")
}
Navigation을 Type-Safe 하게 사용하기 위해 sealed interface를 사용하여 Route를 정의한다.
sealed interface Route {
@Serializable
data object Home : Route
@Serializable
data object Detail : Route
}
각 화면의 Composable을 아래와 같이 정의한다.
@Composable
fun HomeScreen(
navController: NavController,
onAddClick: (String) -> Unit,
onInfoClick: (String) -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Button(onClick = { navController.navigate(Route.Detail) }) {
Text("상세 화면으로 이동")
}
}
}
@Composable
fun DetailScreen(
navController: NavController,
onCallClick: (String) -> Unit,
) {
}
※ 현재 사용하지 않는 인자들은 이후에 구현에 사용할 것이다.
이제 Navigation을 관리하는 MainScreen을 작성하자.
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
topBar = {}
) { padding ->
NavHost(
navController = navController,
startDestination = Route.Home,
modifier = Modifier.padding(padding)
) {
composable<Route.Home> {
HomeScreen(
navController,
{},
{}
)
}
composable<Route.Detail> {
DetailScreen(
navController,
{}
)
}
}
}
}
이제 TopAppBar를 설계해보자! 🚀
우선, 화면별로 아래 사항들을 정의해야 한다.
이걸 위해 TopAppBar의 interface를 아래와 같이 정의했다.
sealed interface Screen {
val route: Route
val isAppBarVisible: Boolean
val navigation: (@Composable () -> Unit)?
val title: String
val actions: (@Composable () -> Unit)?
}
route는 위에서 정의한 Route를 사용할 거고, 그 외에 없을 수 있는 사항들은 nullable 타입으로 정의했다.
이제 Screen을 상속받아 화면별로 명세를 작성하면 된다.
[ 홈 화면 ]
object Home : Screen {
override val route: Route = Route.Home
override val isAppBarVisible: Boolean = true
override val navigation: @Composable (() -> Unit)? = null
override val title: String = "Home"
override val actions: @Composable () -> Unit = {
IconButton(onClick = { }) {
Icon(Icons.Filled.Add, "add")
}
IconButton(onClick = { }) {
Icon(Icons.Filled.Info, "info")
}
}
}
이렇게 작성하면 된다. 여기서 중요한 건 화면에 필요한 navigation이나 actions 버튼을 클릭했을 때 우리가 원하는 동작을 실행하는 것이다.
기초 코드에서 HomeScreen과 DetailScreen의 인자들로 받은 클릭 리스너들을 실행하도록 해야 한다는 의미다.
object Home : Screen {
override val route: Route = Route.Home
override val isAppBarVisible: Boolean = true
override val navigation: @Composable (() -> Unit)? = null
override val title: String = "Home"
override val actions: @Composable () -> Unit = {
IconButton(onClick = { _buttons.tryEmit(AppBarIcons.ADD) }) {
Icon(Icons.Filled.Add, "add")
}
IconButton(onClick = { _buttons.tryEmit(AppBarIcons.INFO) }) {
Icon(Icons.Filled.Info, "info")
}
}
enum class AppBarIcons {
ADD,
INFO
}
private val _buttons = MutableSharedFlow<AppBarIcons>(extraBufferCapacity = 1)
val buttons: Flow<AppBarIcons> = _buttons.asSharedFlow()
}
@Composable
fun HomeScreen(
navController: NavController,
onAddClick: (String) -> Unit,
onInfoClick: (String) -> Unit,
) {
LaunchedEffect(Unit) {
Screen.Home.buttons
.onEach { button ->
when (button) {
Screen.Home.AppBarIcons.ADD -> onAddClick(button.name)
Screen.Home.AppBarIcons.INFO -> onInfoClick(button.name)
}
}.launchIn(this)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Button(onClick = { navController.navigate(Route.Detail) }) {
Text("상세 화면으로 이동")
}
}
}
버튼 이벤트를 관리하기 위해서 위와 같은 코드를 추가했다.
단계 별로 설명을 해보자면
actions에 들어갈 아이콘을 AppBarIcons라는 enum class로 정의한다.Flow인 _buttons를 작성하고, 외부에서 읽을 수 있게 buttons를 작성한다.IconButton의 onClick에서는 _buttons.tryEmit(AppBarIcons.ADD)처럼 버튼 클릭 시 이벤트를 방출한다.Screen.Home.buttons(SharedFlow)를 수집해서 방출된 버튼 이벤트를 처리한다.when을 사용해 버튼 종류별로 실행할 이벤트를 정의한다.이 방식의 장점은 다음과 같다.
TopAppBar의 버튼 클릭 이벤트를 Flow로 처리하여 UI와 로직을 분리.LaunchedEffect 내에서 Flow를 구독하여 Recomposition될 때도 Flow가 유지.AppBarIcons와 onEach만 수정하면 되므로 확장성이 좋음.단점은 이렇게 된다.
AppBarIcons와 buttons를 작성해야 한다.(혹시 더 좋은 방식을 알고 있으시다면 댓글로 알려주세요!😀)
[ 상세 화면 ]
상세 화면도 홈 화면처럼 작성하면 된다.
object Detail : Screen {
override val route: Route = Route.Detail
override val isAppBarVisible: Boolean = true
override val navigation: @Composable () -> Unit = {
IconButton(onClick = { _buttons.tryEmit(AppBarIcons.NAVIGATION) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "navigation")
}
}
override val title: String = "Detail"
override val actions: @Composable () -> Unit = {
IconButton(onClick = { _buttons.tryEmit(AppBarIcons.CALL) }) {
Icon(Icons.Filled.Call, "call")
}
}
enum class AppBarIcons {
CALL,
NAVIGATION
}
private val _buttons = MutableSharedFlow<AppBarIcons>(extraBufferCapacity = 1)
val buttons: Flow<AppBarIcons> = _buttons.asSharedFlow()
}
상세 화면은 홈 화면과 다르게 navigation 아이콘을 사용하니까 그 부분만 신경 써주자.
[ AppBarState 작성 ]
현재 화면에 따라 앱바의 구성 요소(제목, 아이콘, 버튼 등)가 동적으로 변경되어야 한다.
이를 위해 AppBarState 클래스를 도입하여 Screen 정보를 기반으로 앱바의 상태를 관리하도록 설계했다.
class AppBarState(
private val navController: NavController,
) {
private val currentDestination: NavDestination?
@Composable get() = navController
.currentBackStackEntryAsState()
.value?.destination
private val currentScreen: Screen
@Composable get() = Screen::class.nestedClasses
.asSequence()
.map { it.objectInstance as Screen }
.firstOrNull { screen ->
currentDestination?.hasRoute(screen.route::class) == true
} ?: Screen.Home
val isVisible: Boolean
@Composable get() = currentScreen.isAppBarVisible
val navigation: (@Composable () -> Unit)?
@Composable get() = currentScreen.navigation
val title: String
@Composable get() = currentScreen.title
val actions: (@Composable () -> Unit)?
@Composable get() = currentScreen.actions
}
@Composable
fun rememberAppBarState(
navController: NavController,
) = remember { AppBarState(navController) }
이 코드는 간단하게 currentScreen을 가져와서 해당 화면에 대한 정보를 얻어오는 방식이다.
currentScreen 코드가 좀 복잡할 수 있는데, 단계별로 설명하자면 이렇게 된다.
implementation("org.jetbrains.kotlin:kotlin-reflect")
Screen::class.nestedClasses: Screen을 상속받는 모든 클래스를 가져온다. (반환 타입: Collection<KClass<*>>).asSequence(): 컬렉션을 Sequence로 변환하여 지연 연산을 수행.(모든 Screen을 인스턴스화 할 필요는 없기 때문).map { it.objectInstance as Screen }: nestedClasses에서 가져온 KClass<*>를 Screen 객체로 변환한다..firstOrNull { screen -> currentDestination?.hasRoute... }?: Screen.Home: 현재 currentDestination와 route가 일치하는 첫 번째 Screen 객체를 찾고, 없으면 홈 화면을 기본으로 설정한다.이 방식을 사용하면 preview의 Interactive Mode가 작동하지 않을 수 있지만, 앱 실행(런타임)에서는 잘 동작한다.
이 방식이 불편하면 when을 사용해 route별 Screen을 명시적으로 작성할 수도 있다.
when(route){
is Route.Home -> Screen.Home
...
}
[ TopAppBar 컴포넌트 작성 ]
화면에 실제로 보여질 TopAppBar 컴포넌트를 작성한다.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun Example1TopAppBar(
modifier: Modifier = Modifier,
appBarState: AppBarState,
) {
TopAppBar(
modifier = modifier
.fillMaxWidth(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.White,
),
navigationIcon = {
appBarState.navigation?.let { it() }
},
title = {
val title = appBarState.title
if (title.isNotEmpty()) {
Text(
text = title
)
}
},
actions = {
appBarState.actions?.let { it() }
},
)
}
AppBarState를 인자로 받아 navigationIcon, title, actions을 가져오는 간단한 코드다.
nullable타입 처리만 주의해주자!
이렇게 해서 화면에 맞는 TopAppBar를 동적으로 생성할 수 있다!
@Composable
fun MainScreen() {
val scope = rememberCoroutineScope()
val navController = rememberNavController()
val snackbarHostState = remember { SnackbarHostState() }
val appBarState = rememberAppBarState(
navController,
)
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
if (appBarState.isVisible) {
Example1TopAppBar(
appBarState = appBarState
)
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = Route.Home,
modifier = Modifier.padding(padding)
) {
composable<Route.Home> {
HomeScreen(
navController,
{
scope.launch {
snackbarHostState.showSnackbar(
message = it
)
}
},
{
scope.launch {
snackbarHostState.showSnackbar(
message = it
)
}
}
)
}
composable<Route.Detail> {
DetailScreen(
navController,
{
scope.launch {
snackbarHostState.showSnackbar(
message = it
)
}
}
)
}
}
}
}
최종 MainScreen의 코드는 위와 같다.
각 actions를 눌렀을 때 해당 icon의 이름을 snackbar로 출력하도록 구현해주었다.
각 화면에서 개별적으로 TopAppBar를 사용하는 방식은 이전 방식보다 훨씬 직관적이고 간단하게 구현할 수 있다.
기본적인 navigation 코드는 이전과 그대로 사용하면 된다.
MainScreen, HomeScreen 그리고 DetailScreen의 코드는 조금 변동이 있지만, 우선 TopAppBar를 먼저 구현하고 이를 각 화면에 적용해보자.
설계를 할 때 고려해야 할 부분은 이전과 같다.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun Example2TopAppBar(
title: String = "",
navigationIcon: (@Composable () -> Unit)? = null,
actions: (@Composable () -> Unit)? = null,
) {
TopAppBar(
modifier = Modifier
.fillMaxWidth(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.White,
),
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = { navigationIcon?.let { it() } },
actions = { actions?.let { it() } },
)
}
Screen, AppBarState와 같은 코드를 작성할 필요 없이 TopAppBar 컴포넌트만 구현하면 된다.
인자로 title과 navigationIcon, actions를 받도록 하였고, 해당 인자를 알맞은 위치에 할당하면 된다.
[ 홈, 상세 화면 ]
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold { padding ->
NavHost(
navController = navController,
startDestination = Route.Home,
modifier = Modifier.padding(padding)
) {
composable<Route.Home> {
HomeScreen(
navController,
)
}
composable<Route.Detail> {
DetailScreen(
navController,
)
}
}
}
}
@Composable
fun HomeScreen(
navController: NavController,
) {
Scaffold(
topBar = {
Example2TopAppBar(
title = "Home",
actions = {
IconButton(onClick = {/* do something */ }) {
Icon(Icons.Filled.Add, "add")
}
IconButton(onClick = {/* do something */ }) {
Icon(Icons.Filled.Info, "info")
}
},
)
}
) { padding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(padding)
) {
Button(onClick = { navController.navigate(Route.Detail) }) {
Text("상세 화면으로 이동")
}
}
}
}
@Composable
fun DetailScreen(
navController: NavController,
) {
Scaffold(
topBar = {
Example2TopAppBar(
title = "Home",
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "navigation")
}
},
actions = {
IconButton(onClick = { /* do something */ }) {
Icon(Icons.Filled.Call, "call")
}
}
)
}
) {}
}
화면별 코드가 훨씬 간단하고 직관적으로 바뀐다.
화면에 맞는 navigationIcon과 actions만 넣어주면 되며, TopAppBar를 아예 보여주지 않으려면 그냥 작성하지 않으면 된다.
단, 주의할 점은 각 화면에서 TopAppBar를 사용하려면 해당 화면에 Scaffold를 포함해야 한다는 것이다.
[ 최상단에 하나의 TopAppBar를 두고 관리하는 방식 ]
Screen)하여, 각 화면(HomeScreen, DetailScreen)에 해당하는 코드가 간소화되고 가독성이 높아진다.Screen)가 무거워지고, 유지보수에 어려움이 생길 수 있다.[ 화면마다 개별적으로 TopAppBar를 사용하는 방식 ]
actions에 따라 특정 아이콘이 보이거나 사라지는 동적인 처리가 필요할 수 있다```kotlin
actions = {
IconButton(onClick = { /* do something */ }) {
Image(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
)
}
if (!isActionIconVisible) {
IconButton(onClick = { /* do something */ }) {
Image(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
)
}
}
```TopAppBar를 독립적으로 관리하기 때문에, 기능이 추가되거나 복잡한 로직이 필요한 화면에서 유연하게 대응할 수 있다.TopAppBar를 작성해야 하므로, 코드가 중복되거나 무겁게 느껴질 수 있다.TopAppBar에 따라 오버헤드가 있을 수 있다.나는 화면마다 개별적으로 TopAppBar를 사용하는 방식을 택했다.
현재 진행 중인 프로젝트는 기능이 추가될수록 화면이 많아질 가능성이 크고, 일부 화면에서는 TopAppBar에 로직이 필요하다. 이런 경우, 각 화면에 맞는 TopAppBar를 개별적으로 설정하는 것이 유리하다고 판단했다.
추가적으로 이 부분에 관해서 공부하고 레퍼런스를 찾아보면서, 많은 개발자가 어떤 방식이 더 나은지에 대해 고민하고 있다는 것을 알게 되었다.( 링크1, 링크2 )
https://fvilarino.medium.com/shared-action-bar-in-jetpack-compose-6e02f8391c73