iOS SDK (Swift) / Rendering flows

Rendering flows

One view. It fetches the flow by key, caches it, renders every screen as SwiftUI, and calls you back with the answers.

The view

OnboardingScreen.swift
import SwiftUI
import UpliftFunnel

struct OnboardingScreen: View {
    var body: some View {
        UpliftFunnelFlowView(flowKey: "welcome") { result in
            print(result.endReason)   // "completed" / "abandoned" / "skipped"
            print(result.variables)   // every answer the user gave
            print(result.source)      // network / cache / revalidated
        }
    }
}
ParameterTypeDefaultDescription
flowKeyStringThe flow's slug, from the dashboard.
onCompleted((UpliftFunnelFlowResult) -> Void)?nilCalled once, when the flow ends for any reason.
userVariables[String: JSONValue]?nilValues your app already knows, seeded into the session before the first screen.
forceRefreshBoolfalseSkip the cache and refetch. Normal starts are cache-first.
loadingView(() -> AnyView)?nilReplaces the default spinner.
errorView((Error, @escaping () -> Void) -> AnyView)?nilReplaces the default error state. The closure's second argument retries the fetch.

What comes back

FieldTypeDescription
endReasonStringWhy it ended: "completed", "abandoned", "skipped", or a custom reason the flow authored.
variables[String: JSONValue]Every answer collected over the session.
sourceFetchSourcenetwork, cache, or revalidated — useful for surfacing offline state.
experimentUpliftFunnelExperimentAssignment?The A/B assignment this session ran under, or nil.

Seeding what you already know

A flow should not ask for a name your app has. Pass it in, and the screens can interpolate it and branch on it from the first frame.

swift
UpliftFunnelFlowView(
    flowKey: "welcome",
    onCompleted: { result in save(result.variables) },
    // Anything your app already knows. Available to the flow as
    // {{first_name}} and to its transition conditions.
    userVariables: [
        "first_name": .string(user.firstName),
        "is_subscriber": .bool(user.isSubscriber)
    ]
)

Your own loading and error states

The defaults are a spinner and a message with a Retry button. Replace either when the flow is the first thing a user sees and the default would look like a different app.

swift
UpliftFunnelFlowView(
    flowKey: "welcome",
    onCompleted: handle,
    loadingView: { AnyView(MySplash()) },
    errorView: { error, retry in
        AnyView(MyErrorView(message: error.localizedDescription, retry: retry))
    }
)

Running an experiment

Same view, routed through the experiments endpoint: the SDK attaches a sticky subject id, the server buckets the user into a stable variant, and the assignment comes back on the result.

swift
UpliftFunnelFlowView.experiment("paywall-copy-test") { result in
    // Typed assignment — nil for a flow with no running experiment.
    let variant = result.experiment
    analytics.log("onboarding_done", [
        "experiment_id": variant?.experimentId,
        "variant_id": variant?.variantId,
        "variant_name": variant?.variantName
    ])
}

Safe to leave in production after a decision — a stopped experiment serves the baseline and a rolled-out one serves the winner. More on experiments →

Starting a session yourself

When you want the fetch to happen before the view exists — during a splash, say — start it directly and render the session later.

swift
// Start it early — on app launch, behind your splash — and render later.
let start = try await UpliftFunnel.start("welcome", userVariables: seed)

print(start.source)        // where the JSON came from
print(start.flowVersion)   // the published version that was rendered
print(start.experiment)    // assignment, if any

// Then render the session you already have.
UpliftFunnelSessionView(session: start.session) { variables, reason in
    save(variables)
}

UpliftFunnel.startExperiment(_:) is the experiment equivalent and returns the same FlowSessionStart.