After lots of digging, I've finally come up with a solution that works under iOS 15 - iOS 17 (tested with iOS 15.4, 16.4, 17.4, and 17.5).
Under iOS 17, the use of UITextSelectionDisplayInteraction activated
only worked if my custom UITextInput
already had some text in it. I don't know fi that's an issue with my custom input view or not but since my hack for iOS 15/16 also worked with iOS 17 I didn't spend any time trying to figure it out.
I created an extension to UITextInput
that add a method to activate the cursor that you can call from the becomeFirstResponder
of the custom input view. It makes use of a class that fakes a tap gesture enough to make the code work. This is an ugly hack that makes use of several private APIs. It works in development. I've made no attempt to use this code in a production App Store app.
@objcMembers class MyFakeTap: NSObject {
private let myView: UIView
init(view: UIView) {
self.myView = view
super.init()
}
func tapCount() -> Int { return 1 }
func touchesForTap() -> [UITouch] {
return []
}
var view: UIView? {
myView
}
var state: Int {
get {
return 1
}
set {
}
}
func locationInView(_ view: UIView?) -> CGPoint {
return .init(x: 5, y: 5)
}
}
extension UITextInput {
private var textSelectionInteraction: UITextInteraction? {
if let clazz = NSClassFromString("UITextSelectionInteraction") {
return textInputView?.interactions.first { $0.isKind(of: clazz) } as? UITextInteraction
} else {
return nil
}
}
func activateCursor() {
if let textSelectionInteraction {
let tap = MyFakeTap(view: self.textInputView ?? UIView())
textSelectionInteraction.perform(NSSelectorFromString("_handleMultiTapGesture:"), with: tap)
}
}
}
Note that under iOS 15, if you call activateCursor()
from becomeFirstResponder
, be sure you only do so if the view is not already the first responder. Otherwise you will end up in an infinite loop of calls to becomeFirstResponder
.
override func becomeFirstResponder() -> Bool {
// Under iOS 15 we end up in an infinite loop due to activeCursor indirectly calling becomeFirstResponder.
// So don't go any further if already the first responder.
if isFirstResponder { return true }
let didBecomeResponder = super.becomeFirstResponder()
if didBecomeResponder {
self.activateCursor()
}
return didBecomeResponder
}