Skip to content
HotLayout Docs

iOS reference

The details behind the quickstart for iOS: what the HotLayout Swift package takes, what it does with documents, themes, data, actions, language, pictures and the network, what it reports, and how it decides whether it can read a document. The quickstart is the path from an empty app to a published screen; this page is the reference, ordered by topic. The README of the package is a short introduction.

What each schema minor added is in the contract changelog, and which package version reads which minor is in Which package reads which schema. What changed in each package version is on the releases of the package.

Related pages: quickstart for iOS · widgets · compatibility · placements · localization · theme · data dictionary

The package#

Contracts it reads document contract 9.34.0, theme contract 4.0.1, icon set 1.0.0 (the icon drawings are built in)
Widgets all 28 widgets of the contract are drawn
The rule the major version must match: a 9.x reader draws any 9.x document and no 8.x one. A document that uses a field newer than the reader still opens, and the reader skips the field. See Version compatibility
Requires iOS 16 or later, Swift 5.9 or later, no third-party dependencies. macOS 13 is declared only so that the package tests run with swift test; it is not a product platform
Install Swift Package Manager, https://github.com/hotlayout/hotlayout-ios, library HotLayout; the steps are in the quickstart
Names the module is called HotLayout and its types Hotlayout…, on purpose: the names match the other readers of the contract
Deprecated the global functions loadDocument, loadTheme, checkVersion, checkDocumentVersion and resolveLocale still work as deprecated forwards of the members below
Licence notices Apache License 2.0. The icon drawings include icons from Lucide (ISC); the notices are in THIRD-PARTY-NOTICES.md of the package: include them in your app's licences screen

The public API#

Everything below is in the module HotLayout.

HotlayoutScreen the SwiftUI view that draws a document — see Drawing a document
HotlayoutDocument.load(_:), HotlayoutThemeSet.load(_:) read a document or a theme from Data or a parsed JSONValue; throw HotlayoutLoadError — see Loading a document and a theme
HotlayoutVersions.checkDocument(_:), HotlayoutVersions.check(_:readerVersion:), HotlayoutVersions the version rule, without reading the whole document
LocaleResolution.resolve(_:available:source:) which translation, number format and direction a device locale gets
HotlayoutAction, StandardAction, StandardActions.url(for:allowedDeeplinkSchemes:) what reaches onAction, and a helper for the standard actions
TrackEvent, HapticFeedback, NavigationEvent, ScreenShownEvent, ActionSource what the other callbacks receive
ImagePolicy, WritablePaths, ScreenLabels decisions of the host: pictures, writable data paths, texts of the reader
HotlayoutReport what the reader skipped or replaced; never shown to the person
HotlayoutPlacementView, HotlayoutPlacement, HotlayoutPlacementMap placements — see Placements

Drawing a document#

HotlayoutScreen(document:…) draws a document. The quickstart shows it with the usual callbacks; this table is the whole list.

Parameter What you pass
document (required) a HotlayoutDocument read by HotlayoutDocument.load(_:). Read it once, in .task, not on every evaluation of body
theme a HotlayoutThemeSet; HotlayoutThemeSet.builtIn without one
themeId a theme of the set to draw with; the set's default without one
data the values for the document's bindings, a flat map from a data path to a value — see Data
locale the language to show, a locale identifier, passed as is — see Language and right-to-left
entry a named entry point of the document. A name the document does not have opens the initial screen
timeZone the time zone of dates; the device's without one
now the moment countdowns and "days until" count from; the clock without one. With a moment given you own the clock: no countdown ticks
images an ImagePolicy — see Images
labels a ScreenLabels: the few texts the reader says to assistive technology, in your language — see Accessibility
onAction receives every action the reader did not perform itself, with an ActionSource. May be async — see Actions and navigation
onTrack the analytics event of a press (TrackEvent), once per press, before the action
onHaptic the haptic feedback a press asks for (HapticFeedback), before the analytics event; the reader never vibrates by itself
onNavigate asked before the reader changes screens by itself; return false to cancel
onWrite, writablePaths what the user typed or chose, and which paths a document may write — see Writes
autofill false turns every autofill hint of the text fields off
onScreenChange the screen-shown event (ScreenShownEvent: screenId, cause), once per show, the first one included
onReport what the reader skipped or replaced — see What the reader reports
onScreenCovered for a host with its own stack of screens — see Swipe fill

The reader keeps its own stack of screens and performs navigation inside the document. It draws only the document and takes the size your layout gives it.

Loading a document and a theme#

do {
    let document = try HotlayoutDocument.load(documentData)
    let theme = (try? HotlayoutThemeSet.load(themeData)) ?? .builtIn
} catch let error as HotlayoutLoadError {
    // a document the reader cannot use: show your own screen
}

HotlayoutDocument.load(_:) and HotlayoutThemeSet.load(_:) take Data (or a parsed JSONValue) and throw HotlayoutLoadError. The quickstart reads them in .task so that the JSON is read once, and shows ProgressView() until it is: not an empty Group, because an empty view on the first frame never runs .task.

A theme that cannot be read is not a reason to refuse a screen: use HotlayoutThemeSet.builtIn. Where the document and the theme come from is your choice: your backend after a signed webhook, or the published address of the project. See Delivery options.

Version compatibility#

HotlayoutVersions.checkDocument(_:) applies the version rule without reading the whole document, for a host that wants to decide before it loads (and keep the old screen when the new document is too new). HotlayoutVersions.check(_:readerVersion:) takes the version to compare with. The rule is the same in every reader and is described in the compatibility policy: the major must match; a newer minor opens and the reader skips what it does not know.

Package version Schema Theme Icon set Platform
0.5.0 9.34.0 4.0.1 1.0.0 iOS 16+, Swift 5.9+

Older package versions and what they read are in Which package reads which schema.

Data#

data maps paths to values, ["user.name": "Maya", "user.points": 1850]. The rules are the same for every reader: a number stays a number, dates are ISO 8601 strings with time and zone, a missing value shows the fallback of the document, and a list draws at most 50 records. Which paths exist is defined by the project's data dictionary; what your backend returns is in the backend guide. The reader takes the values you pass; it never calls your backend.

Language and right-to-left#

Pass the device locale as is: the reader picks the document's language by the contract's rule, formats numbers and dates for it and mirrors the layout for right-to-left scripts. LocaleResolution.resolve(_:available:source:) tells which translation, number format and direction a locale gets. See Localization.

Device language. Locale.current gives the language of the device only if the app declares the languages it is localized for (the app's localizations in the Xcode project, or CFBundleLocalizations in Info.plist). Without that iOS reports English, and a document that has an Arabic translation stays English on an Arabic device. Declare the languages of your documents, or pass Locale.preferredLanguages.first as locale.

Actions and navigation#

  • Navigation inside a document (navigate, navigate.back, navigate.section) is done by the reader; onNavigate may cancel it by returning false.
  • openUrl, deeplink, close and the actions of your app go to onAction. The reader opens nothing itself. A standard action arrives checked against the forms of the contract, and StandardActions.url(for:allowedDeeplinkSchemes:) checks it again before you open it (the form of the address, the scheme of a deeplink against the allowed ones), so an action assembled by hand is not trusted because of its type.
  • An onAction may be async. A button that confirms by a swipe then stays busy until the handler returns; with a plain handler it stays busy until the screen changes, or eight seconds:
HotlayoutScreen(document: document, onAction: { action, source async in
    if action.type == "redeem" { await redeem(source.record?["item.id"]?.stringValue) }
})

source.record is the record of the node that was pressed: a row of a list carries its own record, so one handler serves every row.

Swipe fill#

A swipe button with confirmTransition: 'ripple' that leads to another screen fills the screen with its colour before the screen changes. By default the fill lies over the view of HotlayoutScreen and the reader changes its own screens under it. If your app keeps its own stack of screens (you cancel the change with onNavigate and show your own view), pass onScreenCovered: the fill then lies in a layer over the whole window, above your views, so it outlives the screen the button was on. The handler gets true when a fill starts to cover the screen, before the action reaches onNavigate or onAction (change your screen then, without a transition of your own), and false once the last fill is gone. With no window to draw in there is no fill and the action runs at once.

Writes#

Fields, setValue and toggleValue write to the session of the screen and to onWrite, with a string, a flag or a number. WritablePaths says which paths a document may write; by default every path of the form of a data path is allowed. A path that is refused is written neither to the session nor to the host.

Images#

Images load only from an address of the form of an openUrl address (https: and a public host name) by default. Pass an ImagePolicy as images to decide yourself:

ImagePolicy
resolve your decision about an address; your answer is final, except that javascript: and vbscript: are never a picture
asset tokens addresses for logo, banner and placeholder
maxDownloadBytes the largest download, 10 MiB by default
maxPixelSide 4096 by default: a larger picture is made smaller, not refused

A refused address makes no request and goes to onReport.

Fonts#

Fonts are the app's: the package ships none. A family the theme names that the device lacks is drawn with the system font.

Accessibility and labels#

The few texts the reader says to assistive technology (the name of the back arrow) are not in the document. Pass ScreenLabels in the language of your app as labels; the defaults are English.

What the reader reports#

onReport receives a list of HotlayoutReport: what the reader skipped or replaced in the document, the theme or while drawing, for your log or analytics. Each report is handed over once, as it appears. It is never shown to the person.

  • Documents, themes and data are untrusted input. A piece of the wrong shape is skipped and reported, never a crash. A tree deeper than 32 levels is refused.
  • Lists draw at most 2550 instances of list templates per screen, nested lists included; what is beyond is not drawn and is reported once.
  • Haptic feedback is asked of the host (onHaptic); the reader never vibrates.

The trust boundary and the tests that pin it are described in the security notes of the package.

Placements#

A placement is a named place of your app (home.banner). The app names the place in code; which published document stands there is decided in the admin, and changed without a release. The files and the rules of the map are in Placements; this part is what the iOS reader does with them.

// Draws what the admin put in the place `home.banner`, and nothing when it is empty.
HotlayoutPlacementView(
    name: "home.banner",
    projectBase: projectBase,
    onPlacement: { placement in
        if let report = placement.report { print("home.banner:", report, placement.mapStatus) }
    }
)

/// Without a view: load the place, then draw the document yourself.
func loadBanner(projectBase: URL) async -> HotlayoutDocument? {
    let placement = await HotlayoutPlacement.load(name: "home.banner", projectBase: projectBase)
    return placement.document  // `placement.entry` is the entry point to pass to `HotlayoutScreen`
}

The reader asks placements/published.json under projectBase with its ETag, and the document of the place under documents/{documentId}/published.json. It keeps the last map and document it read, and falls back, in this order, on the last known map and then on bundledMap (the text of a copy shipped with the app, for a first start without a network). Nothing throws; a place that draws nothing says why in placement.report:

report
empty the place is declared and empty on purpose
unknown the map has no such place (or the project published no map)
unreadable the map, or this place's record, could not be read
documentMissing / documentUnreadable the document is gone (404) / could not be read
entryMissing the entry point is not in the document: it is drawn from its first screen

placement.mapStatus says why that map is in use: fresh, offline (the network was silent), corrupt (it answered with something that is not a map), unsupported (a map of another major version) or notPublished (404).

  • A name is lowercase Latin letters and digits in dot-separated segments; a name of another form is unknown and no request is made for it.
  • Use HotlayoutPlacementView(name:projectBase:…content:) to draw with a HotlayoutScreen of your own configuration.
  • store: (the last known map), documents: (the documents read; files in the caches directory by default) and session: choose where the last known copies live and how the requests are made.
  • Redirects are not followed and a request is given up after 20 seconds.
  • The view loads the map once per name; it does not poll.
  • onPlacement is called with the first state and again whenever placement.event (status, report, mapStatus, documentId, entry) changes.

Schedule#

A place may carry a schedule: promotions {documentId, entry?, from, until} shown over its permanent content while from (included) to until (excluded) lasts, in UTC. An item with documentId: null is a pause: the place is empty (report == .empty) for its window. After the last window the permanent content is back.

The schedule is resolved by the clock of the device, without a network. The view sets a timer for the next edge of the schedule and, when it fires, resolves the place again: it does not ask for the map again, and only reads the document the new window brings. It also resolves again when the app returns to the foreground, because the clock may have been set. The change is an ordinary onPlacement event (documentId, entry, and empty to document and back); if the answer is the same, there is no event.

HotlayoutPlacement.load(…, clock:) and HotlayoutPlacementView(…, clock:) take the clock (@Sendable () -> Date, the device clock by default), which is how you test a schedule. HotlayoutPlacementMap has resolve(_:at:) and nextBoundary(_:after:) to ask a map yourself.

An empty place draws nothing, but the view stays in your layout, and so do the padding and the frame you put around it. Take your own out when the place is empty, and keep the view mounted so that it can wake up on the next window:

HotlayoutPlacementView(
    name: "home.banner",
    projectBase: projectBase,
    onPlacement: { placement in empty = placement.document == nil }
)
.padding(.horizontal, empty ? 0 : 16)
.padding(.vertical, empty ? 0 : 8)

A reader older than the schedule shows the permanent content of a place and ignores its schedule.

Limitations#

Next#