diff options
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/AutomaticKeyboardResponder.swift | 128 | ||||
| -rw-r--r-- | ios/MullvadVPN/LoginContentView.swift | 57 | ||||
| -rw-r--r-- | ios/MullvadVPN/ProblemReportViewController.swift | 61 |
4 files changed, 150 insertions, 100 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 1928db029c..ebdd19d61b 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -225,6 +225,7 @@ 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */; }; 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */; }; + 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -417,6 +418,7 @@ 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Formatting.swift"; sourceTree = "<group>"; }; 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceInteractionRestriction.swift; sourceTree = "<group>"; }; 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseButton.swift; sourceTree = "<group>"; }; + 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyboardResponder.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -645,6 +647,7 @@ 585CA70E25F8C44600B47C62 /* UIMetrics.swift */, 583DA21325FA4B5C00318683 /* LocationDataSource.swift */, 58B993B02608A34500BA7811 /* LoginContentView.swift */, + 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */, ); path = MullvadVPN; sourceTree = "<group>"; @@ -970,6 +973,7 @@ 5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */, 5850367F25A481D800A43E93 /* IPAddressRange+Codable.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, + 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */, 58CB0EE024B86751001EF0D8 /* MullvadRest.swift in Sources */, 580EE20924B3224200F9D8A1 /* RetryOperation.swift in Sources */, diff --git a/ios/MullvadVPN/AutomaticKeyboardResponder.swift b/ios/MullvadVPN/AutomaticKeyboardResponder.swift new file mode 100644 index 0000000000..f427b71bd0 --- /dev/null +++ b/ios/MullvadVPN/AutomaticKeyboardResponder.swift @@ -0,0 +1,128 @@ +// +// AutomaticKeyboardResponder.swift +// MullvadVPN +// +// Created by pronebird on 24/03/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class AutomaticKeyboardResponder { + weak var targetView: UIView? + private let handler: (UIView, CGFloat) -> Void + + private var showsKeyboard = false + private var lastKeyboardRect: CGRect? + + private var presentationFrameObserver: NSKeyValueObservation? + + init<T: UIView>(targetView: T, handler: @escaping (T, CGFloat) -> Void) { + self.targetView = targetView + self.handler = { (view, adjustment) in + handler(view as! T, adjustment) + } + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIWindow.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIWindow.keyboardDidHideNotification, object: nil) + } + + func updateContentInsets() { + guard let keyboardRect = lastKeyboardRect else { return } + + adjustContentInsets(keyboardRect: keyboardRect) + } + + // MARK: - Keyboard notifications + + @objc private func keyboardWillShow(_ notification: Notification) { + showsKeyboard = true + + addPresentationControllerObserver() + handleKeyboardNotification(notification) + } + + @objc private func keyboardDidHide(_ notification: Notification) { + showsKeyboard = false + presentationFrameObserver = nil + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard showsKeyboard else { return } + + handleKeyboardNotification(notification) + } + + // MARK: - Private + + private func handleKeyboardNotification(_ notification: Notification) { + guard let keyboardFrameValue = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return } + + lastKeyboardRect = keyboardFrameValue.cgRectValue + + self.adjustContentInsets(keyboardRect: keyboardFrameValue.cgRectValue) + } + + private func addPresentationControllerObserver() { + // Presentation controller follows the keyboard on iPad. + // Install the observer to listen for the container view frame and adjust the target view + // accordingly. + guard let containerView = parentViewController?.presentationController?.containerView, isFormSheetPresentation else { return } + + let containingView = containerView.subviews.first { (subview) -> Bool in + return targetView?.isDescendant(of: subview) ?? false + } + + presentationFrameObserver = containingView?.observe(\.frame, options: [.new], changeHandler: { [weak self] (containingView, change) in + guard let self = self, let keyboardFrameValue = self.lastKeyboardRect else { return } + + self.adjustContentInsets(keyboardRect: keyboardFrameValue) + }) + } + + /// Returns the first parent controller in the responder chain + private var parentViewController: UIViewController? { + var responder: UIResponder? = targetView + let iterator = AnyIterator { () -> UIResponder? in + let next = responder?.next + responder = next + return next + } + + return iterator.first { $0 is UIViewController } as? UIViewController + } + + private var isFormSheetPresentation: Bool { + return UIDevice.current.userInterfaceIdiom == .pad && + parentViewController?.modalPresentationStyle == .formSheet + } + + private func adjustContentInsets(keyboardRect: CGRect) { + guard let targetView = targetView, let superview = targetView.superview else { return } + + // Compute the target view frame within screen coordinates + let screenRect = superview.convert(targetView.frame, to: nil) + + // Find the intersection between the keyboard and the view + let intersection = keyboardRect.intersection(screenRect) + + handler(targetView, intersection.height) + } +} + +extension AutomaticKeyboardResponder { + + /// A convenience initializer that automatically assigns the offset to the scroll view subclasses + convenience init<T: UIScrollView>(targetView: T) { + self.init(targetView: targetView) { (scrollView, offset) in + if scrollView.canBecomeFirstResponder { + scrollView.contentInset.bottom = targetView.isFirstResponder ? offset : 0 + scrollView.scrollIndicatorInsets.bottom = targetView.isFirstResponder ? offset : 0 + } else { + scrollView.contentInset.bottom = offset + scrollView.scrollIndicatorInsets.bottom = offset + } + } + } +} diff --git a/ios/MullvadVPN/LoginContentView.swift b/ios/MullvadVPN/LoginContentView.swift index ce2bb46a90..714beda334 100644 --- a/ios/MullvadVPN/LoginContentView.swift +++ b/ios/MullvadVPN/LoginContentView.swift @@ -10,6 +10,8 @@ import UIKit class LoginContentView: UIView { + private var keyboardResponder: AutomaticKeyboardResponder? + lazy var titleLabel: UILabel = { let textLabel = UILabel() textLabel.font = UIFont.systemFont(ofSize: 32) @@ -97,8 +99,14 @@ class LoginContentView: UIView { backgroundColor = .primaryColor layoutMargins = UIMetrics.contentLayoutMargins + keyboardResponder = AutomaticKeyboardResponder(targetView: self, handler: { [weak self] (view, adjustment) in + self?.contentContainerBottomConstraint?.constant = adjustment + + self?.layoutIfNeeded() + self?.updateStatusImageVisibility(animated: false) + }) + addSubviews() - addKeyboardHandlers() } required init?(coder: NSCoder) { @@ -197,51 +205,4 @@ class LoginContentView: UIView { ]) } - private func addKeyboardHandlers() { - let notificationCenter = NotificationCenter.default - - notificationCenter.addObserver(self, - selector: #selector(keyboardWillShow(_:)), - name: UIWindow.keyboardWillShowNotification, - object: nil) - notificationCenter.addObserver(self, - selector: #selector(keyboardWillChangeFrame(_:)), - name: UIWindow.keyboardWillChangeFrameNotification, - object: nil) - notificationCenter.addObserver(self, - selector: #selector(keyboardWillHide(_:)), - name: UIWindow.keyboardWillHideNotification, - object: nil) - } - - - // MARK: - Keyboard notifications - - @objc private func keyboardWillShow(_ notification: Notification) { - guard let keyboardFrameValue = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return } - - makeLoginFormVisible(keyboardFrame: keyboardFrameValue.cgRectValue) - } - - @objc private func keyboardWillChangeFrame(_ notification: Notification) { - guard let keyboardFrameValue = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return } - - makeLoginFormVisible(keyboardFrame: keyboardFrameValue.cgRectValue) - } - - @objc private func keyboardWillHide(_ notification: Notification) { - contentContainerBottomConstraint?.constant = 0 - layoutIfNeeded() - updateStatusImageVisibility(animated: false) - } - - private func makeLoginFormVisible(keyboardFrame: CGRect) { - let viewFrame = convert(bounds, to: nil) - let intersection = viewFrame.intersection(keyboardFrame) - - contentContainerBottomConstraint?.constant = intersection.height - - layoutIfNeeded() - updateStatusImageVisibility(animated: false) - } } diff --git a/ios/MullvadVPN/ProblemReportViewController.swift b/ios/MullvadVPN/ProblemReportViewController.swift index 2c8d495909..6f134a049c 100644 --- a/ios/MullvadVPN/ProblemReportViewController.swift +++ b/ios/MullvadVPN/ProblemReportViewController.swift @@ -10,6 +10,9 @@ import UIKit class ProblemReportViewController: UIViewController, UITextFieldDelegate, ConditionalNavigation { + private var textViewKeyboardResponder: AutomaticKeyboardResponder? + private var scrollViewKeyboardResponder: AutomaticKeyboardResponder? + private let mullvadRest = MullvadRest(session: URLSession(configuration: .ephemeral)) private lazy var consolidatedLog: ConsolidatedApplicationLog = { let securityGroupIdentifier = ApplicationConfiguration.securityGroupIdentifier @@ -102,14 +105,6 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit /// Flag indicating when the text view is expanded to fill the entire view private var isMessageTextViewExpanded = false - /// Keyboard intersection with the controller view - private var keyboardIntersectionRect = CGRect.zero - - /// Bottom content inset necessary to compensate for the keyboard overlapping - var scrollViewBottomContentInsetAccountingForKeyboard: CGFloat { - return max(0, keyboardIntersectionRect.height - view.safeAreaInsets.bottom) - } - /// Placeholder view used to fill the space within the scroll view when the text view is /// expanded to fill the entire view private lazy var messagePlaceholder: UIView = { @@ -177,6 +172,9 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit navigationItem.title = NSLocalizedString("Report a problem", comment: "Navigation title") + textViewKeyboardResponder = AutomaticKeyboardResponder(targetView: messageTextView) + scrollViewKeyboardResponder = AutomaticKeyboardResponder(targetView: scrollView) + // Make sure that the user can't easily dismiss the controller on iOS 13 and above if #available(iOS 13.0, *) { isModalInPresentation = true @@ -207,11 +205,10 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - updateScrollViewContentInsets() - updateMessageTextViewContentInsets() + self.scrollViewKeyboardResponder?.updateContentInsets() + self.textViewKeyboardResponder?.updateContentInsets() } - // MARK: - Actions @objc func focusEmailTextField() { @@ -253,10 +250,6 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit private func registerForNotifications() { let notificationCenter = NotificationCenter.default - - notificationCenter.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), - name: UIWindow.keyboardWillChangeFrameNotification, - object: nil) notificationCenter.addObserver(self, selector: #selector(emailTextFieldDidChange), name: UITextField.textDidChangeNotification, object: emailTextField) @@ -382,7 +375,7 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit }) { (completed) in self.isMessageTextViewExpanded = true - self.updateMessageTextViewContentInsets() + self.textViewKeyboardResponder?.updateContentInsets() } } else { @@ -411,29 +404,6 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit } } - private func updateScrollViewContentInsets() { - let scrollViewBottomInset = scrollViewBottomContentInsetAccountingForKeyboard - - scrollView.contentInset.bottom = scrollViewBottomInset - scrollView.scrollIndicatorInsets.bottom = scrollViewBottomInset - } - - private func updateMessageTextViewContentInsets() { - // Ignore updating text view insets until it's fully expanded - guard isMessageTextViewExpanded else { return } - - let textViewBottomInset: CGFloat - - if messageTextView.isFirstResponder { - textViewBottomInset = scrollViewBottomContentInsetAccountingForKeyboard - } else { - textViewBottomInset = 0 - } - - messageTextView.contentInset.bottom = textViewBottomInset - messageTextView.scrollIndicatorInsets.bottom = textViewBottomInset - } - private func animateDescriptionTextView(animations: @escaping () -> Void, completion: @escaping (Bool) -> Void) { UIView.animate(withDuration: 0.25, animations: animations) { (completed) in completion(completed) @@ -615,19 +585,6 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit updatePersistentViewModel() } - // MARK: - Keyboard notifications - - @objc private func keyboardWillChangeFrame(_ notification: Notification) { - guard let keyboardFrameValue = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? NSValue else { return } - - let screenRect = self.view.convert(self.view.bounds, to: nil) - - keyboardIntersectionRect = screenRect.intersection(keyboardFrameValue.cgRectValue) - - updateScrollViewContentInsets() - updateMessageTextViewContentInsets() - } - // MARK: - UITextFieldDelegate func textFieldShouldReturn(_ textField: UITextField) -> Bool { |
