Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/input-accessory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -288,14 +289,20 @@ 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`. |

## How it works

### 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.

Expand Down
36 changes: 33 additions & 3 deletions packages/input-accessory/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;

/**
Expand All @@ -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;
}

/**
Expand All @@ -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;
}
Expand All @@ -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.
*/
Expand Down
18 changes: 18 additions & 0 deletions packages/input-accessory/index.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions packages/input-accessory/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
77 changes: 63 additions & 14 deletions packages/input-accessory/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -125,14 +171,17 @@ 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);

const widthSpec = Utils.layout.makeMeasureSpec(dpWidth, Utils.layout.EXACTLY);
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)) {
Expand Down
Loading
Loading