diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2021-05-11 16:11:05 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-05-11 16:11:05 +0200 |
| commit | 7c66dc69f3d5ca8246d00555a45e8132f0a8a202 (patch) | |
| tree | d734e7d7b344778878c0b4b6f7a146ccb9083e55 | |
| parent | caade65a38763beec6e158df52f95eef3e76555d (diff) | |
| parent | 3c286cc8b066a42dcf829f21574dd36e23c3ccd6 (diff) | |
| download | mullvadvpn-7c66dc69f3d5ca8246d00555a45e8132f0a8a202.tar.xz mullvadvpn-7c66dc69f3d5ca8246d00555a45e8132f0a8a202.zip | |
Merge branch 'ipad-adaptive-ui'
| -rw-r--r-- | ios/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppDelegate.swift | 219 | ||||
| -rw-r--r-- | ios/MullvadVPN/ConnectViewController.swift | 59 | ||||
| -rw-r--r-- | ios/MullvadVPN/CustomSplitViewController.swift | 14 | ||||
| -rw-r--r-- | ios/MullvadVPN/RootContainerViewController.swift | 21 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIMetrics.swift | 6 |
6 files changed, 298 insertions, 22 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 36ece08e5a..21502c7e81 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Added - Enable option to "Select all" when viewing app logs. +- Split view interface for iPad. ### Fixed - Fix bug which caused the tunnel manager to become unresponsive in the rare event of failure to 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/ConnectViewController.swift b/ios/MullvadVPN/ConnectViewController.swift index 8dd31f3346..e79e9ccb71 100644 --- a/ios/MullvadVPN/ConnectViewController.swift +++ b/ios/MullvadVPN/ConnectViewController.swift @@ -16,7 +16,7 @@ protocol ConnectViewControllerDelegate: class { func connectViewControllerShouldReconnectTunnel(_ controller: ConnectViewController) } -class ConnectViewController: UIViewController, RootContainment, TunnelObserver +class ConnectViewController: UIViewController, RootContainment, TunnelObserver, AccountObserver { weak var delegate: ConnectViewControllerDelegate? @@ -34,6 +34,9 @@ class ConnectViewController: UIViewController, RootContainment, TunnelObserver } var preferredHeaderBarStyle: HeaderBarStyle { + if !Account.shared.isLoggedIn { + return .default + } switch tunnelState { case .connecting, .reconnecting, .connected: return .secured @@ -69,6 +72,29 @@ class ConnectViewController: UIViewController, RootContainment, TunnelObserver self.tunnelState = TunnelManager.shared.tunnelState addSubviews() + + Account.shared.addObserver(self) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if previousTraitCollection?.userInterfaceIdiom != traitCollection.userInterfaceIdiom || + previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass { + updateTraitDependentViews() + } + } + + func setMainContentHidden(_ isHidden: Bool, animated: Bool) { + let actions = { + self.mainContentView.containerView.alpha = isHidden ? 0 : 1 + } + + if animated { + UIView.animate(withDuration: 0.25, animations: actions) + } else { + actions() + } } private func addSubviews() { @@ -81,6 +107,20 @@ class ConnectViewController: UIViewController, RootContainment, TunnelObserver ]) } + // MARK: - AccountObserver + + func account(_ account: Account, didLoginWithToken token: String, expiry: Date) { + setNeedsHeaderBarStyleAppearanceUpdate() + } + + func account(_ account: Account, didUpdateExpiry expiry: Date) { + // no-op + } + + func accountDidLogout(_ account: Account) { + setNeedsHeaderBarStyleAppearanceUpdate() + } + // MARK: - TunnelObserver func tunnelStateDidChange(tunnelState: TunnelState) { @@ -102,7 +142,12 @@ class ConnectViewController: UIViewController, RootContainment, TunnelObserver mainContentView.connectButton.setTitle(tunnelState.localizedTitleForConnectButton, for: .normal) mainContentView.selectLocationButton.setTitle(tunnelState.localizedTitleForSelectLocationButton, for: .normal) mainContentView.splitDisconnectButton.primaryButton.setTitle(tunnelState.localizedTitleForDisconnectButton, for: .normal) - mainContentView.setActionButtons(tunnelState.actionButtons) + + updateTraitDependentViews() + } + + private func updateTraitDependentViews() { + mainContentView.setActionButtons(tunnelState.actionButtons(traitCollection: self.traitCollection)) } private func attributedStringForLocation(string: String) -> NSAttributedString { @@ -210,9 +255,9 @@ private extension TunnelState { } } - var actionButtons: [ConnectMainContentView.ActionButton] { - switch UIDevice.current.userInterfaceIdiom { - case .phone: + func actionButtons(traitCollection: UITraitCollection) -> [ConnectMainContentView.ActionButton] { + switch (traitCollection.userInterfaceIdiom, traitCollection.horizontalSizeClass) { + case (.phone, _), (.pad, .compact): switch self { case .disconnected, .disconnecting: return [.selectLocation, .connect] @@ -221,7 +266,7 @@ private extension TunnelState { return [.selectLocation, .disconnect] } - case .pad: + case (.pad, .regular): switch self { case .disconnected, .disconnecting: return [.connect] @@ -231,7 +276,7 @@ private extension TunnelState { } default: - fatalError("Not supported") + return [] } } diff --git a/ios/MullvadVPN/CustomSplitViewController.swift b/ios/MullvadVPN/CustomSplitViewController.swift index 557c2f23d7..ecd0d30dff 100644 --- a/ios/MullvadVPN/CustomSplitViewController.swift +++ b/ios/MullvadVPN/CustomSplitViewController.swift @@ -54,4 +54,18 @@ class CustomSplitViewController: UISplitViewController, RootContainment { dividerView?.backgroundColor = dividerColor } + override func overrideTraitCollection(forChild childViewController: UIViewController) -> UITraitCollection? { + guard let traitCollection = super.overrideTraitCollection(forChild: childViewController) else { return nil } + + // Pass the split controller's horizontal size class to the primary controller when split + // view is expanded. + if !self.isCollapsed, childViewController == self.viewControllers.last { + let sizeOverrideTraitCollection = UITraitCollection(horizontalSizeClass: self.traitCollection.horizontalSizeClass) + + return UITraitCollection(traitsFrom: [traitCollection, sizeOverrideTraitCollection]) + } else { + return traitCollection + } + } + } diff --git a/ios/MullvadVPN/RootContainerViewController.swift b/ios/MullvadVPN/RootContainerViewController.swift index 3057266315..dfa51344b7 100644 --- a/ios/MullvadVPN/RootContainerViewController.swift +++ b/ios/MullvadVPN/RootContainerViewController.swift @@ -52,6 +52,7 @@ class RootContainerViewController: UIViewController { private(set) var headerBarStyle = HeaderBarStyle.default private(set) var headerBarHidden = false + private(set) var overrideHeaderBarHidden: Bool? private(set) var viewControllers = [UIViewController]() @@ -187,6 +188,10 @@ class RootContainerViewController: UIViewController { 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) @@ -197,6 +202,16 @@ class RootContainerViewController: UIViewController { headerBarView.settingsButton.isEnabled = isEnabled } + func setOverrideHeaderBarHidden(_ isHidden: Bool?, animated: Bool) { + overrideHeaderBarHidden = isHidden + + if let isHidden = isHidden { + setHeaderBarHidden(isHidden, animated: animated) + } else { + updateHeaderBarHiddenFromChildPreferences(animated: animated) + } + } + // MARK: - Private private func addTransitionView() { @@ -424,6 +439,8 @@ class RootContainerViewController: UIViewController { } private func updateHeaderBarHiddenFromChildPreferences(animated: Bool) { + guard overrideHeaderBarHidden == nil else { return } + if let conforming = topViewController as? RootContainment { setHeaderBarHidden(conforming.prefersHeaderBarHidden, animated: animated) } @@ -470,4 +487,8 @@ extension UIViewController { rootContainerController?.updateHeaderBarAppearance() } + func setNeedsHeaderBarHiddenAppearanceUpdate() { + rootContainerController?.updateHeaderBarHiddenAppearance() + } + } 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 + } |
