diff options
| -rw-r--r-- | ios/MullvadVPN/AppDelegate.swift | 219 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIMetrics.swift | 6 |
2 files changed, 210 insertions, 15 deletions
diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 3276d5c433..1bbcd2ca36 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -26,6 +26,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #endif private var rootContainer: RootContainerViewController? + private var splitViewController: CustomSplitViewController? private var selectLocationViewController: SelectLocationViewController? private var connectController: ConnectViewController? @@ -105,7 +106,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { self.rootContainer?.delegate = self self.window?.rootViewController = self.rootContainer - self.setupPhoneUI() + switch UIDevice.current.userInterfaceIdiom { + case .pad: + self.setupPadUI() + + case .phone: + self.setupPhoneUI() + + default: + fatalError() + } } } } @@ -125,6 +135,49 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Private + private func setupPadUI() { + let selectLocationController = makeSelectLocationController() + let connectController = makeConnectViewController() + + let splitViewController = CustomSplitViewController() + splitViewController.delegate = self + splitViewController.minimumPrimaryColumnWidth = UIMetrics.minimumSplitViewSidebarWidth + splitViewController.preferredPrimaryColumnWidthFraction = UIMetrics.maximumSplitViewSidebarWidthFraction + splitViewController.primaryEdge = .trailing + splitViewController.dividerColor = UIColor.MainSplitView.dividerColor + splitViewController.viewControllers = [selectLocationController, connectController] + + self.selectLocationViewController = selectLocationController + self.splitViewController = splitViewController + self.connectController = connectController + + self.rootContainer?.setViewControllers([splitViewController], animated: false) + showSplitViewMaster(Account.shared.isLoggedIn, animated: false) + + let rootContainerWrapper = makeLoginContainerController() + + if !Account.shared.isAgreedToTermsOfService { + let consentViewController = self.makeConsentController { [weak self] (viewController) in + guard let self = self else { return } + + if Account.shared.isLoggedIn { + rootContainerWrapper.dismiss(animated: true) { + self.showAccountSettingsControllerIfAccountExpired() + } + } else { + rootContainerWrapper.pushViewController(self.makeLoginController(), animated: true) + } + } + rootContainerWrapper.setViewControllers([consentViewController], animated: false) + self.rootContainer?.present(rootContainerWrapper, animated: false) + } else if !Account.shared.isLoggedIn { + rootContainerWrapper.setViewControllers([makeLoginController()], animated: false) + self.rootContainer?.present(rootContainerWrapper, animated: false) + } else { + self.showAccountSettingsControllerIfAccountExpired() + } + } + private func setupPhoneUI() { let showNextController = { [weak self] (_ animated: Bool) in guard let self = self else { return } @@ -179,6 +232,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func makeConsentController(completion: @escaping (UIViewController) -> Void) -> ConsentViewController { let consentViewController = ConsentViewController() + if UIDevice.current.userInterfaceIdiom == .pad { + consentViewController.modalPresentationStyle = .formSheet + if #available(iOS 13.0, *) { + consentViewController.isModalInPresentation = true + } + } + consentViewController.completionHandler = { (consentViewController) in Account.shared.agreeToTermsOfService() completion(consentViewController) @@ -187,16 +247,43 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return consentViewController } + private func makeLoginContainerController() -> RootContainerViewController { + let rootContainerWrapper = RootContainerViewController() + rootContainerWrapper.delegate = self + rootContainerWrapper.presentationController?.delegate = self + rootContainerWrapper.preferredContentSize = CGSize(width: 480, height: 600) + + if #available(iOS 13.0, *) { + // Prevent swiping off the login or consent controllers + rootContainerWrapper.isModalInPresentation = true + } + + return rootContainerWrapper + } + private func makeLoginController() -> LoginViewController { let controller = LoginViewController() controller.delegate = self + if UIDevice.current.userInterfaceIdiom == .pad { + controller.modalPresentationStyle = .formSheet + if #available(iOS 13.0, *) { + controller.isModalInPresentation = true + } + } + return controller } private func makeSettingsNavigationController(route: SettingsNavigationRoute?) -> SettingsNavigationController { let navController = SettingsNavigationController() navController.settingsDelegate = self + + if UIDevice.current.userInterfaceIdiom == .pad { + navController.preferredContentSize = CGSize(width: 480, height: 568) + navController.modalPresentationStyle = .formSheet + } + navController.presentationController?.delegate = navController if let route = route { @@ -220,11 +307,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Account.shared.startPaymentMonitoring(with: paymentManager) } + private func showSplitViewMaster(_ show: Bool, animated: Bool) { + if show { + splitViewController?.preferredDisplayMode = .allVisible + connectController?.setMainContentHidden(false, animated: animated) + } else { + splitViewController?.preferredDisplayMode = .primaryHidden + connectController?.setMainContentHidden(true, animated: animated) + } + } + } // MARK: - RootContainerViewControllerDelegate extension AppDelegate: RootContainerViewControllerDelegate { + func rootContainerViewControllerShouldShowSettings(_ controller: RootContainerViewController, navigateTo route: SettingsNavigationRoute?, animated: Bool) { let navController = makeSettingsNavigationController(route: route) @@ -305,11 +403,22 @@ extension AppDelegate: LoginViewControllerDelegate { self.logger?.error(chainedError: error, message: "Failed to load relay constraints after log in") } - let connectController = self.makeConnectViewController() - self.rootContainer?.pushViewController(connectController, animated: true) { - self.showAccountSettingsControllerIfAccountExpired() + switch UIDevice.current.userInterfaceIdiom { + case .phone: + let connectController = self.makeConnectViewController() + self.rootContainer?.pushViewController(connectController, animated: true) { + self.showAccountSettingsControllerIfAccountExpired() + } + self.connectController = connectController + case .pad: + self.showSplitViewMaster(true, animated: true) + + controller.dismiss(animated: true) { + self.showAccountSettingsControllerIfAccountExpired() + } + default: + fatalError() } - self.connectController = connectController self.window?.isUserInteractionEnabled = true self.rootContainer?.setEnableSettingsButton(true) @@ -324,14 +433,34 @@ extension AppDelegate: LoginViewControllerDelegate { extension AppDelegate: SettingsNavigationControllerDelegate { func settingsNavigationController(_ controller: SettingsNavigationController, didFinishWithReason reason: SettingsDismissReason) { - if case .userLoggedOut = reason { - rootContainer?.popToRootViewController(animated: false) + switch UIDevice.current.userInterfaceIdiom { + case .phone: + if case .userLoggedOut = reason { + rootContainer?.popToRootViewController(animated: false) - let loginController = rootContainer?.topViewController as? LoginViewController + let loginController = rootContainer?.topViewController as? LoginViewController - loginController?.reset() + loginController?.reset() + } + controller.dismiss(animated: true) + + case .pad: + if case .userLoggedOut = reason { + self.showSplitViewMaster(false, animated: true) + } + + controller.dismiss(animated: true) { + if case .userLoggedOut = reason { + let rootContainerWrapper = self.makeLoginContainerController() + rootContainerWrapper.setViewControllers([self.makeLoginController()], animated: false) + self.rootContainer?.present(rootContainerWrapper, animated: true) + } + } + + default: + fatalError() } - controller.dismiss(animated: true) + } } @@ -415,12 +544,17 @@ extension AppDelegate: ConnectViewControllerDelegate { extension AppDelegate: SelectLocationViewControllerDelegate { func selectLocationViewController(_ controller: SelectLocationViewController, didSelectRelayLocation relayLocation: RelayLocation) { - self.window?.isUserInteractionEnabled = false - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { - self.window?.isUserInteractionEnabled = true - controller.dismiss(animated: true) { - self.selectLocationControllerDidSelectRelayLocation(relayLocation) + // Dismiss view controller in modal presentation + if controller.presentingViewController != nil { + self.window?.isUserInteractionEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + self.window?.isUserInteractionEnabled = true + controller.dismiss(animated: true) { + self.selectLocationControllerDidSelectRelayLocation(relayLocation) + } } + } else { + selectLocationControllerDidSelectRelayLocation(relayLocation) } } @@ -446,6 +580,36 @@ extension AppDelegate: SelectLocationViewControllerDelegate { } } +// MARK: - UIAdaptivePresentationControllerDelegate + +extension AppDelegate: UIAdaptivePresentationControllerDelegate { + + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + if controller.presentedViewController is RootContainerViewController { + // Use .formSheet presentation in regular horizontal environment and .fullScreen + // in compact environment. + if traitCollection.horizontalSizeClass == .regular { + return .formSheet + } else { + return .fullScreen + } + } else { + return .none + } + } + + func presentationController(_ controller: UIPresentationController, viewControllerForAdaptivePresentationStyle style: UIModalPresentationStyle) -> UIViewController? { + return nil + } + + func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) { + // Force hide header bar in .formSheet presentation and show it in .fullScreen presentation + if let wrapper = presentationController.presentedViewController as? RootContainerViewController { + wrapper.setOverrideHeaderBarHidden(style == .formSheet, animated: false) + } + } +} + // MARK: - RelayCacheObserver extension AppDelegate: RelayCacheObserver { @@ -471,3 +635,28 @@ extension AppDelegate: AppStorePaymentManagerDelegate { } } + + +// MARK: - UISplitViewControllerDelegate + +extension AppDelegate: UISplitViewControllerDelegate { + + func primaryViewController(forExpanding splitViewController: UISplitViewController) -> UIViewController? { + // Restore the select location controller as primary when expanding the split view + return selectLocationViewController + } + + func primaryViewController(forCollapsing splitViewController: UISplitViewController) -> UIViewController? { + // Set the connect controller as primary when collapsing the split view + return connectController + } + + func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { + // Dismiss the select location controller when expanding the split view + if self.selectLocationViewController?.presentingViewController != nil { + self.selectLocationViewController?.dismiss(animated: false) + } + return nil + } + +} diff --git a/ios/MullvadVPN/UIMetrics.swift b/ios/MullvadVPN/UIMetrics.swift index 12520b0982..016495ad27 100644 --- a/ios/MullvadVPN/UIMetrics.swift +++ b/ios/MullvadVPN/UIMetrics.swift @@ -18,4 +18,10 @@ extension UIMetrics { /// Maximum width of the split view content container on iPad static var maximumSplitViewContentContainerWidth: CGFloat = 810 * 0.7 + /// Minimum sidebar width in points + static var minimumSplitViewSidebarWidth: CGFloat = 300 + + /// Maximum sidebar width in percentage points (0...1) + static var maximumSplitViewSidebarWidthFraction: CGFloat = 0.3 + } |
