summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2021-05-11 16:11:05 +0200
committerAndrej Mihajlov <and@mullvad.net>2021-05-11 16:11:05 +0200
commit7c66dc69f3d5ca8246d00555a45e8132f0a8a202 (patch)
treed734e7d7b344778878c0b4b6f7a146ccb9083e55
parentcaade65a38763beec6e158df52f95eef3e76555d (diff)
parent3c286cc8b066a42dcf829f21574dd36e23c3ccd6 (diff)
downloadmullvadvpn-7c66dc69f3d5ca8246d00555a45e8132f0a8a202.tar.xz
mullvadvpn-7c66dc69f3d5ca8246d00555a45e8132f0a8a202.zip
Merge branch 'ipad-adaptive-ui'
-rw-r--r--ios/CHANGELOG.md1
-rw-r--r--ios/MullvadVPN/AppDelegate.swift219
-rw-r--r--ios/MullvadVPN/ConnectViewController.swift59
-rw-r--r--ios/MullvadVPN/CustomSplitViewController.swift14
-rw-r--r--ios/MullvadVPN/RootContainerViewController.swift21
-rw-r--r--ios/MullvadVPN/UIMetrics.swift6
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
+
}