Screen in src/components/ui/Screen.tsx is the wrapper for every page. It owns the safe area, the background, scrolling, pull to refresh, a pinned footer and keyboard avoidance. Screens should not add their own KeyboardAvoidingView.

Structure

Props

Behaviour that is always on:
  • keyboardShouldPersistTaps="handled", so a tap on a button works with the keyboard open.
  • Scroll content gets bottom padding of insets.bottom + spacing.huge + 72, which clears the tab bar and the global workout bar.
  • The inner view is centred and capped at sizes.maxContentWidth.

Background rules

Keyboard avoidance

The rule in the code:

Android

The KeyboardAvoidingView is always enabled on Android. It pads the bottom by the keyboard height, the scroll view shrinks, and the native scroll view brings the focused input into view. The Android tab layout also sets tabBarHideOnKeyboard. Project notes record the reason this is done in JS: the Android build runs edge to edge, and under edge to edge the window no longer resizes for the keyboard on its own. The setting is edgeToEdgeEnabled=true in the generated android/gradle.properties. That folder is not tracked in Git. The scroll view’s native automaticallyAdjustKeyboardInsets handles everything. The KeyboardAvoidingView stays disabled so the two do not both add an inset. The footer has to ride above the keyboard, so the KeyboardAvoidingView is enabled with:
The footer already pads itself by insets.bottom + spacing.md for the home indicator. With the keyboard open that safe-area padding is not needed, so the negative offset tucks it behind the keyboard and the buttons sit spacing.md above it. automaticallyAdjustKeyboardInsets is turned off here. The native handler lines the input up with the top of the keyboard, which is now covered by the footer. useRevealFocusedInput replaces it.

useRevealFocusedInput

src/lib/useRevealFocusedInput.ts is enabled only for iOS screens that have both footer and scroll. It returns setScrollRef, contentRef, footerRef and onScrollLayout, which Screen attaches to the scroll view, the inner view and the footer. reveal() runs on keyboardDidShow and whenever the scroll view’s layout height shrinks:
  1. Take the focused input from TextInput.State.currentlyFocusedInput().
  2. Measure its position inside the inner content view with measureLayout. This fails quietly for an input that is not inside this screen.
  3. Measure the footer’s top and the input’s top in window coordinates.
  4. If the input’s bottom is above the footer, do nothing.
  5. Otherwise scroll to inputY + inputHeight - viewportHeight, which puts the input’s bottom at the bottom of the visible area.

Footers

Pass primary actions through footer instead of positioning them absolutely:
The footer has a hairline top border, the page background, and the same max width as the body. Form screens use this for their step buttons. See Forms rendering.

Scroll locking

A child that handles horizontal or vertical drags inside a scrolling screen must stop the page from scrolling while the finger is down. The pattern is to hold a flag in the screen and pass scrollEnabled={!dragging}. The form range slider does this. Without it the scroll view steals the gesture.

Modals and sheets

Screen only manages its own tree. A Modal is a separate native window, so a sheet with an input needs its own KeyboardAvoidingView as its root. The barcode sheet in the nutrition feature does this.

Other keyboard details

  • Tapping outside an input does not dismiss the keyboard by default. Screens that need it call Keyboard.dismiss() from a backdrop press.
  • TextField with multiline grows with its content up to maxLines, so a long answer pushes the layout and the reveal logic keeps the caret line visible.
  • OtpInput auto-focuses on mount.