Skip to content

Keyboard, sheets and safe areas

Chat screens stress three things at once: a composer that must sit exactly on the keyboard, sheets with inputs, and system bars that differ per device. PAM Native 1.4 through 1.20 moved all three into the layout engine and the native hosts, so application code never adds status-bar heights, navigation bar constants or keyboard offsets.

The host passes the window safe area to the Rust layout engine before the first PHP frame. Every SafeAreaView gets padding for exactly the window edges its own frame overlaps, at any nesting level, as part of flex layout (like react-native-safe-area-context). Insets are never applied twice and a nested view that does not reach an edge receives none.

<!-- Screen above a tab bar: the tab bar owns the bottom edge. -->
<SafeAreaView safeAreaEdgeBottom="false">
<ChatList />
</SafeAreaView>
<AppTabBar />
<!-- Bottom bar or sheet content: only the bottom edge. -->
<SafeAreaView safeAreaEdgeTop="false" safeAreaEdgeLeft="false" safeAreaEdgeRight="false">
<Composer />
</SafeAreaView>

App::windowMetrics() and CSS env(safe-area-inset-*) carry the real insets from the first render (the host exports them before PHP starts) and are re-sent whenever the insets change. A window covered by a dialog, sheet or permission prompt keeps its insets.

Safe areas inside modals and sheets (1.20+)

Section titled “Safe areas inside modals and sheets (1.20+)”

Insets are per presentation surface. A SafeAreaView inside a Modal or BottomSheet uses the insets of that surface’s own window, never the screen’s, so the status bar and the navigation bar are cleared exactly once:

Surface Insets its SafeAreaView receives
Android 14 and older, non-translucent Modal None: the dialog window already starts below the status bar and ends above the navigation bar.
Android 14 and older, statusBarTranslucent or navigationBarTranslucent The real insets: the window is edge-to-edge.
Android 15+ (target SDK 35+) The real insets: every dialog is edge-to-edge.
iOS Modal The real insets: the modal covers the window.
BottomSheet, formSheet route (and iOS modal page sheet) Bottom and side insets where it reaches them; never the top inset.

Wrap full-screen modal content in a plain <SafeAreaView> whatever its translucency. statusBarTranslucent is only needed when the modal should draw its own background under the status bar; it is no longer a workaround for a doubled top gap (before 1.20 a non-translucent Android modal padded the status bar twice).

<Modal visible="$open" presentation="fullScreen" on:requestClose="close">
<SafeAreaView class="flex-1 surface">
<Header />
<Body class="flex-1" />
<Footer />
</SafeAreaView>
</Modal>

Some gesture-navigation configurations report a zero bottom inset although system UI overlaps the screen. Configure a fallback (dp, 0–96, default 0):

{ "android": { "safeAreaBottomFallback": 48 } }

Layout frames may have negative origins (1.13.2+): top: -200px, left: -34px and negative margins put a box outside its parent like CSS; only sizes must be finite and non-negative.

Hosts report the visible keyboard height to the engine (Android WindowInsets IME, iOS keyboardWillChangeFrame). A KeyboardAvoidingView with behavior="pan" that ends a vertical container is laid out directly above the keyboard while its flexible siblings shrink, like adjustResize or react-native-keyboard-controller:

<SafeAreaView class="chat">
<VirtualizedList class="timeline" inverted="true">…</VirtualizedList>
<KeyboardAvoidingView behavior="pan">
<Composer />
</KeyboardAvoidingView>
</SafeAreaView>
.chat { flex: 1; }
.timeline { flex: 1; }

The composer is visible, tappable and reported at its real bounds; the timeline keeps its last rows above it. A translated container is drawn above siblings with a higher z-index. Content inside a Modal or BottomSheet avoids that surface’s own keyboard instead (next section).

KeyboardAvoidingBehavior Template behavior Use
Resize (default) resize, height Shrinks the viewport and relays out flex descendants
Pan pan, position Moves an ending container above the keyboard (chat composers)
Padding padding Gives a contained vertical ScrollView the overlap inset and reveals the focused input with 16 dp clearance; keyboardVerticalOffset reserves more

height and position are React Native’s names (1.28+).

Keyboard avoidance inside modals and sheets (1.28+)

Section titled “Keyboard avoidance inside modals and sheets (1.28+)”

Each Modal and BottomSheet has its own keyboard inset, like its own safe area. A KeyboardAvoidingView inside one avoids the keyboard of that surface, measured from that surface’s bottom edge (Android: the IME of the modal’s own Dialog window; iOS: the keyboard frame converted to the modal’s coordinate space). The screen below never moves for it.

<!-- Text composer: the colour palette stays above the keyboard. -->
<Modal :visible="$open" presentation="fullScreen" statusBarTranslucent="true">
<KeyboardAvoidingView flexGrow="1" behavior="padding">
<View flexGrow="1">
<Stage class="flex-1" />
<View class="overlay"> <!-- absolute, space-between -->
<TopBar />
<TextInput multiline="true" />
<Palette />
</View>
</View>
</KeyboardAvoidingView>
</Modal>
Behavior inside a modal Android iOS
padding Engine: the content box loses the overlap, flex and absolute descendants are laid out above the keyboard Same
resize / height Engine: the view loses the overlap Same
pan / position Native translation against the dialog window’s IME Engine: the view moves up by the overlap
  • The overlap follows React Native’s relativeKeyboardHeight: the view’s bottom plus keyboardVerticalOffset below the keyboard’s top edge.
  • Android relays out on every WindowInsetsAnimation frame, so the content moves with the IME; iOS animates the new frames with the keyboard’s own duration and curve.
  • An interactive BottomSheet keeps lifting itself onto its keyboard; its KeyboardAvoidingView only avoids what the lift leaves (nothing for a sheet resting on the window bottom). Extend/FillParent sheets move or resize their window instead.
  • Before 1.28 an Android modal’s KeyboardAvoidingView read the activity window’s IME, which a dialog’s keyboard never reaches, so its content stayed under the keyboard.

Other keyboard rules:

  • Closing the keyboard with Back or the IME key while an input keeps focus blurs it, so on:blur handlers restore the resting layout.
  • <ScrollView keyboardInset="true"> adds the keyboard overlap as bottom content inset and keeps the focused input revealed (KeyboardAwareScrollView).
  • Controlled inputs never let a rendered value that echoes an older change event overwrite newer typed text (React Native mostRecentEventCount); values you set yourself (clearing after send) still apply.
  • Keyboard::dismiss() (Pam\Native\System\Keyboard) hides the keyboard.
use Pam\Native\BottomSheetKeyboardBehavior;
use Pam\Native\UI\BottomSheet;
$sheet = BottomSheet::make($content, snapPoints: [0.35, 0.7, 1.0], index: 1)
->dismissible()
->backdropDismiss()
->handleVisible()
->dragEnabled()
->cornerRadius(24)
->keyboardBehavior(BottomSheetKeyboardBehavior::Interactive)
->onChange(fn ($event) => $this->sheetIndex = $event->index)
->onDismiss(fn () => $this->editing = false);
<BottomSheet :snapPoints="[0.35, 0.7, 1.0]" index="1" keyboardBehavior="interactive"
on:sheetChange="sheetChanged" on:sheetDismiss="sheetDismissed">
<EditMessageForm />
</BottomSheet>

Sheets present like @gorhom/bottom-sheet: the backdrop fades while the sheet slides by its own height. Snap points are fractions of the container height minus the top safe-area inset.

BottomSheetKeyboardBehavior Behaviour
Interactive (1, default) Keeps the detent and rides above its own keyboard, frame by frame; the field and its buttons stay visible
Extend (2) Pan semantics
FillParent (3) A resized full-height surface
  • The screen below ignores the keyboard of an input inside a presented sheet or modal (the chat composer does not move).
  • Drag-to-close starts only when the scrollable under the finger is at its top; a downward drag inside a scrolled list scrolls it.
  • While the keyboard lifts a sheet, a tap on its upper part is not a backdrop tap.

autoFocus (TextInput::autoFocus() or the autoFocus attribute) opens the keyboard for an input inside a just-presented BottomSheet or Modal, also when the input is remounted with a new key in a re-presented sheet and when the sheet is presented while another overlay closes:

<BottomSheet p-if="$editing" :snapPoints="[0.5]" keyboardBehavior="interactive">
<TextInput :key="$editingMessageId" autoFocus="true" multiline="true" :value="$draft" on:change="typed" />
<Button on:press="save">Salvar</Button>
</BottomSheet>

On Android the input waits for its own window focus, asks the window insets controller and the input method manager, and retries (bounded by 3 s of wall clock) until the keyboard is visible; the modal window keeps SOFT_INPUT_STATE_ALWAYS_VISIBLE while an autoFocus input is pending (1.13.3+). PamAutoFocus logcat lines trace the path on devices. iOS runs autoFocus once the sheet is presented.

use Pam\Native\ModalAnimationType;
use Pam\Native\ModalPresentation;
use Pam\Native\UI\Modal;
Modal::make($content, visible: $this->open, presentation: ModalPresentation::FullScreen)
->animationType(ModalAnimationType::SlideFade)
->backdropColor(0x47000000)
->onRequestClose(fn () => $this->open = false);
  • <Modal transparent="true" presentation="fullScreen"> whose content ends in a short options panel stays a short sheet over a translucent backdrop: only children spanning the modal fill the window.
  • Back (down and up) is owned by the modal window.
  • Taps inside a Pressable nested in another Pressable (a panel inside a dismiss backdrop) reach the view under the finger (1.12.1+).
  • animationType: none, slide, fade, slide-fade (backdrop fades while the content slides).

Android behaviour is covered by instrumented suites (PamKeyboardInstrumentedTest, PamSheetKeyboardInstrumentedTest, PamNestedPressableInstrumentedTest) on Android 12 devices and emulators; keyboard avoidance inside modals and sheets by PamModalKeyboardAvoidingInstrumentedTest (real engine, real dialog windows, real IME) on API 31 and API 35 and PamModalKeyboardAvoidingTests (XCTest); per-surface safe areas by PamSurfaceSafeAreaInstrumentedTest (real engine, real dialog windows) on API 31 and API 36. The iOS counterparts are implemented and listed in the core repository’s docs/ios-parity.md for device validation.