
Our Favorites from CES 2023
It’s no surprise that CES continues to be the most influential tech event in the world. With more than 100,000 people in attendance this year,...
Invent with us.
NavigationIn recent years both Apple and Google have been building new user interface (UI) toolkits for their mobile platforms.
Apple has introduced SwiftUI for iOS, and Google has introduced Jetpack Compose for Android. Both toolkits focus on a state-based UI model, while the current and widely used UI toolkits for both platforms use an event-based model.
The current toolkits build their views imperatively. The app uses a series of statements that tell the UI what to do. Imagine a simple app that can load and display a picture of a dog with a press of a button. It might have these methods, each containing a verb:
Jetpack Compose and SwitfUI are state-based toolkits. When using them, you tell the UI what the state is instead of telling it what to do. Instead of building up a state with statements, we are providing the UI framework with a state, and it uses that data to build the view from scratch.
The app ultimately builds the whole UI only the first time. Subsequent updates are much faster – the framework compares the new state against the old state and only updates those items that have changed.
The principal benefit of using these frameworks is making UIs simpler to build and less error-prone. Once your UI is based on state, it limits the mistakes that may have occurred in an event-based framework. In more complex UIs, these errors can occur due to events firing in the wrong order.
Using a state-based UI framework, our simple screen would be built something like the below (pseudo code). The state (currentState) is sent to the view, and the framework builds the UI based on the data.
buildScreen(currentState) { TitleBar.title = currentState.screenTitle if (currentState.loading()) { LoadingIndicator() } //else: show nothing if (currentState.picture != null) { Image(state.picture) } //else: don't show a picture if (currentState.loading()) { Text("Please wait while picture is loading") } else { Button(“Show me a dog”) } }
In this example the pseudo code builds the view. If currentState.loading()
is false
then the LoadingIndicator()
widget is never created to be displayed on the screen.
If the state changes – say the app has finished loading the image and currentState.loading()
changes from true
to false
:
This new way of thinking has turned the UI world on its side. Both frameworks are brand new and often changing. Techniques used for building a screen a few months ago may no longer work with today’s versions. It’s an exciting time, but many changes are happening.
This is such a sea change from traditional UI engineering and the ramp-up time to adopt the new ways of doing things is steep. Vectorform does a lot of development of the typical apps you use every day. But we also develop custom apps that interact with physical devices in interesting ways. These kinds of usage scenarios sometimes require a different way of thinking.
One of the fundamental questions is how this process is controlled. In both state-based and event-based methods, there is always something that tells the view that the image has loaded and shows a picture. This is where app architecture comes into play.
For Compose and SwiftUI, Google and Apple suggest using the MVVM architecture (Model-View-ViewModel), which lends itself to state-based thinking. Google is trying to add features supporting other architectures, such as MVP (Model-View-Presenter).
One of the highlights of Vectorform’s development team is its MVP architecture library. We mirror our approach on both iOS and Android platforms, and our team is trained on how to use it in this cross-platform environment. Currently, both Compose and SwiftUI pose challenges to our MVP architecture. The Presenter controls our View based on events – not the state stored in a view model.
We’re open to redefining our process so we can use these modern toolkits. However, best practices are still being flushed out for both. We feel both need more time for the community to figure out the best way to hook everything together. One thing the community has recently rallied around is using state-driven architectures to control these modern toolkits. The most popular of them is Redux, commonly used in web development but making a comeback in mobile architectures now as well.
In our experience, SwiftUI and Compose seem powerful and flexible enough to create any visual that a designer may wish as part of a UI. The performance of the UI seemed to be acceptable, at least for the small-scale apps we tested it with.
All in all, we see some benefits to using these frameworks. Some things are easier to do in SwiftUI and Compose than in their event-based counterparts.
For example, in SwiftUI, it takes less code to create a Tableview:
Tableview in SwiftUI:
struct TestView: View { @ObservedObject var model: TestViewModel var body: some View { ScrollView { LazyVStack(spacing: 32) { ForEach(model.devices) { device in DeviceView(deviceModel: device) } } } } }
Tableview in UIKit:
[css]class TestViewController: UIViewController { var data: [DeviceViewModelItem] = [] let tableView: UITableView = UITableView(frame: .zero, style: .plain) override func loadView() { super.loadView() view.addSubview(tableView) tableView.register(DeviceViewCell.self, forCellReuseIdentifier: DeviceViewCell.reuseIdentifier) tableView.dataSource = self } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return data.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item: DeviceViewModelItem = data[indexPath.row] return dequeueAndUpdateDevicesCell(item: item, indexPath: indexPath) } private func dequeueAndUpdateDevicesCell(item: DeviceViewModelItem, indexPath: IndexPath) -> UITableViewCell { if let cell: DeviceViewCell = tableView.dequeueReusableCell(withIdentifier: DeviceViewCell.reuseIdentifier, for: indexPath) as? DeviceViewCell { cell.update(with: item) return cell } return UITableViewCell() } }[/css]
Tableview in Jetpack Compose:
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { var viewModel by remember { mutableStateOf(currentModel) } TestTheme { LazyColumn { items(viewModel.devices) { device -> DeviceItem(device) } } } } } }
SwiftUI and Jetpack Compose’s newness means that the toolkits themselves are changing relatively quickly. This means potentially extra time to relearn and rewrite things after they have already been implemented when this happens. The relative lack of familiarity that most developers will have means extra time to ramp up and learn the ways of these new frameworks and the architectures needed to support them.
We found it challenging to measure and debug performance issues within these toolkits. For example, if you suspect your UI is creating too many views or refreshing too often, it is hard to verify what is happening to cause it.
A few bugs in these frameworks are known to the community, creating the need to find workarounds or abandon using the new toolkits altogether.
Any new fixes or toolkit features can only be used on more recent versions of iOS or Android. This means you must decide between ending support of older OS versions immediately or waiting until later to use them.
Overall, we enjoyed working with both SwiftUI and Compose. However, we don’t think they’re ready for prime time and, for now, our plan for incorporating these toolkits is at least a few months off. We’ll continue to try them several times a year and anticipate switching over to these once all the kinks have been worked out at some point in the future. Likely, this will lead to a change in how we architect our mobile applications going forward, and we are already using and creating new architectures to work with declarative UI frameworks.
Contact us if you have any questions or just want to learn more about state-based design with Jetpack Compose and SwiftUI!
Below are some examples of creating a UI control for both SwiftUI and Jetpack Compose. You can see the similarities and differences between the two developers in building the same control for their platforms.
SwiftUI
protocol IntensityViewDelegate: AnyObject { func intensityDecreaseTapped(id: String) func intensityIncreaseTapped(id: String) } struct IntensityView: View { let minValue: Int = 0 let maxValue: Int = 10 var id: String var intensity: Int var tintColor: Color? var delegate: IntensityViewDelegate var body: some View { HStack { Button { delegate.intensityDecreaseTapped(id: id) } label: { Image("Minus").renderingMode(.template) } VStack { Text("\(intensity)").font(Font.custom("AvenirNext-Regular",size: 28)).foregroundColor(tintColor) .offset(x:0,y:5) DotsView(tintColor: tintColor, filledDots: intensity) } Button { delegate.intensityIncreaseTapped(id: id) } label: { Image("Plus").renderingMode(.template) } }.tint(tintColor) } }
struct DotsView: View { var tintColor: Color? var filledDots: Int let unfilledColor: Color = Color(r: 204, g: 204, b: 204) let dotHeightsOffsets: [CGFloat] = [-10, -6, -3, -1, 0, 0, -1, -3, -6, -10] let maxDots: Int = 10 let heightOffset: CGFloat = -10 var body: some View { HStack(spacing: 10) { ForEach(0..<maxDots, id: \.self){ index in Circle() .fill(index < filledDots ? tintColor ?? unfilledColor : unfilledColor) .frame(width: 8, height: 8) .offset(x:0,y:dotHeightsOffsets[index] + heightOffset) } } } }
Jetpack Compose
@Preview() @Composable fun IntensityControl( modifier: Modifier = Modifier, onClickDown: () -> Unit = {}, onClickUp: () -> Unit = {}, intensity: IntensityView = IntensityView.default() ) { Row( modifier = modifier .fillMaxWidth() .background(Color.White), horizontalArrangement = Arrangement.SpaceBetween, ) { IntensityButton( iconResource = R.drawable.ic_circle_minus, contentDescription = "down", tintColor = intensity.downButtonColor, onClick = onClickDown, isEnabled = intensity.downButtonEnabled ) IntensityRow(intensity) IntensityButton( iconResource = R.drawable.ic_circle_plus, contentDescription = "up", tintColor = intensity.upButtonColor, onClick = onClickUp, isEnabled = intensity.upButtonEnabled ) } } @Composable fun IntensityButton( @DrawableRes iconResource: Int, contentDescription: String, tintColor: Color = Color.LightGray, onClick: () -> Unit, isEnabled: Boolean = true, ) { IconButton( onClick = onClick, modifier = Modifier.padding(vertical = 8.dp), enabled = isEnabled, ) { Icon( painterResource(id = iconResource), contentDescription, tint = tintColor ) } } @Composable fun IntensityRow( intensity: IntensityView ) { Box( modifier = Modifier .width(200.dp) .height(70.dp) ) { Column( modifier = Modifier.fillMaxWidth() ) { Text( modifier = Modifier .align(Alignment.CenterHorizontally), text = intensity.intensityLevel.toString(), fontSize = 26.sp, color = intensity.downButtonColor ) Row( modifier = Modifier .fillMaxWidth() .fillMaxHeight(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom ) { val dotOffset: List<Dp> = listOf(10.dp, 6.dp, 3.dp, 1.dp, 0.dp, 0.dp, 1.dp, 3.dp, 6.dp, 10.dp) var dotColor: Color dotOffset.forEachIndexed { index, bottomSpace -> dotColor = intensity.getDotColor(index + 1) IntensityDot(dotColor, bottomSpace) } } } } } @Composable fun IntensityDot( dotColor: Color = Color.LightGray, bottomSpace: Dp = 0.dp, ) { Column { Icon( painterResource(id = R.drawable.ic_intensity_dot), "@null", tint = dotColor, modifier = Modifier.size(12.dp) ) Spacer( modifier = Modifier.height(bottomSpace) ) } }
It’s no surprise that CES continues to be the most influential tech event in the world. With more than 100,000 people in attendance this year,...
The event continues to be one of the most important annual platforms for companies to showcase their products. It sets the tone for the biggest upcoming...
With just a few clicks to set things in motion, users can sit back and let their connected devices track and reorder coffee, dog food,...
Interested in learning more? Let’s start a conversation.