diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-01-20 08:57:49 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-01-20 17:12:26 +0100 |
| commit | 1556599c8f7ffc004b7871cbfa0d30c7d3ff668d (patch) | |
| tree | 8bf75b51ea9c0177a432f8f26a25bb16b5cbbdde /ios | |
| parent | 1cbf0c4a2b34b6150696edcd52b704686990bb28 (diff) | |
| download | mullvadvpn-1556599c8f7ffc004b7871cbfa0d30c7d3ff668d.tar.xz mullvadvpn-1556599c8f7ffc004b7871cbfa0d30c7d3ff668d.zip | |
Move adaptive presentation delegate from scene delegate into its own class.
Also fix issues in former implementation where transitionCoordinator.containerView
used to return UIWindow when changing to split screen.
New implementation makes a manual lookup of UITransitionView to ensure that
the settings cog is added into the right container view.
In the future this better be replaced with UISheetPresentationController subclass
but for now we'll keep adding the settings cog manually within the adaptivity
call.
Diffstat (limited to 'ios')
| -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) { |
