Migrating from ArkoseChallengeView to ArkoseView

This guide is for SwiftUI apps currently using the legacy ArkoseChallengeView (iOS) / ChallengeView (macOS, visionOS) that want to adopt the new native ArkoseView.

This change is additiveArkoseChallengeView / ChallengeView remain fully supported, so you can migrate at your own pace.

Why migrate

  • Cleaner SwiftUI integration — simpler lifecycle and layout.
  • One type across platformsArkoseView replaces ArkoseChallengeView (iOS) and ChallengeView (macOS/visionOS).
  • Unified callbacks — a single .onChallengeCallback closure instead of the ArkoseChallengeDelegate protocol.
  • You own presentation — inline, or your own .sheet / .fullScreenCover.
  • Pre‑warming for lower user‑perceived latency.

Prerequisites

  • Arkose Apple SDK v3.0.0 or above.
  • Minimum OS: the SDK's package deployment target is unchanged (iOS 12 / macOS 12 / visionOS 1.0). ArkoseView itself requires iOS 13 / macOS 12 / visionOS 1.3 at runtime — the same iOS floor as ArkoseChallengeView, so you do not need to bump your app's iOS deployment target. Only visionOS's view floor moved (1.0 → 1.3).

Step 1 — Update your dependency to v3.0.0

ArkoseView ships in Apple SDK v3.0.0. This is a major version bump, so package managers pinned to 2.x will not auto‑resolve to it — update your version rule explicitly.

  • Swift Package Manager: SPM will not move from a 2.x.x rule to 3.0.0 on its own. Update the rule to allow 3.0.0 (e.g. "Up to Next Major Version" starting at 3.0.0), then resolve packages.
// Package.swift — update the version rule (use the Arkose SPM URL from the Installation guide)
.package(url: "<Arkose SDK SPM URL>", from: "3.0.0")
  • XCFramework / static library: download the v3.0.0 artifact and replace your embedded framework.

Step 2 — Enable pre‑warming at initialization

Add withPrewarm: true to your existing ArkoseManager.initialize(...) call (recommended for ArkoseView). Call it on the main thread.

// Before
ArkoseManager.initialize(with: config)

// After
ArkoseManager.initialize(with: config, withPrewarm: true)

Optional — observe the pre‑warm outcome (iOS 13+). An initialize(...) overload takes a callback that fires once with the pre‑warm result — a single .onReady on success, or a single .onError after retries are exhausted. A later initialize(...) replaces it; the callback‑less overload above is unchanged and keeps the iOS 12 floor.

// Optional (iOS 13+): observe the pre‑warm outcome
ArkoseManager.initialize(with: config, withPrewarm: true) { event in
    switch event.type {
    case .onReady: break   // pre‑warm ready
    case .onError: break   // pre‑warm failed after retries were exhausted
    default:       break
    }
}

Step 3 — Replace the view and its bindings

Swap ArkoseChallengeView for ArkoseView. The presentation binding changes: legacy isPresented both mounted and ran the challenge; with ArkoseView you mount the view yourself (inline or in a modal) and use startEC to run it.

Binding mapping

Legacy (ArkoseChallengeView)New (ArkoseView)
isPresented (controls visibility)Host‑controlled: mount ArkoseView inline, or bind your own .sheet / .fullScreenCover. Use startEC to run.
runEnforcement (preload‑then‑run trigger)startEC
delegate: ArkoseChallengeDelegate.onChallengeCallback { event in … }
config:config: (unchanged)
withActivity:withActivity: (unchanged)
cancelActionConfig: / resetActionConfig:cancelActionConfig: / resetActionConfig: (unchanged)
withActivityBackgroundAlpha:Not available — add your own backdrop when presenting modally (see "Adding a dimmed backdrop" below).
(Reset via in‑view button only)Optional programmatic resetEC binding, plus the Reset button.

Adding a dimmed backdrop. ArkoseView has no SDK backdrop of its own (this replaces withActivityBackgroundAlpha). The most reliable way to dim behind the challenge on all iOS versions is a ZStack overlay in your own view — the dim layer and the challenge sit above your (now dimmed) content:

ZStack {
    // your screen content
    if startEC {
        Color.black.opacity(0.8).ignoresSafeArea()          // dim layer
        ArkoseView(startEC: $startEC).onChallengeCallback { … }
    }
}

If you specifically present via .sheet / .fullScreenCover, the cover is opaque by default, so a .background(...) on the presenting view is not visible while the cover is up. To dim the app behind a cover, make the cover transparent with .presentationBackground(.clear) (iOS 16.4+) and put the dim layer inside the cover:

.fullScreenCover(isPresented: $startEC) {
    ZStack {
        Color.black.opacity(0.8).ignoresSafeArea()
        ArkoseView(startEC: $startEC).onChallengeCallback { … }
    }
    .presentationBackground(.clear)   // iOS 16.4+
}

Verify the backdrop on‑device before shipping.

Before

struct LoginView: View, ArkoseChallengeDelegate {
    @State private var isPresented = false

    var body: some View {
        VStack {
            Button("Login") { isPresented = true }
            ArkoseChallengeView(isPresented: $isPresented, delegate: self)
        }
    }

    func onCompleted(response: [String: Any?]) { isPresented = false /* use token */ }
    func onError(response: [String: Any?])     { isPresented = false }
    func onFailed(response: [String: Any?])    { /* check recoverable */ }
}

After

struct LoginView: View {
    @State private var startEC = false

    var body: some View {
        VStack {
            Button("Login") { startEC = true }
            ArkoseView(startEC: $startEC)
                .onChallengeCallback { event in
                    switch event.type {
                    case .onCompleted:  break   // use event.response["token"]; the SDK clears startEC for you
                    case .onFailed:
                        let recoverable = (event.response["recoverable"] as? Bool) ?? false
                        _ = recoverable
                    default: break
                    }
                }
        }
    }
}

Step 4 — Move from ArkoseChallengeDelegate to onChallengeCallback

Replace each delegate method with a case in the switch event.type block. The response dictionary is now event.response (same CAPI Response Object — see the "Response payloads" table in the ArkoseView documentation).

Callback mapping

Legacy delegate methodNew event.typeNotes
onReady().onReady
onShow().onShow
onShown(response:).onShownpayload in event.response
onSuppress(response:).onSuppress
onHide(response:).onHide
onReset().onReset
onResize(response:).onResizewidth / height in event.response
onCompleted(response:).onCompletedtoken in event.response
onError(response:).onError
onWarning(response:).onWarning
onFailed(response:).onFailedcheck event.response["recoverable"]
onPrepareForReset(completion:).onPrepareForResetCallback { … } modifierDedicated hook, not an onChallengeCallback event — call completion when ready (see below).
onForceDismissCompleted().onForceDismissCompleted

Reset preparation — before / after

Reset preparation now uses the dedicated onPrepareForResetCallback modifier (not an onChallengeCallback event). With ArkoseView you no longer need to call ArkoseManager.update(with:) here — update the config state you pass to ArkoseView(config:) inside the hook, and the change applies to that same reset. Then call the completion.

// Before (delegate) — update the runtime config explicitly
func onPrepareForReset(completion: @escaping () -> Void) {
    ArkoseManager.update(with: newConfig)
    completion()
}

// After — use the dedicated onPrepareForResetCallback modifier on ArkoseView
.onPrepareForResetCallback { completion in
    // Do any setup needed for this reset — e.g. get a new APISV token or DX blob.
    // Then call completion when done (this can be async).
    //
    // The completion takes an optional ArkoseConfig:
    //   - pass a config to re-initialize this reset with it (e.g. a language/blob change)
    //   - pass nil to keep the current configuration.
    completion(updatedConfig)   // or: completion(nil)
}
  • You must call the completion closure or the challenge will not reset.
  • ArkoseManager.update(with:) / patch(with:) still work and remain a valid explicit alternative (for example, to change only a subset of settings).

Step 5 — Presentation & programmatic dismissal

Presentation is now your app's responsibility.

  • Inline: just place ArkoseView in your layout.
  • Modal: wrap it in your own .sheet / .fullScreenCover. You can bind it to the same startEC — the SDK sets startEC = false on terminal events, which dismisses the modal automatically.

Dismissal mapping

Legacy (ArkoseChallengeView)New (ArkoseView)
Set isPresented = falseSet startEC = false — this also dismisses a .sheet / .fullScreenCover bound to startEC. (Or set your own presentation binding to false.)
.fullScreenCover(isPresented: $startEC) {
    ArkoseView(startEC: $startEC)
        .onChallengeCallback { event in
            switch event.type {
            case .onCompleted:
                // use event.response["token"]; the SDK sets startEC = false → the cover dismisses
                break
            default: break
            }
        }
}

Step 6 — Reset & action buttons

ArkoseActionConfig is unchanged. In addition to the in‑SDK Reset button, ArkoseView adds a programmatic resetEC binding:

ArkoseView(
    startEC: $startEC,
    resetEC: $resetEC,                                  // new: programmatic reset
    cancelActionConfig: ArkoseActionConfig(title: "Cancel"),
    resetActionConfig: ArkoseActionConfig(title: "Reset")
)

Behaviour to note: resetEC only acts while a challenge is active (startEC == true); toggling it while idle/pre‑warmed is ignored (it will not start a challenge). It is also a one‑shot binding — the SDK sets resetEC back to false after handling it (including when ignored) — so you don't need to clear it yourself.

Step 7 — inlineRunOnTrigger

You no longer need to set inlineRunOnTrigger with ArkoseView — preload‑then‑run is its default behaviour. With initialize(withPrewarm: true), the challenge is prepared ahead of time and runs when you set startEC. Drop the flag and the separate runEnforcement binding.

// Before
ArkoseChallengeView(isPresented: $isPresented,
                    runEnforcement: $runEnforcement,
                    delegate: self,
                    withActivity: false,
                    config: cfg.with(inlineRunOnTrigger: true).build())
// isPresented = true (preload) → runEnforcement = true (run)

// After — no inlineRunOnTrigger flag, no separate runEnforcement binding
ArkoseView(startEC: $startEC,
           config: cfg.build(),
           withActivity: false)
// view appears (preload) → startEC = true (run)

Step 8 — Configuration

No changes. Keep your existing ArkoseConfig.Builder chain — all options (apiBaseUrl, blob, language, userAgent, styleTheme, noSuppress, clientAPIRetryCount, timeoutInSecondsUntilReady, ignoreCookiePersistence, showActivityIndicatorOnReset, challengeBackgroundConfig, …) apply to ArkoseView. (You can drop inlineRunOnTrigger — see Step 7.)


Behavioural differences to verify after migrating

  • Presentation is host‑ownedArkoseView never presents a modal itself; wrap it in your own .sheet/.fullScreenCover if you need one.
  • Inline, self‑sizing layout — width is bounded to the available width; height is content‑driven (scrolls in a short/landscape screen rather than being squeezed). Give the host vertical scroll for tall challenges. An explicit .frame smaller than the challenge clamps and clips.
  • resetEC is gated on an active enforcement and auto‑clears (see Step 6).
  • withActivityBackgroundAlpha is not available — add your own backdrop for modal presentation (see Step 3).
  • Deployment target unchanged (iOS)ArkoseView requires iOS 13 / macOS 12 / visionOS 1.3 at runtime, the same iOS floor as ArkoseChallengeView; the SDK package targets are unchanged and only visionOS's view floor moved (1.0 → 1.3).
  • One type name — replace both ArkoseChallengeView (iOS) and ChallengeView (macOS/visionOS) with ArkoseView.

Rollback

Because the change is additive, you can revert a screen to ArkoseChallengeView / ChallengeView at any time without SDK changes.