One build, fourteen apps: see what users made of Memory Tin

Documentation

Quickstart: iOS

Documentation menu

Requirements: iOS 17+, SwiftUI, Swift 6. The package has no third-party dependencies.

1. Install

In Xcode, File → Add Package Dependencies… and enter https://github.com/tweaklane/tweaklane-ios, or in Package.swift:

.package(url: "https://github.com/tweaklane/tweaklane-ios", from: "1.0.0")

Add the TweakLane product to your app target.

2. Register your components

import TweakLane

@MainActor
func makeCatalog() -> ComponentCatalog {
    var catalog = ComponentCatalog()
    let titled = PropsSchema(properties: ["title": .string], required: ["title"])

    catalog.register("stats.list", displayName: "List", alternatives: ["stats.chart"],
                     props: titled, defaultProps: ["title": "This week"], baseHeight: 160) { props, context in
        StepsList(title: props.string("title"))
    }
    catalog.register("stats.chart", displayName: "Chart", props: titled,
                     defaultProps: ["title": "This week"], baseHeight: 160) { props, _ in
        StepsChart(title: props.string("title"))
    }
    catalog.register("account.delete", displayName: "Delete account", lock: .required) { _, _ in
        DeleteAccountRow()
    }
    return catalog
}

See Catalog for every parameter and Locks for .required and .fixed.

3. Provide a default document per screen

let today = LayoutDocument(screen: "today", root: "root", nodes: [
    .container(.scroll, id: "root", parent: nil, padding: 16),
    .component("greeting", id: "greeting", parent: "root", order: 0, props: ["name": "Sam"]),
    .container(.vstack, id: "summary", parent: "root", order: 1, label: "Summary"),
    .component("stats.list", id: "stats", parent: "summary", order: 0, props: ["title": "Steps"]),
    .component("account.delete", id: "account", parent: "root", order: 3)
])

The default document is the fallback for every failure, so it must be valid. In debug builds registerDefault asserts on it.

4. Create the engine and render screens

@main
struct MyApp: App {
    @State private var model = AppModel()
    @State private var tweak: TweakLaneEngine = {
        let engine = TweakLaneEngine(catalog: makeCatalog(), userId: "local")
        engine.registerDefault(today)
        return engine
    }()

    var body: some Scene {
        WindowGroup {
            TodayView().environment(model).environment(tweak)
        }
    }
}

struct TodayView: View {
    var body: some View { TweakScreen(id: "today") }
}

TweakScreen reads the saved layout once per session, validates it and renders it. The layout only changes while the user edits; TweakLane never swaps the UI under the user.

5. Open edit mode

Button("Customize") { tweak.beginEditing("today") }

Each part shows a compact toolbar (drag handle, name, lock badge, Hide and an edit menu). The tray at the bottom holds hidden parts, Add, Reset and Done. When your screen wraps the TweakScreen in its own ScrollView, pin the tray outside it with .tweakEditTray("today").

Keep important state in your own @Observable model, not in a component's @State: moving or swapping a component gives it a new SwiftUI identity, which resets its @State.

6. Turn on sync

Create an app in the dashboard to get its public SDK key and API URL, then:

let engine = TweakLaneEngine(
    catalog: makeCatalog(),
    userId: account.id,
    sync: SyncConfiguration(
        baseURL: URL(string: "{TWEAKLANE_URL}")!,
        publishableKey: "tl_pk_…",
        tokenProvider: ClosureTokenProvider { userId, forceRefresh in
            try await myBackend.tweakLaneToken(for: userId, refresh: forceRefresh)
        }))

Your backend issues the user token. See Connect your backend. Call engine.setUser(_:) when the signed-in user changes.

Privacy manifest

The package ships PrivacyInfo.xcprivacy: no tracking, no tracking domains, no required-reason APIs. With sync configured it declares two collected data types, both linked to the user, not used for tracking, purpose App Functionality: User ID and Other User Content. Without sync nothing leaves the device.

Next: Connect your backend.