Compose TopAppBar, 어떻게 설계하는 것이 좋을까?

Seogi·2025년 2월 28일

Compose

목록 보기
6/8

프로젝트를 진행하면서 각 화면의 UI를 구현하기 전에, 공통으로 사용될 UI 요소들을 먼저 설계하고자 했다.

그중 하나가 TopAppBar였다.

TopAppBar를 설계하면서 가장 고민이 되었던 부분은 다음 두 가지 접근 방식 중 어떤 것을 선택할지였다.

  • 최상단에 하나의 TopAppBar를 두고 관리하는 방식
  • 화면마다 개별적으로 TopAppBar를 사용하는 방식

각 방식을 선택했을 때의 설계 및 구현 방법을 살펴보고, 각각의 장단점을 분석하면서 어떤 선택이 더 적절한지 고민해보고자 한다.

이를 통해 나뿐만 아니라 이 글을 읽는 사람들도 자신만의 기준을 세우는 데 도움이 되었으면 한다.

최상단에 하나의 TopAppBar를 두고 관리하는 방식

말 그대로 UI의 최상단 ScaffoldTopAppBar를 두는 방식이다.

설계를 할 때 고려해야 할 부분은 다음과 같다.

  • 화면별로 다른 actions(icon)를 가질 수 있다.
  • 같은 actions이더라도 클릭 시 이벤트는 다를 수 있다.
  • navigation icon의 여부를 조절할 수 있다.
  • 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 설계

우선, 화면별로 아래 사항들을 정의해야 한다.

  • navigation
  • 화면 title
  • actions
  • TopAppBar의 visible 여부

이걸 위해 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 버튼을 클릭했을 때 우리가 원하는 동작을 실행하는 것이다.

기초 코드에서 HomeScreenDetailScreen의 인자들로 받은 클릭 리스너들을 실행하도록 해야 한다는 의미다.

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("상세 화면으로 이동")
        }
    }
}

버튼 이벤트를 관리하기 위해서 위와 같은 코드를 추가했다.

단계 별로 설명을 해보자면

  1. actions에 들어갈 아이콘을 AppBarIcons라는 enum class로 정의한다.
  2. 버튼 클릭 이벤트를 담을 Flow_buttons를 작성하고, 외부에서 읽을 수 있게 buttons를 작성한다.
  3. IconButtononClick에서는 _buttons.tryEmit(AppBarIcons.ADD)처럼 버튼 클릭 시 이벤트를 방출한다.
  4. Screen.Home.buttons(SharedFlow)를 수집해서 방출된 버튼 이벤트를 처리한다.
  5. when을 사용해 버튼 종류별로 실행할 이벤트를 정의한다.

이 방식의 장점은 다음과 같다.

  • TopAppBar의 버튼 클릭 이벤트를 Flow로 처리하여 UI와 로직을 분리.
  • LaunchedEffect 내에서 Flow를 구독하여 Recomposition될 때도 Flow가 유지.
  • 새로운 버튼이 추가될 때 AppBarIconsonEach만 수정하면 되므로 확장성이 좋음.

단점은 이렇게 된다.

  • 화면마다 AppBarIconsbuttons를 작성해야 한다.
  • 코드 작성에 강제성이 없어 화면이 많아지면 실수할 수도 있고, 컴파일 타임에 에러를 잡을 수 없다.

(혹시 더 좋은 방식을 알고 있으시다면 댓글로 알려주세요!😀)

[ 상세 화면 ]

상세 화면도 홈 화면처럼 작성하면 된다.

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 코드가 좀 복잡할 수 있는데, 단계별로 설명하자면 이렇게 된다.

  1. 우선 reflection을 사용한 코드이다. 아래 의존성을 필요로 한다.
implementation("org.jetbrains.kotlin:kotlin-reflect")
  1. Screen::class.nestedClasses: Screen을 상속받는 모든 클래스를 가져온다. (반환 타입: Collection<KClass<*>>)
  2. .asSequence(): 컬렉션을 Sequence로 변환하여 지연 연산을 수행.(모든 Screen을 인스턴스화 할 필요는 없기 때문)
  3. .map { it.objectInstance as Screen }: nestedClasses에서 가져온 KClass<*>Screen 객체로 변환한다.
  4. .firstOrNull { screen -> currentDestination?.hasRoute... }?: Screen.Home: 현재 currentDestinationroute가 일치하는 첫 번째 Screen 객체를 찾고, 없으면 홈 화면을 기본으로 설정한다.

이 방식을 사용하면 preview의 Interactive Mode가 작동하지 않을 수 있지만, 앱 실행(런타임)에서는 잘 동작한다.

이 방식이 불편하면 when을 사용해 routeScreen을 명시적으로 작성할 수도 있다.

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를 동적으로 생성할 수 있다!

최종 MainScreen 작성

@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를 사용하는 방식

각 화면에서 개별적으로 TopAppBar를 사용하는 방식은 이전 방식보다 훨씬 직관적이고 간단하게 구현할 수 있다.

기초 코드

기본적인 navigation 코드는 이전과 그대로 사용하면 된다.
MainScreen, HomeScreen 그리고 DetailScreen의 코드는 조금 변동이 있지만, 우선 TopAppBar를 먼저 구현하고 이를 각 화면에 적용해보자.

TopAppBar

설계를 할 때 고려해야 할 부분은 이전과 같다.

  • 화면 별로 다른 actions(icon)를 가질 수 있다.
  • 같은 actions이더라도 클릭 시 이벤트는 다를 수 있다.
  • navigation icon의 여부를 조절할 수 있다.
  • 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")
                    }
                }
            )
        }
    ) {}
}

화면별 코드가 훨씬 간단하고 직관적으로 바뀐다.

화면에 맞는 navigationIconactions만 넣어주면 되며, TopAppBar를 아예 보여주지 않으려면 그냥 작성하지 않으면 된다.

단, 주의할 점은 각 화면에서 TopAppBar를 사용하려면 해당 화면에 Scaffold를 포함해야 한다는 것이다.

각 방식의 비교

[ 최상단에 하나의 TopAppBar를 두고 관리하는 방식 ]

  • 적합한 경우: 앱의 화면이 많지 않거나, TopAppBar가 로직이 필요하지 않는 경우에 적합하다.
  • 장점:
    • TopAppBar 컴포넌트가 하나만 생성되므로 오버헤드가 적다.
    • 화면별로 TopAppBar에 필요한 요소를 별도로 관리(Screen)하여, 각 화면(HomeScreen, DetailScreen)에 해당하는 코드가 간소화되고 가독성이 높아진다.
  • 단점:
    • 구현이 다소 복잡하다.
    • 화면이 많아지면 각 화면에 맞는 TopAppBar를 관리하는 코드(Screen)가 무거워지고, 유지보수에 어려움이 생길 수 있다.

[ 화면마다 개별적으로 TopAppBar를 사용하는 방식 ]

  • 적합한 경우: 화면의 수와 관계없이 유지보수가 용이하고, 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

0개의 댓글