Overview of the Compose architecture using the coordinator pattern
In this article, we will look at an example of implementing a UI layer architecture in Compose, which is based on Uni-directional data flow and state hoisting using the “coordinator” pattern for navigation. The inspiration for me was this publicationbut I decided to expand on the topic of Compose architecture and navigation raised in it.
Uni-directional data flow principle
Uni-directional data flow (UDF) is a design pattern in which state flows down and actions flow up. Following the UDF, we can separate the constituent elements that display status in the UI (Compose functions), from the parts of your application that store and change state (most often this is ViewModel
in our application). The idea is for our UI components to use state and emit events. But as components process externally generated events, multiple sources of truth emerge. And we need any “event” we introduce to be state-based.
Assuming that the essence of changing and storing state is ViewModel
, then it must have one action processing point and one state change event point. An example of this approach:
As you can see, in ViewModel
change events arrive (entry point) and then the UI is updated, which is the exit point. Sample code:
private val _stateFlow: MutableStateFlow<UserListState> =
MutableStateFlow(UserListState(isLoading = true))
val stateFlow: StateFlow<UserListState> = _stateFlow.asStateFlow()
fun action(actions: UserListAction) {
// some code
}
Function action
needed to process actions from the user (entry point), and stateFlow
returns the current screen state (exit point). Using this approach allows you to easily scale the solution and cover it with tests.
State Hoisting Principle
State Hoisting is a technique in which the responsibility for managing and manipulating the state of a component is transferred to a higher-level component. Example approach:
A state raised in this way has several important advantages:
Single data source: By moving state instead of duplicating it, we ensure that there is only one data source. This helps to avoid mistakes.
Encapsulated: Only a Compose function containing a state object can change its state.
Possibility of sharing: Raised state can be used in conjunction with multiple composite objects. If you want to read the name in another composable object, hoisting will allow you to do so.
Interceptability: Stateless callers can ignore or modify events before changing state.
Separation: The state of stateless composable functions can be stored anywhere.
Usage example:
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
}
}
Field name
moved to Compose function HelloContent
, which allows you to reuse this function anywhere. You can read more Here.
State
Essence state
usually describes the state of the screen. Can be described using data class
or sealed interface
. Example implementation:
data class UserListState(
val isLoading: Boolean = true,
val items: List<User> = emptyList()
)
This example state
describes two states: loading display, data display. In any case, state is a “static” representation of your component or entire UI screen that you can easily change.
Screen
Screen
is a Compose function that describes the screen. To follow the state hoisting pattern, we need to make this component independent of passing the viewModel itself, expose user interactions as callbacks, and not pass entities to subscribe to the data. This will make our screen available for testing, previewing and reuse! Example:
@Composable
fun UserListScreen(
state: UserListState,
onClickOnUser: (User) -> Unit,
onBackClick: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "User List")
},
navigationIcon = {
IconButton(onClick = {
onBackClick.invoke()
}) {
Icon(Icons.Filled.ArrowBack, "backIcon")
}
},
)
}, content = { padding ->
if (state.isLoading) {
CircleProgress()
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 56.dp)
) {
items(
count = state.items.size,
itemContent = {
UserCard(user = state.items[it],
modifier = Modifier.fillMaxWidth(),
onClick = { user ->
onClickOnUser.invoke(user)
})
})
}
}
})
We receive a data model as input state
which is displayed on the screen, and callback lambdas for user interaction.
Route
Route is a component that will handle callbacks, pass state Screen
and send it in this case to the controller for navigation. Route
is the entry point into our flow. Example:
@Composable
fun UserListRoute(
navController: NavController,
viewModel: UserListViewModel = hiltViewModel(),
) {
// ... state collection
UserListScreen(
state = uiState,
onClickOnUser = //..
onBackClick = {
// navigate back
}
}
)
}
With each new user interaction and state-based effects, this feature will grow in size, making it more difficult to understand and maintain. Another problem is callbacks (lambdas). With each new user interaction, we will have to add to Screen
one more callback and it can also get quite large.
On the other hand, let's think about testing. We can easily test Screen
And ViewModel
but what about Route
? There's a lot going on here, and not everything can be easily covered with tests.
Let's make changes to the current implementation by adding an entity actions
.
Actions
This is an entity that combines all callbacks (lambdas), which allows you to not change the signatures of the Compose function and expand the number of calls. Example:
data class UserListActions(
val onClickOnUser: (User) -> Unit = {}
val onBackClick : () -> Unit = {}
)
And accordingly for screen
:
@Composable
fun UserListScreen(
state: UserListState,
actions: UserListActions,
) {
// actions. onBackClick.invoke()
}
At the level route
In order not to recreate the object during recompositions, you can make the following changes:
@Composable
fun UserListRoute(
navController: NavController,
viewModel: UserListViewModel = hiltViewModel(),
) {
// ... state collection
val uiState by viewModel.stateFlow.collectAsState()
val actions = rememberPlacesActions(navController)
UserListScreen(
state = uiState,
actions = actions
}
)
}
@Composable
fun rememberUserListActions(navController: NavController): UserListActions {
return remember(coordinator) {
UserListActions(
onClickOnUser = {
navController.navigate("OpenDetailUser")
}
onBackClick = {
navController.navigate("Back")
}
)
}
}
Although route
Now it has become simpler, we just transferred its action logic to another function, and this did not improve either readability or scalability. Moreover, a second problem remains: state-based effects. Our UI logic is now also split, making it harder to read and keep in sync, and testability hasn't improved. It's time to introduce the last component.
Coordinator
A coordinator is designed to coordinate various action handlers and state providers. It observes and reacts to state changes and processes user actions. It can be represented as the Compose state of our thread. Coordinator example:
class UserListCoordinator(
val navController: NavController,
val viewModel: UserListViewModel
) {
val screenStateFlow = viewModel.stateFlow
fun openDetail() {
navController.navigate("OpenDetailUser")
}
fun backClick() {
navController.navigate("Back")
}
}
Note that since our coordinator is no longer inside the Compose function, we can do everything in a simpler way, without the need for LaunchedEffect
just like we usually do in our ViewModel
.
Now let's change action
using coordinator:
@Composable
fun rememberUserListActions(coordinator: UserListCoordinator): UserListActions {
return remember(coordinator) {
UserListActions(
onClickOnUser = {
coordinator. openDetail ()
}
onBackClick = {
coordinator.backClick()
}
)
}
}
A route
taking into account the coordinator it will look like this:
@Composable
fun UserListRoute(
coordinator: UserListCoordinator = rememberUserListCoordinator()
) {
// State observing and declarations
val uiState by coordinator.screenStateFlow.collectAsState()
// UI Actions
val actions = rememberUserListActions(coordinator)
// UI Rendering
UserListScreen(uiState, actions)
}
In the example, the coordinator is now responsible for the UI logic happening in our Compose functions. Since it knows about different states, we can easily react to their changes and build conditional logic for every user interaction. If the interaction is simple, we can easily delegate it to the appropriate component, for example ViewModel
.
Diagram of interaction between components and data flow:
Let's look at an example of clicking a button. Let's say you need to execute a request, get data and make a transition to another screen. To implement this in the current approach, you can consider the following options:
Implement a new state that receives data as input, and then call action, which the coordinator processes and calls the corresponding screen. This approach has a drawback: in fact, the new state does not display anything new on the screen, but rather proxies the call to action. It is also not clear how, for example, to solve the problem of navigation logic related to the display state of the current screen, where to store it.
Add a new event point, for example event in
ViewModel
, subscribe to it and navigate. The advantage is that there is no need to create redundant state, but it violates the principle of Uni-direction data flow, since another data source appears, and the problem of storing navigation state remains.
Coordinator pattern
“Coordinator” is a common pattern in iOS development, introduced by Soroush Hanlow to help you navigate within the application. The idea for implementing this approach was taken from the Application Controller (one of the patterns in the book “Enterprise Application Architecture” by Martin Fowler).
Goals of this pattern:
Avoid so-called Massive ViewControllers (for example, God-Activity), which have great responsibility.
Provide navigation logic within the application.
Reuse Activities or Fragments as they are not related to navigation within the application.
We use an entity for navigation navigator
, this class just does navigation without any logic. Example:
class Navigator() {
private lateinit var navController: NavHostController
var context: Activity? = null
fun showUserDetailScreen() {
navController.navigate(NavigationState.Detail.name)
}
fun showUserLists() {
user = null
navController.navigate(NavigationState.List.name)
}
fun close() {
context?.finish()
}
}
As we can see from the code, there is simply a transition to the screen using various methods: context
for Activity or fragment, NavHostController
to navigate in Compose.
Let's look at the navigation coordinator itself. The idea is simple: the coordinator simply knows which screen to go to next, and for direct navigation he uses navigator
. Coordinator example:
class UserCoordinator(
private val navigator: Navigator
) {
private val state: ArrayDeque<NavigationState> = ArrayDeque<NavigationState>().apply {
add(NavigationState.List)
}
fun openUserDetail(user: User) {
state.add(NavigationState.Detail)
navigator.showUserDetailScreen(user)
}
fun backClick() {
if (state.first() == NavigationState.Detail) {
state.removeLast()
navigator.showUserLists()
} else {
navigator.close()
}
}
}
As we can see from the code above, the coordinator contains a set of screens. If it is necessary to change the screen, an element is added or removed, after which navigator
the appropriate method is called to directly show the screen to the user.
Interaction diagram coordinator
And ViewModel
using the coordinator pattern:
Action processing is performed in the ViewModel, then depending on whether navigation is needed, a screen is opened or a new one is created state
For screen
.
Well, let's make changes to our architecture. ViewModel now looks like this:
@HiltViewModel
class UserListViewModel @Inject constructor(
private val coordinator: UserCoordinator
) : ViewModel() {
private val _stateFlow: MutableStateFlow<UserListState> =
MutableStateFlow(UserListState(isLoading = true))
val stateFlow: StateFlow<UserListState> = _stateFlow.asStateFlow()
fun action(actions: UserListAction) {
when (actions) {
is UserListAction.OpenDetail -> {
coordinator.openUserDetail(actions.user)
}
UserListAction.Back -> {
coordinator.backClick()
}
}
}
}
To transfer, you no longer need to create a separate proxy state
or other subscription, in ViewModel
used coordinator
and all this is easily covered by tests.
Summary
Our screen
remains completely independent of the state. It displays only what is passed as a parameter to the function. All user interactions occur through actions
which can be processed by other components.
Route
now serves as a simple entry point into our navigation graph. It collects the state and remembers our actions during recomposition.
Coordinator
does most of the heavy lifting: responding to state changes and delegating user interaction to other relevant components. It is completely separate from ours screen
And route
allowing for reuse elsewhere and also easy to cover with tests.
CoordinatorNavigation
performs navigation functions and answers the question: “Which screen should I show next?” Can be used with any navigation library or engine that contains navigator
.
The described approach does not depend on third-party libraries and is easily applicable to any application. You can see a code example Here.
Sources: