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 statewhich 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 ViewModelbut 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 routeIn 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 LaunchedEffectjust 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:

  1. 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.

  2. 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:

  1. Avoid so-called Massive ViewControllers (for example, God-Activity), which have great responsibility.

  2. Provide navigation logic within the application.

  3. 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 coordinatorand 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 actionswhich 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 routeallowing 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:

  1. https://engineering.monstar-lab.com/en/post/2023/07/14/Jetpack-Compose-UI-Architecture

  2. https://developer.android.com/develop/ui/compose/state

  3. https://khanlou.com/2015/01/the-coordinator/

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *