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.