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
TheKeyboardAvoidingView 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.
iOS without a footer
The scroll view’s nativeautomaticallyAdjustKeyboardInsets handles everything. The KeyboardAvoidingView stays disabled so the two do not both add an inset.
iOS with a footer
The footer has to ride above the keyboard, so theKeyboardAvoidingView is enabled with:
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:
- Take the focused input from
TextInput.State.currentlyFocusedInput(). - Measure its position inside the inner content view with
measureLayout. This fails quietly for an input that is not inside this screen. - Measure the footer’s top and the input’s top in window coordinates.
- If the input’s bottom is above the footer, do nothing.
- Otherwise scroll to
inputY + inputHeight - viewportHeight, which puts the input’s bottom at the bottom of the visible area.
Footers
Pass primary actions throughfooter instead of positioning them absolutely:
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 passscrollEnabled={!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. TextFieldwithmultilinegrows with its content up tomaxLines, so a long answer pushes the layout and the reveal logic keeps the caret line visible.OtpInputauto-focuses on mount.