Navigate back to the homepage

Android: Configuration Driven UI from Epoxy to Compose

Hari Vignesh Jayapalan
October 14th, 2020 · 4 min read

Android: Configuration driven UI from Epoxy to Jetpack Compose

This is a story of how we came up with the solution for Configuration driven UI (a level below server-driven UI) in Android at Backbase. It’s not a super perfect system or not highly reactive as Jetpack compose, but it absolutely solves the use-case: to create a framework or library, where developers can create or replace view components via configurations.

Who should read?

This is about a special Android library. This solution might not be useful for all the devs who write apps every day. But I guarantee that this is an interesting problem to think and solve.

Background & Requirements

We need to deliver a library, where customers can build or configure UI components into our OOTB (out of the box) screen or collection of screens. They should also be able to do the following

  1. Add new screens
  2. Configure OOTB UI components - Create quickly and add new UI components
    The above all should be possible via the configurations

Configurations?

Configurations can mean anything. It can be a simple class with properties, JSON object (local or remote). Here, we’ll be working with Kotlin DSLs

Why? they are type-safe, IDE intelli-sense support, manageable in terms of upgrading and maintaining the source and binary compatibility (how? more below). Even if we need to do it remotely (Server driven UI), we can receive a JSON object and map them to DSL.

The big picture

epoxy

Not really that big, but here’s how this is achieved

  • A Screen is nothing but a fragment and it just contains the list, here RecyclerView. I believe that this is what Airbnb app consists of - each screen is a RecyclerView with different views
  • The screen gets the configuration injected via DI or Service locators such as Koin
  • Using this DSL configuration, the Epoxy populates all the views in our RecyclerView
  • We are also using Navigation Component here, to use the same screen with multiple instances working together to solve this (a) task

Why Epoxy?

As per the requirements, we need to provide an accelerator solution, where developers should be able to create view components quickly. Using traditional methods might burn a bit more time. Epoxy, on the other hand, manages this complexity very well with a very little learning curve and with DSLs as output, it makes the configuration seamless.

Configuration-driven UI with Epoxy

simple payment

For a better explanation, why not show code? Let’s build a simple payment transfer screen, which will allow users to choose an account & a contact, enter the amount and hit pay!

In this screen, there are 2 configurable/customizable/replaceable components and room for more!

  • Account selector: Allows users to select the originator account and a destination account which opens up a bottom sheet and gets the result back
  • Amount view: Allows users to enter the amount

Here, the button is part of the screen for a reason, not important to know 😛

The Configuration

To better explain, let’s take a look at the actual configuration that brings out this screen to life

1PaymentsConfiguration {
2 // 1. navigation graph
3 navGraph = R.navigation.navigation_payment
4 // 2. step
5 step = Step {
6 title = "Transfer Funds"
7 //3. layout
8 layout = { fragmentManager ->
9 //4. stack
10 StackLayout {
11 stacks = listOf(
12 //5. epoxy views
13 AccountSelectorView_().apply {
14 id(1)
15 fromAccountName("N26")
16 fromAccountNumber("NL 0000 0000 0000 0000 00")
17 toAccountName("Bunq")
18 toAccountNumber("NL 0000 0000 0000 0000 00")
19 onFromSelected { listener ->
20 AccountPicker { listener(it) }
21 .show(fragmentManager, "account-picker-from")
22 }
23 onToSelected { listener ->
24 AccountPicker { listener(it) }
25 .show(fragmentManager, "account-picker-to")
26 }
27 },
28 AmountView_().apply { id(2) }
29 )
30 }
31 }
32 }
33}

1. Navigation Graph: In this example, we are dealing with one screen. If there is a use-case for multiple screens, navigation graph would be a good choice (here, optional)

2. Step: This represents a screen. Multiple steps mean many screens, which can be wrapped using a list of steps (bad naming?)

3. Layout: A sealed class entity, that supports different layouts. Here, a stack of views. Purely business-case oriented (FormLayout, ListLayout etc.)

4. Stack: Here, to stack up the views takes in a list of Epoxy Views

5. Epoxy View: View components created using epoxy

AccountSelectorView uses a few interesting functional callbacks to open up a bottom-sheet dialog and get the result back

For more detailed implementation, please refer the repo

Moving from Epoxy to Composable functions

Let’s try this interesting experiment. If you wonder why epoxy was the first choice is that the API was very stable, it provided a very quick way to create UI components with DSL wrapper - which was seamless with the whole configuration-driven UI concept.

I happened to try Jetpack compose and it was quite promising. It was very close to the Flutter experience. But let’s check the reality on this date (Oct 2020)

  • Still in alpha
  • A lot of breaking changes
  • Not super good with existing projects (Adding compose to existing Kotlin synthetic binding projects causes build failure, probably be fixed at the time of stable release)
  • More features to come

Considering all these, epoxy is still a stable option for the above-mentioned date. But I’m curious about the migration strategy to this promising library.

What if Compose becomes the default way to create UI components in Android? (Maybe!) and it’s already a part of Modern Android Development (MAD) marketing tag! So, this library should be able to cater or move to the new solution.

Configuration-driven UI with Jetpack Compose

compose

  • To migrate from epoxy, our Sealed class implementation of the layout is the key. Instead of using StackLayout we add a new implementation in place - ComposeLayout
  • Also, we replace RecyclerView with ComposeView

Here’s the full configuration

1PaymentsConfiguration {
2 navGraph = R.navigation.navigation_payment
3 step = Step {
4 title = "Transfer Funds"
5 layout = { fragmentManager ->
6 ComposeLayout {
7 content = {
8 Column {
9 // compose view for selecting account
10 accountSelector(
11 fromAccountName = "N26",
12 fromAccountNumber = "NL 0000 0000 0000 0000 00",
13 toAccountName = "Bunq",
14 toAccountNumber = "NL 0000 0000 0000 0000 00",
15 onFromSelected = { listener ->
16 AccountPicker { listener(it) }
17 .show(fragmentManager, "account-picker-from")
18 },
19 onToSelected = { listener ->
20 AccountPicker { listener(it) }
21 .show(fragmentManager, "account-picker-to")
22 }
23 )
24
25 // compose view for amount
26 amountView()
27 }
28 }
29 }
30 }
31}

Adding ComposeLayout to StepLayout

A New class gets to be a part of the Step layout - ComposeLayout

Note: We are creating DSLs this way - to cater for binary compatibility. You can generate DSLs that are binary-safe way + support Java interoperability using this Android Studio plugin - DSL API Generator - Plugins | JetBrains

1sealed class StepLayout {
2
3 /**
4 * Created by Hari on 06/10/2020.
5 * Stack Layout for epoxy lists
6 *
7 * Generated using DSL Builder
8 * @see "https://plugins.jetbrains.com/plugin/14386-dsl-api-generator"
9 *
10 * @param stacks list of epoxy models
11 */
12 @DataApi
13 class StackLayout private constructor(
14 val stacks: List<EpoxyModel<*>>
15 ): StepLayout() {
16
17 /**
18 * A builder for this configuration class
19 *
20 * Should be directly used by Java consumers.
21 * Kotlin consumers should use DSL function
22 */
23 class Builder {
24
25 var stacks: List<EpoxyModel<*>> = listOf()
26 @JvmSynthetic set
27
28 fun setStacks(stacks: List<EpoxyModel<*>>) =
29 apply { this.stacks = stacks }
30
31 fun build() = StackLayout(stacks)
32
33 }
34 }
35
36 /**
37 * Created by Hari on 06/10/2020.
38 * Stack Layout for epoxy lists
39 *
40 * Generated using DSL Builder
41 * @see "https://plugins.jetbrains.com/plugin/14386-dsl-api-generator"
42 *
43 * @param stacks list of epoxy models
44 */
45 @DataApi
46 class ComposeLayout private constructor(
47 val content: @Composable () -> Unit
48 ): StepLayout() {
49
50 /**
51 * A builder for this configuration class
52 *
53 * Should be directly used by Java consumers.
54 * Kotlin consumers should use DSL function
55 */
56 class Builder {
57
58 var content: @Composable () -> Unit = {}
59
60 fun setContent(content: @Composable () -> Unit) =
61 apply { this.content = content }
62
63 fun build() = ComposeLayout(content)
64 }
65 }
66}
67
68/**
69* DSL to create [StackLayout]
70*/
71@JvmSynthetic
72@Suppress("FunctionName")
73fun StackLayout(block: StepLayout.StackLayout.Builder.() -> Unit) =
74 StepLayout.StackLayout.Builder().apply(block).build()
75
76/**
77* DSL to create [ComposeLayout]
78*/
79@JvmSynthetic
80@Suppress("FunctionName")
81fun ComposeLayout(block: StepLayout.ComposeLayout.Builder.() -> Unit) =
82 StepLayout.ComposeLayout.Builder().apply(block).build()

Implementation of Account selector

compose version: 1.0.0-alpha04

Refer this code on Github

1@Composable
2fun accountSelector(
3 fromAccountName: String,
4 toAccountName: String,
5 fromAccountNumber: String,
6 toAccountNumber: String,
7 onFromSelected: (((Account) -> Unit) -> Unit)?,
8 onToSelected: (((Account) -> Unit) -> Unit)?
9) {
10 ConstraintLayout(modifier = Modifier.padding(16.dp)) {
11 val (image, cards) = createRefs()
12 val fromAccount = remember {
13 mutableStateOf(Account(fromAccountName, fromAccountNumber))
14 }
15 val toAccount = remember {
16 mutableStateOf(Account(toAccountName, toAccountNumber))
17 }
18
19 Column(modifier = Modifier.constrainAs(cards) {
20 top.linkTo(parent.top)
21 start.linkTo(parent.start)
22 end.linkTo(parent.end)
23 }) {
24 Card(border = BorderStroke(1.dp,
25 colorResource(id = R.color.colorPrimaryDark)),
26 shape = RoundedCornerShape(8.dp),
27 backgroundColor = MaterialTheme.colors.surface,
28 modifier = Modifier.fillMaxWidth()
29 .fillMaxWidth().clickable(onClick = {
30 onFromSelected?.invoke { fromAccount.value = it }
31 })
32 ) {
33 Box(modifier = Modifier.padding(16.dp)) {
34 Column {
35 Text(
36 text = fromAccount.value.accountName,
37 style = MaterialTheme.typography.subtitle1,
38 )
39 Text(
40 text = fromAccount.value.accountNumber,
41 style = MaterialTheme.typography.subtitle2,
42 color = colorResource(id = R.color.textColorSecondary)
43 )
44 }
45 }
46 }
47
48 Card(
49 border = BorderStroke(1.dp,
50 colorResource(id = R.color.colorPrimaryDark)),
51 shape = RoundedCornerShape(8.dp),
52 backgroundColor = MaterialTheme.colors.surface,
53 modifier = Modifier.padding(top = 8.dp).fillMaxWidth().clickable(onClick = {
54 onToSelected?.invoke { toAccount.value = it }
55 })
56 ) {
57 Box(modifier = Modifier.padding(16.dp)) {
58 Column {
59 Text(
60 text = toAccount.value.accountName,
61 style = MaterialTheme.typography.subtitle1
62 )
63 Text(
64 text = toAccount.value.accountNumber,
65 style = MaterialTheme.typography.subtitle2,
66 color = colorResource(id = R.color.textColorSecondary)
67 )
68 }
69 }
70 }
71 }
72
73 Card(
74 shape = CircleShape,
75 border = BorderStroke(1.dp,
76 colorResource(id = R.color.colorPrimaryDark)),
77 modifier = Modifier.width(32.dp)
78 .height(32.dp).constrainAs(image) {
79 top.linkTo(parent.top)
80 bottom.linkTo(parent.bottom)
81 start.linkTo(parent.start)
82 end.linkTo(parent.end)
83 }) {
84 Box(modifier = Modifier.padding(8.dp), alignment = Alignment.Center) {
85 Icon(
86 asset = vectorResource(id = R.drawable.ic_baseline_double_arrow_24),
87 tint = colorResource(id = R.color.textColorSecondary)
88 )
89 }
90
91 }
92
93 }
94}

For full implementation of compose components and layout, please refer this compose branch

Challenges faced

We did not migrate the actual repo yet. But here are some of the challenges that I faced working on this small repo

  1. Compose hates Kotlin Synthetic binding? when I added all the necessary dependencies, I faced build errors around Kotlin synthetic view binding. Folks in Stackoverflow have suggested moving to ViewBinding or simply use the findViewById approach - may be fixed in the future?
  2. A bit of learning curve for the new state management around Compose - which was expected. My little Flutter knowledge made it better (considering that the above repo was created in 3 hours)

Final thoughts

If you have reached till here, I’d appreciate your time for reading this post. Configuration driven UI might not be for everyone, it’s simply one of the business case and a very interesting problem to solve in terms of the architecture and public APIs. Hope you are taking something home :-)

Thank you and see you on another post!

More articles from Hari Vignesh Jayapalan

Kotlin - Creative function composition with extensions & operator overloading

Compose functions creatively

June 29th, 2020 · 2 min read

Functional Programming in Kotlin - f(4)

Currying Function

May 23rd, 2020 · 2 min read
© 2020 Hari Vignesh Jayapalan
Link to $https://twitter.com/hariofspadesLink to $https://github.com/hariofspadesLink to $https://medium.com/@harivigneshjayapalanLink to $https://linkedin.com/in/harivignesh