Customizing the UI by providing a custom UIViewController

Providing a custom UI implementation by implementing the HaapiUIViewController protocol helps when the framework bundled UI and theming options are not flexible enough to provide the desired design and style or behaviour. For example, when apps are tightly coupled to a brand's styling and design guidelines that require heavy UI customization.

Implementing a custom Form UI that reacts to user input with animations

The below steps demonstrate the required code and configuration to implement custom UI and behaviour.

Provide a custom theme override by creating a theme .plist and provide any required customization.

CustomTheme.plist takes the following structure

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>HaapiFlowViewController</key>
	<dict>
		<key>backgroundColorName</key>
		<string>app_background</string>
		<key>closeButtonImageName</key>
		<string>xmark</string>
		<key>closeButtonMinHeight</key>
		<integer>30</integer>
		<key>closeButtonMinWidth</key>
		<integer>30</integer>
		<key>closeButtonTintColorName</key>
		<string>login_button_color</string>
		<key>paddingBottom</key>
		<integer>0</integer>
		<key>paddingLeft</key>
		<integer>0</integer>
		<key>paddingRight</key>
		<integer>0</integer>
		<key>paddingTop</key>
		<integer>0</integer>
	</dict>
</dict>
</plist>

Create a subclass of UIViewController that will display the credential fields as the user inputs them.

import UIKit
import IdsvrHaapiUIKit

@available(iOS 14.0, *)
class CustomFormLoginUIViewController: UIViewController, HaapiUIViewController {
    var model: AssociatedModel?
    
    var inputs: [String: UITextField] = [:]
    var animatableItems: [UIView] = []
    
    lazy var loadingIndicator: UIActivityIndicatorView = {
        let view = UIActivityIndicatorView(style: .large)
        view.color = UIColor(named: "login_button_color")
        view.translatesAutoresizingMaskIntoConstraints = false
        view.startAnimating()
        view.alpha = 0.0
        return view
    }()
    
    lazy var content: UIStackView = {
        let view = UIStackView()
        view.axis = .vertical
        view.alignment = .center
        view.distribution = .equalSpacing
        view.spacing = 16
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .clear
        return view
    }()
    
    lazy var logoView: UIImageView = {
        let view = UIImageView(image: UIImage(imageLiteralResourceName: "login_logo"))
        view.translatesAutoresizingMaskIntoConstraints = false
        view.contentMode = .center
        return view
    }()
    
    lazy var headerText: UILabel = {
        let label = UILabel()
        label.text = "Log in to Service"
        label.font = UIFont.boldSystemFont(ofSize: 24)
        label.textColor = UIColor(named: "login_button_color")
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        buildUI()
    }
    
    func buildUI() {
        // Add top spacer view
        let topSpacerView = UIView()
        topSpacerView.translatesAutoresizingMaskIntoConstraints = false
        content.addArrangedSubview(topSpacerView)
        let spacerConstraints: [NSLayoutConstraint] = [
            topSpacerView.heightAnchor.constraint(equalToConstant: 1)
        ]
        self.content.addArrangedSubview(topSpacerView)
        NSLayoutConstraint.activate(spacerConstraints)
        
        content.addArrangedSubview(logoView)
        content.addArrangedSubview(headerText)
        self.view.addSubview(content)
        
        let contentConstraints: [NSLayoutConstraint] = [
            content.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
            content.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor),
            content.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor),
            content.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor)
        ]
        NSLayoutConstraint.activate(contentConstraints)
        
        model?.interactionItems.forEach({ item in
            switch (item) {
            case let buttonItem as InteractionItemButtonModel:
                let button = UIButton()
                button.setTitle(buttonItem.label, for: .normal)
                button.setTitleColor(.white, for: .normal)
                button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16)
                button.layer.cornerRadius = 20
                button.clipsToBounds = true
                button.backgroundColor = UIColor(named: "login_button_color")
                button.addTarget(self, action: #selector(didPushLoginButton), for: .touchUpInside)
                button.alpha = 0.0 // Initially hidden
                
                content.addArrangedSubview(button)
                let btnConstraints: [NSLayoutConstraint] = [
                    button.leadingAnchor.constraint(equalTo: self.content.leadingAnchor, constant: 20),
                    button.trailingAnchor.constraint(equalTo: self.content.trailingAnchor, constant: -20),
                    button.heightAnchor.constraint(equalToConstant: 40),
                ]
                NSLayoutConstraint.activate(btnConstraints)
                
                animatableItems.append(button)
            case let inputItem as InteractionItemInputTextModel:
                let inputStack = UIStackView()
                inputStack.axis = .vertical
                inputStack.alignment = .leading
                inputStack.distribution = .fill
                inputStack.backgroundColor = .clear
                inputStack.alpha = 0.0 // Initially hidden
                
                let label = UILabel()
                label.font = UIFont.boldSystemFont(ofSize: 16)
                label.text = inputItem.label
                label.textColor = UIColor(named: "login_text_color")
                label.textAlignment = .left
                inputStack.addArrangedSubview(label)
                
                let lblConstraints: [NSLayoutConstraint] = [
                    label.leadingAnchor.constraint(equalTo: inputStack.leadingAnchor),
                    label.trailingAnchor.constraint(equalTo: inputStack.trailingAnchor),
                    label.heightAnchor.constraint(equalToConstant: 30),
                ]
                NSLayoutConstraint.activate(lblConstraints)
                
                let tf = UITextField()
                tf.backgroundColor = .white
                tf.placeholder = inputItem.placeholder
                tf.textColor = .black
                tf.layer.cornerRadius = 4.0
                tf.clipsToBounds = true
                tf.autocapitalizationType = .none
                tf.addTarget(self, action: #selector(textfieldDidChange), for: .editingChanged)
                inputStack.addArrangedSubview(tf)
                
                let tfConstraints: [NSLayoutConstraint] = [
                    tf.leadingAnchor.constraint(equalTo: inputStack.leadingAnchor),
                    tf.trailingAnchor.constraint(equalTo: inputStack.trailingAnchor),
                    tf.heightAnchor.constraint(equalToConstant: 40),
                ]
                NSLayoutConstraint.activate(tfConstraints)
                
                content.addArrangedSubview(inputStack)
                
                let isConstraints: [NSLayoutConstraint] = [
                    inputStack.leadingAnchor.constraint(equalTo: self.content.leadingAnchor, constant: 20),
                    inputStack.trailingAnchor.constraint(equalTo: self.content.trailingAnchor, constant: -20)
                ]
                NSLayoutConstraint.activate(isConstraints)
                
                inputs[inputItem.key] = tf
                animatableItems.append(inputStack)
            default:
                break
            }
        })
        
        self.view.addSubview(loadingIndicator)
        let liConstraints: [NSLayoutConstraint] = [
            loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loadingIndicator.bottomAnchor.constraint(equalTo: content.bottomAnchor),
            loadingIndicator.heightAnchor.constraint(equalToConstant: 60),
            loadingIndicator.widthAnchor.constraint(equalToConstant: 60)
        ]
        NSLayoutConstraint.activate(liConstraints)
    
        // Animate the appearance of the inputStack
        UIView.animate(withDuration: 0.5, delay: 0.5) {
            self.animatableItems.first?.alpha = 1.0
        }
    }
    
    @objc func didPushLoginButton() {
        guard let action = model?.interactionItems.first(where: { $0 is InteractionItemButtonModel }) as? InteractionItemButtonModel else {
            return
        }
        
        // Animate the appearance of the inputStack
        UIView.animate(withDuration: 0.3, animations: {
            self.animatableItems.forEach { view in
                view.alpha = 0.0
            }
            self.loadingIndicator.alpha = 1.0
        }, completion: { completed in
            if completed {
                // use the delegate to post the interaction action
                var parameters: [String: String] = [:]
                self.inputs.forEach { (key, value) in
                    parameters[key] = value.text
                }
                self.haapiFlowViewControllerDelegate?.submit(interactionActionModel: action, parameters: parameters)
            }
        })
    }
    
    @objc func textfieldDidChange(_ textField: UITextField) {
        guard (textField.text?.count ?? 0) > 3, let index = animatableItems.firstIndex(where: { ($0 as? UIStackView)?.arrangedSubviews.firstIndex(of: textField) != nil }) else {
            return
        }
        
        if index+1 < animatableItems.count {
            // Animate the appearance of the inputStack
            UIView.animate(withDuration: 0.5, delay: 0.5) {
                self.animatableItems[index+1].alpha = 1.0
            }
        }
    }
    
    // MARK:  HaapiUIViewController
    typealias AssociatedModel = FormModel
    var haapiFlowViewControllerDelegate: (any IdsvrHaapiUIKit.HaapiFlowViewControllerDelegate)?
    var uiStylableThemeDelegate: (any IdsvrHaapiUIKit.UIStylableThemeDelegate)?
    
    func stopLoading() { }
    
    func hasLoading() -> Bool {
        // the custom ui handles it's own loading indicator
        return true
    }
    
    func handleProblemModel(_ problemModel: any IdsvrHaapiUIKit.ProblemModel) -> Bool {
        // handle issues with the form by displaying alert dialogs
        UIView.animate(withDuration: 0.3) {
            self.animatableItems.forEach { view in
                view.alpha = 1.0
            }
            self.loadingIndicator.alpha = 0.0
        }
        
        var errorMessage = problemModel.messageItems.map { msg in
            return (msg.text ?? "")
        }.joined(separator: "\n")
        
        if errorMessage.isEmpty {
            errorMessage = problemModel.title ?? ""
        }
        
        let alertController = UIAlertController(title: title,
                                                message: errorMessage,
                                                preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .cancel))
        present(alertController, animated: true)

        // the custom ui is capable of handling a problem model
        return true
    }
    
    func handleInfoMessageModels(_ infoMessageModels: [any IdsvrHaapiUIKit.InfoMessageModel]) {
        
    }
    
    func handleLinkItemModels(_ linkItemModels: [any IdsvrHaapiUIKit.LinkItemModel]) {
        
    }
    
    func handleFormModel(_ formModel: any IdsvrHaapiUIKit.FormModel) -> Bool {
        return true
    }
    
    func hideHeaderView() { }
    
    // Required hooks conformance
    
    func preSubmit(interactionActionModel: any IdsvrHaapiUIKit.InteractionActionModel, parameters: [String: Any], closure: (Bool, [String: Any]) -> Void) {
        closure(true, parameters)
    }
    
    func preSelect(selectorItemModel: any IdsvrHaapiUIKit.SelectorItemInteractionActionModel, closure: (Bool) -> Void) {
        closure(true)
    }
    
    func preFollow(linkItemModel: any IdsvrHaapiUIKit.LinkItemModel, closure: (Bool) -> Void) {
        closure(true)
    }
}

In your AppDelegate, create an instance of ViewControllerFactoryRegistry and set to your HaapiUIKitApplicationBuilder as demonstrated below:

import IdsvrHaapiUIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
      // Registering the custom UIViewController to render FormModel
      let resolver = ViewControllerFactoryRegistry()
          .registerViewControllerFactoryFormModel { model, style, commonStyle in
                let vc = CustomFormLoginUIViewController()
                vc.model = model
                return vc
          }

      do {
          haapiUIKitApplication = try HaapiUIKitApplicationBuilder(haapiUIKitConfiguration: haapiUIKitConfiguration)
            // Set the themeing overrides
            .setThemingPlistFileName("CustomTheme")
            .setViewControllerFactoryRegistry(registry: resolver)
            .buildOrThrow()
      } catch {
          print("An exception was thrown and it should be handled.: \(error)")
      }
      return true
  	}
}

The framework will present the custom UI when starting the authentication flow and rendering a FormModel.

Banner ad with subclass selectorFragment