Blog Infos
Author
Published
Topics
, , , ,
Published
Photo by Ana Cruz on Unsplash

 

This is a series of articles about how to architecture your app that it was inspired by Google Guide to App Architecture and my personal experience.

Today we finally explore the Presentation layer of our architecture. It contains all UI-related logic and everything the user can see and interact with. This layer is also responsible for interpreting application data to the user-readable form and vice versa, transforming user interaction to the data changes in the app.

In this guide, I will demonstrate how to implement and organize a UI layer. There are a lot of libraries, frameworks, and patterns you can use to build a UI layer. Today I’m building everything based on the next tech stack: Android Fragment + Jatpack Compose + Orbit-MVI + Kotlin + Flow + Coroutines + Koin (DI). This is one of the optimal combinations. I will focus on the UI’s building blocks such as RouterRouter Container, ViewModel, Screen, and Navigator.

Router

The Route is the main UI unit responsible for:

  • Holding ViewModel, Navigator, and Screen composable with UI elements.
  • Consuming the UI state.
  • Handling Side Effects (one-time action).
  • Passing user interaction to the ViewModel.

The main idea that sits behind the Router is to encapsulate and make a self-sustainable UI unit that knows how to produce and manage the UI state (implement unidirectional data flow) and navigate.

Naming conventions

The Route classes are named after the Screen name they’re responsible for.

Screen name + Route.

For example: FareListRouteConformationRoute.

@Composable
fun FareListRoute(
navigator: FareListNavigator,
ryderId: String,
viewModel: FareListViewModel = koinViewModel { parametersOf(ryderId) },
scaffoldState: ScaffoldState = rememberScaffoldState(),
) {
val state by viewModel.collectAsState()
FareListScreen(
uiState = state,
scaffoldState = scaffoldState,
onFareClick = viewModel::onFareClick,
)
viewModel.RenderEffect(scaffoldState = scaffoldState, navigator = navigator)
}
@Composable
private fun FareListViewModel.RenderEffect(
scaffoldState: ScaffoldState,
navigator: FareListNavigator,
) {
collectSideEffect { effect ->
when (effect) {
is FareListEffect.GoToConfirmation -> {
navigator.goToConfirmation(ryderId = effect.ryderId, fare = effect.fare)
}
FareListEffect.ShowGeneralNetworkError -> scaffoldState.showSnackBar("Network error")
}
}
}

In the code above you can see what Router looks like. The ViewModel passed as a parameter and injected by Koin (DI). Along with it, we pass Navigator and ryderId as data passed from the previous screen. The one cool feature of the Koin is that you can inject ryderId it into ViewModel the constructor.

The Router can have more than one ViewModel.

I’ll cover it in the section about ViewModel. In the Router we collect the state that ViewModel holds and pass it as a parameter to the Screen.

Do not pass ViewModel as an argument to the Screen composable function. Doing so couples the composable function with the ViewModel type, making it less reusable and harder to test and preview. Also, there would be no clear single source of truth that manages the ViewModel instance.

The collectAsState is extension function ContainerHost that ViewModel implement from the Orbit library.

@Composable
fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.collectAsState(
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED
): State<STATE> {
return this.collectAsState(lifecycleState)
}

lifecycleState — The Lifecycle where the restarting collecting from this flow work will be kept alive.

RenderEffect — another extension function (to be able to call ViewModel extension function) responsible for collecting side effects using collectSideEffect.

@Composable
fun <STATE : Any, SIDE_EFFECT : Any> ContainerHost<STATE, SIDE_EFFECT>.collectSideEffect(
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
action: (suspend (sideEffect: SIDE_EFFECT) -> Unit),
) {
this.collectSideEffect(sideEffect = action, lifecycleState = lifecycleState)
}

The Side Effect is a one-time action often it’s navigation like GoToConfirmation screen, show Snack Bar, Toast, and Dialog in some cases.

when (effect) {
is FareListEffect.GoToConfirmation -> {
navigator.goToConfirmation(ryderId = effect.ryderId, fare = effect.fare)
}
FareListEffect.ShowGeneralNetworkError -> scaffoldState.showSnackBar("Network error")
}
The Router Container

The Fragment is the UI container for Route. It can contain DI-injected fields we don’t want to pass to the Router, and hold arguments.

Naming conventions

These classes are named after the UI component that they’re responsible for.

UI component name + Fragment.

For example FareListFragment.

UI component name + Controller.

For example FareListController.

class FareListFragment : Fragment(R.layout.compose_fragment) {
private val arguments: FareListFragmentArgs by navArgs()
private val navigator: FareListNavigator by inject {
parametersOf(findNavController(), lifecycleScope, requireContext())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val composeView = ComposeFragmentBinding.bind(view).compose
setAppComposeContent(composeView) {
FareListRoute(navigator = navigator, ryderId = arguments.ryderId)
}
}
}

As you can see the code of fragment classes is small because all UI logic is encapsulated in Route, which makes it easy to change the container implementation.

The Router shouldn’t know navigation implementation details, it should depend on Navigator.

That is why the injection logic of the navigator is inside the Fragment, not the Router because the navigator requires a Fragment NavController to implement navigation logic. It keeps the Router decoupled from the container implementation and allows us to easily change container implementation, for example — using Compose navigation or Controller from Conductor lib.

Navigator

The Navigator is responsible for:

  • Encapsulates navigation logic from the Router.
  • Restricts navigation API for certain screens.
  • Define explicit API for each screen.

If the screen has navigation to another screen, it should have its navigator class. It can be extended from the base ScreenNavigator with a default go-back action, can contain other navigators and platform-dependent components like Fragment NavController.

Naming conventions

The navigator classes are named after the Screen name that they’re responsible for:

Screen name + Navigator.

For example FareListNavigator.

Let’s look at the next diagram that shows the relationship between gradle modules of our app.

Here you can see core modules CoreShared. The feature modules such as Fare and Profile, and the App module.

For example, we have a Fare module with features and one of them has a button for navigation to the profile screen of the user. The user profile page is in the Profile module. How to implement this navigation?

For that, we need to create an interface ProfileSharedNavigator that knows how to navigate to the user Profile page, and keep it in the Shared module.

interface ProfileSharedNavigator {
fun goToProfile(userId: String)
}

According to our architecture, the Fare module depends on Shared, so we can use ProfileSharedNavigator in the FareListNavigator.

class FareListNavigator(
private val navController: NavController,
private val profileNavigator: ProfileSharedNavigator,
) : ScreenNavigator(navController) {
fun goToConfirmation(ryderId: String, fare: FareModel) {
navController.navigateSafely(
FareListFragmentDirections.actionFareListFragmentToConfirmationFragment(
ryderId = ryderId,
fare = fare
)
)
}
fun goToProfile(userId: String) {
profileNavigator.goToProfile(userId)
}
}

We pass ProfileSharedNavigator to the FareListNavigator as one of its arguments and delegate navigation calls to it.

The ScreenNavigator is the base class that knows only how to navigate back.

abstract class ScreenNavigator(
private val navController: NavController
) {
open fun goBack() {
navController.navigateUp()
}
}

The App module knows everything about everyone in the app. The main purpose of this module is to organize all dependency injection logic between all feature modules in the project.

class AppNavigator(
private val navController: NavController,
) : ProfileSharedNavigator {
override fun goToProfile(userId: String) {
navController.navigateSafely(
FareListFragmentDirections.actionFareListFragmentToProfileFragment(userId)
)
}
}
view raw AppNavigator.kt hosted with ❤ by GitHub

As you can see AppNavigator hold the real implementation of the ProfileSharedNavigator interface. We can depend on this interface across different modules, and create real instances of it in the App module following the Dependency Inversion Principle (DIP).

State

The state file contains the UI state data class and for the Side Effects sealed class suites the best. State class can be Parcelable (optional) if you want the state to survive through the configuration changes. All properties should have a default value if it’s possible. Effect class contains one-time action on UI, like navigation, show toast, snack bar, bottom sheet, or dialog. To learn more about Effect you can read Orbit Side Effect documentation.

Naming conventions

The state classes are named after the UI component type they’re responsible for. The convention is as follows:

UI component name + State.

UI component name + Effect.

For example: FareListStateand FareListEffect.

@Parcelize
data class FareListState(
val status: ScreenContentStatus = ScreenContentStatus.Idle,
val showRequestLoading: Boolean = false,
val fares: List<FareModel> = emptyList(),
) : Parcelable
sealed class FareListEffect {
data class GoToConfirmation(val ryderId: String, val fare: FareModel) : FareListEffect()
data object ShowGeneralNetworkError : FareListEffect()
}

If your screen has different loading states, better to explicitly split it in your state class. The screen can have few content states, such as IdleLoadingRefreshingSuccessFailure. After the initial content loading, we might want to make a request to the server and show loading to the user, in that case better to show the loading dialog using a separate showRequestLoading property instead of using status a field and set ScreenContentStatus.Loading. The point is not to try to reuse one field to cover different loading cases.

Model

The Presentation layer also has its data models that reflect models from the Domain layer but are more UI-specific. The mapping logic of the presentation model to the domain and vice versa should be placed in the ViewModel class.

@Parcelize
data class FareModel(
val description: String,
val price: Float,
) : Parcelable
view raw FareModel.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

  • The presentation layer shouldn’t expose the UI model to other layers.
  • The presentation model can implement platform-specific ways of serialization such as Parcelable and Serializable.
  • The presentation model should be immutable.
Naming conventions

The model classes are named after the data type that they’re responsible for:

type of data + Model.

For example: RyderFare.

ViewModel

The ViewModel is a business logic state holder. In Android development, ViewModel is suitable for providing access to the business logic and preparing the application data for presentation on the screen. Also, process user events and transform data from the data or domain layers to screen UI state.

For the current implementation, I’m using androidx.lifecycle.ViewModel and Orbit-MVI lib. The ViewModel holds the Orbit container and implements ContainerHost. Check out Orbit API documentation to understand better what going on.

Naming conventions

The ViewModel classes are named after the UI component type that they’re responsible for:

UI component name + ViewModel.

For example FareListViewModel.

class FareListViewModel(
private val exceptionHandler: ExceptionHandler,
private val ryderId: String,
private val getFaresByIdUseCase: GetFaresByIdUseCase,
) : ViewModel(), ContainerHost<FareListState, FareListEffect> {
override val container: Container<FareListState, FareListEffect> = container(
initialState = FareListState(),
buildSettings = {
this.exceptionHandler =
this@FareListViewModel.exceptionHandler.asCoroutineExceptionHandler()
},
) {
fetchFares()
}
private fun fetchFares() = intent {
reduce { state.copy(status = ScreenContentStatus.Loading) }
executeUseCase { getFaresByIdUseCase(ryderId).asPresentation() }
.onSuccess { fares ->
reduce {
state.copy(
status = ScreenContentStatus.Success,
fares = fares
)
}
}
.onFailure {
reduce { state.copy(status = ScreenContentStatus.Failure) }
postSideEffect(FareListEffect.ShowGeneralNetworkError)
}
}
fun onFareClick(fare: FareModel) = intent {
postSideEffect(FareListEffect.GoToConfirmation(ryderId, fare))
}
}

In the code above you can see the example of ViewModel. Let’s shed light on what is going on there.

Let’s start with the constructor. As you can see we inject the use case from the domain layer, ryderId which we pass from the previous screen, and ExceptionHandler. The ViewModel can have multiple use cases.

If you end up in a situation where you have 10+ use cases in the ViewModel, it’s a signal for you to split your ViewModel on few smaller.

Do not try to put some use cases to wrapper classes like this:

data class UseCaseHolder(
private val usecase1: UseCase1,
private val usecase2: UseCase2,
private val usecase3: UseCase3,
)

and then put it in your ViewModel

class FareListViewModel(
private val useCaseHolder: UseCaseHolder,
)

The more interesting stuff going on in fetchFares method.

private fun fetchFares() = intent {
reduce { state.copy(status = ScreenContentStatus.Loading) }
executeUseCase { getFaresByIdUseCase(ryderId).asPresentation() }
.onSuccess { fares ->
reduce {
state.copy(
status = ScreenContentStatus.Success,
fares = fares
)
}
}
.onFailure {
reduce { state.copy(status = ScreenContentStatus.Failure) }
postSideEffect(FareListEffect.ShowGeneralNetworkError)
}
}
view raw fetchFares.kt hosted with ❤ by GitHub

A few words about Orbit lib API. The intent method is executed lambda on Dispatcher.Default. The reduce a method is executed lambda on Dispatcher.Main. It reduces the state and updates the UI state.

Do not execute the use case in the lambda of reduce the method.

The executeUseCase is an extension method of ViewModel to execute the use case and wrap its result to kotlin.Result<R>. It allows you to use extension methods of the Result class such as onSuccessonFailure. Also, pass the exception to the ViewModel handler.

suspend inline fun <R> ViewModel.executeUseCase(block: () -> R): Result<R> =
viewModelScope.executeUseCase(block)
suspend inline fun <R> CoroutineScope.executeUseCase(block: () -> R): Result<R> {
return runSuspendCatching(block)
.onFailure { e ->
coroutineScope { coroutineContext }.let { coroutineContext ->
coroutineContext[CoroutineExceptionHandler]?.run {
handleException(coroutineContext, e)
}
}
}
}
inline fun <R> runSuspendCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (cancellationException: CancellationException) {
throw cancellationException
} catch (e: Throwable) {
Result.failure(e)
}
}

The executeUseCase method intended to execute only one use case.

If you face a situation when you need to execute 2+ use cases for one operation then you should consider the following options:

  • Create a new use case, put all the logic there, and combine the use case you need.
  • If you need to wait for results from multiple use cases and combine them:
private fun fetchData() = intent {
reduce { state.copy(status = ScreenContentStatus.Loading) }
val fetched = coroutineScope {
awaitAll(
async { fetchUser() },
async { fetchFares() },
).all { it }
}
reduce {
state.copy(
status = if (fetched) {
ScreenContentStatus.Success
} else {
ScreenContentStatus.Failure
}
)
}
}
private suspend fun SimpleSyntax<FareListState, FareListEffect>.fetchFares(): Boolean =
executeUseCase { getFaresByIdUseCase(ryderId).asPresentation() }
.onSuccess { fares -> reduce { state.copy(fares = fares) } }
.onFailure { /* Handle error */ }
.isSuccess
private suspend fun SimpleSyntax<FareListState, FareListEffect>.fetchUser(): Boolean =
executeUseCase { getUserUseCase(userId).asPresentation() }
.onSuccess { user -> reduce { state.copy(user = user) } }
.isSuccess
view raw FetchData.kt hosted with ❤ by GitHub

The asPresentation() method responsible for mapping the data model from the domain layer to the model of the presentation layer. You can read how to pam data between layers here.

Screen

The Screen file contains all UI-composed implementations with a Compose preview of each screen state like empty, error, loading, and content.

Naming conventions

The screen classes are named after the UI component type that they’re responsible for:

UI component name + Screen.

For example FareListScreen.

@Composable
fun FareListScreen(
uiState: FareListState,
scaffoldState: ScaffoldState = rememberScaffoldState(),
onFareClick: (FareModel) -> Unit,
) {
Scaffold(
modifier = Modifier.statusBarsPadding(),
scaffoldState = scaffoldState,
topBar = {
TopAppBar(
title = {
Text(text = stringResource(id = R.string.select_fare_title))
},
backgroundColor = AppTheme.colors.material.surface,
)
},
content = {
ScreenContent(
status = uiState.status,
forceLoading = uiState.status.isLoading,
) {
FareList(
fares = uiState.fares,
onClick = onFareClick
)
}
}
)
}
@Preview(name = "Fares Content", showBackground = true)
@Composable
fun PreviewFareListScreenSuccess() {
AppTheme {
FareListScreen(
uiState = FareListState(
status = ScreenContentStatus.Success,
fares = fakeFareModels,
),
onFareClick = {}
)
}
}
@Preview(name = "Fares Content", showBackground = true)
@Composable
fun PreviewFareListScreenLoading() {
AppTheme {
FareListScreen(
uiState = FareListState(
status = ScreenContentStatus.Loading,
fares = fakeFareModels,
),
onFareClick = {}
)
}
}
@Preview(name = "Fares Content", showBackground = true)
@Composable
fun PreviewFareListScreenFailure() {
AppTheme {
FareListScreen(
uiState = FareListState(
status = ScreenContentStatus.Failure,
fares = fakeFareModels,
),
onFareClick = {}
)
}
}

There are a few rules I recommend you follow when building your UI using Compose.

  • Choose stateless composable over stateful. You can read more about it here.
  • Pass all callbacks up to the top screen composable and pass all user interaction to the ViewModel on the Router level.
  • Make composable previews for different states of UI components.

Imagine we need to write TopAppBar Composable function with title as parameter. There are two ways you can consider, pass title as a String or @Composable () -> Unit function.

Option 1.

@Composable
fun TopAppBar(
modifier: Modifier = Modifier,
title: String = "",
) {
Text(text = title)
}
Scaffold(
topBar = {
TopAppBar(
title = stringResource(id = R.string.select_fare_title),
)
},
)
view raw TopAppBar2.kt hosted with ❤ by GitHub

Option 2.

@Composable
fun TopAppBar(
modifier: Modifier = Modifier,
title: @Composable () -> Unit = {},
) {
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.select_fare_title)) },
)
},
)
view raw TopAppBar.kt hosted with ❤ by GitHub

Always choose option 2. It will make your Composable function more customizable and robust at the same time.

Screen preview

To make the screen preview look as close as possible to the real-world scenario we need some random data to create a state. For that, you can create FareModelFake class, put it in the same package as FareModel.

 model/
├─FareModel
├─FareModelFake

The FareModelFake class contains FareModel with fake data that you can use for your previews.

internal val fakeFareModels: List<FareModel>
get() = listOf(
FareModel(
description = "2.5 Hour Ticket",
price = 2.5f,
),
FareModel(
description = "1 Day Pass",
price = 5.0f,
),
FareModel(
description = "30 Day Pass",
price = 100f,
)
)
Packaging conventions
presentation/
├─ fare/
│ ├─ component/
│ │ ├─ FareList
│ │ ├─ FareItem
│ ├─ model/
│ ├─ ├─FareModel
│ ├─ ├─FareModelFake
│ ├─ FareListFragment
│ ├─ FareListNavigator
│ ├─ FareListRoute
│ ├─ FareListScreen
│ ├─ FareListState
│ ├─ FareListViewModel
├─ confirmation/
│ ├─ component/
│ │ ├─ ConfirmationItem
│ ├─ model/
│ ├─ ├─ConfirmationModel
│ ├─ ├─ConfirmationModelFake
│ ├─ ConfirmationFragment
│ ├─ ConfirmationNavigator
│ ├─ ConfirmationRoute
│ ├─ ConfirmationScreen
│ ├─ ConfirmationState
│ ├─ ConfirmationViewModel
Wrapping up

There are a lot of different ways to implement the Presentation layer. Today I shared with you some ideas on how the Presentation layer can be done. You can follow this approach or use some ideas in your implementation.

You can check the sample project on github.

Stay tuned for the next App Architecture topic to cover.

This artice is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu