Screen templates — using Compose, SwiftUI & KMM
At Octopus Energy we started building native mobile apps in mid-2020, just as frameworks like Jetpack Compose & SwiftUI were on the horizon. As we were starting from a blank canvas — with no large legacy codebases — we made the decision to adopt these frameworks from the get go of our mobile journey.
In the beginning information on these tools was pretty sparse, so we largely built screens in a similar way to traditional Android or iOS, just using a different tool. As times gone on, we’ve adapted how we write screens to use the power of these new frameworks.
Many of the things we’ve tried were possible in the traditional frameworks, they were just hard to do and required a lot of boilerplate. Compose & SwiftUI make them easy to do with minimal code, as they actively encourage the use of lego like, reusable, and data driven UI.
In this post I’ll go through one idea we’ve experimented with and adopted — creating application specific screen templates, which cut down the amount of code we need to write when making screens and which make our apps far more consistent from a UI/UX perspective.
Want to work with KMM, Jetpack Compose, SwiftUI; AND make a dent in climate change?
We’re always hiring talented, passionate mobile developers with an interest in making the world a better place, so if you like what you see here please reach out! Or apply directly at: https://jobs.lever.co/octoenergy
Duplication of UI code
In our typical Android & iOS projects, a huge amount of UI code gets duplicated across screens. Duplication means more work for us to write and maintain, and inconsistent UX for users as different screens get implemented in slightly different ways by different team members.
As a non-exhaustive list of commonly duplicated UI:
- Loading screen state
- Error screen state
- Confirmation screen state
- Call to actions
- Drawing behind system UI
Many of these apply to all projects, and most end up having project specific solutions. For example, actionable alerts in Electric Juice commonly use a bottom sheet pattern, whereas many apps may use a dialog pattern.
By moving many of these UI components to a reusable screen template and making that template data driven, we can achieve single responsibility, reduce the amount of code we need to write, make app wide changes easier to achieve, and give a consistent UX to end users.
How do we do it?
What we’ll make
To create the app I will use KMM, which we use commonly in Octopus to share logic between mobile platforms.
Why is that useful here? Our template is data driven, and that data model and all the logic to manipulate the model is non-framework-dependent. With KMM we can make the logic once and share it between multiple platforms whilst keeping 100% native UI.
To demonstrate how to go about this, I’ll create something which looks similar to the screen grabs below — excuse the atrocious design, this example was purely functional.
The apps a simple 2 screens, which need a loading, error, and loaded screen state; a generic toolbar; and the first screen contains a call to action to go to a second screen.
Even though I’ll use KMM here, the concepts are exactly the same as if we were making a purely iOS or purely Android project using SwiftUI or Jetpack compose respectively.
There are two layers we care about when screen templating, the presentation layer, and the UI layer. All other layers of the app ideally have no knowledge of how data is manipulated into UI for end users.
In our presentation layer we first need to create a data model to represent screen state, this model will be used by the UI to drive what components to display.
There’s a closed set of states a screen can take, so we can use a sealed interface to represent those states, for all other components we can use standard data classes.
Even though in this simple example all the data classes only contain a single
String, it’s useful to have one data class per component, as even in a medium size app, each component is likely to diverge in the data it contains.
Now we have our ScreenState defined we can expose this in our presentation layer. There’s lots of different ways to set this layer up, but the basic principle is that the presentation class will update ScreenState on user actions or lifecycle events and allow the UI to listen to those changes — in this case I use a MVI like pattern.
The ViewModel begins life in a default state of loading, with all it’s parameters initialised, so the UI can display information as quickly as possible. Then, after half a second, the ViewModel ‘loads’ to simulate a network call and a new state gets emitted to the UI.
It’s useful to point out the screen state is a param inside the overall
An alternative would be making the entire
ViewState the sealed class so only params relevant to the loaded state are visible when the screen state is in a loaded state, or only params relevant to the error state are visible when the screen state is in an error, and so on.
Whilst that approach is possible, we’ve found it to have problems as view models inevitably scale, and it leads to a lot of type checking or re-creation of data. Having the screen state as its own param allows us to keep or code simple, flexible, and boilerplate free.
Listening to State
For each screen we usually begin with building a
Destination composable which defines all interactions with the presentation layer and holds the screen template.
Often it’s useful to keep presentation code interactions to a root SwiftUI or Compose component, as this makes it simple to build previews for sub views later — previews where we don’t need to re-create an entire ViewModel. And avoids scattering links to our presentation layer throughout our UI, meaning less coupling between the UI & presentation layers.
Jetpack Compose Destination:
On a slight tangent, it’s possible to see from the above just how similar Compose & SwiftUI are. The languages backing them are similar and the concepts within them are almost exactly the same. The main thing that changes is the syntax to write them.
As we’ve integrated KMM further into our projects we often find that it’s relatively simple for an android developer to pick up a feature across both mobile platforms or an iOS developer to do the same. One of the exciting things about KMM alongside SwiftUI & Compose is that instead of being Android or iOS developers, we can become mobile developers.
But back to the article, what does the template actually look like?
The template is used across every screen in the app, so we want to ensure it follows several core concepts — the template must be reusable, have sensible defaults, and be overridable.
Template function params
It’s important to keep the template generic, so it can’t contain any references to ViewModels. All it knows about is the data objects passed to describe how it displays itself, and the callbacks to describe what to do when certain actions happen. Essentially state hoisting.
In almost any app the one certainty is that requirements will change and there’ll be features that don’t fit the pretty template we make at the beginning of a project. Luckily with both Jetpack Compose & SwiftUI it’s easy to pass views into other views — the slot architectural pattern which is explained very nicely by Chris Banes.
We can use that behaviour to ensure any screen can override the default template behaviour. Here we just use the slot concept for the
loadedState/content param. But it could equally be used by any component in the template to make the UI simple to override. For anyone whose used the Compose Scaffold, it’ll be very familiar.
The reason we go for nullable data classes here instead of the slot pattern is that on simple projects we’re often not creating a general api, we’re creating an opinionated template — so don’t always want the views to be overridable. But depending on your project requirements you could make more use of the slot pattern throughout the template — as a rule of thumb, the bigger and longer lived the project the more useful the slot pattern becomes.
We also need to build the components which make up the template. These will all be small chunks of UI which use state hoisting to ensure they stay simple.
We should make sure all these components get passed the full view state object as a function parameter— why? So if we want to adjust the component later, and need new data from the presentation layer, we don’t need to deal with editing wiring up code across the app. We just add an initialised field to the view state object, and can edit the component — voila.
Now we’re ready to put everything together in a template.
The full template
Jetpack Compose example:
I won’t go into much detail into what the template does, as it would be different for every project. The beauty of screen templating is that it’s adaptable to fit your project requirements and it can be as flexible as you choose to make it.
In simple projects it may be appropriate to simply use the
Scaffold which ships with compose. As projects increase in size and complexity you will likely have more extensive requirements and different common elements between screens which need something more custom — your own screen template.
With the inbuilt power of Jetpack Compose and SwiftUI it becomes simple to create reusable templates which are flexible thanks to the Slot architecture pattern — letting us write great looking apps, quicker.
But the real superpower of this approach is when KMM gets thrown into the mix.
Before when we built screens we’d not only have the the problem of inconsistent design between screens in the same app. We’d have inconsistent design between apps on separate platforms, built by separate teams.
By creating similar templates with similar components, driven by common data, we simply write state logic in KMM ViewModels once, start each new screen with the template on each platform, and can be confident that the user will see battle tested, consistent — and native — UI/UX.
And if we need to make a data change, those changes need made in one place to affect every screen on every platform of our product.
Bonus: server driven UI
The approach used here of driving UI change through data is very similar to the principles behind server driven UI. We can minimise logic in the UI by defining components, and driving their display based on data.
In server driven UI that data is usually JSON delivered from a backend to multiple platforms. The great thing about KMM is that we can do exactly the same thing, but instead of JSON coming from a separate backend team, we’re delivering native Kotlin & Swift(ish) objects to the UI — built by the people closest to the UI, the mobile team.
Double bonus: adding to an existing project?
We did that too!
As when adding any new pattern to a large project, often the best approach is to try it out on a new feature, and if it works use it in more features, if it doesn’t row back to where you came from.