diff options
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/ModalRootAdaptivePresentationDelegate.swift | 127 | ||||
| -rw-r--r-- | ios/MullvadVPN/SceneDelegate.swift | 113 |
3 files changed, 163 insertions, 81 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 1994428b55..9c6b216d72 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ 58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A8EE592976BFBB009C0F8D /* SKError+Localized.swift */; }; 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A8EE5D2976DB00009C0F8D /* StorePaymentManagerError+Display.swift */; }; 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A99ED2240014A0006599E9 /* TermsOfServiceViewController.swift */; }; + 58ACA9ED2979569500B5825C /* ModalRootAdaptivePresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ACA9EC2979569500B5825C /* ModalRootAdaptivePresentationDelegate.swift */; }; 58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */; }; 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */; }; 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ACF64C26567A4F00ACE4B7 /* CustomSwitch.swift */; }; @@ -803,6 +804,7 @@ 58A8EE5D2976DB00009C0F8D /* StorePaymentManagerError+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorePaymentManagerError+Display.swift"; sourceTree = "<group>"; }; 58A94AE326CFD945001CB97C /* TunnelStatusNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusNotificationProvider.swift; sourceTree = "<group>"; }; 58A99ED2240014A0006599E9 /* TermsOfServiceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServiceViewController.swift; sourceTree = "<group>"; }; + 58ACA9EC2979569500B5825C /* ModalRootAdaptivePresentationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalRootAdaptivePresentationDelegate.swift; sourceTree = "<group>"; }; 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewController.swift; sourceTree = "<group>"; }; 58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSwitchCell.swift; sourceTree = "<group>"; }; 58ACF64C26567A4F00ACE4B7 /* CustomSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSwitch.swift; sourceTree = "<group>"; }; @@ -1490,6 +1492,7 @@ 58F7CA872692E34000FC59FD /* WireguardKeysContentView.swift */, 58E11187292FA11F009FCA84 /* SettingsMigrationUIHandler.swift */, 58A8EE592976BFBB009C0F8D /* SKError+Localized.swift */, + 58ACA9EC2979569500B5825C /* ModalRootAdaptivePresentationDelegate.swift */, ); path = MullvadVPN; sourceTree = "<group>"; @@ -2413,6 +2416,7 @@ 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */, 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, 58421034282E4B1500F24E46 /* TunnelSettingsV2+REST.swift in Sources */, + 58ACA9ED2979569500B5825C /* ModalRootAdaptivePresentationDelegate.swift in Sources */, 58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */, 5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, diff --git a/ios/MullvadVPN/ModalRootAdaptivePresentationDelegate.swift b/ios/MullvadVPN/ModalRootAdaptivePresentationDelegate.swift new file mode 100644 index 0000000000..2a6d250196 --- /dev/null +++ b/ios/MullvadVPN/ModalRootAdaptivePresentationDelegate.swift @@ -0,0 +1,127 @@ +// +// ModalRootAdaptivePresentationDelegate.swift +// MullvadVPN +// +// Created by pronebird on 19/01/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/** + + Adaptive presentation delegate for `SceneDelegate.modalRootContainer` used for presenting + the login flow on iPad. + + The primary purpose of this class is to swap between fullscreen and formsheet presentation based + on horizontal size class and make settings (cog) accessible even when parent root is overlayed with + modal root. + + Unlike iPhone where only one `RootContainerViewController` is used and behaves very much like + navigation controller, iPad uses two of such controllers defined as parent and (think child) modal + within this class. + + ## iPhone view controller hierarchy + + - UIWindow + - RootContainerViewController + - LoginViewController + - etc. + + ## iPad view controller hierarchy + + - UIWindow + - RootContainerViewController (parent) + - UISplitViewController + - TunnelViewController + - SelectLocationViewController + - RootContainerViewController (child [modal]) + - LoginViewController + - etc. + + */ +final class ModalRootAdaptivePresentationDelegate: NSObject, + UIAdaptivePresentationControllerDelegate +{ + let parentRootContainer: RootContainerViewController + let modalRootContainer: RootContainerViewController + + init( + parentRootContainer: RootContainerViewController, + modalRootContainer: RootContainerViewController + ) { + self.parentRootContainer = parentRootContainer + self.modalRootContainer = modalRootContainer + + super.init() + } + + func finishPresentation() { + parentRootContainer.removeSettingsButtonFromPresentationContainer() + } + + func adaptivePresentationStyle( + for controller: UIPresentationController, + traitCollection: UITraitCollection + ) -> UIModalPresentationStyle { + if controller.presentedViewController is RootContainerViewController { + return traitCollection.horizontalSizeClass == .regular ? .formSheet : .fullScreen + } else { + return .none + } + } + + func presentationController( + _ presentationController: UIPresentationController, + willPresentWithAdaptiveStyle style: UIModalPresentationStyle, + transitionCoordinator: UIViewControllerTransitionCoordinator? + ) { + // The style is set to none when adaptive presentation is not changing. + let actualStyle: UIModalPresentationStyle = style == .none + ? presentationController.presentedViewController.modalPresentationStyle + : style + + // Force hide header bar in .formSheet presentation and show it in .fullScreen presentation + modalRootContainer.setOverrideHeaderBarHidden(actualStyle == .formSheet, animated: false) + + let transitionActions = { + if let containerView = self.modalRootContainer.modalPresentationContainerView { + self.parentRootContainer.addSettingsButtonToPresentationContainer(containerView) + } + } + + if actualStyle == .formSheet { + // Add settings button into the modal container to make it accessible by users + if let transitionCoordinator = transitionCoordinator { + transitionCoordinator.animate { _ in + transitionActions() + } + } else { + transitionActions() + } + } else { + // Move settings button back into header bar + finishPresentation() + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finishPresentation() + } +} + +private extension UIViewController { + /// Returns private `UITransitionView` used by UIKit that acts as a container view for modally + /// presented controllers. When implementing a presentation controller subclass, this view + /// is the one that is used to add additional decorations. + var modalPresentationContainerView: UIView? { + var currentView = view + let iterator = AnyIterator { () -> UIView? in + currentView = currentView?.superview + return currentView + } + return iterator.first { view -> Bool in + return view.description.starts(with: "<UITransitionView") + } + } +} diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index a08ebcca75..549ec3c28e 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -14,12 +14,11 @@ import RelayCache import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate, - UIAdaptivePresentationControllerDelegate, RootContainerViewControllerDelegate, - LoginViewControllerDelegate, DeviceManagementViewControllerDelegate, - SettingsNavigationControllerDelegate, OutOfTimeViewControllerDelegate, - SelectLocationViewControllerDelegate, RevokedDeviceViewControllerDelegate, - NotificationManagerDelegate, TunnelObserver, RelayCacheTrackerObserver, - SettingsMigrationUIHandler + RootContainerViewControllerDelegate, LoginViewControllerDelegate, + DeviceManagementViewControllerDelegate, SettingsNavigationControllerDelegate, + OutOfTimeViewControllerDelegate, SelectLocationViewControllerDelegate, + RevokedDeviceViewControllerDelegate, NotificationManagerDelegate, TunnelObserver, + RelayCacheTrackerObserver, SettingsMigrationUIHandler { private let logger = Logger(label: "SceneDelegate") @@ -32,6 +31,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe // Modal root container is used on iPad to present login, TOS, revoked device, device management // view controllers above `rootContainer` which only contains split controller. private lazy var modalRootContainer = RootContainerViewController() + private lazy var modalRootAdaptivePresentationDelegate = ModalRootAdaptivePresentationDelegate( + parentRootContainer: rootContainer, + modalRootContainer: modalRootContainer + ) private var splitViewController: CustomSplitViewController? private var selectLocationViewController: SelectLocationViewController? @@ -300,23 +303,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe switch self.tunnelManager.deviceState { case .loggedIn: - let didDismissModalRoot = { - self.handleExpiredAccount() - } - self.modalRootContainer.setViewControllers( viewControllers, animated: self.isModalRootPresented && animated ) // Dismiss modal root container if needed before proceeding. - if self.isModalRootPresented { - self.modalRootContainer.dismiss( - animated: animated, - completion: didDismissModalRoot - ) - } else { - didDismissModalRoot() + self.dismissModalRootContainerIfNeeded(animated: animated) { + self.handleExpiredAccount() } return @@ -350,13 +344,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe } private func presentModalRootContainerIfNeeded(animated: Bool) { + guard !isModalRootPresented else { return } + modalRootContainer.preferredContentSize = CGSize(width: 480, height: 600) - modalRootContainer.modalPresentationStyle = .formSheet - modalRootContainer.presentationController?.delegate = self + modalRootContainer.presentationController?.delegate = modalRootAdaptivePresentationDelegate modalRootContainer.isModalInPresentation = true - if modalRootContainer.presentingViewController == nil { - rootContainer.present(modalRootContainer, animated: animated) + rootContainer.present(modalRootContainer, animated: animated) + } + + private func dismissModalRootContainerIfNeeded( + animated: Bool, + completion: @escaping () -> Void + ) { + guard isModalRootPresented else { + completion() + return + } + + modalRootContainer.dismiss(animated: animated) { + self.modalRootAdaptivePresentationDelegate.finishPresentation() + completion() } } @@ -678,7 +686,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe lastLoginAction = nil // Move the settings button back into header bar - rootContainer.removeSettingsButtonFromPresentationContainer() setEnableSettingsButton(isEnabled: true, from: controller) let relayConstraints = tunnelManager.settings.relayConstraints @@ -696,12 +703,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe viewControllers.append(tunnelViewController) rootContainer.setViewControllers(viewControllers, animated: true) handleExpiredAccount() + case .pad: showSplitViewMaster(true, animated: true) - controller.dismiss(animated: true) { + dismissModalRootContainerIfNeeded(animated: true) { self.handleExpiredAccount() } + default: fatalError() } @@ -831,64 +840,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe } } - // MARK: - UIAdaptivePresentationControllerDelegate - - func adaptivePresentationStyle( - for controller: UIPresentationController, - traitCollection: UITraitCollection - ) -> UIModalPresentationStyle { - if controller.presentedViewController is RootContainerViewController { - return traitCollection.horizontalSizeClass == .regular ? .formSheet : .fullScreen - } else { - return .none - } - } - - func presentationController( - _ presentationController: UIPresentationController, - willPresentWithAdaptiveStyle style: UIModalPresentationStyle, - transitionCoordinator: UIViewControllerTransitionCoordinator? - ) { - let actualStyle: UIModalPresentationStyle - - // When adaptive presentation is not changing, the `style` is set to `.none` - if case .none = style { - actualStyle = presentationController.presentedViewController.modalPresentationStyle - } else { - actualStyle = style - } - - // Force hide header bar in .formSheet presentation and show it in .fullScreen presentation - if let wrapper = presentationController - .presentedViewController as? RootContainerViewController - { - wrapper.setOverrideHeaderBarHidden(actualStyle == .formSheet, animated: false) - } - - guard actualStyle == .formSheet else { - // Move the settings button back into header bar - rootContainer.removeSettingsButtonFromPresentationContainer() - - return - } - - // Add settings button into the modal container to make it accessible by user - if let transitionCoordinator = transitionCoordinator { - transitionCoordinator.animate { context in - self.rootContainer.addSettingsButtonToPresentationContainer(context.containerView) - } - } else if let containerView = presentationController.containerView { - rootContainer.addSettingsButtonToPresentationContainer(containerView) - } else { - logger.warning( - """ - Cannot obtain the containerView for presentation controller when presenting with \ - adaptive style \(actualStyle.rawValue) and missing transition coordinator. - """ - ) - } - } - // MARK: - TunnelObserver func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { |
