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
- Add new screens
- 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
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
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 graph3 navGraph = R.navigation.navigation_payment4 // 2. step5 step = Step {6 title = "Transfer Funds"7 //3. layout8 layout = { fragmentManager ->9 //4. stack10 StackLayout {11 stacks = listOf(12 //5. epoxy views13 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
- 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
withComposeView
Here’s the full configuration
1PaymentsConfiguration {2 navGraph = R.navigation.navigation_payment3 step = Step {4 title = "Transfer Funds"5 layout = { fragmentManager ->6 ComposeLayout {7 content = {8 Column {9 // compose view for selecting account10 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 )2425 // compose view for amount26 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 {23 /**4 * Created by Hari on 06/10/2020.5 * Stack Layout for epoxy lists6 *7 * Generated using DSL Builder8 * @see "https://plugins.jetbrains.com/plugin/14386-dsl-api-generator"9 *10 * @param stacks list of epoxy models11 */12 @DataApi13 class StackLayout private constructor(14 val stacks: List<EpoxyModel<*>>15 ): StepLayout() {1617 /**18 * A builder for this configuration class19 *20 * Should be directly used by Java consumers.21 * Kotlin consumers should use DSL function22 */23 class Builder {2425 var stacks: List<EpoxyModel<*>> = listOf()26 @JvmSynthetic set2728 fun setStacks(stacks: List<EpoxyModel<*>>) =29 apply { this.stacks = stacks }3031 fun build() = StackLayout(stacks)3233 }34 }3536 /**37 * Created by Hari on 06/10/2020.38 * Stack Layout for epoxy lists39 *40 * Generated using DSL Builder41 * @see "https://plugins.jetbrains.com/plugin/14386-dsl-api-generator"42 *43 * @param stacks list of epoxy models44 */45 @DataApi46 class ComposeLayout private constructor(47 val content: @Composable () -> Unit48 ): StepLayout() {4950 /**51 * A builder for this configuration class52 *53 * Should be directly used by Java consumers.54 * Kotlin consumers should use DSL function55 */56 class Builder {5758 var content: @Composable () -> Unit = {}5960 fun setContent(content: @Composable () -> Unit) =61 apply { this.content = content }6263 fun build() = ComposeLayout(content)64 }65 }66}6768/**69* DSL to create [StackLayout]70*/71@JvmSynthetic72@Suppress("FunctionName")73fun StackLayout(block: StepLayout.StackLayout.Builder.() -> Unit) =74 StepLayout.StackLayout.Builder().apply(block).build()7576/**77* DSL to create [ComposeLayout]78*/79@JvmSynthetic80@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@Composable2fun 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 }1819 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 }4748 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.subtitle162 )63 Text(64 text = toAccount.value.accountNumber,65 style = MaterialTheme.typography.subtitle2,66 color = colorResource(id = R.color.textColorSecondary)67 )68 }69 }70 }71 }7273 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 }9091 }9293 }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
- 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 thefindViewById
approach - may be fixed in the future? - 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!