Fun with Compose: Bad UI in a Great Framework

· 5 min read

Posted by Touchlab
To celebrate Jetpack Compose finally hitting 1.0, I decided to bring back an old reddit trend of creating the absolute worst volume control UI possible.

Intro

To celebrate Jetpack Compose finally hitting 1.0, I wanted to take a bit of a deep dive to see what the framework has to offer. Instead of more of the same code labs with useful UI, I decided to bring back an old reddit trend of creating the absolute worst volume control UI possible (highlights here). You can take a look at the code on my Github.

mrf7 / volume-gore

The Results

MicrotransactionsMicrotransactions Radio ButtonsRadio Buttons Catapult (WIP) Catapult (WIP) Digits Of PIDigits Of PI Seven SegmentSeven Segment

Takeaways

As someone who’s never really been a fan of UI work, building UI in Compose has been extremely pleasant and straightforward. Things like custom animations that I haven’t done in the View system were surprisingly simple and concise. With Compose I no longer need to context switch between writing Kotlin code and XML layouts, the flow between writing backend code and UI code is much more seamless. The best part was the often lauded LazyColumn in compose that turns 3 files worth of boilerplate RecyclerView code into a couple lines. While it took some effort to change my approach for the Compose mental model, this is a huge leap in the right direction towards making UI work quicker for experienced devs and easier for new devs to learn.

// This is all you need to scroll to a particular item when loading the view 
val listState = rememberLazyLazyListState(selectedItem) 
LazyColumn(state = listState) { 
        stickyHeader {
           // Marked experimental, but  provides a super easy way to keep a header at the top
        }
        items(itemsToDisplay) { item -> 
          // Layout for each item is defined here. Covers everything that used to be in the RecyclerView adapters 
          // without worrying about creating then updating viewholders that might have stale data in them already 
        }
}

Lessons Learned

A vital concept when learning Compose is how the runtime manages state and recomposition via (Mutable)State objects. Most of the “magic” of recomposition relies on the getValue and setValue methods of these classes. and if you mistakenly go around these methods to read and update your apps state, you can actually prevent the UI from updating as you intended.

I learned this the hard way while implementing the editable seven segment display by using a MutableState\<MutableList\> and updating the underlying MutableList values on user interaction (i.e. state.value[index] = newValue). Desugaring that statement to state.getValue()[index] = newValue we see that we’re never calling MutableState.setValue, so the Compose runtime has no indication of the underlying value change, never triggering a recomposition. On top of that, even if something else triggered a recomposition, the UI wouldn’t be updated since we mutated the list without changing the reference, so the underlying equality check used to determine if our composable should run again never detects a change.

 Luckily,Compose gives us a mutableStateListOf(...) and List.toMutableStateList() that return a SnapshotStateList, which is a subclass of MutableList (and equivalents for Maps). That means we can just treat it as a MutableList and the state updates will be handled for us when we do things like snapshotStateList[index] = newValue. The main rule of thumb to learn from this is that in Compose you should avoid mutating objects you intend to use as a state and instead emit a brand new value to your LiveData/Flow/State using the copy method on data classes. You can force this practice on yourself by avoiding vars entirely in classes used for UI state.

For a demonstration of this issue, here’s a snippet showing my initial MutableList approach, a functional workaround I made using a list of mutable states, and the proper solution using mutableStateListOf().

/**
 * This one updates the list on click but since the state handling framework sees the same reference
 * it doesnt know that the state is updated, so no recompose
 */
@Composable
fun StateOfMutableList() {
    // Tip: If we can cast to non mutable state we're doing it wrong. Need to call [MutableState].setValue to recompose
    val state: State<MutableList<Int>> = remember { mutableStateOf(listOf(1, 2, 3, 4).toMutableList()) }
    Column {
        state.value.forEachIndexed { index, num ->
            Text(num.toString(), Modifier.clickable { state.value[index] += 1 })

        }
    }
}

/**
 * This works because we're calling set on [MutableState.value] not  [MutableList.set], so it knows to recompose
 */
@Composable
fun ListOfMutableState() {
    val state: List<MutableState<Int>> = remember { listOf(1, 2, 3, 4).map { mutableStateOf(it) } }
    Column {
        state.forEachIndexed { index, num ->
            Text(num.value.toString(), Modifier.clickable { state[index].value += 1 })
        }
    }
}


/**
 * Think this is the proper way to handle. A subclass of mutable list that handles  state updates properly is built in
 * Gets proper recomposition and can treat the object as a mutable list so you dont have to think about it
 */
@Composable
fun MutableStateList() {
    val state: SnapshotStateList<Int> = remember {
        mutableStateListOf(1, 2, 3, 4)
    }
    // Can upcast to use as mutable list and forget about state management entirely 
    val nums = state as MutableList<Int>
    Column {
        nums.forEachIndexed { index, num ->
            Text(num.toString(), Modifier.clickable { state[index] += 1 })
        }
    }
}

Conclusion

Now that Compose is officially at 1.0, you should give it a shot ASAP. Wherever you update the state of your UI, keep an eye out for spots that mutate the value in the State instead of the State itself. Above all, don’t be afraid to get creative and have fun when playing with compose, you might be surprised by the things you’re able to make easily compared to the old View system.