Server-Driven UI with SwiftUI

Pubudu Mihiranga
6 min readMar 24, 2024

--

What is Server-Driven UI?

Traditional mobile UI designs typically feature static layouts where UI elements are predefined and fixed within the app codebase. Any changes or updates to the UI require releasing new versions of the app, which can be time-consuming and cumbersome.

In contrast, Server-Driven UI is a way of designing mobile applications, where the entire UI is dynamically created and provided by a specific micro-service. This empowers developers to modify the UI and content without the need for app updates.

How does this work?

To make this work, a crucial step is establishing a clear contract between the mobile application and the micro-service.

In this established contract, the micro-service defines the major UI layouts (containers such as lists, carousels, and grids; I call these ‘widgets’ going forward), along with the list of components nested within them (children elements within layouts).

For each widget and its components defined on the micro-service, there is a corresponding UI implementation within the app.

Notably, all widget and component UIs are standalone and can be reused throughout the application.

Let’s delve deeper into the widgets and components concept using the following UI as an example.

High-level widgets and components breakdown of the above UI,

|- Carousel Widget
|----- Image Banner Component
|- Grid Widget
|----- Explore Tile Component
|- List Widget
|----- Descriptive Text Component

Example JSON response for the above UI,

{
"title": "Home",
"widgets": [
{
"type": "carousel",
"header": "Carousel Widget",
"components": [
{
"type": "image-banner",
"data": {
"image": "event-banner-1"
}
},
{
"type": "image-banner",
"data": {
"image": "event-banner-2"
}
}
]
},
{
"type": "grid",
"header": "Grid Widget",
"components": [
{
"type": "explore-tile",
"data": {
"image": "album-poster-1",
"title": "Pop",
"subtitle": "Today's chart-toppers and pop sensations"
}
},
{
"type": "explore-tile",
"data": {
"image": "album-poster-2",
"title": "Indie",
"subtitle": "Fresh underground hits and indie gems"
}
}
]
},
{
"type": "list",
"header": "List Widget",
"components": [
{
"type": "descriptive-text",
"data": {
"text": "Dive into a world of melodies, rhythms, and beats where every song tells a story and every note ignites an emotion"
}
}
]
}
]
}

Next, let’s see how this can be implemented using SwiftUI.

Server-Driven UI implementation using SwiftUI

This is a four-step process,

  1. Define the Component Provider
  2. Define the Widgets Provider
  3. Decode and construct the UI based on the received API response
  4. Render the UI

Step 1: Define the Component Provider

I’ll take the ImageBannerComponent, which is supposed to display an image view, as an example.

struct ImageBannerData: Decodable {
let image: String
}

The ImageBannerData structure contains all the necessary data required to render the ImageBannerComponent view, and it is also used during the JSON response decoding phase.

struct ImageBannerComponent: View {

let data: ImageBannerData

var body: some View {
Image(data.image)
.resizable()
.frame(width: 320, height: 120)
.cornerRadius(20)
.aspectRatio(contentMode: .fit)
}
}

ImageBannerComponent is a SwiftUI view that creates an image-banner component, and it takes ImageBannerData as a dependency.

enum ComponentProvider: Identifiable, View {
case imageBanner(data: ImageBannerData)

var id: String { return UUID().uuidString }

@ViewBuilder
var body: some View {
switch self {
case .imageBanner(let data):
ImageBannerComponent(data: data)
}
}
}

The ComponentProvider enum encapsulates different component types along with their associated data necessary for building each specific component view. Depending on the component type, ComponentProvider constructs the respective component.

Similarly, you’ll need to adhere to this pattern when implementing other components.

You may argue that we could have used a protocol like the one below for this purpose. However, the usage of AnyView is often discouraged due to its tendency to erase underlying type information. This erasure can lead to SwiftUI losing important details about the view hierarchy, potentially causing issues.

protocol ComponentProvider {
var id: String { get }
func render() -> AnyView
}

Step 2: Define the Widgets Provider

I’ll take the CarouselWidget as an example. The following SwiftUI view is designed to display components in a horizontal carousel layout. The components property holds the child items to be rendered inside this widget.

struct CarouselWidget: View {

var header: String
var components: [ComponentProvider]

var body: some View {
VStack {
WidgetHeaderView(header: header)

ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(components, id: \.id) { component in
component
}
}
}
.scrollIndicators(.hidden)
}
}
}

Similar to the ComponentProvider, the WidgetProvider enum encapsulates various widget types along with their associated component data, which are rendered inside the widget. Depending on the widget type, WidgetProvider constructs the respective widget.

enum WidgetProvider: Identifiable, View {
case carousel(header: String, components: [ComponentProvider])

var id: String { return UUID().uuidString }

@ViewBuilder
var body: some View {
switch self {
case .carousel(let header, let components):
CarouselWidget(header: header, components: components)
}
}
}

Once again, you’ll need to adhere to this pattern when implementing other widgets.

Step 3: Decode and construct the UI based on the received API response

In this step, we implement the decoding logic for our Server-Driven UI object from the JSON data. Let’s break down this process step by step:

We start by initializing a container and this container represents the top-level keys of the JSON object.

{
"title": "Home",
"widgets": [
...
]
}
struct SDUIProvider: Decodable {
let title: String
let widgets: [WidgetProvider]

enum CodingKeys: String, CodingKey {
case title, widgets
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decode(String.self, forKey: .title)
let widgetsContainer = try container.decode(WidgetContainer.self, forKey: .widgets)
self.widgets = widgetsContainer.widgets
}
}

Since widgets are nested within the JSON, we create a nested container in the WidgetsContainer structure.

We initialize an empty array, widgetsArray, to store the decoded widgets. Then, we enter a loop to iterate through each widget within the JSON.

With the decoded widget properties, then we create a relevant widget object and append it to the widgetsArray.

enum WidgetType: String, Decodable {
case carousel
}

struct WidgetContainer: Decodable {
let widgets: [WidgetProvider]

enum CodingKeys: String, CodingKey {
case type, header, components
}

init(from decoder: Decoder) throws {
var widgetContainer = try decoder.unkeyedContainer()
var widgetsArray = [WidgetProvider]()

while !widgetContainer.isAtEnd {
let parsedWidget = try widgetContainer.nestedContainer(keyedBy: CodingKeys.self)
let type = try parsedWidget.decode(WidgetType.self, forKey: .type)
let header = try parsedWidget.decode(String.self, forKey: .header)
let componentsContainer = try parsedWidget.decode(ComponentContainer.self, forKey: .components)

switch type {
case .carousel:
let item = WidgetProvider.carousel(header: header, components: componentsContainer.components)
widgetsArray.append(item)
}
}

self.widgets = widgetsArray
}
}

Here, we are following the same decoding and initialization approach for components within the JSON object.

enum ComponentType: String, Decodable {
case imageBanner = "image-banner"
}

struct ComponentContainer: Decodable {
var components: [ComponentProvider]

enum CodingKeys: String, CodingKey {
case type, data
}

init(from decoder: Decoder) throws {
var componentsContainer = try decoder.unkeyedContainer()
var componentsArray = [ComponentProvider]()

while !componentsContainer.isAtEnd {
let parsedComponent = try componentsContainer.nestedContainer(keyedBy: CodingKeys.self)
let type = try parsedComponent.decode(ComponentType.self, forKey: .type)

switch type {
case .imageBanner:
let data = try parsedComponent.decode(ImageBannerData.self, forKey: .data)
let item = ComponentProvider.imageBanner(data: data)
componentsArray.append(item)
}
}

self.components = componentsArray
}
}

Now you can use the above decoders to translate the JSON response received from the server.

Step 4: Render the UI

We have completed the Server-Driven framework, and now we can proceed with displaying it on the home page.

struct HomeView: View {

@StateObject var viewModel: SDUIViewModel = SDUIViewModel()

var body: some View {
NavigationView {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 20) {
ForEach(viewModel.widgets, id: \.id) { widget in
widget
}
}
}
.padding()
.navigationTitle(viewModel.title)
.navigationBarTitleDisplayMode(.inline)
.scrollIndicators(.hidden)
}
}
}

The sole responsibility of the HomeView is to render widgets fetched from the server in a vertical layout.

And that’s it, run and see!

Conclusion

Server-Driven UI is the best way of managing content without new app releases. However, it has several limitations, especially when it comes to testing and debugging.

You can find the complete project on GitHub.

--

--