mvm_core_ui/MVMCoreUI/BaseControllers/ViewController.swift
2021-04-22 19:27:54 +05:30

660 lines
28 KiB
Swift

//
// ViewController.swift
// MVMCoreUI
//
// Created by Scott Pfeil on 11/5/19.
// Copyright © 2019 Verizon Wireless. All rights reserved.
//
import UIKit
@objc open class ViewController: UIViewController, MVMCoreViewControllerProtocol, MVMCoreViewManagerViewControllerProtocol, MoleculeDelegateProtocol, FormHolderProtocol, MVMCoreActionDelegateProtocol, MVMCoreLoadDelegateProtocol, UITextFieldDelegate, UITextViewDelegate, ObservingTextFieldDelegate, MVMCoreUIDetailViewProtocol, PageProtocol, PageBehaviorHandlerProtocol {
//--------------------------------------------------
// MARK: - Properties
//--------------------------------------------------
@objc public var pageType: String?
@objc public var loadObject: MVMCoreLoadObject?
public var model: MVMControllerModelProtocol?
public var pageModel: PageModelProtocol? {
get { model }
set { model = newValue as? MVMControllerModelProtocol }
}
/// Set if this page is containted in a manager.
public var manager: (UIViewController & MVMCoreViewManagerProtocol)?
/// A temporary iVar backer for delegateObject() until we change the protocol
public lazy var delegateObjectIVar: MVMCoreUIDelegateObject = {
MVMCoreUIDelegateObject.create(withDelegateForAll: self)
}()
public func delegateObject() -> DelegateObject? { delegateObjectIVar }
public var formValidator: FormValidator?
public var behaviors: [PageBehaviorProtocol]?
public var needsUpdateUI = false
private var observingForResponses = false
private var initialLoadFinished = false
public var previousScreenSize = CGSize.zero
public var selectedField: UIView?
// Stores the previous tab bar index.
public var tabBarIndex: Int?
/// Checks if the screen width has changed
open func screenSizeChanged() -> Bool {
!MVMCoreGetterUtility.cgfequalwiththreshold(previousScreenSize.width, view.bounds.size.width, 0.1)
}
//--------------------------------------------------
// MARK: - Response handling
//--------------------------------------------------
open func observeForResponseJSONUpdates() {
guard !observingForResponses,
(pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0)
else { return }
observingForResponses = true
NotificationCenter.default.addObserver(self, selector: #selector(responseJSONUpdated(notification:)), name: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil)
}
open func stopObservingForResponseJSONUpdates() {
guard observingForResponses else { return }
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil)
observingForResponses = false
}
open func pagesToListenFor() -> [String]? {
guard let pageType = loadObject?.pageType else { return nil }
return [pageType]
}
open func modulesToListenFor() -> [String]? {
loadObject?.requestParameters?.allModules()
}
@objc open func responseJSONUpdated(notification: Notification) {
// Checks for a page we are listening for.
var newData = false
if let pagesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyPageMap),
let pageType = pagesToListenFor()?.first(where: { (pageTypeListened) -> Bool in
guard let page = pagesLoaded.optionalDictionaryForKey(pageTypeListened),
let pageType = page.optionalStringForKey(KeyPageType),
pageType == pageTypeListened
else { return false }
return true
}) {
newData = true
loadObject?.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType)
}
// Checks for modules we are listening for.
if let modulesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyModuleMap),
let modulesListened = modulesToListenFor() {
for moduleName in modulesListened {
if let module = modulesLoaded.optionalDictionaryForKey(moduleName) {
newData = true
var currentModules = loadObject?.modulesJSON ?? [:]
currentModules.updateValue(module, forKey: moduleName)
loadObject?.modulesJSON = currentModules
}
}
}
guard newData else { return }
do {
try parsePageJSON()
MVMCoreDispatchUtility.performBlock(onMainThread: {
self.handleNewDataAndUpdateUI()
if MVMCoreUIUtility.getCurrentVisibleController() == self {
// Update navigation bar if showing.
self.setNavigationBar()
self.manager?.refreshNavigationUI()
}
// Update splitview properties
if self == MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() {
MVMCoreUISplitViewController.main()?.setBottomProgressBarProgress(self.bottomProgress() ?? 0)
self.updateTabBar()
}
})
} catch {
if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: pageType))") {
MVMCoreLoggingHandler.shared()?.addError(toLog: coreError)
}
}
}
open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer<MVMCoreErrorObject>) -> Bool {
pageType = loadObject.pageType
self.loadObject = loadObject
// Verifies all modules needed are loaded.
guard ViewController.verifyRequiredModulesLoaded(for: loadObject, error: error) else { return false }
// Parse the model for the page.
do {
try parsePageJSON()
} catch let parsingError {
// Log all parsing errors and fail load.
handleLoggingFor(parsingError: parsingError)
if let errorObject = MVMCoreErrorObject.createErrorObject(for: parsingError, location: MVMCoreLoadHandler.sharedGlobal()?.errorLocation(forRequest: loadObject)) {
errorObject.messageToDisplay = MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorUnableToProcess)
error.pointee = errorObject
}
return false
}
if let pageData = loadObject.dataForPage {
executeBehaviors { (behavior: PageLocalDataShareBehavior) in
behavior.receiveLocalPageData(pageData, delegateObjectIVar)
}
}
return true
}
func handleLoggingFor(parsingError: Error) {
if let registryError = parsingError as? ModelRegistry.Error {
switch (registryError) {
case .decoderErrorModelNotMapped(let identifier, let codingKey, let codingPath) where identifier != nil && codingKey != nil && codingPath != nil:
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Model identifier \"\(identifier!)\" is not mapped for \"\(codingKey!.stringValue)\" @ \(codingPath!.map { return $0.stringValue })")
case .decoderErrorObjectNotPresent(let codingKey, codingPath: let codingPath):
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Required model \"\(codingKey.stringValue)\" was not found @ \(codingPath.map { return $0.stringValue })")
default:
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Registry error: \(registryError)")
}
}
if let decodingError = parsingError as? DecodingError {
switch (decodingError) {
case .keyNotFound(let codingKey, let context):
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Key \(codingKey.stringValue) was not found @ \(context.codingPath.map { return $0.stringValue })")
case .valueNotFound(_, let context):
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Value not found @ \(context.codingPath.map { return $0.stringValue })")
case .typeMismatch(_, let context):
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Type mismatch @ \(context.codingPath.map { return $0.stringValue })")
case .dataCorrupted(let context):
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Data corrupted @ \(context.codingPath.map { return $0.stringValue })")
@unknown default:
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: \(parsingError)")
}
}
}
open func parsePageJSON() throws { }
open class func verifyRequiredModulesLoaded(for loadObject: MVMCoreLoadObject?, error: AutoreleasingUnsafeMutablePointer<MVMCoreErrorObject>) -> Bool {
guard let pageType = loadObject?.pageType,
var modulesRequired = MVMCoreUIViewControllerMappingObject.shared()?.modulesRequired(forPageType: pageType),
!modulesRequired.isEmpty
else { return true }
guard let loadedModules = loadObject?.modulesJSON else { return false }
for case let key as String in Array(loadedModules.keys) {
guard modulesRequired.count > 0 else { break }
if let index = modulesRequired.firstIndex(where: {($0 as? String) == key}) {
modulesRequired.remove(at: index)
}
}
guard modulesRequired.count == 0 else {
if let errorObject = MVMCoreErrorObject(title: nil, message: MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorCritical), messageToLog: modulesRequired.description, code: ErrorCode.requiredModuleNotPresent.rawValue, domain: ErrorDomainNative, location: MVMCoreLoadHandler.sharedGlobal()?.errorLocation(forRequest: loadObject!)) {
error.pointee = errorObject
}
return false
}
return true
}
/// Calls processNewData and then sets the ui to update with updateView
open func handleNewDataAndUpdateUI() {
handleNewData()
needsUpdateUI = true
view.setNeedsLayout()
}
/// Creates a legacy navigation model.
open func createDefaultLegacyNavigationModel() -> NavigationItemModel {
let navigationModel = NavigationItemModel()
navigationModel.title = model?.screenHeading
return navigationModel
}
/// Processes any new data. Called after the page is loaded the first time and on response updates for this page,
open func handleNewData() {
executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in
behavior.onPageNew(rootMolecules: getRootMolecules(), delegateObjectIVar)
}
if formValidator == nil {
let rules = model?.formRules
formValidator = FormValidator(rules)
}
if let backgroundColor = model?.backgroundColor {
view.backgroundColor = backgroundColor.uiColor
}
// Sets up the navigation item based on the data.
setNavigationItem()
}
//--------------------------------------------------
// MARK: - Navigation Item
//--------------------------------------------------
open func getNavigationModel() -> NavigationItemModelProtocol? {
// TODO: remove legacy. Temporary, convert legacy to navigation model.
if model?.navigationBar == nil {
let navigationItem = createDefaultLegacyNavigationModel()
model?.navigationBar = navigationItem
}
return model?.navigationBar
}
/// Sets the navigation item for this view controller.
open func setNavigationItem() {
guard let navigationItemModel = getNavigationModel(),
let navigationController = navigationController
else { return }
// Utilize helper function to set the navigation item state.
NavigationController.setNavigationItem(navigationController: navigationController, navigationItemModel: navigationItemModel, viewController: self)
}
/// Sets the appearance of the navigation bar based on the model.
open func setNavigationBar() {
guard let navigationItemModel = getNavigationModel(),
let navigationController = navigationController else {
MVMCoreUISession.sharedGlobal()?.splitViewController?.parent?.setNeedsStatusBarAppearanceUpdate()
return
}
// Utilize helper function to set the split view and navigation item state.
MVMCoreUISplitViewController.setNavigationBarUI(for: self, navigationController: navigationController, navigationItemModel: navigationItemModel)
}
//--------------------------------------------------
// MARK: - TabBar
//--------------------------------------------------
open func updateTabBar() {
guard MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() == self else { return }
MVMCoreUISplitViewController.main()?.tabBar?.delegateObject = delegateObjectIVar
if let index = (model as? TabPageModelProtocol)?.tabBarIndex {
MVMCoreUISplitViewController.main()?.tabBar?.highlightTab(at: index)
} else if let index = loadObject?.requestParameters?.actionMap?["tabBarIndex"] as? Int {
MVMCoreUISplitViewController.main()?.tabBar?.highlightTab(at: index)
} else if let index = self.tabBarIndex {
MVMCoreUISplitViewController.main()?.tabBar?.highlightTab(at: index)
} else if let index = MVMCoreUISplitViewController.main()?.tabBar?.currentTabIndex() {
// Store current tab index for cases like back button.
self.tabBarIndex = index
}
if let hidden = (model as? TabPageModelProtocol)?.tabBarHidden {
MVMCoreUISplitViewController.main()?.updateTabBarShowing(!hidden)
} else if let hidden = loadObject?.requestParameters?.actionMap?["tabBarHidden"] as? Bool {
MVMCoreUISplitViewController.main()?.updateTabBarShowing(!hidden)
} else {
MVMCoreUISplitViewController.main()?.updateTabBarShowing(true)
}
}
//--------------------------------------------------
// MARK: - View Lifecycle
//--------------------------------------------------
/// Called only once in viewDidLoad
open func initialLoad() {
observeForResponseJSONUpdates()
}
/// Called on screen size update.
open func updateViews() {
_ = formValidator?.validate()
}
override open func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Loaded : \(self)")
// We use our own margins.
viewRespectsSystemMinimumLayoutMargins = false
// Presents from the bottom.
modalPresentationStyle = MVMCoreGetterUtility.isOnIPad() ? .formSheet : .overCurrentContext
// Do some initial loading.
if !initialLoadFinished {
initialLoadFinished = true
initialLoad()
}
handleNewDataAndUpdateUI()
}
open override func viewDidLayoutSubviews() {
// Add to fix a constraint bug where the width is zero and things get messed up.
guard isViewLoaded, view.bounds.width > 1 else {
super.viewDidLayoutSubviews()
return
}
// First update should be explicit (hence the zero check)
if needsUpdateUI || (previousScreenSize != .zero && screenSizeChanged()) {
needsUpdateUI = false
updateViews()
}
previousScreenSize = view.bounds.size;
super.viewDidLayoutSubviews()
}
open func pageShown() {
// Update split view properties if this is the current detail controller.
if self == MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() {
MVMCoreUISplitViewController.main()?.setupPanels()
MVMCoreUISplitViewController.main()?.setBottomProgressBarProgress(bottomProgress() ?? 0)
updateTabBar()
}
// Update the navigation bar ui when view is appearing.
setNavigationBar()
// Track.
MVMCoreUISession.sharedGlobal()?.currentPageType = pageType
MVMCoreUILoggingHandler.shared()?.defaultLogPageState(forController: self)
executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in
behavior.onPageShown(self?.delegateObjectIVar)
}
}
open override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if manager == nil {
pageShown()
}
}
open override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in
behavior.onPageHidden(self?.delegateObjectIVar)
}
}
deinit {
stopObservingForResponseJSONUpdates()
MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Deallocated : \(self)")
}
open override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
MVMCoreGetterUtility.isOnIPad() ? UIInterfaceOrientationMask.all : UIInterfaceOrientationMask.portrait
}
open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// Updates the detail view width
coordinator.animate(alongsideTransition: { UIViewControllerTransitionCoordinatorContext in
}) { UIViewControllerTransitionCoordinatorContext in
self.view.setNeedsLayout()
}
}
//--------------------------------------------------
// MARK: - MVMCoreViewManagerViewControllerProtocol
//--------------------------------------------------
open func viewControllerReady(inManager manager: UIViewController & MVMCoreViewManagerProtocol) {
pageShown()
}
//--------------------------------------------------
// MARK: - MVMCoreLoadDelegateProtocol
//--------------------------------------------------
// TODO: Move this function out of here after architecture cleanup.
open func loadFinished(_ loadObject: MVMCoreLoadObject?, loadedViewController: (UIViewController & MVMCoreViewControllerProtocol)?, error: MVMCoreErrorObject?) {
MVMCoreUILoggingHandler.log(withDelegateLoadFinished: loadObject, loadedViewController: loadedViewController, error: error)
// Open the support panel
if error == nil,
loadObject?.requestParameters?.openSupportPanel ?? (loadObject?.systemParametersJSON?.boolForKey(KeyOpenSupport) ?? false) == true {
MVMCoreUISession.sharedGlobal()?.splitViewController?.showRightPanel(animated: true)
}
}
/// Override this method to avoid adding form params.
open func addFormParams(_ requestParameters: MVMCoreRequestParameters) {
formValidator?.addFormParams(requestParameters: requestParameters)
}
public func handleFieldErrors(_ fieldErrors: [Any]?, loadObject: MVMCoreLoadObject) {
for case let fieldError as [AnyHashable: Any] in fieldErrors ?? [] {
guard let fieldName = fieldError["fieldName"] as? String,
let userError = fieldError["userMessage"] as? String,
let entryFieldModel = formValidator?.fields[fieldName] as? EntryFieldModel
else { continue }
entryFieldModel.shouldClearText = fieldError["clearText"] as? Bool ?? true
entryFieldModel.dynamicErrorMessage = userError
}
}
//--------------------------------------------------
// MARK: - MVMCoreActionDelegateProtocol
//--------------------------------------------------
open func handleOpenPage(for requestParameters: MVMCoreRequestParameters, actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) {
addFormParams(requestParameters)
requestParameters.parentPageType = loadObject?.pageJSON?.optionalStringForKey("parentPageType")
var pageForwardedData = additionalData ?? [:]
executeBehaviors { (behavior: PageLocalDataShareBehavior) in
let dataMap = behavior.compileLocalPageDataForTransfer(delegateObjectIVar)
pageForwardedData.merge(dataMap) { (current, _) in current }
}
MVMCoreActionHandler.defaultHandleOpenPage(for: requestParameters, actionInformation: actionInformation, additionalData: pageForwardedData, delegateObject: delegateObject())
}
open func logAction(withActionInformation actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) {
MVMCoreUILoggingHandler.shared()?.defaultLogAction(forController: self, actionInformation: actionInformation, additionalData: additionalData)
}
open func handleUnknownActionType(_ actionType: String?, actionInformation: [AnyHashable : Any]?, additionalData: [AnyHashable : Any]?) {
var handled = false
executeBehaviors { (behavior: PageCustomActionHandlerBehavior) in
if (!handled) {
handled = behavior.handleAction(type: actionType, information: actionInformation, additionalData: additionalData)
}
}
if (!handled) {
MVMCoreUIActionHandler.defaultHandleUnknownActionType(actionType, actionInformation: actionInformation, additionalData: additionalData, delegateObject: delegateObjectIVar)
}
}
//--------------------------------------------------
// MARK: - MoleculeDelegateProtocol
//--------------------------------------------------
open func getRootMolecules() -> [MoleculeModelProtocol] {
return model?.rootMolecules ?? []
}
open func getModuleWithName(_ name: String?) -> [AnyHashable: Any]? {
guard let name = name else { return nil }
return loadObject?.modulesJSON?.optionalDictionaryForKey(name)
}
open func getModuleWithName(_ moleculeName: String) -> MoleculeModelProtocol? {
guard let moduleJSON = loadObject?.modulesJSON?.optionalDictionaryForKey(moleculeName),
let moleculeName = moduleJSON.optionalStringForKey("moleculeName"),
let modelType = ModelRegistry.getType(for: moleculeName, with: MoleculeModelProtocol.self)
else { return nil }
do {
return try modelType.decode(jsonDict: moduleJSON) as? MoleculeModelProtocol
} catch {
MVMCoreUILoggingHandler.logDebugMessage(withDelegate: "error: \(error)")
}
return nil
}
// Needed otherwise when subclassed, the extension gets called.
open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { }
open func getIndexPath(for molecule: ListItemModelProtocol & MoleculeModelProtocol) -> IndexPath? { nil }
open func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], indexPath: IndexPath, animation: UITableView.RowAnimation) { }
open func removeMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], animation: UITableView.RowAnimation) { }
//--------------------------------------------------
// MARK: - MVMCoreUIDetailViewProtocol
//--------------------------------------------------
// Reset the navigation state.
public func splitViewDidReset() {
setNavigationBar()
}
public func isLeftPanelAccessible() -> Bool {
// TODO: Remove when hamburger menu is fully phased out.
if loadObject?.pageJSON?.boolForKey(KeyHideMainMenu) ?? false {
return false
}
return MVMCoreUISession.sharedGlobal()?.launchAppLoadedSuccessfully ?? false
}
public func isRightPanelAccessible() -> Bool {
// TODO: Remove when FAB is 100%.
if loadObject?.pageJSON?.boolForKey(KeyHideMainMenu) ?? false {
return false
}
return (MVMCoreUISession.sharedGlobal()?.launchAppLoadedSuccessfully ?? false) || showRightPanelForScreenBeforeLaunchApp()
}
open func showRightPanelForScreenBeforeLaunchApp() -> Bool {
loadObject?.pageJSON?.lenientBoolForKey("showRightPanel") ?? false
}
// TODO: make molecular
open func isOverridingRightButton() -> Bool {
guard let rightPanelLink = loadObject?.pageJSON?.optionalDictionaryForKey("rightPanelButtonLink")
else { return false }
MVMCoreActionHandler.shared()?.handleAction(with: rightPanelLink, additionalData: nil, delegateObject: delegateObject())
return true
}
// TODO: make molecular
open func isOverridingLeftButton() -> Bool {
guard let leftPanelLink = loadObject?.pageJSON?.optionalDictionaryForKey("leftPanelButtonLink")
else { return false }
MVMCoreActionHandler.shared()?.handleAction(with: leftPanelLink, additionalData: nil, delegateObject: delegateObject())
return true
}
// Eventually will be moved to Model
open func bottomProgress() -> Float? {
guard let progressString = loadObject?.pageJSON?.optionalStringForKey(KeyProgressPercent),
let progress = Float(progressString)
else { return nil }
return progress / Float(100)
}
//--------------------------------------------------
// MARK: - UITextFieldDelegate
//--------------------------------------------------
// To Remove TextFields Bug: Keyboard is not dismissing after reaching textfield max length limit
open func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
open func textFieldDidBeginEditing(_ textField: UITextField) {
selectedField = textField
// TODO: Make this into a protocol
if UIAccessibility.isVoiceOverRunning {
if let toolBar = textField.inputAccessoryView as? UIToolbar,
let _ = toolBar.items?.last,
let pickerView = textField.inputView as? UIPickerView {
view.accessibilityElements = [pickerView, toolBar]
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
UIAccessibility.post(notification: .layoutChanged, argument: textField.inputView)
}
}
}
open func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
if textField === selectedField {
if UIAccessibility.isVoiceOverRunning {
view.accessibilityElements = nil
UIAccessibility.post(notification: .layoutChanged, argument: textField)
}
selectedField = nil
}
}
@objc open func dismissFieldInput(_ sender: Any?) {
selectedField?.resignFirstResponder()
}
//--------------------------------------------------
// MARK: - UITextViewDelegate
//--------------------------------------------------
public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
selectedField = textView
return true
}
open func textViewDidBeginEditing(_ textView: UITextView) {
selectedField = textView
}
open func textViewDidEndEditing(_ textView: UITextView) {
if textView === selectedField {
selectedField = nil
}
}
//--------------------------------------------------
// MARK: - Behavior Execution
//--------------------------------------------------
public func executeBehaviors<T>(_ behaviorBlock: (_ behavior: T) -> Void) {
behaviors?.compactMap { $0 as? T }.forEach { behaviorBlock($0) }
}
}