Component-based Approach. Implementing Screens with the Decompose Library
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.
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 theby
keyword). This approach is a standard practice in creating components with Decompose and is important to remember. - We utilized the
componentCoroutineScope
method to establish aCoroutineScope
for running asynchronous operations (coroutines). ThisCoroutineScope
is automatically canceled when the component is destroyed, leveraging the lifecycle features ofComponentContext
. - 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 aTODO
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 thecollectAsState
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 ComponentContext
s, 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:
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 namedunlockBonusTheoryMaterial
, which will enable access to the bonus educational material. - In
RealFeedbackComponent
, we’ll pass a callback functiononPositiveFeedbackGiven: () -> 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()
}
)
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
- Decompose on GitHub: A concise overview of the library, issues, discussions, and the option to star the project.
- Decompose Documentation: Learn about the additional capabilities that
ComponentContext
provides.
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.