UI Extensibility (UI Layer)#

When theming can’t reach what you need — a fundamentally different layout, conditional rendering driven by your own state, a wrapped third-party UI component — drop down to UI Extensibility. The framework maps each HAAPI step to a UIModel, then resolves a view controller (iOS) or fragment (Android) for that model through a resolver you can override. Swap one step’s renderer, all of them, or just the data mapping that feeds them.

For the foundational builder, see Configuration . For the theming-first approach, see Theming .

Try theming first. Theming changes the look — colors, fonts, drawables, per-component styles — without taking over framework lifecycle. UI Extensibility takes ownership of how a screen renders and forwards user input, which means lifecycle correctness becomes your responsibility (see the warning at the bottom of this page). If the default layout is structurally what you want and you only need it restyled, Theming is the right tool.

When to Use Extensibility#

Reach for UI Extensibility when:

  • Theming alone won’t get you there. You need a different layout — different order of elements, additional content from your app, or a screen that uses a custom component.
  • You need to inject application state. The view should react to something the framework doesn’t know about (feature flag, account state, A/B variant).
  • You want to wrap a different rendering technology. Embedding SwiftUI inside an iOS view controller, replacing a Fragment with a Compose host on Android.
  • The default mapping doesn’t match your domain. The UIModel doesn’t carry the field you want to show, or carries it in a form your view expects differently.

When the default screen is structurally correct and you only want to restyle it, theming is the right tool — extensibility is heavier.

Default Model → View Mapping#

Both platforms map the same set of HAAPI representations to view models, then to default view classes:

UIModeliOS view controllerAndroid fragment
BankIdModelBankIdViewControllerBankIdFragment
FormModelFormViewControllerFormFragment
GenericModelGenericViewControllerGenericFragment
PollingModelPollingViewControllerPollingFragment (or BankIdFragment)
ProblemModelProblemViewControllerProblemFragment
SelectorModelSelectorViewControllerSelectorFragment
WebAuthnOperationModelWebAuthnViewControllerWebAuthnFragment
ContinueSameModel(form)FormFragment

Customizations register against the same model types — you don’t define your own UIModel subclasses.

Customization Methods (by Cost)#

Three increasingly invasive options, in the order to try them. Method 1 changes the layout while keeping default behavior. Method 2 keeps the default layout shape but injects custom behavior. Method 3 replaces the view class wholesale.

Method 1: Different LayoutMethod 2: Subclass + BehaviorMethod 3: Replace Entirely
EffortLow — one .xib / .xml swapMedium — selective overrides + careful super. callsHigh — own the full view lifecycle
You controlLayout fileLayout shape + the behaviors you overrideEverything visible to the user
Framework keepsAll behavior, lifecycle, and result-emissionDefault behavior for everything you don’t overrideOnly flow-driving — state delivery in, results out; no view or lifecycle
When to chooseThe default behavior fits; only the layout needs to changeNeed to react to app state the framework doesn’t know about (feature flags, analytics, account context)Need a fundamentally different rendering (SwiftUI / Compose host, third-party UI kit, branded design system)
Lifecycle responsibilityFramework’sShared — wherever you override, you must call super.Yours
Common pitfallMissing the parent’s required @IBOutlet / @+id references crashes at view-loadForgetting a super. call breaks flow progression silentlyForgetting to wire the framework’s result-emission APIs leaves the user on a stuck screen with no error

Pick the lowest-cost method that meets your need. Most apps that customize at all only need Method 1 or Method 2; Method 3 is reserved for cases where the prebuilt structure is genuinely the wrong shape.

Method 1: Different Layout via Subclass#

Subclass the framework’s view controller and override nibName to point at your own .xib. Default behavior is preserved; only the layout file changes.

final class MyFormViewController: FormViewController {
    override var nibName: String? {
        "MyFormLayout"      // refers to MyFormLayout.xib in the app bundle
    }
}

let registry = ViewControllerFactoryRegistry()
registry.register(modelType: FormModel.self) { uiModel in
    MyFormViewController(uiModel: uiModel)
}

Your .xib must wire up the same @IBOutlets the parent class expects. Inspect FormViewController’s outlets in the autodoc; any missing outlet crashes at view-load time.

Method 2: Subclass and Override Behavior#

Subclass to inject application state, override lifecycle hooks, or change how user input is processed. Call super on every override so the framework’s flow management still runs.

final class MyFormViewController: FormViewController {
    override func viewDidLoad() {
        super.viewDidLoad()                  // required
        applyFeatureFlagOverrides()
    }

    override func submitForm() {
        analytics.log("form_submitted")
        super.submitForm()                   // required — forwards to flow
    }

    private func applyFeatureFlagOverrides() { /* app-specific */ }
}

Register the subclass the same way as Method 1.

What it looks like. A SelectorViewController subclass can inject extra views on top of the framework’s standard selector layout. The screenshot below shows a custom subclass that adds an ad banner above the authenticator picker — the framework still drives the HAAPI flow and dispatches user-selected actions; only the rendered chrome is yours.

SelectorViewController subclass rendering a custom ad banner above the standard authenticator selector

Method 2 — SelectorViewController subclass with an extra ad-banner UIView layered above the framework’s default selector. The selector buttons and flow dispatch are unchanged.

Method 3: Replace the View Controller Entirely#

Register a custom class conforming to HaapiUIViewController directly. You own the full lifecycle; the framework still drives the HAAPI flow underneath but does not provide any layout or behavior for you.

final class MyCompletelyCustomFormVC: UIViewController, HaapiUIViewController {
    let uiModel: FormModel
    init(uiModel: FormModel) {
        self.uiModel = uiModel
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Build the UI from scratch reading uiModel
    }

    // Implement HaapiUIViewController's required methods so the
    // framework can deliver state updates and receive user actions.
}

let registry = ViewControllerFactoryRegistry()
registry.register(modelType: FormModel.self) { uiModel in
    MyCompletelyCustomFormVC(uiModel: uiModel)
}

haapiUIKitApplication = HaapiUIKitApplicationBuilder(
    haapiUIKitConfiguration: haapiUIKitConfiguration
)
.setViewControllerFactoryRegistry(registry)
.build()

What it looks like. A class conforming to HaapiUIViewController can render any UI you want. The screenshot below shows a fully custom login screen — branded header, custom field styling, custom button layout — replacing the framework’s default FormViewController. The framework still delivers the FormModel to your init(uiModel:), drives the underlying HAAPI flow, and receives user actions through the methods you implement.

FormViewController fully replaced by a hand-built custom login screen

Method 3 — the entire FormViewController swapped out for a HaapiUIViewController implementation. Visual design, layout, and field styling are owned by the host app; the HAAPI flow contract is preserved.

Customizing Data Mappings#

When the issue is what data reaches the view rather than how it’s rendered, register a DataMapper against the relevant input type. The framework converts HAAPI server responses into UIModel instances through a chain of mappers; your mapper can inject extra fields, transform existing fields, or return an app-specific subclass that your custom view reads:

let dataMapperRegistry = DataMapperRegistry()
dataMapperRegistry.register(modelType: FormModel.self) { input in
    var model = FormModel(from: input)
    model.extraField = FeatureFlags.formExtraField()
    return model
}

haapiUIKitApplication = HaapiUIKitApplicationBuilder(
    haapiUIKitConfiguration: haapiUIKitConfiguration
)
.setDataMapperRegistry(dataMapperRegistry)
.build()

Data-mapping customizations layer cleanly with view-class customizations — replace the mapping, then write a custom view that consumes the new fields. See the DataMappers reference in IdsvrHaapiUIKit/docs/ui_extensibility/ for the upstream detail.

A custom view controller / fragment that doesn’t call the framework’s lifecycle hooks correctly will silently break the flow — the user sees a stuck screen with no error. When subclassing or replacing, always call the base lifecycle methods (e.g., super.viewDidLoad(), super.onCreateView(...)) and use the framework’s published result-emission APIs to forward user input back into the flow. Test the full end-to-end flow whenever you swap a view, not just the visual rendering.

Was this helpful?