Skip to content

Latest commit

 

History

History
310 lines (251 loc) · 11 KB

File metadata and controls

310 lines (251 loc) · 11 KB

@nativescript/input-accessory

Keyboard input accessory — docked input bar, interactive dismiss, and auto-scroll on both iOS and Android. Commonly used with chat interfaces, search focused views, or anywhere you want a keyboard-attached input experience.

Features

  • iOS: Moves your input bar into a inputAccessoryView docked to the keyboard with system blur effect, interactive dismiss via scroll gesture, and smooth keyboard transitions (like iMessage)
  • Android: Uses WindowInsetsAnimationCompat for smooth keyboard transitions with translationY animation, interactive swipe-to-dismiss, and scroll padding management (API 30+)
  • Auto-growing input: Automatically resizes the accessory as the TextView content grows (up to a configurable max height)
  • Auto-scroll: Scrolls to bottom when the keyboard appears; maintains scroll position when it hides
  • Any layout: Use any NativeScript view markup for your input bar — the plugin handles the keyboard docking

Installation

npm install @nativescript/input-accessory

Usage

  • one ScrollView for content
  • one input container at the bottom
  • one TextView
  • call setup(...) after views are loaded
  • call updateAccessoryHeight() on textChange
  • call cleanup() on teardown

Core (XML + TypeScript)

<Page navigatingTo="navigatingTo" class="page" xmlns="http://schemas.nativescript.org/tns.xsd">
  <GridLayout rows="*, auto">
    <ScrollView id="scrollView" row="0">
      <StackLayout tap="{{ dismissKeyboard }}">
        <Label text="Messages..." />
      </StackLayout>
    </ScrollView>

    <GridLayout id="inputContainer" row="1" columns="*, auto" class="p-8">
      <TextView
        id="messageInput"
        col="0"
        hint="Type..."
        text="{{ inputText }}"
        loaded="{{ onTextViewLoaded }}"
        textChange="{{ onTextChange }}"
      />
      <Button col="1" text="Send" tap="{{ send }}" />
    </GridLayout>
  </GridLayout>
</Page>
import { EventData, Observable, Page, ScrollView, TextView, View } from '@nativescript/core';
import { InputAccessoryManager } from '@nativescript/input-accessory';

export function navigatingTo(args: EventData) {
  const page = args.object as Page;
  page.bindingContext = new DemoModel(page);
}

class DemoModel extends Observable {
  private manager: InputAccessoryManager | null = null;
  private textView: TextView | null = null;

  constructor(private page: Page) {
    super();
    this.set('inputText', '');
    this.page.on(Page.navigatingFromEvent, () => this.manager?.cleanup());
  }

  onTextViewLoaded(args: EventData) {
    this.textView = args.object as TextView;
    const scrollView = this.page.getViewById<ScrollView>('scrollView');
    const inputContainer = this.page.getViewById<View>('inputContainer');
    if (!scrollView || !inputContainer || !this.textView || this.manager) return;

    this.manager = new InputAccessoryManager();
    this.manager.setup({ page: this.page, scrollView, inputContainer, textView: this.textView });
  }

  onTextChange() {
    this.manager?.updateAccessoryHeight();
  }

  dismissKeyboard() {
    this.manager?.dismissKeyboard();
  }

  send() {
    this.set('inputText', '');
    this.manager?.updateAccessoryHeight();
  }
}

Angular

<GridLayout rows="*, auto">
  <ScrollView #scrollView row="0"><StackLayout (tap)="dismissKeyboard()"><Label text="Messages..." /></StackLayout></ScrollView>
  <GridLayout #inputContainer row="1" columns="*, auto">
    <TextView #messageInput col="0" hint="Type..." (loaded)="onTextViewLoaded($event)" (textChange)="onTextChange()"></TextView>
    <Button col="1" text="Send"></Button>
  </GridLayout>
</GridLayout>
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core';
import { EventData, Page, ScrollView, TextView, View } from '@nativescript/core';
import { InputAccessoryManager } from '@nativescript/input-accessory';

@Component({ selector: 'chat', templateUrl: './chat.component.html' })
export class ChatComponent implements AfterViewInit, OnDestroy {
  @ViewChild('scrollView', { read: ElementRef }) scrollView!: ElementRef<ScrollView>;
  @ViewChild('inputContainer', { read: ElementRef }) inputContainer!: ElementRef<View>;
  private page = inject(Page);
  private manager: InputAccessoryManager | null = null;
  private textView: TextView | null = null;

  ngAfterViewInit() {
    setTimeout(() => this.setup(), 0);
  }

  onTextViewLoaded(args: EventData) {
    this.textView = args.object as TextView;
    this.setup();
  }

  onTextChange() {
    this.manager?.updateAccessoryHeight();
  }

  dismissKeyboard() {
    this.manager?.dismissKeyboard();
  }

  ngOnDestroy() {
    this.manager?.cleanup();
  }

  private setup() {
    if (this.manager || !this.textView || !this.scrollView?.nativeElement || !this.inputContainer?.nativeElement) return;
    this.manager = new InputAccessoryManager();
    this.manager.setup({
      page: this.page,
      scrollView: this.scrollView.nativeElement,
      inputContainer: this.inputContainer.nativeElement,
      textView: this.textView,
    });
  }
}

Solid

import { onCleanup, onMount } from 'solid-js';
import { Page, ScrollView, TextView, View } from '@nativescript/core';
import { InputAccessoryManager } from '@nativescript/input-accessory';

export function Home() {
  let pageRef: Page;
  let scrollRef: ScrollView;
  let inputRef: View;
  let textRef: TextView;
  const manager = new InputAccessoryManager();

  onMount(() => {
    manager.setup({ page: pageRef, scrollView: scrollRef, inputContainer: inputRef, textView: textRef });
  });

  onCleanup(() => manager.cleanup());

  return (
    <page ref={pageRef!}>
      <gridLayout rows="*, auto">
        <scrollView ref={scrollRef!} row={0}><stackLayout><label text="Messages..." /></stackLayout></scrollView>
        <gridLayout ref={inputRef!} row={1} columns="*, auto">
          <textView ref={textRef!} col={0} hint="Type..." onTextChange={() => manager.updateAccessoryHeight()} />
          <button col={1} text="Send" />
        </gridLayout>
      </gridLayout>
    </page>
  );
}

Svelte

<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import type { Page, ScrollView, TextView, View } from '@nativescript/core';
  import { InputAccessoryManager } from '@nativescript/input-accessory';

  let pageRef: Page;
  let scrollRef: ScrollView;
  let inputRef: View;
  let textRef: TextView;
  const manager = new InputAccessoryManager();

  onMount(() => {
    manager.setup({ page: pageRef, scrollView: scrollRef, inputContainer: inputRef, textView: textRef });
  });

  onDestroy(() => manager.cleanup());
</script>

<Page bind:this={pageRef}>
  <GridLayout rows="*, auto">
    <ScrollView bind:this={scrollRef} row="0"><StackLayout><Label text="Messages..." /></StackLayout></ScrollView>
    <GridLayout bind:this={inputRef} row="1" columns="*, auto">
      <TextView bind:this={textRef} col="0" hint="Type..." on:textChange={() => manager.updateAccessoryHeight()} />
      <Button col="1" text="Send" />
    </GridLayout>
  </GridLayout>
</Page>

Vue

<template>
  <Page @loaded="onPageLoaded" @unloaded="onPageUnloaded">
    <GridLayout rows="*, auto">
      <ScrollView ref="scrollView" row="0"><StackLayout @tap="dismissKeyboard"><Label text="Messages..." /></StackLayout></ScrollView>
      <GridLayout ref="inputContainer" row="1" columns="*, auto">
        <TextView ref="messageInput" col="0" hint="Type..." @textChange="onTextChange" />
        <Button col="1" text="Send" />
      </GridLayout>
    </GridLayout>
  </Page>
</template>

<script lang="ts">
import { InputAccessoryManager } from '@nativescript/input-accessory';

export default {
  data() {
    return {
      manager: new InputAccessoryManager(),
    };
  },
  methods: {
    onPageLoaded(args) {
      this.manager.setup({
        page: args.object,
        scrollView: this.$refs.scrollView.nativeView,
        inputContainer: this.$refs.inputContainer.nativeView,
        textView: this.$refs.messageInput.nativeView,
      });
    },
    onTextChange() {
      this.manager.updateAccessoryHeight();
    },
    dismissKeyboard() {
      this.manager.dismissKeyboard();
    },
    onPageUnloaded() {
      this.manager.cleanup();
    },
  },
};
</script>

API

InputAccessoryConfig

Property Type Required Default Description
page Page Yes NativeScript Page instance (iOS: viewController access, Android: Activity access)
scrollView ScrollView Yes The ScrollView containing your messages
inputContainer View Yes The View (any layout) that serves as the input bar
textView TextView Yes The TextView for text input (used for auto-height)
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)

InputAccessoryManager

Method Description
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.
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). On iOS 26+, UIScrollEdgeElementContainerInteraction provides glass-morphism blending between the ScrollView and the accessory.

Interactive dismiss is handled via a CADisplayLink that tracks the accessory position during scroll gestures, updating the ScrollView's contentInset every frame.

Android

The plugin uses a Kotlin helper that hooks into WindowInsetsAnimationCompat for per-frame keyboard tracking. It uses a "deferred transition" pattern — NativeScript handles the actual layout changes, and the helper smooths the transition with translationY animation. Interactive swipe-to-dismiss is implemented via controlWindowInsetsAnimation() which gives direct control over the keyboard position, tracking the user's finger.

Requires API 30+. On older APIs, the plugin is a no-op and default keyboard behavior is used.

License

Apache-2.0