iOS inputAccessoryView with NativeScript 📲 The Apple Messages Experience
Creating a native iOS messaging UI with a keyboard-docked input bar is common, but matching the polish of Apple's Messages app takes more than pinning a view to the bottom. In this guide, we'll build a production-ready inputAccessoryView setup with smooth keyboard animations, correct scrolling behavior, and pixel-perfect layout.
We could implement the whole thing in TypeScript, but we'll pair it with a bit of Swift to show how much power and flexibility NativeScript provides. We'll also wire up the GitHub Copilot SDK to supply the messaging data.
By the end, you'll have a drop-in setup you can use in any NativeScript project, regardless of framework (Angular, React, Solid, Svelte, or Vue).
What we'll build:
- A keyboard-tracking input bar that docks to the iOS keyboard
- A view you can layout and style with NativeScript components
- Translucent blur effect matching the system keyboard (scroll content visible behind the accessory)
- Automatic ScrollView management with
contentInsetwhen the keyboard appears - Auto-scroll to bottom when keyboard opens (like Apple Messages)
- Interactive keyboard dismiss tracking (swipe-down to dismiss)
- Clean tap-to-dismiss
- Dynamic TextView height expansion
- Smooth animations matching iOS system behavior
tl;dr — Sample Project
The full source code is available on GitHub.
Architecture Overview
The implementation is composed of two parts:
-
Swift (
KeyboardTrackingView.swift) - Native iOS keyboard handling- Native keyboard notifications
- ScrollView frame and
contentInsetmanagement - inputAccessoryView management with
UIInputViewfor translucent blur - Interactive dismiss tracking via CADisplayLink
- Programmatic dismiss with scroll-jump prevention
-
TypeScript (
keyboard-accessory.ts) - NativeScript layout coordination
- NativeScript Swift coordination
- Manages layout remeasurement
- Keyboard dismiss orchestration
Part 1: Swift Keyboard Helper
KeyboardTrackingView.swift
This invisible UIView acts as a first responder proxy, enabling the inputAccessoryView to always be visible.
@objcMembers
public class KeyboardTrackingView: UIView {
private var _keyboardAccessoryView: InputAccessoryContainerView?
private var accessoryHeight: CGFloat = 48
private var contentView: UIView?
private weak var trackedScrollView: UIScrollView?
private let maxAccessoryHeight: CGFloat = 200
private var safeAreaBottomInset: CGFloat = 0
// Track keyboard state
private var previousKeyboardY: CGFloat = 0
// Flag to suppress animation during programmatic keyboard dismiss (tap close).
private var isDismissingKeyboard: Bool = false
// Callback for TypeScript integration
private var scrollViewRelayoutCallback: (() -> Void)?
// Interactive keyboard dismiss tracking via CADisplayLink
private var displayLink: CADisplayLink?
private var displayLinkProxy: DisplayLinkProxy?
private var isInteractiveDismissActive: Bool = false
public override var canBecomeFirstResponder: Bool { true }
public override var inputAccessoryView: UIView? {
return _keyboardAccessoryView
}
Key insight: The inputAccessoryView is always attached to the first responder. By making our tracking view the first responder, the input bar stays visible even when the keyboard is hidden.
Setting Up the Input Container
public func setup(inputContainer: UIView, scrollView: UIScrollView, height: CGFloat) {
self.accessoryHeight = height
self.trackedScrollView = scrollView
self.contentView = inputContainer
// Initialize tracking (keyboard starts hidden at bottom of screen)
self.previousKeyboardY = UIScreen.main.bounds.height
// Get safe area inset for home indicator
if #available(iOS 15.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
self.safeAreaBottomInset = max(0, window.safeAreaInsets.bottom - 20)
}
}
let totalHeight = height + self.safeAreaBottomInset
let screenWidth = UIScreen.main.bounds.width
let accessoryContainer = InputAccessoryContainerView(
frame: CGRect(x: 0, y: 0, width: screenWidth, height: totalHeight)
)
accessoryContainer.safeAreaBottomInset = self.safeAreaBottomInset
accessoryContainer.contentHeight = height
// Store original position before removal
let originalSuperview = inputContainer.superview
var originalIndex = 0
if let superview = originalSuperview,
let idx = superview.subviews.firstIndex(of: inputContainer) {
originalIndex = idx
}
// Move NativeScript view into accessory using frame-based positioning
inputContainer.removeFromSuperview()
inputContainer.backgroundColor = .clear // Let blur effect show through
inputContainer.frame = CGRect(x: 0, y: 0, width: screenWidth, height: height)
inputContainer.autoresizingMask = [.flexibleWidth]
accessoryContainer.addSubview(inputContainer)
accessoryContainer.contentViewRef = inputContainer
// Create placeholder at original position to keep native subview array consistent
if let superview = originalSuperview {
let placeholder = UIView(frame: CGRect(x: 0, y: 0, width: superview.bounds.width, height: 0))
placeholder.isHidden = true
superview.insertSubview(placeholder, at: originalIndex)
}
self._keyboardAccessoryView = accessoryContainer
// On iOS 26+, add scroll edge blending (like Apple Notes / iMessage)
if #available(iOS 26.0, *) {
let edgeInteraction = UIScrollEdgeElementContainerInteraction()
edgeInteraction.scrollView = scrollView
edgeInteraction.edge = .bottom
accessoryContainer.addInteraction(edgeInteraction)
}
scrollView.keyboardDismissMode = .interactive
// Content extends behind the translucent accessory for the blur effect.
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: totalHeight, right: 0)
scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: totalHeight, right: 0)
// Track pan gesture for interactive keyboard dismiss
scrollView.panGestureRecognizer.addTarget(self, action: #selector(handleScrollViewPan(_:)))
// Observe keyboard changes
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillChangeFrame(_:)),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
// Become first responder after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.becomeFirstResponder()
}
}
Why frame-based positioning? NativeScript's layout system is frame-based. Auto Layout frame changes don't trigger NativeScript's onMeasure cycle, causing children not to resize properly.
Why a placeholder view? When removeFromSuperview() moves the input container into the accessory, the parent's native subview array shifts. Creating a zero-height hidden placeholder at the original index keeps the array consistent for NativeScript's layout system.
Why contentViewRef? UIInputView (used for the .keyboard blur style) adds its own internal backdrop subviews. Using subviews.first to find the NativeScript content view would return the wrong view. A direct weak reference (contentViewRef) avoids this fragile lookup.
Why contentInset? The ScrollView frame extends to the screen bottom so content can scroll behind both the translucent accessory and the keyboard for blur-through visibility. contentInset.bottom ensures the last message rests above the accessory, not hidden behind it.
iOS 26+ scroll edge blending: On iOS 26, UIScrollEdgeElementContainerInteraction creates the same glass fade effect seen in Apple Notes and iMessage, where scroll content seamlessly blends into the translucent accessory bar.
The Critical Keyboard Handler
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
// During interactive dismiss, the display link handles tracking.
// Just update previousKeyboardY so state detection stays correct.
if isInteractiveDismissActive {
if let userInfo = notification.userInfo,
let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
previousKeyboardY = endFrame.origin.y
}
return
}
stopInteractiveTracking()
guard let scrollView = trackedScrollView,
let userInfo = notification.userInfo,
let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
return
}
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? 7
let curve = UIView.AnimationOptions(rawValue: curveValue << 16)
let screenHeight = UIScreen.main.bounds.height
let scrollViewTopInWindow = scrollView.superview?.convert(
scrollView.frame.origin, to: nil).y ?? scrollView.frame.origin.y
// Frame always extends to the screen bottom so content is behind both the translucent accessory and the keyboard (for blur-through visibility).
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
// contentInset covers the full keyboard+accessory area from the screen bottom.
let accessoryTotalHeight = self.accessoryHeight + self.safeAreaBottomInset
let keyboardOverlap = max(accessoryTotalHeight, screenHeight - endFrame.origin.y)
// Detect keyboard showing vs hiding using accessory-aware threshold.
let accessoryOnlyThreshold = self.accessoryHeight + self.safeAreaBottomInset + 10
let isKeyboardShowing = endFrame.origin.y < screenHeight - accessoryOnlyThreshold
let wasKeyboardHidden = previousKeyboardY >= screenHeight - accessoryOnlyThreshold
let keyboardJustAppeared = isKeyboardShowing && wasKeyboardHidden
previousKeyboardY = endFrame.origin.y
// Calculate if user is at bottom before changes (account for contentInset)
let currentOffset = scrollView.contentOffset.y
let currentVisibleHeight = scrollView.bounds.height - scrollView.contentInset.bottom
let currentMaxOffset = max(0, scrollView.contentSize.height - currentVisibleHeight)
let isNearBottom = (currentMaxOffset < 10) || (currentMaxOffset - currentOffset < 100)
// Trigger content remeasurement before frame change
self.relayoutScrollViewContent()
// Only proceed if frame or inset actually changes
let frameChanged = abs(targetFrameHeight - scrollView.frame.size.height) > 1
let insetChanged = abs(keyboardOverlap - scrollView.contentInset.bottom) > 1
guard frameChanged || insetChanged else { return }
if isDismissingKeyboard {
if frameChanged {
var newFrame = scrollView.frame
newFrame.size.height = targetFrameHeight
scrollView.frame = newFrame
}
scrollView.contentInset.bottom = keyboardOverlap
scrollView.verticalScrollIndicatorInsets.bottom = keyboardOverlap
// Clamp scroll position to valid range without animation
let contentHeight = scrollView.contentSize.height
let visibleHeight = targetFrameHeight - keyboardOverlap
let maxOffset = max(0, contentHeight - visibleHeight)
let clampedOffset = max(0, min(currentOffset, maxOffset))
scrollView.contentOffset = CGPoint(x: 0, y: clampedOffset)
self.relayoutScrollViewContent()
return
}
// Animate frame, inset, and scroll position together
UIView.animate(withDuration: duration, delay: 0, options: curve) {
if frameChanged {
var newFrame = scrollView.frame
newFrame.size.height = targetFrameHeight
scrollView.frame = newFrame
}
scrollView.contentInset.bottom = keyboardOverlap
scrollView.verticalScrollIndicatorInsets.bottom = keyboardOverlap
let contentHeight = scrollView.contentSize.height
let visibleHeight = targetFrameHeight - keyboardOverlap
let maxOffset = max(0, contentHeight - visibleHeight)
// Apple Messages behavior
if keyboardJustAppeared {
if contentHeight > visibleHeight {
scrollView.contentOffset = CGPoint(x: 0, y: maxOffset)
}
} else if isKeyboardShowing && isNearBottom {
if contentHeight > visibleHeight {
scrollView.contentOffset = CGPoint(x: 0, y: maxOffset)
}
} else if !isKeyboardShowing && currentOffset > 0 {
let newOffset = max(0, min(currentOffset, maxOffset))
scrollView.contentOffset = CGPoint(x: 0, y: newOffset)
}
}
}
Key behaviors:
- Interactive dismiss bypass: When the CADisplayLink is already tracking per-frame, skip the notification handler to avoid a race condition
- Screen-bottom frame: The ScrollView frame always extends to the screen bottom. This allows content to scroll behind both the translucent accessory and the keyboard for blur-through visibility
- Dynamic
contentInset:contentInset.bottomtracks the full keyboard+accessory overlap. This defines the actual visible area while allowing content to extend behind the blur - Accessory overlap clamp:
keyboardOverlapis clamped to at leastaccessoryTotalHeight. Since theKeyboardTrackingViewis always first responder, the accessory never disappears — so the overlap should never drop below it. This prevents a transientinset=0state during first-responder transfers that would cause scroll jumps - Tap-dismiss instant path: When
isDismissingKeyboardis set, all changes are applied instantly (noUIView.animate). The keyboard's animation curve interpolatescontentOffsetover the animation duration, causing a visible "jump then settle" artifact. Skipping animation eliminates this - Accessory-aware threshold: Use
accessoryHeight + safeAreaBottomInset + 10instead of a hardcoded value, since iOS reports the accessory as part of the keyboard frame - Remeasure before guard: Call
relayoutScrollViewContent()before the change guard socontentSizeis always fresh - Frame + inset + scroll in one animation: All three are synchronized in the same animation block
- Auto-scroll to bottom: When keyboard opens, show latest messages
- Sticky bottom: If user is at bottom, keep them there as keyboard height changes
- Preserve position: If user scrolled up, don't auto-scroll
The InputAccessoryContainerView
class InputAccessoryContainerView: UIInputView {
var safeAreaBottomInset: CGFloat = 0
var contentHeight: CGFloat = 48
// Direct reference to the NativeScript content view
weak var contentViewRef: UIView?
private var heightConstraint: NSLayoutConstraint!
init(frame: CGRect) {
// .keyboard style gives the exact same translucent blur as the system keyboard.
super.init(frame: frame, inputViewStyle: .keyboard)
self.allowsSelfSizing = true
// Height constraint is the reliable way to resize inputAccessoryViews
self.translatesAutoresizingMaskIntoConstraints = false
heightConstraint = self.heightAnchor.constraint(equalToConstant: frame.size.height)
heightConstraint.priority = .required
heightConstraint.isActive = true
}
func updateHeightConstraint(_ newHeight: CGFloat) {
heightConstraint.constant = newHeight
invalidateIntrinsicContentSize()
superview?.setNeedsLayout()
superview?.layoutIfNeeded()
}
override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: heightConstraint.constant)
}
// Enforce content view positioning on every layout pass
override func layoutSubviews() {
super.layoutSubviews()
if let contentView = contentViewRef {
contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
}
}
override var safeAreaInsets: UIEdgeInsets {
return .zero // Prevent double safe-area padding
}
}
Why UIInputView with .keyboard style? This is Apple's API specifically designed for views that should match the keyboard's appearance. It provides the exact same translucent blur as the system keyboard — no separate UIVisualEffectView needed. Attempting to replicate this manually with UIVisualEffectView results in a visible gray box that doesn't match the keyboard background.
Why a height constraint? The iOS keyboard system monitors the inputAccessoryView's height constraint to resize the keyboard area. Frame-only updates don't trigger this.
Why contentViewRef? UIInputView adds its own internal backdrop subviews for the blur effect. Using subviews.first would return one of these internal views, not the NativeScript content. A weak reference provides a direct, reliable path.
Why override layoutSubviews? The keyboard system can trigger layout passes at any time (e.g., reloadInputViews()). Without enforcing the content frame on every pass, the NativeScript view can drift or creep when the accessory height changes dynamically.
Interactive Keyboard Dismiss Tracking
Setting keyboardDismissMode = .interactive lets users swipe down to dismiss the keyboard, but keyboardWillChangeFrame only fires at the end of the gesture — not during. This means the ScrollView stays compressed while the keyboard slides away, creating a jarring visual gap.
The solution is a CADisplayLink that polls the accessory's position every frame during the swipe:
@objc private func handleScrollViewPan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
if !isInteractiveDismissActive {
// Only start tracking when the keyboard is actually showing
let screenHeight = UIScreen.main.bounds.height
let accessoryOnlyThreshold = self.accessoryHeight + self.safeAreaBottomInset + 10
if previousKeyboardY < screenHeight - accessoryOnlyThreshold {
startInteractiveTracking()
}
}
case .ended, .cancelled, .failed:
// After the snap animation completes, finalize the ScrollView height
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.stopInteractiveTracking()
self?.finalizeScrollViewHeight()
}
default:
break
}
}
private func startInteractiveTracking() {
guard displayLink == nil else { return }
isInteractiveDismissActive = true
let proxy = DisplayLinkProxy(self)
displayLinkProxy = proxy
displayLink = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick))
displayLink?.add(to: .main, forMode: .common)
}
Every frame, trackKeyboardPosition() reads the accessory's real position in the window and updates the ScrollView's contentInset to track the moving keyboard:
@objc func trackKeyboardPosition() {
guard let accessoryView = _keyboardAccessoryView,
let window = accessoryView.window,
let scrollView = trackedScrollView else { return }
let frameInWindow = accessoryView.convert(accessoryView.bounds, to: window)
let accessoryTop = frameInWindow.origin.y
let screenHeight = UIScreen.main.bounds.height
let scrollViewTopInWindow = scrollView.superview?.convert(
scrollView.frame.origin, to: nil).y ?? scrollView.frame.origin.y
// Frame always extends to screen bottom
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
// contentInset tracks the moving keyboard+accessory area
let keyboardOverlap = max(0, screenHeight - accessoryTop)
let frameChanged = abs(targetFrameHeight - scrollView.frame.size.height) > 0.5
let insetChanged = abs(keyboardOverlap - scrollView.contentInset.bottom) > 0.5
guard frameChanged || insetChanged else { return }
// Check if user is near bottom before changes (account for contentInset)
let contentHeight = scrollView.contentSize.height
let currentVisibleHeight = scrollView.bounds.height - scrollView.contentInset.bottom
let currentMaxOffset = max(0, contentHeight - currentVisibleHeight)
let currentOffset = scrollView.contentOffset.y
let isNearBottom = (currentMaxOffset < 10) || (currentMaxOffset - currentOffset < 50)
if frameChanged {
var newFrame = scrollView.frame
newFrame.size.height = targetFrameHeight
scrollView.frame = newFrame
}
scrollView.contentInset.bottom = keyboardOverlap
scrollView.verticalScrollIndicatorInsets.bottom = keyboardOverlap
if isNearBottom {
let newVisibleHeight = targetFrameHeight - keyboardOverlap
let newMaxOffset = max(0, contentHeight - newVisibleHeight)
if contentHeight > newVisibleHeight {
scrollView.contentOffset = CGPoint(x: 0, y: newMaxOffset)
}
}
}
Avoiding retain cycles: CADisplayLink retains its target. Using self directly would prevent KeyboardTrackingView from being deallocated. A weak-reference proxy solves this:
private class DisplayLinkProxy: NSObject {
weak var target: KeyboardTrackingView?
init(_ target: KeyboardTrackingView) { self.target = target }
@objc func tick() { target?.trackKeyboardPosition() }
}
Part 2: TypeScript
keyboard-accessory.ts
This manager connects Swift callbacks to NativeScript's layout system.
export class KeyboardAccessoryManager {
private keyboardTrackingView: any = null;
private scrollView: UIScrollView | null = null;
private nsScrollViewContainer: View | null = null;
private inputContainerView: UIView | null = null;
private nsInputContainer: View | null = null;
private textView: TextView | null = null;
private baseHeight: number = 48;
private maxHeight: number = 200;
private isRelayoutingScrollView: boolean = false;
setup(
viewController: UIViewController,
inputContainer: View,
scrollView: UIScrollView,
scrollViewView: View,
textView: TextView
): void {
this.scrollView = scrollView;
this.nsScrollViewContainer = scrollViewView;
this.nsInputContainer = inputContainer;
this.inputContainerView = inputContainer.ios as UIView;
this.textView = textView;
// Cap initial height to avoid excessive top padding from NativeScript's
// pre-layout frame measurement. updateAccessoryHeight() handles dynamic growth.
const frameHeight = this.inputContainerView.frame.size.height;
const inputHeight = (frameHeight > 0 && frameHeight <= 50) ? frameHeight : 48;
this.baseHeight = inputHeight;
// Create native KeyboardTrackingView (invisible, just for first responder chain)
this.keyboardTrackingView = KeyboardTrackingView.alloc().initWithFrame(
CGRectMake(0, 0, 0, 0)
);
viewController.view.addSubview(this.keyboardTrackingView);
// Swift moves the view into the inputAccessoryView
this.keyboardTrackingView.setupWithInputContainerScrollViewHeight(
this.inputContainerView,
scrollView,
inputHeight
);
// Collapse in parent GridLayout (row 2 becomes 0 height)
(inputContainer as any).isCollapsed = true;
if (inputContainer.parent) {
inputContainer.parent.requestLayout();
}
// Set callback for Swift to trigger relayout
const relayoutCallback = () => {
this.relayoutScrollViewContent();
};
this.keyboardTrackingView.setScrollViewRelayoutCallback(relayoutCallback);
// Initial layout of children within the accessory dimensions
setTimeout(() => this.relayoutAccessory(), 50);
}
The Critical Relayout Method
public relayoutScrollViewContent(): void {
// Prevent recursive calls
if (this.isRelayoutingScrollView) {
return;
}
if (!this.scrollView || !this.nsScrollViewContainer) return;
this.isRelayoutingScrollView = true;
try {
// ScrollView extends ContentView with a single 'content' child
const stackLayout = (this.nsScrollViewContainer as any).content;
if (!stackLayout) return;
// Get current frame dimensions
const frame = this.scrollView.frame;
const width = frame.size.width;
const height = frame.size.height;
if (width <= 0 || height <= 0) return;
const dpWidth = Utils.layout.toDevicePixels(width);
// Remeasure StackLayout
const widthSpec = Utils.layout.makeMeasureSpec(dpWidth, Utils.layout.EXACTLY);
const heightSpec = Utils.layout.makeMeasureSpec(0, Utils.layout.UNSPECIFIED);
stackLayout.measure(widthSpec, heightSpec);
const measuredHeight = stackLayout.getMeasuredHeight();
stackLayout.layout(0, 0, dpWidth, measuredHeight);
// Recalculate contentSize
const contentHeight = Utils.layout.toDeviceIndependentPixels(measuredHeight);
this.scrollView.contentSize = CGSizeMake(width, contentHeight);
} finally {
this.isRelayoutingScrollView = false;
}
}
Why this matters: When Swift resizes the UIScrollView's frame, NativeScript's layout system isn't triggered. The StackLayout still has old measurements, causing contentSize to be stale. This creates a huge empty void and pushes content up under the header.
By manually remeasuring the StackLayout after frame changes, we ensure contentSize always reflects the actual content height.
Dismissing the Keyboard
/**
* Dismiss the keyboard by transferring first responder to the KeyboardTrackingView.
*/
dismissKeyboard(): void {
if (this.keyboardTrackingView) {
this.keyboardTrackingView.setDismissingKeyboard();
this.keyboardTrackingView.becomeFirstResponder();
}
}
Why setDismissingKeyboard() before becomeFirstResponder()? The flag must be set before the first-responder transfer begins. becomeFirstResponder() triggers keyboardWillChangeFrame synchronously — if the flag isn't already set, the first notification takes the animated path and causes the scroll jump.
Why not dismissSoftInput()? NativeScript's dismissSoftInput() calls resignFirstResponder() on the UITextView, which removes all first responders. This causes the inputAccessoryView to disappear briefly and then reappear when KeyboardTrackingView becomes first responder again — creating a visible flash and scroll jump. Transferring first responder directly avoids this intermediate state.
Dynamic TextView Height
updateAccessoryHeight(): void {
if (!this.keyboardTrackingView || !this.textView) return;
const nativeTextView = this.textView.ios as UITextView;
if (!nativeTextView) return;
// sizeThatFits returns natural text height (contentSize doesn't work with scrollEnabled=false)
const currentWidth = nativeTextView.frame.size.width;
const fittingSize = nativeTextView.sizeThatFits(CGSizeMake(currentWidth, 10000));
const containerPadding = 16;
let newHeight = fittingSize.height + containerPadding;
newHeight = Math.max(this.baseHeight, Math.min(newHeight, this.maxHeight));
// Update native accessory height (Swift handles height constraint + reloadInputViews)
this.keyboardTrackingView.updateHeight(newHeight);
// Re-layout NativeScript children within the new dimensions
this.relayoutAccessory();
}
Part 3: Framework Integration
We'll use Angular as an example however the same approach applies to React, Vue, Solid or Svelte.
Component Setup
@Component({
selector: 'ai-chat',
templateUrl: './ai-chat.component.html',
imports: [NativeScriptCommonModule, Streamdown],
schemas: [NO_ERRORS_SCHEMA],
})
export class AiChatComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('scrollView', { read: ElementRef }) scrollView?: ElementRef;
@ViewChild('messageInput', { read: ElementRef }) messageInput?: ElementRef;
@ViewChild('inputContainer', { read: ElementRef }) inputContainer?: ElementRef;
private nativeScrollView: UIScrollView | null = null;
private keyboardAccessoryManager: KeyboardAccessoryManager | null = null;
private textView: TextView | null = null;
ngAfterViewInit() {
if (__APPLE__) {
setTimeout(() => this.setupKeyboardAccessory(), 100);
}
}
tapCloseKeyboard() {
if (__APPLE__ && this.keyboardAccessoryManager) {
this.keyboardAccessoryManager.dismissKeyboard();
}
}
private setupKeyboardAccessory() {
if (this.isAccessorySetup) return;
if (!this.inputContainer?.nativeElement ||
!this.messageInput?.nativeElement ||
!this.nativeScrollView) {
setTimeout(() => this.setupKeyboardAccessory(), 100);
return;
}
this.isAccessorySetup = true;
this.keyboardAccessoryManager = new KeyboardAccessoryManager();
const inputContainerView = this.inputContainer.nativeElement as View;
const scrollViewView = this.scrollView.nativeElement as ScrollView;
const viewController = this.page.viewController as UIViewController;
this.keyboardAccessoryManager.setup(
viewController,
inputContainerView,
this.nativeScrollView,
scrollViewView,
this.textView
);
}
onTextChange(args: any) {
const textView = args.object as TextView;
this.inputText.set(textView.text);
if (__APPLE__ && this.keyboardAccessoryManager) {
this.keyboardAccessoryManager.updateAccessoryHeight();
}
}
ngOnDestroy() {
if (__APPLE__ && this.keyboardAccessoryManager) {
this.keyboardAccessoryManager.cleanup();
}
}
}
Template Structure
<GridLayout rows="auto, *, auto">
<!-- Header -->
<GridLayout row="0" columns="*, auto" class="header">
<!-- App branding -->
</GridLayout>
<!-- Messages ScrollView -->
<ScrollView
row="1"
#scrollView
(loaded)="onScrollViewLoaded($event)">
<StackLayout (tap)="tapCloseKeyboard()">
@for (message of messages(); track message.id) {
@if (message.role === "user") {
<!-- User bubble: right-aligned, blue -->
}
@if (message.role === "assistant") {
<!-- Assistant bubble: left-aligned with avatar, uses Streamdown for markdown -->
<Streamdown [content]="message.content" [config]="getMessageConfig(message)"></Streamdown>
}
}
</StackLayout>
</ScrollView>
<!-- Input Container (will be moved to inputAccessoryView) -->
<GridLayout
#inputContainer
row="2"
rows="*"
columns="auto,*,auto"
class="bg-transparent">
<MenuImage
src="sys://plus"
[options]="addOptions"
(selected)="selectOption($event)">
</MenuImage>
<TextView
#messageInput
col="1"
colSpan="2"
[text]="inputText()"
(textChange)="onTextChange($event)"
(loaded)="onTextViewLoaded($event)"
hint="Ask me anything..."
[class.rounded-3xl]="isMultiLine()"
[class.rounded-full]="!isMultiLine()">
</TextView>
<Button
col="2"
[text]="isLoading() ? '⋯' : '↑'"
[isEnabled]="!isLoading() && inputText().length > 0"
(tap)="sendMessage()">
</Button>
</GridLayout>
</GridLayout>
Key details:
- The input container starts in
row="2"of the GridLayout. After setup, it's moved to theinputAccessoryViewandisCollapsed=truemakes row 2 collapse to 0 height tapCloseKeyboard()on the StackLayout usesdismissKeyboard()to transfer first responder cleanly instead ofdismissSoftInput()- The
TextViewusescolSpan="2"to overlap the send button, which sits on top incol="2" isMultiLine()toggles betweenrounded-full(pill shape) androunded-3xl(rounded rectangle) as the user typesStreamdownrenders assistant markdown responses with streaming support
Critical Lessons Learned
1. Frame Resize ≠ Layout Recalculation
In NativeScript, changing a View's native frame does not trigger the measure/layout cycle. You must manually call measure() and layout() on affected views.
// ❌ Wrong - contentSize becomes stale
scrollView.frame = newFrame;
// ✅ Correct - remeasure content after frame change
scrollView.frame = newFrame;
relayoutScrollViewContent(); // Triggers measure/layout
2. Frame + contentInset for Blur-Through Visibility
To achieve the Apple Messages translucent blur effect, the ScrollView needs both a screen-bottom frame and dynamic contentInset:
// ScrollView frame always extends to the screen bottom.
// Content scrolls behind the translucent accessory and keyboard.
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
// contentInset defines the actual visible area.
// Content "rests" above the keyboard+accessory, but can scroll behind it.
let keyboardOverlap = max(accessoryTotalHeight, screenHeight - endFrame.origin.y)
scrollView.contentInset.bottom = keyboardOverlap
Why not just frame resize? If the frame stops at the accessory top, content behind the accessory is clipped — no blur-through. Extending the frame to the screen bottom and using contentInset to define the "visible" area gives you the translucent scroll-through effect.
Why clamp to accessoryTotalHeight? The accessory is always visible (KeyboardTrackingView is always first responder). During first-responder transfers, iOS can briefly report endFrame.origin.y = screenHeight (keyboard fully off-screen), which would make keyboardOverlap = 0. Clamping prevents this transient state from causing a scroll jump.
3. Preventing Recursive Loops
Frame changes can trigger keyboard notifications, which trigger more frame changes. An incremental delta approach (tracking previousOverlap and adjusting by the difference) is inherently unstable — resizing the frame changes the overlap, causing the delta to flip sign and bounce infinitely.
Solution: Absolute target height. Instead of computing overlap relative to the (changing) frame bottom, compute the target height from the keyboard top minus the ScrollView's top. The ScrollView's origin never changes, making this calculation deterministic:
let scrollViewTopInWindow = scrollView.superview?.convert(
scrollView.frame.origin, to: nil).y ?? scrollView.frame.origin.y
let targetFrameHeight = max(100, screenHeight - scrollViewTopInWindow)
If the notification fires again with the same keyboard position, targetFrameHeight == currentHeight and the guard rejects it. No loop possible.
Additional protection:
- Re-entrancy guard in TypeScript (
isRelayoutingScrollView) - Height-change threshold in Swift (
abs(targetHeight - currentHeight) > 1)
4. Why sizeThatFits() Not contentSize
With scrollEnabled=false, UITextView's contentSize equals its bounds, NOT the text content height.
// ❌ Wrong
let height = textView.contentSize.height
// ✅ Correct
let fittingSize = textView.sizeThatFits(CGSize(width: currentWidth, height: 10000))
let height = fittingSize.height
5. First Responder for reloadInputViews()
When the user is typing, the UITextView is the first responder, not the KeyboardTrackingView proxy.
// ❌ Wrong
self.reloadInputViews()
// ✅ Correct - find actual first responder
if let firstResponder = self.findFirstResponder(in: accessoryView) {
firstResponder.reloadInputViews()
} else {
self.reloadInputViews()
}
6. Tap-Dismiss Scroll Jump Prevention
When the user taps above the keyboard to dismiss it, transferring first responder fires multiple keyboardWillChangeFrame notifications. The default animated path causes a visible "jump then settle" as contentOffset is interpolated over the animation duration.
// ❌ Wrong - causes scroll jump
keyboardTrackingView.becomeFirstResponder()
// ✅ Correct - flag suppresses animation for the full transition
keyboardTrackingView.setDismissingKeyboard() // Sets flag + resets previousKeyboardY + starts 0.5s timer
keyboardTrackingView.becomeFirstResponder()
Three things setDismissingKeyboard() does:
- Sets
isDismissingKeyboard = trueso the keyboard handler takes the instant (non-animated) path - Resets
previousKeyboardYto screen height so subsequent notifications don't detect a false keyboard transition - Starts a 0.5s timer to clear the flag, ensuring all notifications during the transfer are handled instantly
Performance Considerations
Debouncing Layout Updates
The isRelayoutingScrollView flag prevents multiple simultaneous relayouts:
if (this.isRelayoutingScrollView) return;
this.isRelayoutingScrollView = true;
try {
// ... relayout logic
} finally {
this.isRelayoutingScrollView = false;
}
Absolute Height Guard
Only trigger relayout when the target height actually differs from the current height:
let frameChanged = abs(targetFrameHeight - scrollView.frame.size.height) > 1
let insetChanged = abs(keyboardOverlap - scrollView.contentInset.bottom) > 1
guard frameChanged || insetChanged else { return }
CADisplayLink Lifecycle
The display link only runs during interactive dismiss gestures, and uses a tighter threshold (0.5pt vs 1pt) for smoother tracking. It starts on .changed and stops with a 0.5s delay after .ended to allow the snap animation to complete. A weak-reference DisplayLinkProxy prevents retain cycles.
Troubleshooting
Empty Void in ScrollView
Symptom: Huge dark space, content pushed up under header
Cause: Stale contentSize after frame resize
Fix: Call relayoutScrollViewContent() after frame changes
Content Not Scrolling to Bottom
Symptom: Auto-scroll doesn't work
Cause: contentHeight <= visibleHeight check failing
Fix: Always check before scrolling:
if contentHeight > visibleHeight {
scrollView.contentOffset = CGPoint(x: 0, y: maxOffset)
}
Recursive Bouncing
Symptom: Frame bounces between two heights infinitely
Cause: Incremental delta approach — resizing the frame changes the overlap calculation, causing delta to flip sign
Fix: Use absolute target height (screenHeight - scrollViewTop for frame, dynamic contentInset for visible area) instead of incremental deltas
Scroll Jump on Tap Dismiss
Symptom: Content jumps up and then settles back when tapping above keyboard to dismiss
Cause: UIView.animate interpolates contentOffset changes over the keyboard animation duration, and multiple keyboardWillChangeFrame notifications fire during first-responder transfer
Fix: Set isDismissingKeyboard flag before becomeFirstResponder(). The flag causes keyboardWillChangeFrame to apply all changes instantly (no animation). Reset previousKeyboardY to screen height to prevent false transition detection. Use a 0.5s timer to clear the flag (not on first notification) so ALL notifications during the transfer take the instant path.
Accessory Background Doesn't Match Keyboard
Symptom: Visible gray box or tint mismatch between accessory and keyboard
Cause: Using UIView with UIVisualEffectView instead of UIInputView
Fix: Extend UIInputView with .keyboard inputViewStyle instead of UIView. This is Apple's API specifically designed to match the keyboard's exact appearance — no manual blur configuration needed.
TextView Not Expanding
Symptom: Multi-line text doesn't grow the input bar
Cause: scrollEnabled=true or wrong height measurement
Fix: Set scrollEnabled=false and use sizeThatFits()
Gap During Interactive Dismiss
Symptom: ScrollView stays compressed while keyboard slides away during swipe-down
Cause: keyboardWillChangeFrame only fires at the end of the interactive dismiss gesture, not during
Fix: Use a CADisplayLink to poll the accessory's window position every frame and resize the ScrollView in real-time
Conclusion
Building a production-ready inputAccessoryView requires understanding:
- iOS keyboard system - How inputAccessoryView attaches to first responder
- UIInputView
.keyboardstyle - How to match the system keyboard's translucent blur - Frame + contentInset - How to enable blur-through scroll visibility
- Frame vs Auto Layout - Why NativeScript needs frame-based positioning
- Layout cycles - When to manually trigger measure/layout
- Scroll behavior - How to match Apple's Messages experience
- Interactive dismiss - Tracking keyboard position per-frame during swipe gestures
- First-responder transfers - Preventing scroll jumps during programmatic dismiss
The result is a chat interface that feels native, smooth, and polished with the same translucent blur-through effect you see in Apple Messages.