NativeScript Blog

iOS inputAccessoryView with NativeScript 📲 The Apple Messages Experience

Nathan Walker February 9, 2026

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 contentInset when 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:

  1. Swift (KeyboardTrackingView.swift) - Native iOS keyboard handling

    • Native keyboard notifications
    • ScrollView frame and contentInset management
    • inputAccessoryView management with UIInputView for translucent blur
    • Interactive dismiss tracking via CADisplayLink
    • Programmatic dismiss with scroll-jump prevention
  2. 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:

  1. Interactive dismiss bypass: When the CADisplayLink is already tracking per-frame, skip the notification handler to avoid a race condition
  2. 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
  3. Dynamic contentInset: contentInset.bottom tracks the full keyboard+accessory overlap. This defines the actual visible area while allowing content to extend behind the blur
  4. Accessory overlap clamp: keyboardOverlap is clamped to at least accessoryTotalHeight. Since the KeyboardTrackingView is always first responder, the accessory never disappears — so the overlap should never drop below it. This prevents a transient inset=0 state during first-responder transfers that would cause scroll jumps
  5. Tap-dismiss instant path: When isDismissingKeyboard is set, all changes are applied instantly (no UIView.animate). The keyboard's animation curve interpolates contentOffset over the animation duration, causing a visible "jump then settle" artifact. Skipping animation eliminates this
  6. Accessory-aware threshold: Use accessoryHeight + safeAreaBottomInset + 10 instead of a hardcoded value, since iOS reports the accessory as part of the keyboard frame
  7. Remeasure before guard: Call relayoutScrollViewContent() before the change guard so contentSize is always fresh
  8. Frame + inset + scroll in one animation: All three are synchronized in the same animation block
  9. Auto-scroll to bottom: When keyboard opens, show latest messages
  10. Sticky bottom: If user is at bottom, keep them there as keyboard height changes
  11. 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 the inputAccessoryView and isCollapsed=true makes row 2 collapse to 0 height
  • tapCloseKeyboard() on the StackLayout uses dismissKeyboard() to transfer first responder cleanly instead of dismissSoftInput()
  • The TextView uses colSpan="2" to overlap the send button, which sits on top in col="2"
  • isMultiLine() toggles between rounded-full (pill shape) and rounded-3xl (rounded rectangle) as the user types
  • Streamdown renders 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:

  1. Re-entrancy guard in TypeScript (isRelayoutingScrollView)
  2. 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:

  1. Sets isDismissingKeyboard = true so the keyboard handler takes the instant (non-animated) path
  2. Resets previousKeyboardY to screen height so subsequent notifications don't detect a false keyboard transition
  3. 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 }

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:

  1. iOS keyboard system - How inputAccessoryView attaches to first responder
  2. UIInputView .keyboard style - How to match the system keyboard's translucent blur
  3. Frame + contentInset - How to enable blur-through scroll visibility
  4. Frame vs Auto Layout - Why NativeScript needs frame-based positioning
  5. Layout cycles - When to manually trigger measure/layout
  6. Scroll behavior - How to match Apple's Messages experience
  7. Interactive dismiss - Tracking keyboard position per-frame during swipe gestures
  8. 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.

Resources

Join the conversation

Share your feedback or ask follow-up questions below.