// // ViewController.swift // MVMCoreUI // // Created by Scott Pfeil on 11/5/19. // Copyright © 2019 Verizon Wireless. All rights reserved. // import UIKit import MVMCore @objc open class ViewController: UIViewController, MVMCoreViewControllerProtocol, MVMCoreViewManagerViewControllerProtocol, MoleculeDelegateProtocol, FormHolderProtocol, MVMCoreActionDelegateProtocol, ActionDelegateProtocol, 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 weak var manager: (UIViewController & MVMCoreViewManagerProtocol)? /// A temporary iVar backer for delegateObject() until we change the protocol open 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: NSObjectProtocol? private var initialLoadFinished = false public var isFirstRender = true public var previousScreenSize = CGSize.zero public var selectedField: UIView? public var pageUpdateQueue: OperationQueue = { let pageUpdateQueue = OperationQueue() pageUpdateQueue.maxConcurrentOperationCount = 1 pageUpdateQueue.qualityOfService = .userInteractive return pageUpdateQueue }() /// 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 == nil, (pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0) else { return } observingForResponses = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil, queue: pageUpdateQueue) { [weak self] notification in self?.responseJSONUpdated(notification: notification) } } open func stopObservingForResponseJSONUpdates() { guard let observingForResponses = observingForResponses else { return } NotificationCenter.default.removeObserver(observingForResponses) self.observingForResponses = nil } open func pagesToListenFor() -> [String]? { guard let pageType = loadObject?.pageType else { return nil } return [pageType] } open func modulesToListenFor() -> [String]? { let requestModules = loadObject?.requestParameters?.allModules() ?? [] let behaviorModules = behaviors?.flatMap { $0.modulesToListenFor } ?? [] return requestModules + behaviorModules } @objc open func responseJSONUpdated(notification: Notification) { // Checks for a page we are listening for. var hasDataUpdate = false var pageModel: PageModelProtocol? = nil if let pagesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyPageMap), let loadObject, 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 }) { hasDataUpdate = true loadObject.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType) // Separate page updates from the module updates to avoid unecessary resets to behaviors and full re-renders. do { pageModel = try parsePageJSON(loadObject: loadObject) } catch { if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: pageType))") { MVMCoreLoggingHandler.shared()?.addError(toLog: coreError) } } } // 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) { hasDataUpdate = true var currentModules = loadObject?.modulesJSON ?? [:] currentModules.updateValue(module, forKey: moduleName) loadObject?.modulesJSON = currentModules } } } guard hasDataUpdate else { return } MVMCoreDispatchUtility.performBlock(onMainThread: { self.handleNewData(pageModel) }) } open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer) -> 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 { let template = try parsePageJSON(loadObject: loadObject) pageModel = template // TODO: Eventually this page parsing should be done outside of this class and then set by the caller. For now, double duty. isFirstRender = true // Assuming this is only on the first page load from the handler. Might need to revist later. if let backgroundRequest = loadObject.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject.identifier { MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageProcessingComplete(pageType: pageType, requestUUID: identifier, webUrl: nil)) } } catch let parsingError { // Log all parsing errors and fail load. if let errorObject = MVMCoreLoadHandler.sharedGlobal()?.error(for: loadObject, causedBy: parsingError) { errorObject.messageToDisplay = MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorUnableToProcess) errorObject.messageToLog = describe(parsingError: parsingError) error.pointee = errorObject } return false } if let pageData = loadObject.dataForPage { executeBehaviors { (behavior: PageLocalDataShareBehavior) in behavior.receiveLocalPageData(pageData, delegateObjectIVar) } } ///Check with behavior if it can continue do{ let allFinishedProcessingLoad = try executeThrowingBehaviors {(behavior: PageMoleculeTransformationBehavior) in return try behavior.shouldFinishProcessingLoad(loadObject) } guard allFinishedProcessingLoad else { return false } } catch let behaviorError { if let errorObject = MVMCoreErrorObject.createErrorObject(for: behaviorError, location: MVMCoreLoadHandler.sharedGlobal()?.errorLocation(forRequest: loadObject)) { error.pointee = errorObject } return false } return true } func describe(parsingError: Error) -> String { if let error = parsingError as? HumanReadableDecodingErrorProtocol { return "Error parsing template. \(error.readableDescription)" } return "Error parsing template. \((parsingError as NSError).localizedFailureReason ?? parsingError.localizedDescription)" } open func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { throw MVMCoreError.error(code: ErrorCode.parsingJSON.rawValue, messageToDisplay: "Template needs to define its model!", messageToLog: "Template needs to define its model!") } open class func verifyRequiredModulesLoaded(for loadObject: MVMCoreLoadObject?, error: AutoreleasingUnsafeMutablePointer) -> 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 loadObject = loadObject, let errorObject = MVMCoreLoadHandler.sharedGlobal()?.error(for: loadObject, withTitle:nil, message: MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorCritical), code: ErrorCode.requiredModuleNotPresent.rawValue, domain: ErrorDomainNative) { errorObject.messageToLog = modulesRequired.description error.pointee = errorObject } return false } return true } /// 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. Triggers a render refresh unless specified otherwise. @MainActor open func handleNewData(_ pageModel: PageModelProtocol? = nil, shouldTriggerRender: Bool = true) { guard var newPageModel = pageModel ?? self.pageModel else { return } let originalModel = isFirstRender ? nil : self.pageModel as? MVMControllerModelProtocol // Refresh our behaviors if there is a page change. if let behaviorContainer = newPageModel as? (PageBehaviorContainerModelProtocol & TemplateModelProtocol), (originalModel == nil || originalModel!.id != behaviorContainer.id) { var behaviorHandler = self behaviorHandler.applyBehaviors(pageBehaviorModel: behaviorContainer) } // Setup the default navigation bar if it is missing. if newPageModel.navigationBar == nil { let navigationItem = createDefaultLegacyNavigationModel() newPageModel.navigationBar = navigationItem } // Make the template available for onPageNew behavior handling. See if we can have behaviors rely on roots later. self.pageModel = newPageModel // Run through behavior tranformations. var behaviorUpdatedModels = [MoleculeModelProtocol]() if var newTemplateModel = newPageModel as? TemplateModelProtocol { behaviorUpdatedModels = runBehaviorTransformations(on: &newTemplateModel) } // Apply the form validator to the controller. if formValidator == nil { // TODO: Can't change form rules? let rules = (newPageModel as? FormHolderModelProtocol)?.formRules formValidator = FormValidator(rules) } // Reset after tranformations. self.pageModel = newPageModel // Run through the differences between separate page model trees. //var pageUpdatedModels = [MoleculeModelProtocol]() if let originalModel, // We had a prior. let newPageModel = newPageModel as? TemplateModelProtocol, originalModel.id != newPageModel.id { // This isn't ready yet. handleNewData for the ListTemplate triggers a row item reset and full rebuild + StackTemplate isn't targeting individual refreshes anyway. // pageUpdatedModels = originalModel.findAllTheirsNotEqual(against: newPageModel) // debugLog("Page molecule updates\n\(pageUpdatedModels)") isFirstRender = true // Instead force a full render whenever there is a page data change. } let allUpdatedMolecules = behaviorUpdatedModels //+ pageUpdatedModels // Notify the manager of new data. // Warning: Some flows cause table reloads. Until the UI update is decoupled, should be after the updateUI. manager?.newDataReceived?(in: self) guard shouldTriggerRender else { return } // Dispatch to decouple execution. First massage data through template classes, then render. Task { @MainActor in if allUpdatedMolecules.isEmpty || isFirstRender { debugLog("Performing full page render...") updateUI() } else { debugLog("Performing partial render of \(allUpdatedMolecules) molecules...") updateUI(for: allUpdatedMolecules) } } } /// Applies the latest model to the UI. open func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { isFirstRender = false executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willRender(rootMolecules: molecules ?? getRootMolecules(), delegateObjectIVar) } guard molecules == nil else { return } if let backgroundColor = model?.backgroundColor { view.backgroundColor = backgroundColor.uiColor } needsUpdateUI = true view.setNeedsLayout() } func runBehaviorTransformations(on newTemplateModel: inout any TemplateModelProtocol) -> [any MoleculeModelProtocol] { var behaviorUpdatedModels = [MoleculeModelProtocol]() executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in var changes = [any MoleculeModelProtocol]() if let updatedMolecules = behavior.onPageNew(rootMolecules: newTemplateModel.rootMolecules, delegateObjectIVar, changes: &changes) { updatedMolecules.forEach { molecule in // Replace again in case there is a template level child. if let _ = try? newTemplateModel.replaceChildMolecule(with: molecule) { // Only recognize the molecules that actually changed. if changes.count > 0 { debugLog("\(behavior) updated \(changes) in template model.") changes = changes.filter({ model in !behaviorUpdatedModels.contains { $0.id == model.id } }) behaviorUpdatedModels.append(contentsOf: changes) } } else { debugLog("Failed to replace \(molecule) in the template model.") } } } } return behaviorUpdatedModels } public func generateMoleculeView(from model: MoleculeModelProtocol) -> MoleculeViewProtocol? { executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willSetupMolecule(with: model, updating: nil) } guard let moleculeView = ModelRegistry.createMolecule(model, delegateObject: delegateObjectIVar) else { return nil } executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.didSetupMolecule(view: moleculeView, withModel: model) } return moleculeView } public func updateMoleculeView(_ view: MoleculeViewProtocol, from model: MoleculeModelProtocol) { executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willSetupMolecule(with: model, updating: view) } view.set(with: model, delegateObjectIVar, nil) executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.didSetupMolecule(view: view, withModel: model) } } //-------------------------------------------------- // 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 } //-------------------------------------------------- // 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. debugLog("View Controller Loaded") // 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() } handleNewData(pageModel, shouldTriggerRender: false) // Set outside shouldFinishProcessingLoad. updateUI() // Force the rendering on the same main UI thread. } 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() { // Track. MVMCoreUISession.sharedGlobal()?.currentPageType = pageType MVMCoreUILoggingHandler.shared()?.defaultLogPageState(forController: self) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.onPageShown(self?.delegateObjectIVar) } if let backgroundRequest = loadObject?.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject?.identifier { MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageRenderComplete(pageType: pageType, requestUUID: identifier, templateName: loadObject?.pageJSON?.optionalStringForKey("template"), controllerName: "\(type(of: self))", error: loadObject?.responseInfoMap?.optionalStringForKey("message"))) } } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.willShowPage(self?.delegateObjectIVar) } } open override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if manager == nil { pageShown() } } open override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.willHidePage(self?.delegateObjectIVar) } } open override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.onPageHidden(self?.delegateObjectIVar) } } deinit { stopObservingForResponseJSONUpdates() debugLog("Deallocated") } 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 ?? false) || (loadObject?.systemParametersJSON?.boolForKey(KeyOpenSupport) ?? false) { MVMCoreUISession.sharedGlobal()?.splitViewController?.showRightPanel(animated: true) } } /// Override this method to avoid adding form params. open func addFormParams(requestParameters: MVMCoreRequestParameters, additionalData: [AnyHashable: Any]?) { formValidator?.addFormParams(requestParameters: requestParameters, model: MVMCoreUIActionHandler.getSourceModel(from: additionalData) as? MoleculeModelProtocol & FormItemProtocol) } 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 logAction(withActionInformation actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) { MVMCoreUILoggingHandler.shared()?.defaultLogAction(forController: self, actionInformation: actionInformation, additionalData: additionalData) } open func performAction(with model: ActionModelProtocol, additionalData: [AnyHashable : Any]?, delegateObject: DelegateObject?) async throws { var additionalData = additionalData if let model = model as? ActionOpenPageModel { addFormParams(requestParameters: model.requestParameters, additionalData: additionalData) model.requestParameters.parentPageType = loadObject?.pageJSON?.optionalStringForKey("parentPageType") var pageForwardedData = additionalData ?? [:] executeBehaviors { (behavior: PageLocalDataShareBehavior) in let dataMap = behavior.compileLocalPageDataForTransfer(delegateObjectIVar) pageForwardedData.merge(dataMap) { current, _ in current } } additionalData = pageForwardedData } do { if let behavior = behaviors?.compactMap({ $0 as? PageCustomActionHandlerBehavior }).first(where: { $0.canHandleAction(with: model, additionalData: additionalData) }) { logAction(withActionInformation: model.toJSON(), additionalData: additionalData) try await behavior.handleAction(with: model, additionalData: additionalData) } else { try await MVMCoreUIActionHandler.shared()?.handleAction(with: model, additionalData: additionalData, delegateObject: delegateObject) } } catch { handleAction(error: error, model: model, additionalData: additionalData, delegateObject: delegateObject) throw error } } open func handleAction(error: Error, model: ActionModelProtocol, additionalData: [AnyHashable : Any]?, delegateObject: DelegateObject?) { let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: MVMCoreActionHandler.getErrorLocation(with: delegateObject?.actionDelegate, actionType: model.actionType))! switch (model) { case let model as ActionOpenPageModel: errorObject.silentError = model.background ?? false default: errorObject.silentError = false } MVMCoreUIActionHandler.shared()?.defaultHandleActionError(errorObject, additionalData: additionalData, delegateObject: delegateObject) } //-------------------------------------------------- // MARK: - MoleculeDelegateProtocol //-------------------------------------------------- open func getTemplateModel() -> TemplateModelProtocol? { model } open func getRootMolecules() -> [MoleculeModelProtocol] { model?.rootMolecules ?? [] } // Needed otherwise when subclassed, the extension gets called. open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { } //-------------------------------------------------- // MARK: - MVMCoreUIDetailViewProtocol //-------------------------------------------------- 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 let hideRightPanel = model?.hideRightPanel, hideRightPanel == true { 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 } } } extension ViewController: CoreLogging { public var loggingPrefix: String { "\(self) \(pageType ?? ""): " } public static var loggingCategory: String? { return "Rendering" } }