Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow interactive dismissal of VCs that need custom first responder handling #2536

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 42 additions & 46 deletions deltachat-ios/Helper/GiveBackMyFirstResponder.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import UIKit
import Combine

/// https://gist.github.com/Amzd/223979ef5a06d98ef17d2d78dbd96e22
extension UIViewController {
Expand All @@ -24,80 +25,75 @@ extension UIViewController {
// In iOS 17 and iOS 16 the UIDocumentPickerViewController does not
// give back the first responder when search was used
if #available(iOS 16, *) {
let vc = GiveBackMyFirstResponder(culprit: documentPicker)
present(vc, animated: animated, completion: completion)
} else {
present(documentPicker as UIViewController, animated: animated, completion: completion)
documentPicker.returnFirstRespondersOnDismiss()
}
present(documentPicker as UIViewController, animated: animated, completion: completion)
}

/// In iOS 16 and below and iOS 18 the UIImagePickerController does not give back the first responder when search was used.
/// This function fixes that by making the previous first responder, first responder again when the image picker is dismissed.
public func present(_ imagePicker: UIImagePickerController, animated: Bool, completion: (() -> Void)? = nil) {
if #available(iOS 17, *) {
if #unavailable(iOS 18) {
return present(imagePicker as UIViewController, animated: animated, completion: completion)
}
if #unavailable(iOS 17) { // pre iOS 17
imagePicker.returnFirstRespondersOnDismiss()
} else if #available(iOS 18, *) { // iOS 18 and up
imagePicker.returnFirstRespondersOnDismiss()
}

let vc = GiveBackMyFirstResponder(culprit: imagePicker)
present(vc, animated: animated, completion: completion)
present(imagePicker as UIViewController, animated: animated, completion: completion)
}
}

private class GiveBackMyFirstResponder<VC: UIViewController>: FirstResponderReturningViewController {
@MainActor var culprit: VC

@MainActor init(culprit: VC) {
self.culprit = culprit
super.init(nibName: nil, bundle: nil)
private var lastRespondersKey: UInt8 = 0
extension UIViewController {
/// Resigns first responders when called and returns first responders when this view is dismissed. Call before presenting.
fileprivate func returnFirstRespondersOnDismiss() {
Self.swizzleOnce()
while let next = UIResponder.currentFirstResponder, next.resignFirstResponder() {
self.lastResponders.append(next)
}
}

@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
fileprivate var lastResponders: [UIResponder] {
get { objc_getAssociatedObject(self, &lastRespondersKey) as? [UIResponder] ?? [] }
/// Filter for self because that could create a reference cycle
set { objc_setAssociatedObject(self, &lastRespondersKey, newValue.filter { $0 !== self }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)}
}

override func viewDidLoad() {
super.viewDidLoad()
addChild(culprit)
culprit.view.frame = view.frame
view.addSubview(culprit.view)
culprit.didMove(toParent: self)
private var selfOrParentIsBeingDismissed: Bool {
parent?.isBeingDismissed ?? isBeingDismissed || isBeingDismissed
}
}

private class FirstResponderReturningViewController: UIViewController {
private var lastResponders: [UIResponder] = {
var lastResponders: [UIResponder] = []
while let next = UIResponder.currentFirstResponder, next.resignFirstResponder() {
lastResponders.append(next)
}
return lastResponders
}()

override var isBeingDismissed: Bool {
parent?.isBeingDismissed ?? super.isBeingDismissed || super.isBeingDismissed
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isBeingDismissed, !lastResponders.isEmpty, let newFirstResponder = UIResponder.currentFirstResponder {
@objc fileprivate func _viewWillDisappear(_ animated: Bool) {
_viewWillDisappear(animated)
if selfOrParentIsBeingDismissed, !lastResponders.isEmpty, let newFirstResponder = UIResponder.currentFirstResponder {
// Resigning here makes the animation smoother when we make lastResponders first responder again
newFirstResponder.resignFirstResponder()
}
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed, !lastResponders.isEmpty {
@objc fileprivate func _viewDidDisappear(_ animated: Bool) {
_viewDidDisappear(animated)
if selfOrParentIsBeingDismissed, !lastResponders.isEmpty {
lastResponders.reversed().forEach { $0.becomeFirstResponder() }
lastResponders = []
}
}

fileprivate static func swizzleOnce() { return _swizzle }
private static var _swizzle: Void = {
if let originalWillMethod = class_getInstanceMethod(UIViewController.self, #selector(viewWillDisappear)),
let swizzledWillMethod = class_getInstanceMethod(UIViewController.self, #selector(_viewWillDisappear)) {
method_exchangeImplementations(originalWillMethod, swizzledWillMethod)
}
if let originalDidMethod = class_getInstanceMethod(UIViewController.self, #selector(viewDidDisappear)),
let swizzledDidMethod = class_getInstanceMethod(UIViewController.self, #selector(_viewDidDisappear)) {
method_exchangeImplementations(originalDidMethod, swizzledDidMethod)
}
}()
}

extension UIResponder {
/// Note: Do not replace this with the `UIApplication.shared.sendAction(_, to: nil, from: nil, for: nil)` method
/// because that does not work reliably in all cases. eg, when you initialise a UIImagePickerController on iOS 16 it returns nil even if your textfield is still first responder.
/// because that does not work reliably in all cases. eg, when you initialise a UIImagePickerController on iOS 16 `sendAction` returns nil even if your textfield is still first responder.
static var currentFirstResponder: UIResponder? {
for window in UIApplication.shared.windows {
if let firstResponder = window.previousFirstResponder {
Expand Down