From ed08a572c46b722a5b1a36c7ef193366ccbd0d0f Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 23 Sep 2026 11:57:30 -0700 Subject: [PATCH] feat(input-accessory): ios 26+ clear bar --- packages/input-accessory/README.md | 9 +- packages/input-accessory/common.ts | 36 ++- packages/input-accessory/index.android.ts | 18 ++ packages/input-accessory/index.d.ts | 24 ++ packages/input-accessory/index.ios.ts | 77 ++++- .../ios/src/KeyboardTrackingView.swift | 286 +++++++++++++++--- packages/input-accessory/typings/ios.d.ts | 8 + 7 files changed, 405 insertions(+), 53 deletions(-) diff --git a/packages/input-accessory/README.md b/packages/input-accessory/README.md index 09c0eb50..a94ad0ad 100644 --- a/packages/input-accessory/README.md +++ b/packages/input-accessory/README.md @@ -280,6 +280,7 @@ export default { | `baseHeight` | `number` | No | `48` | Minimum height of the input bar (DIPs) | | `maxHeight` | `number` | No | `200` | Maximum height the input bar can grow to (DIPs) | | `containerPadding` | `number` | No | `16` | Padding added to text height when calculating container height (DIPs) | +| `collapsedHorizontalInset` | `number` | No | `0` | iOS only. Horizontal inset (DIPs) of the docked bar while the keyboard is hidden; the bar animates to full width with the keyboard | ### `InputAccessoryManager` @@ -288,6 +289,8 @@ export default { | `setup(config)` | Initialize keyboard accessory behavior. Call after all views are loaded. | | `updateAccessoryHeight()` | Recalculate height based on current text content. Call on every text change. | | `dismissKeyboard()` | Dismiss the keyboard programmatically. | +| `suspend()` | iOS: hide the docked bar while a sheet, popover or dialog is presented over the page (the bar's window sits above sheets). No-op on Android. | +| `restore()` | iOS: bring the bar back after a modal or sheet that covered the page is dismissed; also lifts `suspend()`. Safe to call as soon as the modal starts closing. No-op on Android. | | `relayoutScrollViewContent()` | Remeasure ScrollView content after external changes (e.g., new messages added). | | `cleanup()` | Remove all listeners and restore original state. Call in `ngOnDestroy`. | @@ -295,7 +298,11 @@ export default { ### iOS -The plugin creates an invisible `KeyboardTrackingView` that acts as a first responder with a `inputAccessoryView`. Your input bar view is moved into this accessory container, which docks to the keyboard with the system blur effect (`.keyboard` style [UIInputView](https://developer.apple.com/documentation/uikit/uiinputview)). On iOS 26+, [UIScrollEdgeElementContainerInteraction](https://developer.apple.com/documentation/uikit/uiscrolledgeelementcontainerinteraction) provides glass-morphism blending between the ScrollView and the accessory. +The plugin creates an invisible `KeyboardTrackingView` that acts as a first responder with a `inputAccessoryView`. Your input bar view is moved into this accessory container ([UIInputView](https://developer.apple.com/documentation/uikit/uiinputview)), which docks to the keyboard. Before iOS 26 the container uses the `.keyboard` style, so it carries the system keyboard's blur. On iOS 26+ the container is clear and the ScrollView's scroll edge effect is the bar's background: the plugin registers the container through [UIScrollEdgeElementContainerInteraction](https://developer.apple.com/documentation/uikit/uiscrolledgeelementcontainerinteraction), so content blurs and washes out under the bar the way it does under system bars, with the keyboard open or closed. Set the ScrollView's edge effect to the soft style for the blurred look. + +The plugin does not set `textContainerInset` or input traits (autocorrection, spell checking, smart punctuation) on the hosted TextView: size the text with the TextView's CSS padding and configure traits with its own attributes. + +With `collapsedHorizontalInset`, the docked bar floats narrower while the keyboard is hidden and widens in step with the keyboard as it opens. Interactive dismiss is handled via a `CADisplayLink` that tracks the accessory position during scroll gestures, updating the ScrollView's `contentInset` every frame. diff --git a/packages/input-accessory/common.ts b/packages/input-accessory/common.ts index 95f311d8..2b113f83 100644 --- a/packages/input-accessory/common.ts +++ b/packages/input-accessory/common.ts @@ -46,6 +46,14 @@ export interface InputAccessoryConfig { * Padding added to the text height when calculating container height. Default: 16. */ containerPadding?: number; + + /** + * Extra horizontal inset (DIPs) applied to the input container while the + * software keyboard is hidden, so the docked bar floats narrower against + * the device's rounded bottom corners. Animates to full width in sync + * with the keyboard opening. iOS only. Default: 0. + */ + collapsedHorizontalInset?: number; } /** @@ -60,6 +68,7 @@ export class InputAccessoryManagerBase { protected baseHeight: number = 48; protected maxHeight: number = 200; protected containerPadding: number = 16; + protected collapsedHorizontalInset: number = 0; protected isRelayoutingScrollView: boolean = false; /** @@ -72,6 +81,7 @@ export class InputAccessoryManagerBase { if (config.baseHeight != null) this.baseHeight = config.baseHeight; if (config.maxHeight != null) this.maxHeight = config.maxHeight; if (config.containerPadding != null) this.containerPadding = config.containerPadding; + if (config.collapsedHorizontalInset != null) this.collapsedHorizontalInset = config.collapsedHorizontalInset; } /** @@ -90,14 +100,21 @@ export class InputAccessoryManagerBase { const width = this.getScrollViewWidth(); if (width <= 0) return; - const widthSpec = Utils.layout.makeMeasureSpec(width, Utils.layout.EXACTLY); + // Content sits inside the safe-area insets of a viewport that + // overflows them, and contentSize includes those insets, as in + // core's ScrollView layout. Unlike core, contentSize is not padded + // up to the viewport: the keyboard handling scrolls by what the + // content really measures, so short content stays put. + const insets = this.getScrollViewInsets(); + const contentWidth = Math.max(0, width - insets.left - insets.right); + const widthSpec = Utils.layout.makeMeasureSpec(contentWidth, Utils.layout.EXACTLY); const heightSpec = Utils.layout.makeMeasureSpec(0, Utils.layout.UNSPECIFIED); stackLayout.measure(widthSpec, heightSpec); const measuredHeight = stackLayout.getMeasuredHeight(); - stackLayout.layout(0, 0, width, measuredHeight); + stackLayout.layout(insets.left, insets.top, insets.left + contentWidth, insets.top + measuredHeight); - this.updateScrollContentSize(width, measuredHeight); + this.updateScrollContentSize(width, measuredHeight + insets.top + insets.bottom); } finally { this.isRelayoutingScrollView = false; } @@ -110,6 +127,19 @@ export class InputAccessoryManagerBase { return 0; } + /** + * The safe-area insets core lays the scroll content out with, in device + * pixels. Zero on Android and for an iOS-managed content inset, as in core. + */ + public getScrollViewInsets(): { left: number; top: number; right: number; bottom: number } { + const scrollView = this.nsScrollViewContainer; + const zero = { left: 0, top: 0, right: 0, bottom: 0 }; + if (!scrollView || typeof scrollView.getSafeAreaInsets !== 'function') return zero; + const behavior = (scrollView as ScrollView & { iosContentInsetAdjustmentBehavior?: string }).iosContentInsetAdjustmentBehavior; + if (behavior && behavior !== 'never') return zero; + return scrollView.getSafeAreaInsets(); + } + /** * Override on iOS to set UIScrollView.contentSize. No-op on Android. */ diff --git a/packages/input-accessory/index.android.ts b/packages/input-accessory/index.android.ts index b32afee0..d6d79689 100644 --- a/packages/input-accessory/index.android.ts +++ b/packages/input-accessory/index.android.ts @@ -85,6 +85,24 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { } } + /** + * No-op on Android. Dialogs and sheets are their own windows drawn above + * the activity content, so they already cover the accessory bar. Provided + * for API parity with iOS, where the accessory floats above sheets. + */ + suspend(): void { + // intentionally empty + } + + /** + * No-op on Android. The accessory bar stays in the layout (it is animated + * via window insets, not an inputAccessoryView tied to first responder), + * so a modal dismiss never removes it. Provided for API parity. + */ + restore(): void { + // intentionally empty + } + cleanup(): void { if (this.helper) { this.helper.cleanup(); diff --git a/packages/input-accessory/index.d.ts b/packages/input-accessory/index.d.ts index ac060d5d..73c8f084 100644 --- a/packages/input-accessory/index.d.ts +++ b/packages/input-accessory/index.d.ts @@ -29,6 +29,30 @@ export class InputAccessoryManager { */ dismissKeyboard(): void; + /** + * Hide the docked accessory bar while a sheet, popover or dialog is + * presented over the host page. On iOS, sheet-style presentations keep the + * presenting page in the window and the accessory lives in the keyboard's + * own window (UITextEffectsWindow), which UIKit z-orders above the sheet, + * so the bar would float on top of the dialog. Call this as the dialog + * opens: the bar (and keyboard, if open) slides away with the standard + * UIKit animation and the plugin's auto-restore paths are gated off. Pair + * with restore() when the dialog closes. Safe to call repeatedly. No-op on + * Android (dialogs are separate windows that already draw above the bar). + */ + suspend(): void; + + /** + * Re-show the docked accessory bar after a modal that covered the host page + * is dismissed, and lift a suspend() suspension. On iOS, UIKit does not + * restore first responder (and thus the inputAccessoryView) automatically + * after a full-screen modal closes, so the input bar would stay gone. Call + * this when returning from such a modal. It polls internally until the + * modal is fully gone, so it is safe to call as soon as the modal begins + * closing. No-op on Android. + */ + restore(): void; + /** * Relayout ScrollView content after external changes (e.g., new messages). */ diff --git a/packages/input-accessory/index.ios.ts b/packages/input-accessory/index.ios.ts index 80bc4c85..7f653680 100644 --- a/packages/input-accessory/index.ios.ts +++ b/packages/input-accessory/index.ios.ts @@ -25,6 +25,10 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { this.keyboardTrackingView = KeyboardTrackingView.alloc().initWithFrame(CGRectMake(0, 0, 0, 0)); viewController.view.addSubview(this.keyboardTrackingView); + // Must be set before setup so the accessory starts in the collapsed + // (keyboard hidden) width state. + this.keyboardTrackingView.setCollapsedHorizontalInset(this.collapsedHorizontalInset); + // Swift moves the native UIView into the inputAccessoryView this.keyboardTrackingView.setupWithInputContainerScrollViewHeight(this.inputContainerView, scrollViewNative, inputHeight); @@ -41,21 +45,25 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { this.relayoutScrollViewContent(); }); + // Invoked inside the native width animation block so the composer + // children re-layout (and therefore animate) with the keyboard. + this.keyboardTrackingView.setAccessoryRelayoutCallback(() => { + this.relayoutAccessory(); + }); + // Configure UITextView for auto-growing const nativeTextView = config.textView.ios as UITextView; if (nativeTextView) { nativeTextView.scrollEnabled = false; - nativeTextView.textContainerInset = new UIEdgeInsets({ - top: 10, - left: 10, - bottom: 10, - right: 10, - }); - nativeTextView.autocorrectionType = UITextAutocorrectionType.Yes; - nativeTextView.spellCheckingType = UITextSpellCheckingType.No; - nativeTextView.smartQuotesType = UITextSmartQuotesType.No; - nativeTextView.smartDashesType = UITextSmartDashesType.No; - nativeTextView.smartInsertDeleteType = UITextSmartInsertDeleteType.No; + // textContainerInset is core's: it maps the TextView's CSS padding + // onto the inset and re-applies that mapping on every full style + // pass (appearance change, trait change, hint transition), so a + // value written here would be stomped later and misalign the text. + // Callers size the text through the TextView's CSS padding. + // Input traits (autocorrection, spell checking, smart punctuation, + // inline predictions) stay at their UIKit defaults so the composer + // follows the user's keyboard settings and the hosted TextView's + // own attributes (e.g. autocorrect="false") apply. nativeTextView.inputAssistantItem.leadingBarButtonGroups = Utils.ios.collections.jsArrayToNSArray([]); nativeTextView.inputAssistantItem.trailingBarButtonGroups = Utils.ios.collections.jsArrayToNSArray([]); this.keyboardTrackingView.setTextInputView(nativeTextView); @@ -75,8 +83,23 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { const currentWidth = nativeTextView.frame.size.width; const fittingSize = nativeTextView.sizeThatFits(CGSizeMake(currentWidth, 10000)); - let newHeight = fittingSize.height + this.containerPadding; - newHeight = Math.max(this.baseHeight, Math.min(newHeight, this.maxHeight)); + const naturalHeight = fittingSize.height + this.containerPadding; + const newHeight = Math.max(this.baseHeight, Math.min(naturalHeight, this.maxHeight)); + + // Past the capped accessory height the bar can no longer grow, so the + // TextView scrolls itself to keep the newest lines and the caret + // reachable; below the cap it stays unscrollable and the bar grows. + const shouldScroll = naturalHeight > this.maxHeight; + if (nativeTextView.scrollEnabled !== shouldScroll) { + nativeTextView.scrollEnabled = shouldScroll; + if (!shouldScroll) { + // Back below the cap: a leftover offset would leave the top lines clipped. + nativeTextView.setContentOffsetAnimated(CGPointMake(0, 0), false); + } + } + if (shouldScroll) { + nativeTextView.scrollRangeToVisible(nativeTextView.selectedRange); + } // Update native accessory container height this.keyboardTrackingView.updateHeight(newHeight); @@ -98,6 +121,29 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { Utils.dismissKeyboard(); } + /** + * Hide the docked accessory while a sheet/popover/dialog is presented over + * the host page. Sheet presentations keep the page in the window, and the + * accessory lives in the keyboard's own window which UIKit z-orders above + * the sheet, so without this the bar floats on top of the dialog. Animates + * the bar (and keyboard, if open) away and blocks the plugin's auto-restore + * paths until restore() is called. + */ + suspend(): void { + this.keyboardTrackingView?.suspendAccessory(); + } + + /** + * Re-show the docked accessory after a modal that covered the host page was + * dismissed. UIKit does not restore first responder automatically, so call + * this when returning from a modal/resource view. Polls internally until the + * modal is fully gone, so it is safe to call as soon as the modal closes. + * Also lifts a suspend() suspension. + */ + restore(): void { + this.keyboardTrackingView?.restoreAccessory(); + } + cleanup(): void { if (this.keyboardTrackingView) { this.keyboardTrackingView.cleanup(); @@ -125,6 +171,9 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { if (width <= 0 || height <= 0) return; + // frame.origin.x carries the collapsed horizontal inset; laying out at + // 0 would snap the container back to the screen edge. + const dpX = Utils.layout.toDevicePixels(frame.origin.x); const dpWidth = Utils.layout.toDevicePixels(width); const dpHeight = Utils.layout.toDevicePixels(height); @@ -132,7 +181,7 @@ export class InputAccessoryManager extends InputAccessoryManagerBase { const heightSpec = Utils.layout.makeMeasureSpec(dpHeight, Utils.layout.EXACTLY); this.nsInputContainer.measure(widthSpec, heightSpec); - this.nsInputContainer.layout(0, 0, dpWidth, dpHeight); + this.nsInputContainer.layout(dpX, 0, dpX + dpWidth, dpHeight); // Force hint placeholder to re-render after reparenting into the accessory if (this.textView && (!this.textView.text || this.textView.text.length === 0)) { diff --git a/packages/input-accessory/platforms/ios/src/KeyboardTrackingView.swift b/packages/input-accessory/platforms/ios/src/KeyboardTrackingView.swift index b53336f0..cb2ac21f 100644 --- a/packages/input-accessory/platforms/ios/src/KeyboardTrackingView.swift +++ b/packages/input-accessory/platforms/ios/src/KeyboardTrackingView.swift @@ -32,6 +32,12 @@ public class KeyboardTrackingView: UIView { // software keyboard is visible so the bar sits flush above the keyboard. private var currentAccessoryBottomInset: CGFloat = 0 + // Extra horizontal inset applied to the accessory content while the + // software keyboard is hidden, so the docked bar floats narrower against + // the device's rounded bottom corners (ChatGPT-style). Animates to zero + // in sync with the keyboard as it opens. + private var collapsedHorizontalInset: CGFloat = 0 + // Track previous keyboard position to detect show/hide direction private var previousKeyboardY: CGFloat = 0 @@ -44,9 +50,22 @@ public class KeyboardTrackingView: UIView { private weak var textInputView: UIView? private var isCleaningUp: Bool = false + // While true, every auto-restore path (end-editing notification, window + // re-entry, restore polling) is gated off so the accessory stays hidden + // until restoreAccessory() explicitly lifts the suspension. Needed while a + // sheet/popover is presented over the host page: those presentations keep + // the page in the window, and the accessory — living in the keyboard's own + // UITextEffectsWindow — would float ABOVE them. + private var isSuspended: Bool = false + // Callback for triggering ScrollView content relayout from TypeScript private var scrollViewRelayoutCallback: (() -> Void)? + // Callback that re-runs the NativeScript measure+layout of the accessory + // content. Invoked INSIDE the width animation block so the composer + // children's frame changes inherit the keyboard animation. + private var accessoryRelayoutCallback: (() -> Void)? + // Interactive keyboard dismiss tracking via CADisplayLink private var displayLink: CADisplayLink? private var displayLinkProxy: DisplayLinkProxy? @@ -102,6 +121,8 @@ public class KeyboardTrackingView: UIView { let accessoryContainer = InputAccessoryContainerView(frame: CGRect(x: 0, y: 0, width: screenWidth, height: totalHeight)) accessoryContainer.safeAreaBottomInset = self.currentAccessoryBottomInset accessoryContainer.contentHeight = height + // Keyboard starts hidden — begin in the collapsed (inset) width state. + accessoryContainer.horizontalInset = self.collapsedHorizontalInset // Store original superview and subview index before removal let originalSuperview = inputContainer.superview @@ -121,7 +142,12 @@ public class KeyboardTrackingView: UIView { // NativeScript uses frame-based layout internally — setting the frame triggers // layoutSubviews which triggers NativeScript's measure+layout cycle for children. // Auto Layout bypasses this, causing children (like the TextView) not to resize. - inputContainer.frame = CGRect(x: 0, y: 0, width: screenWidth, height: height) + inputContainer.frame = CGRect( + x: self.collapsedHorizontalInset, + y: 0, + width: screenWidth - self.collapsedHorizontalInset * 2, + height: height + ) inputContainer.autoresizingMask = [.flexibleWidth] accessoryContainer.addSubview(inputContainer) accessoryContainer.contentViewRef = inputContainer @@ -166,7 +192,8 @@ public class KeyboardTrackingView: UIView { // Become first responder to show the accessory view // We need to do this after a slight delay to ensure view hierarchy is ready DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.becomeFirstResponder() + guard let self = self, !self.isSuspended else { return } + self.becomeFirstResponder() } } @@ -198,6 +225,24 @@ public class KeyboardTrackingView: UIView { self.scrollViewRelayoutCallback = callback } + /** + * Extra horizontal inset for the keyboard-hidden (accessory-only) state. + * Set before setup(). The bar animates between this inset and full width + * in sync with the keyboard show/hide animation. + */ + public func setCollapsedHorizontalInset(_ inset: CGFloat) { + self.collapsedHorizontalInset = max(0, inset) + } + + /** + * Callback that re-runs the NativeScript measure+layout of the accessory + * content at its current frame. Invoked inside the width animation block + * so child frame changes animate with the keyboard. + */ + public func setAccessoryRelayoutCallback(_ callback: @escaping () -> Void) { + self.accessoryRelayoutCallback = callback + } + /** * Register the editable view hosted inside the accessory. This lets the * plugin recover when callers use UIApplication/endEditing based dismissal @@ -238,34 +283,25 @@ public class KeyboardTrackingView: UIView { } } + /** + * Only clears the iPad text-input assistant (shortcut bar) button groups. + * Input traits (autocorrection, spell checking, smart punctuation, inline + * predictions) are deliberately left at their UIKit defaults so a docked + * composer types like Messages — following the user's system keyboard + * settings — and so app-level overrides on the hosted TextView/TextField + * are respected. This runs again on every begin-editing, so it must never + * force traits. + */ private func suppressTextInputAssistant(for textInputView: UIView) { textInputView.inputAssistantItem.leadingBarButtonGroups = [] textInputView.inputAssistantItem.trailingBarButtonGroups = [] if let textView = textInputView as? UITextView { - textView.autocorrectionType = .no - textView.spellCheckingType = .no - textView.smartQuotesType = .no - textView.smartDashesType = .no - textView.smartInsertDeleteType = .no - textView.textContentType = nil - if #available(iOS 17.0, *) { - textView.inlinePredictionType = .no - } textView.reloadInputViews() return } if let textField = textInputView as? UITextField { - textField.autocorrectionType = .no - textField.spellCheckingType = .no - textField.smartQuotesType = .no - textField.smartDashesType = .no - textField.smartInsertDeleteType = .no - textField.textContentType = nil - if #available(iOS 17.0, *) { - textField.inlinePredictionType = .no - } textField.reloadInputViews() } } @@ -455,7 +491,8 @@ public class KeyboardTrackingView: UIView { // Update the content view's frame - this triggers NativeScript's layoutSubviews // which re-measures and re-lays out children (making the TextView actually resize) if let contentView = self.contentView { - contentView.frame = CGRect(x: 0, y: 0, width: accessoryView.bounds.width, height: clampedHeight) + let inset = accessoryView.horizontalInset + contentView.frame = CGRect(x: inset, y: 0, width: accessoryView.bounds.width - inset * 2, height: clampedHeight) } // reloadInputViews must be called on the ACTUAL first responder. @@ -481,22 +518,67 @@ public class KeyboardTrackingView: UIView { return nil } - private func updateAccessoryBottomInset(_ bottomInset: CGFloat) { - guard abs(self.currentAccessoryBottomInset - bottomInset) > 0.5 else { - return - } - + /** + * Apply the keyboard-visible / keyboard-hidden chrome to the accessory: + * bottom (home indicator) padding and the collapsed horizontal inset. + * The width change is animated with the keyboard's own duration/curve so + * the bar widens exactly in step with the keyboard sliding up — invoking + * the NativeScript relayout callback INSIDE the animation block so the + * composer children (pill, send button) animate natively too. + */ + private func updateAccessoryChrome( + keyboardShowing: Bool, + animationDuration: TimeInterval = 0.25, + curve: UIView.AnimationOptions = .curveEaseOut + ) { + let bottomInset: CGFloat = keyboardShowing ? 0 : self.safeAreaBottomInset + let targetHorizontalInset: CGFloat = keyboardShowing ? 0 : self.collapsedHorizontalInset + + let bottomChanged = abs(self.currentAccessoryBottomInset - bottomInset) > 0.5 self.currentAccessoryBottomInset = bottomInset guard let accessoryView = _keyboardAccessoryView else { return } - accessoryView.safeAreaBottomInset = bottomInset - accessoryView.updateHeightConstraint(self.accessoryHeight + bottomInset) + let widthChanged = abs(accessoryView.horizontalInset - targetHorizontalInset) > 0.5 + guard bottomChanged || widthChanged else { + return + } - if let contentView = self.contentView { - contentView.frame = CGRect(x: 0, y: 0, width: accessoryView.bounds.width, height: self.accessoryHeight) + if bottomChanged { + accessoryView.safeAreaBottomInset = bottomInset + accessoryView.updateHeightConstraint(self.accessoryHeight + bottomInset) + // UIKit keeps its own required height constraint on the accessory + // and only refreshes it when input views reload. Without this the + // container can get stuck at the previous state's height (bar + // floating high above the keyboard, or sitting flush to the screen + // bottom with no home-indicator padding). Mirrors updateHeight(). + if let firstResponder = self.findFirstResponder(in: accessoryView) { + firstResponder.reloadInputViews() + } else if self.isFirstResponder { + self.reloadInputViews() + } + } + + let applyContentFrame = { [weak self] in + guard let self = self else { return } + accessoryView.horizontalInset = targetHorizontalInset + if let contentView = self.contentView { + contentView.frame = CGRect( + x: targetHorizontalInset, + y: 0, + width: accessoryView.bounds.width - targetHorizontalInset * 2, + height: self.accessoryHeight + ) + } + self.accessoryRelayoutCallback?() + } + + if widthChanged && animationDuration > 0.01 { + UIView.animate(withDuration: animationDuration, delay: 0, options: curve, animations: applyContentFrame) + } else { + applyContentFrame() } } @@ -504,7 +586,7 @@ public class KeyboardTrackingView: UIView { let accessoryOnlyThreshold = self.accessoryHeight + self.safeAreaBottomInset + 10 let keyboardOverlap = max(0, screenHeight - accessoryTop) let isKeyboardShowing = keyboardOverlap > accessoryOnlyThreshold - updateAccessoryBottomInset(isKeyboardShowing ? 0 : self.safeAreaBottomInset) + updateAccessoryChrome(keyboardShowing: isKeyboardShowing) } /** @@ -572,6 +654,7 @@ public class KeyboardTrackingView: UIView { private func restoreAccessoryFirstResponderIfNeeded() { guard !isCleaningUp, + !isSuspended, let accessoryView = _keyboardAccessoryView, let window = accessoryView.window else { return } @@ -604,6 +687,123 @@ public class KeyboardTrackingView: UIView { return false } + // MARK: - Window Re-entry (modal dismiss recovery) + + /** + * Recover the inputAccessoryView after a modal that covered the host page + * is dismissed. + * + * The accessory bar is only on screen while this view is the first + * responder. Presenting a full-screen modal (e.g. the resource viewer) + * removes the host page's view from the window and resigns our first + * responder, which removes the accessory — expected. But UIKit does NOT + * restore first responder when the page returns, so the input bar would + * stay gone, leaving the user with no way to type. + * + * Naively reclaiming first responder here does not work: a full-screen + * modal re-adds the presenter's view to the window at the *start* of its + * dismiss animation, so this fires while the modal is still the presented + * controller — and a becomeFirstResponder() in that window is silently + * dropped by UIKit. We therefore poll until the modal is actually gone + * (no presented controller, key window) before reclaiming. + */ + public override func didMoveToWindow() { + super.didMoveToWindow() + + // Skip while tearing down, while suspended for an overlaying sheet, + // while off-screen (the modal-present transition or navigating away), + // before setup has built the accessory, or if we somehow already hold + // first responder. + guard !isCleaningUp, + !isSuspended, + self.window != nil, + self._keyboardAccessoryView != nil, + !self.isFirstResponder else { return } + + restoreAccessoryWhenReady(retriesRemaining: 12) + } + + /** + * Hide the accessory bar while a sheet/popover/dialog is presented over the + * host page. Sheet-style presentations do NOT remove the presenting page + * from its window, and the accessory lives in the keyboard's own window + * (UITextEffectsWindow) which UIKit z-orders above the sheet — so without + * this the bar floats on top of the dialog. Resigning first responder + * slides the bar (and the keyboard, if open) away with the standard UIKit + * dismiss animation; the isSuspended gate then blocks every auto-restore + * path until restoreAccessory() is called after the sheet closes. + * Safe to call repeatedly. + */ + public func suspendAccessory() { + guard !isCleaningUp, let accessoryView = _keyboardAccessoryView, !isSuspended else { return } + isSuspended = true + stopInteractiveTracking() + // Resign the hosted text input first (if the user was typing), then + // ourselves — whichever holds first responder anchors the accessory. + if let active = findFirstResponder(in: accessoryView) { + active.resignFirstResponder() + } + if self.isFirstResponder { + self.resignFirstResponder() + } + } + + /** + * Explicit entry point so callers (e.g. right after dismissing a modal that + * was opened over the chat page) can ask the accessory to come back without + * depending on window-membership changes. Also lifts a suspendAccessory() + * suspension. Safe to call repeatedly. + */ + public func restoreAccessory() { + guard !isCleaningUp, self._keyboardAccessoryView != nil else { return } + isSuspended = false + restoreAccessoryWhenReady(retriesRemaining: 12) + } + + private func restoreAccessoryWhenReady(retriesRemaining: Int) { + guard !isCleaningUp, !isSuspended, let accessoryView = _keyboardAccessoryView else { return } + + let window = accessoryView.window ?? self.window + + // Wait until the page is actually back on screen: in a key window with + // no modal still presented over it. A full-screen modal re-adds us to + // the window before its dismiss animation finishes, so reclaiming first + // responder before the presented controller clears would be dropped. + let modalStillPresented = window?.rootViewController?.presentedViewController != nil + let onScreen = (window != nil) && (window?.isKeyWindow ?? false) + + if !onScreen || modalStillPresented { + if retriesRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.restoreAccessoryWhenReady(retriesRemaining: retriesRemaining - 1) + } + } + return + } + + guard let activeWindow = window else { return } + + // Something else legitimately holds first responder (e.g. the user + // tapped straight into the text field) — don't steal it. + if let active = findFirstResponder(in: activeWindow), + active !== self, + !isView(active, descendantOf: accessoryView) { + return + } + + guard !self.isFirstResponder else { return } + + setDismissingKeyboard() + if !self.becomeFirstResponder(), retriesRemaining > 0 { + // Rejected while UIKit is still settling — try again shortly. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.restoreAccessoryWhenReady(retriesRemaining: retriesRemaining - 1) + } + return + } + finalizeScrollViewHeight() + } + // MARK: - Keyboard Handling @objc private func keyboardWillChangeFrame(_ notification: Notification) { @@ -649,8 +849,10 @@ public class KeyboardTrackingView: UIView { let isKeyboardShowing = endFrame.origin.y < screenHeight - accessoryOnlyThreshold let wasKeyboardHidden = previousKeyboardY >= screenHeight - accessoryOnlyThreshold let keyboardJustAppeared = isKeyboardShowing && wasKeyboardHidden - let desiredBottomInset: CGFloat = isKeyboardShowing ? 0 : self.safeAreaBottomInset - updateAccessoryBottomInset(desiredBottomInset) + // Width + bottom inset ride the keyboard's own animation so the bar + // widens/narrows exactly in step with it (including tap-away dismiss, + // where isDismissingKeyboard suppresses only the ScrollView animation). + updateAccessoryChrome(keyboardShowing: isKeyboardShowing, animationDuration: duration, curve: curve) // contentInset covers the full keyboard+accessory area from the screen bottom. // Clamp to at least the accessory height — the accessory is always visible @@ -769,12 +971,19 @@ public class KeyboardTrackingView: UIView { * Custom input accessory container that properly sizes itself and supports dynamic height. * Uses an internal height constraint which is the reliable way to resize inputAccessoryViews. * Extends UIInputView with .keyboard style so the background matches the system keyboard exactly. + * On iOS 26+ it is transparent instead: the tracked scroll view's edge effect (registered through + * UIScrollEdgeElementContainerInteraction) is the bar's background, and the keyboard material + * would sit on top of it. */ class InputAccessoryContainerView: UIInputView { var safeAreaBottomInset: CGFloat = 0 var contentHeight: CGFloat = 48 + // Horizontal inset applied to the content view. Non-zero while the + // keyboard is hidden (collapsed/floating state), zero while it is open. + var horizontalInset: CGFloat = 0 + // Direct reference to the NativeScript content view weak var contentViewRef: UIView? @@ -784,7 +993,14 @@ class InputAccessoryContainerView: UIInputView { init(frame: CGRect) { // .keyboard style gives the exact same translucent blur as the system keyboard. // No separate UIVisualEffectView needed — the accessory blends seamlessly. - super.init(frame: frame, inputViewStyle: .keyboard) + // Where the scroll edge effect exists it plays that role, so the bar stays clear. + let style: UIInputView.Style + if #available(iOS 26.0, *) { + style = .default + } else { + style = .keyboard + } + super.init(frame: frame, inputViewStyle: style) self.allowsSelfSizing = true @@ -815,7 +1031,7 @@ class InputAccessoryContainerView: UIInputView { super.layoutSubviews() // Enforce content view positioning on every layout pass. if let contentView = contentViewRef { - contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight) + contentView.frame = CGRect(x: horizontalInset, y: 0, width: bounds.width - horizontalInset * 2, height: contentHeight) } } diff --git a/packages/input-accessory/typings/ios.d.ts b/packages/input-accessory/typings/ios.d.ts index ebf1ee24..21ab3244 100644 --- a/packages/input-accessory/typings/ios.d.ts +++ b/packages/input-accessory/typings/ios.d.ts @@ -74,6 +74,12 @@ declare class KeyboardTrackingView extends UIView { dismissKeyboard(): void; + restoreAccessory(): void; + + setAccessoryRelayoutCallback(callback: () => void): void; + + setCollapsedHorizontalInset(inset: number): void; + setScrollViewRelayoutCallback(callback: () => void): void; setupWithInputContainerScrollViewHeight(inputContainer: UIView, scrollView: UIScrollView, height: number): void; @@ -82,6 +88,8 @@ declare class KeyboardTrackingView extends UIView { showKeyboardWithTextField(textField: UITextField): void; + suspendAccessory(): void; + setDismissingKeyboard(): void; trackKeyboardPosition(): void;