summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2021-03-24 16:02:07 +0100
committerAndrej Mihajlov <and@mullvad.net>2021-03-26 15:16:21 +0100
commitced4e1ec03d8e72e97f31d31cf8f6ca34fe832ee (patch)
tree6083ce4d13cc3af2e967352cacaf892b250a6400 /ios
parent39979c7a0936bd94425fc5c28bfeebaa873969c8 (diff)
downloadmullvadvpn-ced4e1ec03d8e72e97f31d31cf8f6ca34fe832ee.tar.xz
mullvadvpn-ced4e1ec03d8e72e97f31d31cf8f6ca34fe832ee.zip
Add automatic keyboard responder helper
Diffstat (limited to 'ios')
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj4
-rw-r--r--ios/MullvadVPN/AutomaticKeyboardResponder.swift128
-rw-r--r--ios/MullvadVPN/LoginContentView.swift57
-rw-r--r--ios/MullvadVPN/ProblemReportViewController.swift61
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 {