To show a flow in a UIKit app, you ask FlowPilot to resolve and present a placement key from a view controller. The SDK works out which flow to show (cache, network, experiment variant, audience) by calling the resolve endpoint, renders it natively on top of your view controller, and hands you back a FlowResult when the user finishes.
All of the present methods are declared @MainActor, so call them from the main actor (for example from a view controller method, inside a Task {}). They are only available where UIKit is (#if canImport(UIKit)); for SwiftUI, see SwiftUI integration.
The SDK must be configured first. FlowPilot.shared is nil until you call FlowPilot.configure(_:) at launch. See Configuration.
The three present overloads
FlowPilot exposes three presentPlacement overloads. They all resolve and present the same way; they differ in how they hand back the result and how they behave when there is no flow.
| Overload | Returns | Throws? | Use when |
|---|
presentPlacement(_:from:options:) | FlowResult | async throws | You want the result back with await and will handle the “no flow” error. |
presentPlacement(_:from:options:completion:) | Void (delivers FlowResult to completion) | async throws | You prefer a callback over an awaited return value. |
presentPlacement(_:from:fallback:options:) | FlowResult | async (never throws) | Onboarding and other “must not fail” entry points. Presents your own view controller if FlowPilot has nothing to show. |
Awaited result (throws)
@MainActor
public func presentPlacement(
_ placementKey: String,
from viewController: UIViewController,
options: PresentationOptions? = nil
) async throws -> FlowResult
This is the most direct form. It throws a FlowPilotError when no flow could be resolved (no default flow, audience mismatch, offline with no cache, and so on), so wrap it in do/catch.
Callback variant (throws)
@MainActor
public func presentPlacement(
_ placementKey: String,
from viewController: UIViewController,
options: PresentationOptions? = nil,
completion: ((FlowResult) -> Void)? = nil
) async throws
Functionally the same as the awaited form, but the FlowResult is delivered to completion after the flow dismisses instead of being returned. You still try await the call.
Never-throws fallback variant
@MainActor
@discardableResult
public func presentPlacement(
_ placementKey: String,
from viewController: UIViewController,
fallback: @escaping @MainActor () -> UIViewController,
options: PresentationOptions? = nil
) async -> FlowResult
This overload never throws. It walks the full fail-safe chain (fresh cache, then a live resolve bounded by resolveTimeout, then stale cache, then a bundled default flow). If none of those produce a presentable flow, it presents the view controller you return from fallback (your app’s own native onboarding) instead. The fallback closure runs on the main actor.
When it has to fall back to your view controller, the returned FlowResult has outcome == .error and a populated error. This is the resilient choice for onboarding, because integrating FlowPilot can never leave the user on a blank screen. See the fail-safe fallback chain.
Presentation options
Pass a PresentationOptions to add per-presentation context or turn off the present animation.
public struct PresentationOptions: Sendable {
public let additionalContext: SDKContext? // merged for this presentation only
public let presentationStyle: PresentationStyle?
public let animated: Bool // default true
public init(
additionalContext: SDKContext? = nil,
presentationStyle: PresentationStyle? = nil,
animated: Bool = true
)
}
public enum PresentationStyle: String, Sendable {
case fullScreen
case modal
case bottomSheet
}
additionalContext (SDKContext is [String: Any]) is merged on top of the SDK-wide context for this presentation only. Use it to pass values the flow can read as App Parameter variables (for example which button opened a paywall). See Variables and SDK context.
animated controls whether the modal present and dismiss are animated. Defaults to true.
presentationStyle is accepted by the initializer but is not applied by the current SDK. Flows always present full screen (FlowHostingController sets .fullScreen). Do not rely on .modal or .bottomSheet changing the presentation; verify against Sources/FlowPilotSDK/Presentation/FlowPresenter.swift and Core/FlowPilot.swift before depending on it.
Example
Present from viewDidAppear, inside a Task, with all three styles shown.
import UIKit
import FlowPilotSDK
final class HomeViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task {
guard let flowPilot = FlowPilot.shared else { return }
// 1. Awaited result, with extra context for this presentation.
do {
let options = PresentationOptions(
additionalContext: ["paywall_source": "home_banner"]
)
let result = try await flowPilot.presentPlacement(
"paywall",
from: self,
options: options
)
handle(result)
} catch {
// No flow could be resolved. Decide what your app does here.
print("FlowPilot could not present a flow: \(error)")
}
}
}
func handle(_ result: FlowResult) {
// See "Results and outcomes" for the full FlowResult shape.
switch result.outcome {
case .completed: print("User finished the flow")
case .dismissed: print("User closed the flow early")
case .error: print("Error: \(result.error?.message ?? "unknown")")
}
}
}
For onboarding, prefer the never-throws fallback overload so the user always sees something:
Task {
let result = await FlowPilot.shared?.presentPlacement(
"onboarding",
from: self,
fallback: { MyNativeOnboardingViewController() }
)
// result?.outcome == .error means FlowPilot fell back to your native UI.
}
Common mistakes
- Calling off the main thread. The present methods are
@MainActor. Call them from the main actor (UIKit lifecycle methods already run there). A background Task that calls them without hopping to the main actor will not compile cleanly or will assert.
- Not awaiting the call.
presentPlacement is async. Wrap it in a Task {} and await it; do not call it and walk away.
- Presenting before
configure. FlowPilot.shared is nil until FlowPilot.configure(_:) runs. Guard FlowPilot.shared or your call is a silent no-op.
- Not handling the “no flow” case. The throwing overloads throw when there is nothing to show. Either
catch it, use the fallback: overload, or check isPlacementReady first.
- Expecting
presentationStyle to change the modal. It does not (see the warning above). Flows present full screen today.
Troubleshooting
The throwing overloads threw, or the fallback overload returned .error:
| Symptom | Likely cause | Fix |
|---|
flow_not_found error | No published flow attached to the placement, or it is paused | Attach and publish a flow, set the placement active. See Placements. |
| Nothing resolves, no error offline | Device offline with no cache and no bundled default | Ship a bundled default flow or pre-warm with prefetch. |
timeout error | Resolve exceeded resolveTimeout (default 4s) | Network is slow; the SDK already falls back to cache/bundled. Raise resolveTimeout only if needed. |
| Resolves on one device, not another | Audience targeting excludes this user/context | Check the placement’s audience rules and the context you pass. |
| Always empty for one app | Wrong appId, the placement belongs to another app | Configure the appId that owns the placement. |
See Error handling for every error code.
Related pages