diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-03-07 15:16:59 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-03-22 16:42:30 +0100 |
| commit | a51757ce590b5063c1c8099b3ed8ea0fa8b3bcdb (patch) | |
| tree | 85935823680100affad563ebeca45a07a71938ee /ios/MullvadVPN/Containers/Root/RootContainerViewController.swift | |
| parent | 1c2c6f58dc1d175d00bea8037ca989ca80b1fcb8 (diff) | |
| download | mullvadvpn-a51757ce590b5063c1c8099b3ed8ea0fa8b3bcdb.tar.xz mullvadvpn-a51757ce590b5063c1c8099b3ed8ea0fa8b3bcdb.zip | |
Add coordinators and app router
Fixes IOS-10
Diffstat (limited to 'ios/MullvadVPN/Containers/Root/RootContainerViewController.swift')
| -rw-r--r-- | ios/MullvadVPN/Containers/Root/RootContainerViewController.swift | 676 |
1 files changed, 676 insertions, 0 deletions
diff --git a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift new file mode 100644 index 0000000000..65ebaffd47 --- /dev/null +++ b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift @@ -0,0 +1,676 @@ +// +// RootContainerViewController.swift +// MullvadVPN +// +// Created by pronebird on 25/05/2019. +// Copyright © 2019 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +enum HeaderBarStyle { + case transparent, `default`, unsecured, secured + + fileprivate func backgroundColor() -> UIColor { + switch self { + case .transparent: + return UIColor.clear + case .default: + return UIColor.HeaderBar.defaultBackgroundColor + case .secured: + return UIColor.HeaderBar.securedBackgroundColor + case .unsecured: + return UIColor.HeaderBar.unsecuredBackgroundColor + } + } +} + +struct HeaderBarPresentation { + let style: HeaderBarStyle + let showsDivider: Bool + + static var `default`: HeaderBarPresentation { + return HeaderBarPresentation(style: .default, showsDivider: false) + } +} + +/// A protocol that defines the relationship between the root container and its child controllers +protocol RootContainment { + /// Return the preferred header bar style + var preferredHeaderBarPresentation: HeaderBarPresentation { get } + + /// Return true if the view controller prefers header bar hidden + var prefersHeaderBarHidden: Bool { get } +} + +protocol RootContainerViewControllerDelegate: AnyObject { + func rootContainerViewControllerShouldShowSettings( + _ controller: RootContainerViewController, + navigateTo route: SettingsNavigationRoute?, + animated: Bool + ) + + func rootContainerViewSupportedInterfaceOrientations(_ controller: RootContainerViewController) + -> UIInterfaceOrientationMask + + func rootContainerViewAccessibilityPerformMagicTap(_ controller: RootContainerViewController) + -> Bool +} + +/// A root container view controller +class RootContainerViewController: UIViewController { + typealias CompletionHandler = () -> Void + + private let headerBarView = HeaderBarView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + private let transitionContainer = UIView(frame: UIScreen.main.bounds) + private var presentationContainerSettingsButton: UIButton? + + private(set) var headerBarPresentation = HeaderBarPresentation.default + private(set) var headerBarHidden = false + private(set) var overrideHeaderBarHidden: Bool? + + private(set) var viewControllers = [UIViewController]() + + private var appearingController: UIViewController? + private var disappearingController: UIViewController? + private var interfaceOrientationMask: UIInterfaceOrientationMask? + + var topViewController: UIViewController? { + return viewControllers.last + } + + weak var delegate: RootContainerViewControllerDelegate? + + override var childForStatusBarStyle: UIViewController? { + return topViewController + } + + override var childForStatusBarHidden: UIViewController? { + return topViewController + } + + override var shouldAutomaticallyForwardAppearanceMethods: Bool { + return false + } + + override var disablesAutomaticKeyboardDismissal: Bool { + return topViewController?.disablesAutomaticKeyboardDismissal ?? super + .disablesAutomaticKeyboardDismissal + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + var margins = view.layoutMargins + margins.left = UIMetrics.contentLayoutMargins.left + margins.right = UIMetrics.contentLayoutMargins.right + view.layoutMargins = margins + + definesPresentationContext = true + + addTransitionView() + addHeaderBarView() + updateHeaderBarBackground() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateAdditionalSafeAreaInsetsIfNeeded() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + updateHeaderBarLayoutMarginsIfNeeded() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let childController = topViewController { + beginChildControllerTransition(childController, isAppearing: true, animated: animated) + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if let childController = topViewController { + endChildControllerTransition(childController) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if let childController = topViewController { + beginChildControllerTransition(childController, isAppearing: false, animated: animated) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if let childController = topViewController { + endChildControllerTransition(childController) + } + } + + // MARK: - Autorotation + + override var shouldAutorotate: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return interfaceOrientationMask ?? super.supportedInterfaceOrientations + } + + // MARK: - Public + + func setViewControllers( + _ newViewControllers: [UIViewController], + animated: Bool, + completion: CompletionHandler? = nil + ) { + // Fetch the initial orientation mask + if interfaceOrientationMask == nil { + updateInterfaceOrientation(attemptRotateToDeviceOrientation: false) + } + + setViewControllersInternal( + newViewControllers, + isUnwinding: false, + animated: animated, + completion: completion + ) + } + + func pushViewController( + _ viewController: UIViewController, + animated: Bool, + completion: CompletionHandler? = nil + ) { + var newViewControllers = viewControllers.filter { $0 != viewController } + newViewControllers.append(viewController) + + setViewControllersInternal( + newViewControllers, + isUnwinding: false, + animated: animated, + completion: completion + ) + } + + func popToViewController( + _ controller: UIViewController, + animated: Bool, + completion: CompletionHandler? = nil + ) { + guard let index = viewControllers.firstIndex(of: controller) else { return } + + let newViewControllers = Array(viewControllers[...index]) + + setViewControllersInternal( + newViewControllers, + isUnwinding: true, + animated: animated, + completion: completion + ) + } + + func popViewController(animated: Bool, completion: CompletionHandler? = nil) { + guard viewControllers.count > 1 else { return } + + var newViewControllers = viewControllers + newViewControllers.removeLast() + + setViewControllersInternal( + newViewControllers, + isUnwinding: true, + animated: animated, + completion: completion + ) + } + + func popToRootViewController(animated: Bool, completion: CompletionHandler? = nil) { + if let rootController = viewControllers.first, viewControllers.count > 1 { + setViewControllersInternal( + [rootController], + isUnwinding: true, + animated: animated, + completion: completion + ) + } + } + + /// Request the root container to query the top controller for the new header bar style + func updateHeaderBarAppearance() { + updateHeaderBarStyleFromChildPreferences(animated: UIView.areAnimationsEnabled) + } + + func updateHeaderBarHiddenAppearance() { + updateHeaderBarHiddenFromChildPreferences(animated: UIView.areAnimationsEnabled) + } + + /// Request to display settings controller + func showSettings(navigateTo route: SettingsNavigationRoute? = nil, animated: Bool) { + delegate?.rootContainerViewControllerShouldShowSettings( + self, + navigateTo: route, + animated: animated + ) + } + + /// Enable or disable the settings bar button displayed in the header bar + func setEnableSettingsButton(_ isEnabled: Bool) { + headerBarView.settingsButton.isEnabled = isEnabled + presentationContainerSettingsButton?.isEnabled = isEnabled + } + + /// Add settings bar button into the presentation container to make settings accessible even + /// when the root container is covered with modal. + func addSettingsButtonToPresentationContainer(_ presentationContainer: UIView) { + let settingsButton: UIButton + + if let transitionViewSettingsButton = presentationContainerSettingsButton { + transitionViewSettingsButton.removeFromSuperview() + settingsButton = transitionViewSettingsButton + } else { + settingsButton = HeaderBarView.makeSettingsButton() + settingsButton.isEnabled = headerBarView.settingsButton.isEnabled + settingsButton.addTarget( + self, + action: #selector(handleSettingsButtonTap), + for: .touchUpInside + ) + + presentationContainerSettingsButton = settingsButton + } + + // Hide the settings button inside the header bar to avoid color blending issues + headerBarView.settingsButton.alpha = 0 + + presentationContainer.addSubview(settingsButton) + + NSLayoutConstraint.activate([ + settingsButton.centerXAnchor + .constraint(equalTo: headerBarView.settingsButton.centerXAnchor), + settingsButton.centerYAnchor + .constraint(equalTo: headerBarView.settingsButton.centerYAnchor), + ]) + } + + func removeSettingsButtonFromPresentationContainer() { + presentationContainerSettingsButton?.removeFromSuperview() + headerBarView.settingsButton.alpha = 1 + } + + func setOverrideHeaderBarHidden(_ isHidden: Bool?, animated: Bool) { + overrideHeaderBarHidden = isHidden + + if let isHidden = isHidden { + setHeaderBarHidden(isHidden, animated: animated) + } else { + updateHeaderBarHiddenFromChildPreferences(animated: animated) + } + } + + // MARK: - Accessibility + + override func accessibilityPerformMagicTap() -> Bool { + return delegate?.rootContainerViewAccessibilityPerformMagicTap(self) ?? super + .accessibilityPerformMagicTap() + } + + // MARK: - Private + + private func addTransitionView() { + let constraints = [ + transitionContainer.topAnchor.constraint(equalTo: view.topAnchor), + transitionContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + transitionContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), + transitionContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + + transitionContainer.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(transitionContainer) + + NSLayoutConstraint.activate(constraints) + } + + private func addHeaderBarView() { + let constraints = [ + headerBarView.topAnchor.constraint(equalTo: view.topAnchor), + headerBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + headerBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ] + + headerBarView.translatesAutoresizingMaskIntoConstraints = false + + // Prevent automatic layout margins adjustment as we manually control them. + headerBarView.insetsLayoutMarginsFromSafeArea = false + + headerBarView.settingsButton.addTarget( + self, + action: #selector(handleSettingsButtonTap), + for: .touchUpInside + ) + + view.addSubview(headerBarView) + + NSLayoutConstraint.activate(constraints) + } + + @objc private func handleSettingsButtonTap() { + showSettings(animated: true) + } + + private func setViewControllersInternal( + _ newViewControllers: [UIViewController], + isUnwinding: Bool, + animated: Bool, + completion: CompletionHandler? = nil + ) { + assert( + Set(newViewControllers).count == newViewControllers.count, + "All view controllers in root container controller must be distinct" + ) + + guard viewControllers != newViewControllers else { + completion?() + return + } + + // Dot not handle appearance events when the container itself is not visible + let shouldHandleAppearanceEvents = view.window != nil + + // Animations won't run when the container is not visible, so prevent them + let shouldAnimate = animated && shouldHandleAppearanceEvents + + let sourceViewController = topViewController + let targetViewController = newViewControllers.last + + let viewControllersToAdd = newViewControllers.filter { !viewControllers.contains($0) } + let viewControllersToRemove = viewControllers.filter { !newViewControllers.contains($0) } + + let finishTransition = { + /* + Finish transition appearance. + Note this has to be done before the call to `didMove(to:)` or `removeFromParent()` + otherwise `endAppearanceTransition()` will fire `didMove(to:)` twice. + */ + if shouldHandleAppearanceEvents { + if let targetViewController = targetViewController, + sourceViewController != targetViewController + { + self.endChildControllerTransition(targetViewController) + } + + if let sourceViewController = sourceViewController, + sourceViewController != targetViewController + { + self.endChildControllerTransition(sourceViewController) + } + } + + // Notify the added controllers that they finished a transition into the container + for child in viewControllersToAdd { + child.didMove(toParent: self) + } + + // Remove the controllers that transitioned out of the container + // The call to removeFromParent() automatically calls child.didMove() + for child in viewControllersToRemove { + child.view.removeFromSuperview() + child.removeFromParent() + } + + // Remove the source controller from view hierarchy + if sourceViewController != targetViewController { + sourceViewController?.view.removeFromSuperview() + } + + self.updateInterfaceOrientation(attemptRotateToDeviceOrientation: true) + self.updateAccessibilityElementsAndNotifyScreenChange() + + completion?() + } + + let alongSideAnimations = { + self.updateHeaderBarStyleFromChildPreferences(animated: shouldAnimate) + self.updateHeaderBarHiddenFromChildPreferences(animated: shouldAnimate) + } + + // Add new child controllers. The call to addChild() automatically calls child.willMove() + // Children have to be registered in the container for Storyboard unwind segues to function + // properly, however the child controller views don't have to be added immediately, and + // appearance methods have to be handled manually. + for child in viewControllersToAdd { + addChild(child) + } + + // Make sure that all new view controllers have loaded their views + // This is important because the unwind segue calls the unwind action which may rely on + // IB outlets to be set at that time. + for newViewController in newViewControllers { + newViewController.loadViewIfNeeded() + } + + // Add the destination view into the view hierarchy + if let targetView = targetViewController?.view { + addChildView(targetView) + } + + // Notify the controllers that they will transition out of the container + for child in viewControllersToRemove { + child.willMove(toParent: nil) + } + + viewControllers = newViewControllers + + // Begin appearance transition + if shouldHandleAppearanceEvents { + if let sourceViewController = sourceViewController, + sourceViewController != targetViewController + { + beginChildControllerTransition( + sourceViewController, + isAppearing: false, + animated: shouldAnimate + ) + } + if let targetViewController = targetViewController, + sourceViewController != targetViewController + { + beginChildControllerTransition( + targetViewController, + isAppearing: true, + animated: shouldAnimate + ) + } + setNeedsStatusBarAppearanceUpdate() + } + + if shouldAnimate { + CATransaction.begin() + CATransaction.setCompletionBlock { + finishTransition() + } + + let transition = CATransition() + transition.duration = 0.35 + transition.type = .push + + // Pick the animation movement direction + let sourceIndex = sourceViewController.flatMap { newViewControllers.firstIndex(of: $0) } + let targetIndex = targetViewController.flatMap { newViewControllers.firstIndex(of: $0) } + + switch (sourceIndex, targetIndex) { + case let (.some(lhs), .some(rhs)): + transition.subtype = lhs > rhs ? .fromLeft : .fromRight + case (.none, .some): + transition.subtype = isUnwinding ? .fromLeft : .fromRight + default: + transition.subtype = .fromRight + } + + transitionContainer.layer.add(transition, forKey: "transition") + alongSideAnimations() + + CATransaction.commit() + } else { + alongSideAnimations() + finishTransition() + } + } + + private func addChildView(_ childView: UIView) { + childView.translatesAutoresizingMaskIntoConstraints = true + childView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + childView.frame = transitionContainer.bounds + + transitionContainer.addSubview(childView) + } + + /// Updates the header bar's layout margins to make sure it doesn't go below the system status + /// bar. + private func updateHeaderBarLayoutMarginsIfNeeded() { + let offsetTop = view.safeAreaInsets.top - additionalSafeAreaInsets.top + + if headerBarView.layoutMargins.top != offsetTop { + headerBarView.layoutMargins.top = offsetTop + } + } + + /// Updates additional safe area insets to push the child views below the header bar + private func updateAdditionalSafeAreaInsetsIfNeeded() { + let offsetTop = view.safeAreaInsets.top - additionalSafeAreaInsets.top + let insetTop = headerBarHidden ? 0 : headerBarView.frame.height - offsetTop + + if additionalSafeAreaInsets.top != insetTop { + additionalSafeAreaInsets.top = insetTop + } + } + + private func setHeaderBarPresentation(_ presentation: HeaderBarPresentation, animated: Bool) { + headerBarPresentation = presentation + + let action = { + self.updateHeaderBarBackground() + } + + if animated { + UIView.animate(withDuration: 0.25, animations: action) + } else { + action() + } + } + + private func setHeaderBarHidden(_ hidden: Bool, animated: Bool) { + headerBarHidden = hidden + + let action = { + self.headerBarView.alpha = hidden ? 0 : 1 + } + + if animated { + UIView.animate(withDuration: 0.25, animations: action) + } else { + action() + } + } + + private func updateHeaderBarBackground() { + headerBarView.backgroundColor = headerBarPresentation.style.backgroundColor() + headerBarView.showsDivider = headerBarPresentation.showsDivider + } + + private func updateHeaderBarStyleFromChildPreferences(animated: Bool) { + if let conforming = topViewController as? RootContainment { + setHeaderBarPresentation(conforming.preferredHeaderBarPresentation, animated: animated) + } + } + + private func updateHeaderBarHiddenFromChildPreferences(animated: Bool) { + guard overrideHeaderBarHidden == nil else { return } + + if let conforming = topViewController as? RootContainment { + setHeaderBarHidden(conforming.prefersHeaderBarHidden, animated: animated) + } + } + + private func updateInterfaceOrientation(attemptRotateToDeviceOrientation: Bool) { + let newSupportedOrientations = delegate? + .rootContainerViewSupportedInterfaceOrientations(self) + + if interfaceOrientationMask != newSupportedOrientations { + interfaceOrientationMask = newSupportedOrientations + + // Tell UIKit to update the interface orientation + if attemptRotateToDeviceOrientation { + Self.attemptRotationToDeviceOrientation() + } + } + } + + private func beginChildControllerTransition( + _ controller: UIViewController, + isAppearing: Bool, + animated: Bool + ) { + if appearingController != controller, isAppearing { + appearingController = controller + controller.beginAppearanceTransition(isAppearing, animated: animated) + } + + if disappearingController != controller, !isAppearing { + disappearingController = controller + controller.beginAppearanceTransition(isAppearing, animated: animated) + } + } + + private func endChildControllerTransition(_ controller: UIViewController) { + if controller == appearingController { + appearingController = nil + controller.endAppearanceTransition() + } + + if controller == disappearingController { + disappearingController = nil + controller.endAppearanceTransition() + } + } + + private func updateAccessibilityElementsAndNotifyScreenChange() { + // Update accessibility elements to define the correct navigation order: header bar, content + // view. + view.accessibilityElements = [headerBarView, topViewController?.view].compactMap { $0 } + + // Tell accessibility that the significant part of screen was changed. + UIAccessibility.post(notification: .screenChanged, argument: nil) + } +} + +/// A UIViewController extension that gives view controllers an access to root container +extension UIViewController { + var rootContainerController: RootContainerViewController? { + var current: UIViewController? = self + let iterator = AnyIterator { () -> UIViewController? in + current = current?.parent + return current + } + + return iterator.first { $0 is RootContainerViewController } as? RootContainerViewController + } + + func setNeedsHeaderBarStyleAppearanceUpdate() { + rootContainerController?.updateHeaderBarAppearance() + } + + func setNeedsHeaderBarHiddenAppearanceUpdate() { + rootContainerController?.updateHeaderBarHiddenAppearance() + } +} |
