Skip to content
← All posts

6 min read

Server-driven forms on iOS: the app that doesn't know its own screens

  • iOS
  • Architecture
  • Forms

A normal app knows its screens by heart, which is rather reassuring: the developer draws them in Xcode, checks them three times, ships, and everyone goes home for dinner. Then a customer calls to add a question to their safety checklist, a second one wants it removed, and a third wants it in Arabic, with a mandatory photo, but only on Tuesdays (yes, that customer exists, that customer always exists). If every checkbox needs a new release, you will spend your career in Apple's review queue, which is not exactly famous for its sense of urgency. That is precisely the problem a forms engine solves: the app stops knowing its screens, and the server sends them instead.

I owned an engine like this on iOS for more than five years at Enablon, from 2019 to 2024, underneath the inspection, audit and reporting apps of large enterprise customers (the full story is in the Dynamic Forms SDK case study). Five years is long enough to learn what really matters, and above all what hurts when you forget it.

The app that has no idea what it looks like (on purpose)

The idea fits in one sentence: instead of a screen written in Swift, the app receives a description of the form, usually as JSON, and turns it into a screen when it opens. A field looks roughly like this, and yes, it is less glamorous than a Figma mock-up:

{ "id": "anomaly", "kind": "text", "label": "Describe the problem",
  "required": true, "visibleIf": { "field": "extinguisher", "equals": "no" } }

On the app side, the engine decodes those definitions, picks a component for each kind of field (a choice, a photo, some text, a date, a signature, and the list grows faster than anyone would like) and assembles the screen. Here is a deliberately simplified version, written for this article rather than lifted from the real SDK, so nobody sends me a lawyer:

struct FieldDefinition: Decodable {
    enum Kind: String, Decodable { case choice, photo, text }
    struct Condition: Decodable { let field: String; let equals: String }

    let id: String
    let kind: Kind
    let label: String
    let required: Bool
    let visibleIf: Condition?
}

/// The fields to draw right now, given what the user has already answered.
func visibleFields(_ fields: [FieldDefinition], answers: [String: String]) -> [FieldDefinition] {
    fields.filter { field in
        guard let rule = field.visibleIf else { return true }
        return answers[rule.field] == rule.equals
    }
}

The animation below tells the rest in four episodes, and I will not spoil the last one: a screen that builds itself, a rule that wakes up, a factory with no signal, and a new release that has to land in three apps without breaking any of them.

Rules, or the art of making a field appear

Drawing fields is the easy part, any motivated intern will do it in an afternoon, coffee included. The real work starts with rules: a field that only shows up if you answered "no" above it, a validation that refuses a number out of range, a field that depends on another one, a value pre-filled from the site or the user. Customers love rules, and they are right to, because that is what turns a dumb form into a real working tool.

The trap is letting those rules leak into the screen code. A little if here for a customer in a hurry, another one there for Thursday's demo, and six months later nobody knows why the "comment" field vanishes when you switch languages (spoiler: it was Thursday's demo). All the logic has to live in the engine, in one place, and that engine deserves its own test suite, because a rule that gets it wrong does not crash the app, which would almost be polite: it quietly lets a wrong report through, which is far worse.

Offline, because factories do not have fibre

Inspections have an annoying habit of happening in basements, boiler rooms or the middle of an industrial site where the network is more of an urban legend. A form that refuses to send without a connection is therefore a decorative form. In our case, checklists were filled in with no network, stored on the device and synced on their own when the connection came back, without the technician having to think about it. It is the kind of feature nobody notices when it works, and everybody notices the day half a day's report evaporates.

A small tip while we are here: the form definitions need to be available offline too. A brilliant engine that waits for the server to find out what the screen looks like makes a very pretty blank page at the bottom of an underground car park.

One engine, three apps, zero room for error

The Dynamic Forms SDK sat underneath three different products. On paper that is the dream: fix a bug once and three apps benefit. In real life it is also a slightly dizzying responsibility, since a regression travels exactly the same way, three times, and preferably on a Friday.

What kept us afloat comes down to a few things: numbered SDK releases on a schedule the three apps shared, a test suite over the rule engine, releases taken from release candidate all the way to production, and one person reviewing what each app actually depended on. Add what an enterprise engine has to absorb without flinching, OAuth 2.0 with PKCE, OData APIs, Arabic reading right to left and a 12-language redesign of one of the apps, and you can see why a label must never be hard-coded, not even "just to test". Especially not "just to test".

Do you really need an engine? (spoiler: not always)

This is probably the most useful advice in the article, and the least commercial. A server-driven engine pays off when your customers configure their own screens, or when your forms change faster than your store releases. If you have five forms that change twice a year, write them in SwiftUI like everyone else and sleep soundly: otherwise you pay for the complexity twice, once to build the engine and once to keep it alive.

And if you have an Android app as well, the rule engine is the perfect candidate to share between the two platforms, so that "visible if" means exactly the same thing on both sides, not almost the same thing. That is typically the kind of code Kotlin Multiplatform lets you write once.

Concretely, what would I do?

I would start by counting, honestly, how many forms really change and who changes them, because the answer decides everything else. If the engine is worth it, I would keep its vocabulary tiny at first (a few field types, a handful of rules) and grow it later, rather than inventing a programming language disguised as JSON that nobody will want to maintain. I would write the rule engine tests before the first pretty screen, plan for offline on day one rather than after the first complaint, and version the definition format, because one day the server will send a field that an old version of the app on some customer's phone has never heard of, and on that day it had better know.

If you want a hand

If your app is buried under forms that keep changing, or you are wondering whether an engine is worth the trouble, I can help you decide, then build it if the game is worth the candle. The full story is in the Dynamic Forms SDK case study, and if you would rather start with an outside look at the app you already have, the five-day audit begins exactly there.

Working on a mobile app? Let's talk.

Keep your app shippable

One email a week on store deadlines and what breaks in production. Written for people who own an app, not just for developers.

One email a week about keeping mobile apps shippable. Unsubscribe in one click, and I never pass your address on. How I handle your data: privacy notice.