Skip to content

Commit

Permalink
feat: iOS 12 compatibility (#764)
Browse files Browse the repository at this point in the history
## 📜 Description

Make `useFocusedInputHandler` compatible with iOS < 13.

## 💡 Motivation and Context

The library was always compatible with really old iOS versions. However
there was a one handler (`onSelectionChange`) which was a stub on iOS <
13.

In this PR I'm adding support for old iOS versions for this handler via
`KVO` approach + dispatching events on text changes.

The new algorithm can be breaking into next steps:
- on saving a new reference to delegate we remove old KVO;
- on saving a new reference to delegate we add KVO for UITextField (iOS
< 13 only);
- we dispatch selection event from KVO/change text events (to be fully
backward comaptible with iOS 13+, because single KVO don't emit events
for text changes - only selection);
- when text editing has been completed - we remove KVO.

One thing I potentially don't really like a lot is that we remove KVO in
`endEditing` event. Starting from
#760
we will return delegate to its original variant on `endEditing` event,
but it seems like we'll have kind of transitive dependencies (the fact
that we receive this event in two places). However at the moment I can
not imagine a situation when we will not substitute delegate back when
we finish text editing, so theoretically this solution should be okay.

Closes
#763

## 📢 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### iOS

- `setTextFieldDelegate` accepts second `textField` param; 
- store `observedTextFieldForSelection` in composite delegate class;
- added KVO helpers function (to assure we can remove it only one time);
- remove KVO when new delegate is set;
- add new KVO when new delegate is set;
- send selection events from text change/kvo;

## 🤔 How Has This Been Tested?

Tested on macOS 12 with XCode 14 and iPhone X iOS 12.4 simulator.

## 📸 Screenshots (if appropriate):


https://github.com/user-attachments/assets/98d01bae-0d58-42e7-9658-ff382b692087

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko authored Jan 20, 2025
1 parent 6da1bb4 commit 6a6328c
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 2 deletions.
74 changes: 73 additions & 1 deletion ios/delegates/KCTextInputCompositeDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ class KCTextInputCompositeDelegate: NSObject, UITextViewDelegate, UITextFieldDel
weak var textViewDelegate: UITextViewDelegate?
weak var textFieldDelegate: UITextFieldDelegate?

// Keep track of which textField we’re observing (iOS < 13 only)
private weak var observedTextFieldForSelection: UITextField?

public init(
onSelectionChange: @escaping (_ event: NSDictionary) -> Void,
onTextChange: @escaping (_ text: String?) -> Void
Expand All @@ -62,13 +65,35 @@ class KCTextInputCompositeDelegate: NSObject, UITextViewDelegate, UITextFieldDel
// MARK: setters/getters

public func setTextViewDelegate(delegate: UITextViewDelegate?) {
// remove KVO from any old textField
if let oldTextField = observedTextFieldForSelection {
removeSelectionRangeObserver(from: oldTextField)
}

textViewDelegate = delegate
textFieldDelegate = nil
observedTextFieldForSelection = nil
}

public func setTextFieldDelegate(delegate: UITextFieldDelegate?) {
public func setTextFieldDelegate(delegate: UITextFieldDelegate?, textField: UITextField?) {
// remove KVO from any old textField
if let oldTextField = observedTextFieldForSelection {
removeSelectionRangeObserver(from: oldTextField)
}

textFieldDelegate = delegate
textViewDelegate = nil

// If iOS < 13, add KVO to the actual textField object
if #available(iOS 13.0, *) {
// rely on textFieldDidChangeSelection
observedTextFieldForSelection = nil
} else {
if let realTextField = textField {
addSelectionRangeObserver(to: realTextField)
observedTextFieldForSelection = realTextField
}
}
}

// Getter for the active delegate
Expand Down Expand Up @@ -116,9 +141,56 @@ class KCTextInputCompositeDelegate: NSObject, UITextViewDelegate, UITextFieldDel
self.onTextChange(textField.text)
}

if #unavailable(iOS 13.0) {
DispatchQueue.main.asyncAfter(deadline: .now() + UIUtils.nextFrame) {
updateSelectionPosition(textInput: textField, sendEvent: self.onSelectionChange)
}
}

return textFieldDelegate?.textField?(textField, shouldChangeCharactersIn: range, replacementString: string) ?? true
}

func textFieldDidEndEditing(_ textField: UITextField) {
textFieldDelegate?.textFieldDidEndEditing?(textField)

if #unavailable(iOS 13.0) {
removeSelectionRangeObserver(from: textField)
if observedTextFieldForSelection === textField {
observedTextFieldForSelection = nil
}
}
}

// MARK: KVO for iOS < 13

private func addSelectionRangeObserver(to textField: UITextField) {
textField.addObserver(
self,
forKeyPath: "selectedTextRange",
options: [.new],
context: nil
)
}

private func removeSelectionRangeObserver(from textField: UITextField) {
textField.removeObserver(self, forKeyPath: "selectedTextRange")
}

// swiftlint:disable:next block_based_kvo
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
guard keyPath == "selectedTextRange", let textField = object as? UITextField else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
// selection changed => forward the event
updateSelectionPosition(textInput: textField, sendEvent: onSelectionChange)
}

// MARK: call forwarding

override func responds(to aSelector: Selector!) -> Bool {
Expand Down
2 changes: 1 addition & 1 deletion ios/observers/FocusedInputObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public class FocusedInputObserver: NSObject {
private func substituteDelegate(_ input: UIResponder?) {
if let textField = input as? UITextField {
if !(textField.delegate is KCTextInputCompositeDelegate) {
delegate.setTextFieldDelegate(delegate: textField.delegate)
delegate.setTextFieldDelegate(delegate: textField.delegate, textField: textField)
textField.delegate = delegate
}
} else if let textView = input as? UITextView {
Expand Down

0 comments on commit 6a6328c

Please sign in to comment.