summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj4
-rw-r--r--ios/MullvadVPN/ModalRootAdaptivePresentationDelegate.swift127
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift113
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) {