Since I started using compose it has been amazing for architecture as well as my free time!
In the dark times (before compose) I was accustomed to using Adapter Delegates (by Hannes Dorfmann) in order to create modular/generic screens. That however comes with all the usual issues that a RecyclerView brings… Its complicated to understand, a hassle to set up and update, and communicating between view holders is cumbersome.
After getting used to how compose works I started wondering whether I could recreate the same logic but without the problems.
My aim was to design a screen that can:
- Dynamically change its elements based on state
- Be worked on by many developers with no conflicts or slow downs
What I came up with is basically LEGOs for compose! Compose makes it extremely easy to handle UI state. This means we can create our UI in small building blocks without worrying too much about updating it correctly since Compose will do the heavy lifting.
The base of our UI will be a LazyColumn.
@Composable
fun View() {
LazyColumn(Modifier.fillMaxSize()) {
items(
items = state.viewData,
contentType = { item -> item.getType() }
) { item ->
LazyItem(views, item)
}
}
}
This will allow us to provide a list of view data to be loaded in their proper views. This is handled by “LazyItem”:
@Composable
fun LazyItem(
views: List<ComposeView>,
item: ComposeViewData
) {
views.find { it.isValidForView(item) }?.View(item)
?: Timber.e(
IllegalArgumentException("Tried to parse invalid ComposeViewData: $item")
)
}
Let’s take a closer look at how the views are created. We will need 2 interfaces; ComposeView and ComposeViewData.
ComposeView will provide our view, while ComposeViewData will hold the data required for the view.
interface ComposeView {
fun isValidForView(data: ComposeViewData): Boolean@Composable
fun View(data: ComposeViewData)
}
ComposeView will only need a couple of methods:
- isValidForView: Validates whether the view data provided can be displayed by this view
- View: a composable function to create the view
interface ComposeViewData {
fun getType(): Any = this::class.simpleName ?: this::class.javafun getKey(): Any
}
ComposeViewData will provide a type and key to the LazyColumn for reusability. You can omit using the custom key as the LazyColumn will use the item index by default. If you do use a custom key make sure its unique or the app will crash!
As an example let’s take a look at a generic text view made using these interfaces.
class GenericTextView : ComposeView {override fun isValidForView(data: ComposeViewData) =
data is GenericTextViewData
@Composable
override fun View(data: ComposeViewData) {
data as GenericTextViewData
Box(
modifier = Modifier
.then(data.containerParams.size.toSizeModifier(Modifier.fillMaxWidth(), null))
.then(data.containerParams.outerColors.toBackgroundModifier())
.then(data.containerParams.outerPadding.toPaddingModifier())
) {
Text(
modifier = Modifier
.fillMaxWidth()
.then(data.containerParams.innerColors.toBackgroundModifier())
.then(data.containerParams.innerPadding.toPaddingModifier()),
text = data.textAttribute.text(),
style = data.textAttribute.styleWithColorAttribute(),
overflow = data.textAttribute.overflow ?: TextOverflow.Visible,
maxLines = data.textAttribute.maxLines ?: Int.MAX_VALUE
)
}
}
}
@Immutable
data class GenericTextViewData(
val containerParams: ContainerParams = ContainerParams(),
val textAttribute: TextAttribute
) : ComposeViewData {
private val _key by lazy { UUID.randomUUID().toString() }
override fun getKey() = _key
}
In order to make this view completely generic we need to be able to customize it. To that end, we will use a container (Box) to enclose the actual text. The box can then be used to setup the size, color, shape, padding of the view (as well as any extra modifiers we need) using the ContainerParams class:
data class ContainerParams(
val outerPadding: ContainerPadding? = null,
val innerPadding: ContainerPadding? = null,
val outerColors: ContainerColors? = null,
val innerColors: ContainerColors? = null,
val size: ContainerSize? = null,
val extraModifiers: Modifier? = null
)data class ContainerPadding(
val top: Dp? = null,
val start: Dp? = null,
val end: Dp? = null,
val bottom: Dp? = null
) {
constructor(
horizontal: Dp? = null,
vertical: Dp? = null
) : this(
vertical,
horizontal,
horizontal,
vertical
)
}
data class ContainerSize(
val width: Modifier? = null,
val height: Modifier? = null
)
data class ContainerColors(
val backgroundColor: Color? = null,
val backgroundGradient: Modifier? = null
)
This will help with most of the customizations that need to be done. Additionally, for this view we will need a way to customize the text:
open class TextAttribute(
val text: @Composable () -> String,
val style: TextStyle,
val color: ColorAttribute? = null,
val overflow: TextOverflow? = null,
val maxLines: Int? = null
)
In our view both these classes are utilized by using extensions. To keep this concise I won’t add them here but you can find them in the project linked at the end of the article!
Next, we need a transformer that will create our view data from our data source (probably a network call):
class CharacterDetailsViewDataTransformer @Inject constructor(): ViewDataTransformer<DomainCharacter> {override fun transform(model: DomainCharacter): List<ComposeViewData> {
val data = mutableListOf<ComposeViewData>()
data += RemoteImageViewData(
containerParams = ContainerParams(
size = ContainerSize(
width = Modifier.fillMaxWidth(),
height = Modifier.height(350.dp)
)
),
imageAttribute = RemoteImageAttribute(
url = model.image,
isGif = false,
crossfade = true,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
contentDescription = model.name
)
)
data += GenericTextViewData(
containerParams = ContainerParams(
size = ContainerSize(width = Modifier.fillMaxWidth()),
outerPadding = ContainerPadding(top = 16.dp, start = 16.dp, end = 16.dp)
),
textAttribute = TextAttribute(
text = { "${model.species}, ${model.gender}" },
overflow = TextOverflow.Ellipsis,
style = typography.titleLarge,
color = OnBackground
)
)
data += GenericTextViewData(
containerParams = ContainerParams(
size = ContainerSize(width = Modifier.fillMaxWidth()),
outerPadding = ContainerPadding(top = 32.dp, start = 16.dp, end = 16.dp)
),
textAttribute = TextAttribute(
text = {
"""
${stringResource(id = R.string.character_origin, model.origin.name)}
${stringResource(id = R.string.character_location, model.location.name)}
""".trimIndent()
},
overflow = TextOverflow.Ellipsis,
style = typography.titleMedium,
color = OnBackground
)
)
return data
}
}
Here we will use 2 view data types — RemoteImageViewData and GenericTextViewData. Now let’s finish it off with a view model to bind everything together:
@HiltViewModel(assistedFactory = CharacterDetailsViewModelFactory::class)
class CharacterDetailsViewModel @AssistedInject constructor(
private val fetchCharacterUseCase: FetchCharacterUseCase,
private val transformer: ViewDataTransformer<DomainCharacter>,
private val dispatchers: DispatchersWrapper,
@Assisted private val id: Int
) : ViewModel() {val uiState = CharacterDetailsUiState()
init {
fetch()
}
private fun fetch() {
viewModelScope.launch(dispatchers.io) {
updateInMain(dispatchers) { uiState.state = State.Loading }
if (id == -1) {
handleErrorResponse()
return@launch
}
val response = fetchCharacterUseCase.fetch(id)
if (response !is DomainResponse.Success) {
handleErrorResponse()
return@launch
}
val data = response.body
val viewData: PersistentList<ComposeViewData>
withContext(dispatchers.default) {
viewData = transformer.transform(data).toPersistentList()
}
updateInMain(dispatchers) {
uiState.state = State.Data(
name = data.name,
viewData = viewData
)
}
}
}
private suspend fun handleErrorResponse() {
updateInMain(dispatchers) { uiState.state = State.Error }
}
}
@AssistedFactory
interface CharacterDetailsViewModelFactory {
fun create(id: Int): CharacterDetailsViewModel
}
Note that the state must be updated on the main thread since I elected not to use a flow to avoid copying the class on every update. You can ofcourse substitute a flow and provide that to the composable instead.
And here’s our main composable now:
class CharacterDetailsUi @Inject constructor() {@Composable
fun View(uiState: CharacterDetailsUiState) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
when (val state = uiState.state) {
State.None, State.Loading -> {
TitleOnly(stringResource(id = R.string.title_character))
.View(modifier = Modifier.fillMaxWidth())
DefaultFullPageLoader(Modifier.fillMaxSize())
}
State.Error -> {
TitleOnly(stringResource(id = R.string.title_character))
.View(modifier = Modifier.fillMaxWidth())
DefaultErrorView(Modifier.fillMaxSize())
}
is State.Data -> {
TitleOnly(state.name).View(modifier = Modifier.fillMaxWidth())
val views = persistentListOf(
GenericTextView(),
RemoteImageView(),
)
LazyColumn(Modifier.fillMaxSize()) {
items(
items = state.viewData,
key = { item -> item.getKey() },
contentType = { item -> item.getType() }
) { item ->
LazyItem(views, item)
}
}
}
}
}
}
}
@Stable
class CharacterDetailsUiState() {
var state by mutableStateOf<State>(State.None)
sealed interface State {
data object None : State
data object Loading : State
data object Error : State
data class Data(
val name: String,
val viewData: PersistentList<ComposeViewData>
) : State
}
}
The view model will fetch, transform the data we need and then update the UI state. Then the LazyColumn will load our view data and delegate each one to the proper view!
The final rendered screen: