@@ -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