Component-based Approach. Implementing Screens with the Decompose Library

Artur Artikov
ITNEXT
Published in
8 min readNov 27, 2023

--

This article is the second in a series about the component-based approach. If you haven’t read the first part ‘Component-based Approach. Fighting Complexity in Android Applications’, I recommend starting there. In our previous discussion, we explored how the component-based approach organizes an application into a hierarchical structure: UI elements ➜ functional blocks ➜ screens ➜ flows ➜ application. This structure is effective in managing the complexity of screens and navigation.

Now, I suggest we put this approach into practice. We’ll use the Decompose library to create both simple and complex screens. Let’s delve into examples from real applications to understand how this works. I hope you’ll find it interesting and insightful.

Decompose Library

The component-based approach is versatile and can be implemented with various technology stacks. Among the libraries that facilitate this approach, the Decompose library stands out, created by Arkadii Ivanov, a developer at Google.

Decompose is well suited for building functional blocks, screens, flows, and the overall structure of an application. It’s less necessary for UI elements, which typically involve minimal logic.

Decompose offers a straightforward and user-friendly mechanism for handling components. While I won’t cover all of its features here — as its extensive documentation does a thorough job of that — there are a couple of key elements you’ll need to create screens with Decompose:

  • ComponentContext — this is the core entity in Decompose, serving as the heart of a component. It gives components their lifecycle, allowing them to be created, function, and eventually be destroyed.
  • childContext — this function enables the creation of child components.

Decompose is most effective when used alongside declarative UI frameworks. For the examples in this series, I will use Jetpack Compose.

If you’re working with a more traditional stack (like XML-layout, Fragment, and ViewModel), don’t worry. The component-based approach is a flexible concept, not confined to a specific set of libraries. By learning it through Decompose, you can adapt its principles to suit any technology.

Creating a Simple Screen with Decompose

Let’s create a sign-in screen using Decompose and Jetpack Compose. Given its simplicity, there’s no need to divide it into separate functional blocks.

The sign-in screen is an example of a simple screen

Component Logic

Let’s start with the logic of this screen. We’ll create a SignInComponent interface, along with its implementation, named RealSignInComponent. The rationale behind introducing the interface will be discussed a little later.

Here is the code for SignInComponent:

interface SignInComponent {

val login: StateFlow<String>

val password: StateFlow<String>

val inProgress: StateFlow<Boolean>

fun onLoginChanged(login: String)

fun onPasswordChanged(password: String)

fun onSignInClick()
}

And the code of RealSignInComponent:

class RealSignInComponent(
componentContext: ComponentContext,
private val authorizationRepository: AuthorizationRepository
) : ComponentContext by componentContext, SignInComponent {

override val login = MutableStateFlow("")

override val password = MutableStateFlow("")

override val inProgress = MutableStateFlow(false)

private val coroutineScope = componentCoroutineScope()

override fun onLoginChanged(login: String) {
this.login.value = login
}

override fun onPasswordChanged(password: String) {
this.password.value = password
}

override fun onSignInClick() {
coroutineScope.launch {
inProgress.value = true
authorizationRepository.signIn(login.value, password.value)
inProgress.value = false

// TODO: navigate to the next screen
}
}
}

Let’s review the key points:

  • In the interface, we defined the component’s properties and methods for handling user actions. With StateFlow, these properties become observable, meaning they automatically notify about changes.
  • We passed ComponentContext in the class constructor and implemented the same interface using delegation (indicated by the by keyword). This approach is a standard practice in creating components with Decompose and is important to remember.
  • We utilized the componentCoroutineScope method to establish a CoroutineScope for running asynchronous operations (coroutines). This CoroutineScope is automatically canceled when the component is destroyed, leveraging the lifecycle features of ComponentContext.
  • In the onSignInClick method, we execute the login process using the username and password. I’ve simplified this example by excluding field validation and error handling. Upon successful login, we would typically navigate to the next screen. However, as navigation specifics are not yet covered, we’ll mark this with a TODO for future implementation.

Overall, the code is straightforward. If you’re familiar with MVVM, you might find it to be quite intuitive.

Component UI

Let’s now implement the screen’s UI. For the sake of brevity, I’ve omitted some layout settings and focused only on the essential parts:

@Composable
fun SignInUi(component: SignInComponent) {

val login by component.login.collectAsState(Dispatchers.Main.immediate)
val password by component.password.collectAsState(Dispatchers.Main.immediate)
val inProgress by component.inProgress.collectAsState()

Column {
TextField(
value = login,
onValueChange = component::onLoginChanged
)

TextField(
value = password,
onValueChange = component::onPasswordChanged
)

if (inProgress) {
CircularProgressIndicator()
} else {
Button(onClick = component::onSignInClick)
}
}
}

To link the component with its UI:

  • We obtain values from StateFlow using the collectAsState function and incorporate these values into the UI elements. The UI will automatically update whenever there are changes in the component’s properties.
  • We connect text inputs and button presses to the component’s handler methods.

Important Information about the Terminology

The term ‘component’ has evolved to have two distinct meanings. In a broad sense, a component encompasses all the code responsible for a specific functionality. This includes entities like SignInComponent, RealSignInComponent, SignInUi, and even AuthorizationRepository. However, within the context of the Decompose library, ‘component’ often specifically refers to the class or interface that manages the logic — namely, RealSignInComponent and SignInComponent. Generally, this dual usage does not lead to confusion, as the intended meaning is usually clear from the context.

UI Preview

Introducing an interface for a component is required to add a preview in Android Studio, where a visual preview of the UI is displayed next to the code. To facilitate this, we will create a fake implementation of the component and link it to the preview:

class FakeSignInComponent : SignInComponent {

override val login = MutableStateFlow("login")
override val password = MutableStateFlow("password")
override val inProgress = MutableStateFlow(false)

override fun onLoginChanged(login: String) = Unit
override fun onPasswordChanged(password: String) = Unit
override fun onSignInClick() = Unit
}

@Preview(showSystemUi = true)
@Composable
fun SignInUiPreview() {
AppTheme {
SignInUi(FakeSignInComponent())
}
}

Root ComponentContext

The last point to consider is where to obtain the ComponentContext that needs to be provided to RealSignInComponent.

ComponentContext must be created, but importantly, this is done only once for the entire application, specifically for the root component. While other components will also have their own ComponentContexts, these are obtained differently, a topic we will explore later.

For the purpose of our discussion, let’s consider our application has only a single screen — the sign-in screen. The SignInComponent effectively becomes the root component. To initiate the ComponentContext, we use the defaultComponentContext utility method from Decompose. This method should be invoked from an Activity, ensuring that the ComponentContext lifecycle is synchronized with the Activity’s lifecycle.

The code would look like this:

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val rootComponent = RealSignInComponent(defaultComponentContext(), ...)

setContent {
AppTheme {
SignInUi(rootComponent)
}
}
}
}

The component for the simple screen is ready.

Breaking Down a Complex Screen into Parts

Dividing a complex screen into parts is a practical approach. This would involve a parent component and multiple child components, each representing different functional blocks. For instance, consider the main screen of an application designed for driving exam preparation:

The main screen of the DMV Genie application, along with its functional blocks

On this screen, distinct blocks are clearly visible, including a toolbar with progress, the ‘Next Test’ card, sections for all tests, theory, exam, and feedback.

Child Components

We will develop a separate component for each functional block. The code for these components follows a familiar pattern, consisting of the component’s interface, its implementation, and the UI.

For instance, here is what the toolbar component looks like:

interface ToolbarComponent {

val passingPercent: StateFlow<Int>

fun onHintClick()
}
class RealToolbarComponent(componentContext: ComponentContext) :
ComponentContext by componentContext, ToolbarComponent {
// some logic
}
@Composable
fun ToolbarUi(component: ToolbarComponent) {
// some UI
}

Similarly, we will create NextTestComponent, TestsComponent, TheoryComponent, ExamComponent, FeedbackComponent, and their respective UIs.

A Parent Component

The screen component will serve as the parent for the functional block components.

Let’s declare its interface:

interface MainComponent {

val toolbarComponent: ToolbarComponent

val nextTestComponent: NextTestComponent

val testsComponent: TestsComponent

val theoryComponent: TheoryComponent

val examComponent: ExamComponent

val feedbackComponent: FeedbackComponent

}

As you can see, the component doesn’t hide its child components. On the contrary, it openly declares them in the interface.

In the implementation, we’ll utilize the childContext method from Decompose:

class RealMainComponent(
componentContext: ComponentContext
) : ComponentContext by componentContext, MainComponent {

override val toolbarComponent = RealToolbarComponent(
childContext(key = "toolbar")
)

override val nextTestComponent = RealNextTestComponent(
childContext(key = "nextTest")
)

override val testsComponent = RealTestsComponent(
childContext(key = "tests")
)

override val theoryComponent = RealTheoryComponent(
childContext(key = "theory")
)

override val examComponent = RealExamComponent(
childContext(key = "exam")
)

override val feedbackComponent = RealFeedbackComponent(
childContext(key = "feedback")
)
}

The childContext method creates a new child ComponentContext. It’s important that each child component has its own context. Decompose mandates that these child contexts have unique names, which we achieve by using the key parameter.

Now, all that remains is to add the UI, and then it’s all set:

@Composable
fun MainUi(component: MainComponent) {
Scaffold(
topBar = { ToolbarUi(component.toolbarComponent) }
) {
Column(Modifier.verticalScroll()) {
NextTestUi(component.nextTestComponent)

TestsUi(component.testsComponent)

TheoryUi(component.theoryComponent)

ExamUi(component.examComponent)

FeedbackUi(component.feedbackComponent)
}
}
}

As a result, the component’s code is both simple and compact. We wouldn’t have achieved this without breaking down the screen into parts.

Organizing Interaction Between Components

Ideally, child components should be completely independent of each other. However, this isn’t always possible. Sometimes, an event in one component may require a specific action in another.

Consider the screen from our previous example. Imagine a scenario where, upon leaving positive feedback, the user receives complimentary educational material in the ‘Theory’ block. This specific requirement isn’t part of the actual application; I’ve made it up for illustrative purposes.

We need to organize interaction between the FeedbackComponent and the TheoryComponent. The first thought that might come to mind is to create a reference to TheoryComponent from RealFeedbackComponent. However, this is a suboptimal solution! It would lead to the feedback component handling tasks beyond its primary function, such as managing theoretical materials. If we continue adding such direct links between components, they would quickly become overloaded and non-reusable.

Let’s take a different approach. We’ll assign the responsibility for inter-component interaction to the parent component. To signal the necessary event, we will employ a callback.

Here’s how we’ll structure the code:

  • In TheoryComponent, we’ll introduce a method named unlockBonusTheoryMaterial, which will enable access to the bonus educational material.
  • In RealFeedbackComponent, we’ll pass a callback function onPositiveFeedbackGiven: () -> Unit through the constructor. This function will be triggered by the component at the appropriate time.
  • Within RealMainComponent, we’ll then establish a connection between these two components:
override val feedbackComponent = RealFeedbackComponent(
childContext(key = "feedback"),
onPositiveFeedbackGiven = {
theoryComponent.unlockBonusTheoryMaterial()
}
)
Inter-component interaction

In summary, the guidelines for inter-component interaction are:

  • Child components should not interact directly with each other.
  • A child component may notify its parent using a callback.
  • The parent component is allowed to directly call a method on a child component.

Further Reading

Decompose

Other Libraries

  • RIBs: One of the first open-source implementations of the component-based approach for mobile applications.
  • appyx: A modern library with a notable limitation — it is exclusively integrated with Compose Multiplatform.

Classic Stack

Article “How to Communicate Between Fragments?”: The author demonstrates how to organize communication between fragments using a callback.

To Be Continued

We have covered the topic of complex screens. By utilizing the techniques described, you’ll be equipped to manage screens of any complexity.

Next, we’ll focus on organizing navigation using Decompose.

--

--