Skip to content

Commit 7a6d6ea

Browse files
authored
fix(input-accessory): improved positioning and state handling (#664)
1 parent 79fcaec commit 7a6d6ea

3 files changed

Lines changed: 236 additions & 23 deletions

File tree

packages/input-accessory/index.ios.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ export class InputAccessoryManager extends InputAccessoryManagerBase {
5151
bottom: 10,
5252
right: 10,
5353
});
54+
nativeTextView.autocorrectionType = UITextAutocorrectionType.No;
55+
nativeTextView.spellCheckingType = UITextSpellCheckingType.No;
56+
nativeTextView.smartQuotesType = UITextSmartQuotesType.No;
57+
nativeTextView.smartDashesType = UITextSmartDashesType.No;
58+
nativeTextView.smartInsertDeleteType = UITextSmartInsertDeleteType.No;
59+
nativeTextView.inputAssistantItem.leadingBarButtonGroups = [];
60+
nativeTextView.inputAssistantItem.trailingBarButtonGroups = [];
61+
this.keyboardTrackingView.setTextInputView(nativeTextView);
5462
}
5563

5664
// Run initial layout of children within the accessory dimensions
@@ -84,9 +92,10 @@ export class InputAccessoryManager extends InputAccessoryManagerBase {
8492
*/
8593
dismissKeyboard(): void {
8694
if (this.keyboardTrackingView) {
87-
this.keyboardTrackingView.setDismissingKeyboard();
88-
this.keyboardTrackingView.becomeFirstResponder();
95+
this.keyboardTrackingView.dismissKeyboard();
96+
return;
8997
}
98+
Utils.dismissKeyboard();
9099
}
91100

92101
cleanup(): void {

packages/input-accessory/platforms/ios/src/KeyboardTrackingView.swift

Lines changed: 221 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,25 @@ public class KeyboardTrackingView: UIView {
2525
// Maximum height for the input area (prevents infinite growth)
2626
private let maxAccessoryHeight: CGFloat = 200
2727

28-
// Safe area bottom inset for home indicator
28+
// Home-indicator padding used when the accessory is visible without the keyboard.
2929
private var safeAreaBottomInset: CGFloat = 0
3030

31+
// Active bottom padding inside the accessory. This is removed while the
32+
// software keyboard is visible so the bar sits flush above the keyboard.
33+
private var currentAccessoryBottomInset: CGFloat = 0
34+
3135
// Track previous keyboard position to detect show/hide direction
3236
private var previousKeyboardY: CGFloat = 0
3337

3438
// Flag to suppress animation during programmatic keyboard dismiss (tap close).
3539
private var isDismissingKeyboard: Bool = false
3640

41+
// Text input hosted inside the accessory. When it resigns first responder,
42+
// the tracking view must take first responder back or iOS removes the
43+
// inputAccessoryView from the screen.
44+
private weak var textInputView: UIView?
45+
private var isCleaningUp: Bool = false
46+
3747
// Callback for triggering ScrollView content relayout from TypeScript
3848
private var scrollViewRelayoutCallback: (() -> Void)?
3949

@@ -62,6 +72,7 @@ public class KeyboardTrackingView: UIView {
6272
* @param height The height of the input container
6373
*/
6474
public func setup(inputContainer: UIView, scrollView: UIScrollView, height: CGFloat) {
75+
self.isCleaningUp = false
6576
self.accessoryHeight = height
6677
self.trackedScrollView = scrollView
6778
self.contentView = inputContainer
@@ -81,13 +92,15 @@ public class KeyboardTrackingView: UIView {
8192
}
8293
}
8394

95+
self.currentAccessoryBottomInset = self.safeAreaBottomInset
96+
8497
// Total height includes content height + safe area for home indicator
85-
let totalHeight = height + self.safeAreaBottomInset
98+
let totalHeight = height + self.currentAccessoryBottomInset
8699

87100
// Create the accessory view container
88101
let screenWidth = UIScreen.main.bounds.width
89102
let accessoryContainer = InputAccessoryContainerView(frame: CGRect(x: 0, y: 0, width: screenWidth, height: totalHeight))
90-
accessoryContainer.safeAreaBottomInset = self.safeAreaBottomInset
103+
accessoryContainer.safeAreaBottomInset = self.currentAccessoryBottomInset
91104
accessoryContainer.contentHeight = height
92105

93106
// Store original superview and subview index before removal
@@ -172,6 +185,78 @@ public class KeyboardTrackingView: UIView {
172185
self.scrollViewRelayoutCallback = callback
173186
}
174187

188+
/**
189+
* Register the editable view hosted inside the accessory. This lets the
190+
* plugin recover when callers use UIApplication/endEditing based dismissal
191+
* instead of InputAccessoryManager.dismissKeyboard().
192+
*/
193+
public func setTextInputView(_ textInputView: UIView) {
194+
self.textInputView = textInputView
195+
suppressTextInputAssistant(for: textInputView)
196+
197+
if textInputView is UITextView {
198+
NotificationCenter.default.addObserver(
199+
self,
200+
selector: #selector(textInputDidBeginEditing(_:)),
201+
name: UITextView.textDidBeginEditingNotification,
202+
object: textInputView
203+
)
204+
NotificationCenter.default.addObserver(
205+
self,
206+
selector: #selector(textInputDidEndEditing(_:)),
207+
name: UITextView.textDidEndEditingNotification,
208+
object: textInputView
209+
)
210+
}
211+
212+
if textInputView is UITextField {
213+
NotificationCenter.default.addObserver(
214+
self,
215+
selector: #selector(textInputDidBeginEditing(_:)),
216+
name: UITextField.textDidBeginEditingNotification,
217+
object: textInputView
218+
)
219+
NotificationCenter.default.addObserver(
220+
self,
221+
selector: #selector(textInputDidEndEditing(_:)),
222+
name: UITextField.textDidEndEditingNotification,
223+
object: textInputView
224+
)
225+
}
226+
}
227+
228+
private func suppressTextInputAssistant(for textInputView: UIView) {
229+
textInputView.inputAssistantItem.leadingBarButtonGroups = []
230+
textInputView.inputAssistantItem.trailingBarButtonGroups = []
231+
232+
if let textView = textInputView as? UITextView {
233+
textView.autocorrectionType = .no
234+
textView.spellCheckingType = .no
235+
textView.smartQuotesType = .no
236+
textView.smartDashesType = .no
237+
textView.smartInsertDeleteType = .no
238+
textView.textContentType = nil
239+
if #available(iOS 17.0, *) {
240+
textView.inlinePredictionType = .no
241+
}
242+
textView.reloadInputViews()
243+
return
244+
}
245+
246+
if let textField = textInputView as? UITextField {
247+
textField.autocorrectionType = .no
248+
textField.spellCheckingType = .no
249+
textField.smartQuotesType = .no
250+
textField.smartDashesType = .no
251+
textField.smartInsertDeleteType = .no
252+
textField.textContentType = nil
253+
if #available(iOS 17.0, *) {
254+
textField.inlinePredictionType = .no
255+
}
256+
textField.reloadInputViews()
257+
}
258+
}
259+
175260
/**
176261
* Trigger NativeScript content remeasurement for ScrollView after frame resize
177262
*/
@@ -240,15 +325,19 @@ public class KeyboardTrackingView: UIView {
240325
let window = accessoryView.window,
241326
let scrollView = trackedScrollView else { return }
242327

243-
let frameInWindow = accessoryView.convert(accessoryView.bounds, to: window)
244-
let accessoryTop = frameInWindow.origin.y
245-
246328
let screenHeight = UIScreen.main.bounds.height
329+
var frameInWindow = accessoryView.convert(accessoryView.bounds, to: window)
330+
var accessoryTop = frameInWindow.origin.y
331+
updateAccessoryBottomInsetForAccessoryPosition(accessoryTop, screenHeight: screenHeight)
332+
frameInWindow = accessoryView.convert(accessoryView.bounds, to: window)
333+
accessoryTop = frameInWindow.origin.y
334+
247335
let scrollViewTopInWindow = scrollView.superview?.convert(
248336
scrollView.frame.origin, to: nil).y ?? scrollView.frame.origin.y
249337

250338
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
251-
let keyboardOverlap = max(0, screenHeight - accessoryTop)
339+
let accessoryTotalHeight = self.accessoryHeight + self.currentAccessoryBottomInset
340+
let keyboardOverlap = max(accessoryTotalHeight, screenHeight - accessoryTop)
252341

253342
let frameChanged = abs(targetFrameHeight - scrollView.frame.size.height) > 1
254343
let insetChanged = abs(keyboardOverlap - scrollView.contentInset.bottom) > 1
@@ -287,7 +376,8 @@ public class KeyboardTrackingView: UIView {
287376
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
288377

289378
// contentInset tracks the moving keyboard+accessory area
290-
let keyboardOverlap = max(0, screenHeight - accessoryTop)
379+
let accessoryTotalHeight = self.accessoryHeight + self.currentAccessoryBottomInset
380+
let keyboardOverlap = max(accessoryTotalHeight, screenHeight - accessoryTop)
291381

292382
let frameChanged = abs(targetFrameHeight - scrollView.frame.size.height) > 0.5
293383
let insetChanged = abs(keyboardOverlap - scrollView.contentInset.bottom) > 0.5
@@ -334,11 +424,13 @@ public class KeyboardTrackingView: UIView {
334424

335425
self.accessoryHeight = clampedHeight
336426

337-
// Total height includes safe area
338-
let totalHeight = clampedHeight + self.safeAreaBottomInset
427+
// Total height includes the currently active bottom inset. This is
428+
// zero while the keyboard is open and safe-area padding when closed.
429+
let totalHeight = clampedHeight + self.currentAccessoryBottomInset
339430

340431
// Update the container's heights
341432
accessoryView.contentHeight = clampedHeight
433+
accessoryView.safeAreaBottomInset = self.currentAccessoryBottomInset
342434

343435
// Update via the internal height constraint - the reliable way to resize inputAccessoryViews
344436
accessoryView.updateHeightConstraint(totalHeight)
@@ -375,6 +467,32 @@ public class KeyboardTrackingView: UIView {
375467
}
376468
return nil
377469
}
470+
471+
private func updateAccessoryBottomInset(_ bottomInset: CGFloat) {
472+
guard abs(self.currentAccessoryBottomInset - bottomInset) > 0.5 else {
473+
return
474+
}
475+
476+
self.currentAccessoryBottomInset = bottomInset
477+
478+
guard let accessoryView = _keyboardAccessoryView else {
479+
return
480+
}
481+
482+
accessoryView.safeAreaBottomInset = bottomInset
483+
accessoryView.updateHeightConstraint(self.accessoryHeight + bottomInset)
484+
485+
if let contentView = self.contentView {
486+
contentView.frame = CGRect(x: 0, y: 0, width: accessoryView.bounds.width, height: self.accessoryHeight)
487+
}
488+
}
489+
490+
private func updateAccessoryBottomInsetForAccessoryPosition(_ accessoryTop: CGFloat, screenHeight: CGFloat) {
491+
let accessoryOnlyThreshold = self.accessoryHeight + self.safeAreaBottomInset + 10
492+
let keyboardOverlap = max(0, screenHeight - accessoryTop)
493+
let isKeyboardShowing = keyboardOverlap > accessoryOnlyThreshold
494+
updateAccessoryBottomInset(isKeyboardShowing ? 0 : self.safeAreaBottomInset)
495+
}
378496

379497
/**
380498
* Show the keyboard (make text field first responder)
@@ -401,6 +519,78 @@ public class KeyboardTrackingView: UIView {
401519
}
402520
}
403521

522+
/**
523+
* Dismiss the software keyboard while keeping the inputAccessoryView alive.
524+
* This transfers first responder from the hosted TextView/TextField back to
525+
* KeyboardTrackingView, producing an accessory-only state instead of fully
526+
* removing the input bar.
527+
*/
528+
public func dismissKeyboard() {
529+
stopInteractiveTracking()
530+
setDismissingKeyboard()
531+
532+
if !self.isFirstResponder {
533+
self.becomeFirstResponder()
534+
} else {
535+
self.reloadInputViews()
536+
}
537+
538+
// The frame notification usually handles this, but finalize on the next
539+
// turn as a guard against UIKit sending notification(s) before the
540+
// responder transfer settles.
541+
DispatchQueue.main.async { [weak self] in
542+
self?.finalizeScrollViewHeight()
543+
}
544+
}
545+
546+
@objc private func textInputDidBeginEditing(_ notification: Notification) {
547+
if let textInputView = notification.object as? UIView {
548+
suppressTextInputAssistant(for: textInputView)
549+
}
550+
}
551+
552+
@objc private func textInputDidEndEditing(_ notification: Notification) {
553+
guard !isCleaningUp,
554+
let accessoryView = _keyboardAccessoryView,
555+
accessoryView.window != nil else { return }
556+
557+
restoreAccessoryFirstResponderIfNeeded()
558+
}
559+
560+
private func restoreAccessoryFirstResponderIfNeeded() {
561+
guard !isCleaningUp,
562+
let accessoryView = _keyboardAccessoryView,
563+
let window = accessoryView.window else { return }
564+
565+
if let activeResponder = findFirstResponder(in: window),
566+
activeResponder !== self,
567+
!isView(activeResponder, descendantOf: accessoryView) {
568+
return
569+
}
570+
571+
guard !self.isFirstResponder else { return }
572+
573+
setDismissingKeyboard()
574+
DispatchQueue.main.async { [weak self] in
575+
guard let self = self, !self.isCleaningUp else { return }
576+
if !self.isFirstResponder {
577+
self.becomeFirstResponder()
578+
}
579+
self.finalizeScrollViewHeight()
580+
}
581+
}
582+
583+
private func isView(_ view: UIView, descendantOf ancestor: UIView) -> Bool {
584+
var currentView: UIView? = view
585+
while let candidate = currentView {
586+
if candidate === ancestor {
587+
return true
588+
}
589+
currentView = candidate.superview
590+
}
591+
return false
592+
}
593+
404594
// MARK: - Keyboard Handling
405595

406596
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
@@ -437,14 +627,6 @@ public class KeyboardTrackingView: UIView {
437627
// the translucent accessory AND the keyboard (for blur-through visibility).
438628
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
439629

440-
// contentInset covers the full keyboard+accessory area from the screen bottom.
441-
// Clamp to at least the accessory height — the accessory is always visible
442-
// (KeyboardTrackingView is always first responder), so the overlap never drops
443-
// below it. This prevents a transient inset=0 state during first-responder
444-
// transfers (e.g., UITextView → KeyboardTrackingView) that would cause a scroll jump.
445-
let accessoryTotalHeight = self.accessoryHeight + self.safeAreaBottomInset
446-
let keyboardOverlap = max(accessoryTotalHeight, screenHeight - endFrame.origin.y)
447-
448630
// Detect keyboard showing/hiding for scroll behavior.
449631
// iOS includes the inputAccessoryView in the reported keyboard frame,
450632
// so when only the accessory is visible (keyboard hidden), endFrame.origin.y
@@ -454,6 +636,16 @@ public class KeyboardTrackingView: UIView {
454636
let isKeyboardShowing = endFrame.origin.y < screenHeight - accessoryOnlyThreshold
455637
let wasKeyboardHidden = previousKeyboardY >= screenHeight - accessoryOnlyThreshold
456638
let keyboardJustAppeared = isKeyboardShowing && wasKeyboardHidden
639+
let desiredBottomInset: CGFloat = isKeyboardShowing ? 0 : self.safeAreaBottomInset
640+
updateAccessoryBottomInset(desiredBottomInset)
641+
642+
// contentInset covers the full keyboard+accessory area from the screen bottom.
643+
// Clamp to at least the accessory height — the accessory is always visible
644+
// (KeyboardTrackingView is always first responder), so the overlap never drops
645+
// below it. This prevents a transient inset=0 state during first-responder
646+
// transfers (e.g., UITextView → KeyboardTrackingView) that would cause a scroll jump.
647+
let accessoryTotalHeight = self.accessoryHeight + self.currentAccessoryBottomInset
648+
let keyboardOverlap = max(accessoryTotalHeight, screenHeight - endFrame.origin.y)
457649

458650
// Store current position for next comparison
459651
previousKeyboardY = endFrame.origin.y
@@ -488,12 +680,18 @@ public class KeyboardTrackingView: UIView {
488680
scrollView.contentInset.bottom = keyboardOverlap
489681
scrollView.verticalScrollIndicatorInsets.bottom = keyboardOverlap
490682

491-
// Clamp scroll position to valid range without animation
683+
// Preserve visual position for short content. Clamping to zero while
684+
// the keyboard is handing off causes a visible up/down jump, and the
685+
// chat view will issue its own scroll after adding the sent message.
492686
let contentHeight = scrollView.contentSize.height
493687
let visibleHeight = targetFrameHeight - keyboardOverlap
494688
let maxOffset = max(0, contentHeight - visibleHeight)
495-
let clampedOffset = max(0, min(currentOffset, maxOffset))
496-
scrollView.contentOffset = CGPoint(x: 0, y: clampedOffset)
689+
if contentHeight > visibleHeight {
690+
let clampedOffset = max(0, min(currentOffset, maxOffset))
691+
if abs(clampedOffset - currentOffset) > 0.5 {
692+
scrollView.contentOffset = CGPoint(x: 0, y: clampedOffset)
693+
}
694+
}
497695

498696
self.relayoutScrollViewContent()
499697
return
@@ -537,11 +735,13 @@ public class KeyboardTrackingView: UIView {
537735
// MARK: - Cleanup
538736

539737
public func cleanup() {
738+
isCleaningUp = true
540739
stopInteractiveTracking()
541740
trackedScrollView?.panGestureRecognizer.removeTarget(self, action: #selector(handleScrollViewPan(_:)))
542741
NotificationCenter.default.removeObserver(self)
543742
self._keyboardAccessoryView = nil
544743
self.contentView = nil
744+
self.textInputView = nil
545745
self.trackedScrollView = nil
546746
self.scrollViewRelayoutCallback = nil
547747
self.resignFirstResponder()

0 commit comments

Comments
 (0)