The Android Update That Broke My Form Layout
You update your build configuration, bump targetSdkVersion to 35 to satisfy Google Play requirements, and trigger a test build. On iOS, everything behaves normally. But on Android 15, tapping into any form input near the bottom of the screen triggers a subtle disaster: the soft keyboard opens, but the screen does not budge. The bottom fields and the submit button remain completely trapped beneath the keyboard.
Nothing in your JavaScript changed. Your AndroidManifest.xml still declares android:windowSoftInputMode="adjustResize". Yet on Android 15, the window refuses to resize.
When the OS changes window behaviour, fix it at the window layer, not per screen, and know when the fix expires.
This is Day 7 of Ship Native, beginning our three-part series on Forms and uploads. Yesterday's lesson covered UIKit modal conflicts on iOS. Today we examine what Android 15 did to window boundaries, the temporary native opt-out that rescues shipped apps, and the permanent inset-aware layout solution.
The accompanying video uses schematic illustrations, English synthetic narration, and synchronized bilingual subtitles. The code examples below are reviewed production patterns without physical test device execution.
The mechanism: Android 15 edge-to-edge enforcement
Prior to Android 15 (API level 35), applications opted into edge-to-edge display explicitly. When an app did not opt in, the Android system window shrank automatically when the Input Method Editor (IME / soft keyboard) opened, provided the activity declared adjustResize. React Native's root container adjusted its viewport height accordingly, lifting bottom-pinned inputs into view.
Starting in Android 15, if your application targets SDK 35 (targetSdkVersion = 35), edge-to-edge display is enforced unconditionally.
Under this enforcement:
- The application window is configured to draw behind the system bars (status bar and navigation bar) and behind the IME.
- The classic
adjustResizebehavior that shrank the root window is ignored by the window manager. - The React Native root view maintains its full display height regardless of keyboard visibility.
- Because the root view does not shrink, components with
behavior={undefined}on Android (or default flex layouts) remain stationary. Any input anchored to the bottom sits directly under the keyboard.
Why per-screen manual padding is the wrong layer
When developers encounter this bug on a single screen, such as a support enquiry form, the immediate temptation is to patch that specific screen:
// Flawed approach: manual keyboard listener and bottom padding
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
const showSub = Keyboard.addListener('keyboardDidShow', e => {
setKeyboardHeight(e.endCoordinates.height);
});
const hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0));
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
This per-screen approach suffers from three major architectural flaws:
- Incomplete coverage. In a real app, this leaves every other form broken. In our production application (Liana), fixing one screen left seven other bottom-anchored screens broken: Login, Register, EditAccount, AddressForm, EmailChange, GuestCheckout, and CompleteOrder.
- Double-padding regressions. If window resizing is later restored, this manual offset adds on top of window resizing, leaving a blank void between the input and the keyboard.
- Mismatched animation curves. The keyboard event in React Native fires after or during keyboard animation, causing noticeable layout jumping and jank.
When an operating system update alters window-level mechanics, patching individual React views is the wrong layer. The issue is a window configuration change, so the first fix belongs at the window layer.
The immediate native fix: values-v35 opt-out
To give teams time to adapt without rushing major layout refactors, Android 15 provides an opt-out attribute: android:windowOptOutEdgeToEdgeEnforcement.
By declaring this in an Android resource file specifically targeting API 35 (android/app/src/main/res/values-v35/styles.xml), you restore the classic window resizing behavior app-wide:
<resources>
<!--
Android 15 (API 35) forces edge-to-edge on every app that targets it,
and this app targets 35. Under enforcement the window draws BEHIND the
IME instead of shrinking for it, so the `adjustResize` declared on
MainActivity is ignored: the React view keeps its full height and any
input anchored to the bottom of a screen ends up under the keyboard.
Opting out restores classic `adjustResize` behaviour app-wide, which
fixes all affected screens at once rather than padding each one by a
measured keyboard height in JS.
This file lives in `values-v35` because the attribute only exists from
API 35; older devices never had the enforcement to opt out of.
⚠️ This is an escape hatch with a deadline. Android 16 (API 36) removes it,
at which point the app must handle insets properly using inset-aware layout
(e.g., react-native-keyboard-controller). Treat this as buying time to do
that deliberately, not as the final answer.
-->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>
Why this works
Placing this file in values-v35/ ensures older Android versions (API 34 and below) never see an unrecognized attribute. Every screen in your app that previously relied on adjustResize immediately behaves correctly again without changing a single line of React Native or TypeScript code. This exact commit (8e43849 in Liana) resolved the issue across eight production screens at once.
The critical limitation: Android 16 (SDK 36) removes the opt-out
Here is the essential warning every mobile engineering team must understand:
[!WARNING]
android:windowOptOutEdgeToEdgeEnforcementis deprecated and strictly temporary. Once your application targets Android 16 (API level 36), the Android runtime completely ignores this attribute. Edge-to-edge becomes non-negotiable.
Treat the values-v35 opt-out as an escape hatch that buys your team time to adopt durable layout architectures, not as a permanent resolution.
The durable fix: react-native-keyboard-controller
The long-term, future-proof solution for edge-to-edge layouts on both Android and iOS is inset-aware layout.
Instead of relying on legacy window resizing, modern React Native apps should use libraries specifically engineered for edge-to-edge window insets, such as react-native-keyboard-controller.
Implementation: Inset-aware form layout
import React, { useState } from 'react';
import {
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
ScrollView,
Platform,
} from 'react-native';
import {
KeyboardAvoidingView,
KeyboardProvider,
} from 'react-native-keyboard-controller';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export interface FormValues {
name: string;
email: string;
message: string;
}
export function InsetAwareForm({
onSubmit,
}: {
onSubmit?: (values: FormValues) => void;
}) {
const insets = useSafeAreaInsets();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = () => {
if (!name.trim() || !email.trim()) return;
setSubmitting(true);
onSubmit?.({ name, email, message });
};
return (
<KeyboardProvider>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
keyboardVerticalOffset={Platform.OS === 'ios' ? insets.top : 0}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Math.max(insets.top, 16) },
]}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.title}>Project Enquiry</Text>
<Text style={styles.subtitle}>
Tell us about your team and timeline.
</Text>
<Text style={styles.label}>Full Name</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Ada Lovelace"
placeholderTextColor="#68778d"
autoCapitalize="words"
/>
<Text style={styles.label}>Work Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="[email protected]"
placeholderTextColor="#68778d"
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.label}>Project Scope</Text>
<TextInput
style={[styles.input, styles.multilineInput]}
value={message}
onChangeText={setMessage}
placeholder="What are you building?"
placeholderTextColor="#68778d"
multiline
numberOfLines={4}
/>
</ScrollView>
<View
style={[
styles.bottomBar,
{ paddingBottom: Math.max(insets.bottom, 16) },
]}
>
<TouchableOpacity
style={[styles.submitButton, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
activeOpacity={0.8}
>
<Text style={styles.submitText}>
{submitting ? 'Submitting...' : 'Submit Enquiry'}
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</KeyboardProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0f141c',
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
title: {
fontSize: 26,
fontWeight: '700',
color: '#f3f1ea',
marginBottom: 6,
},
subtitle: {
fontSize: 15,
color: '#8e9baa',
marginBottom: 24,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#cfd8dc',
marginBottom: 8,
},
input: {
backgroundColor: '#1b222d',
borderWidth: 1,
borderColor: '#2b3545',
borderRadius: 10,
color: '#f3f1ea',
fontSize: 16,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 16,
},
multilineInput: {
height: 100,
textAlignVertical: 'top',
},
bottomBar: {
paddingHorizontal: 20,
paddingTop: 12,
borderTopWidth: 1,
borderColor: '#242c3a',
backgroundColor: '#131822',
},
submitButton: {
backgroundColor: '#22c55e',
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
},
buttonDisabled: {
opacity: 0.6,
},
submitText: {
color: '#07100b',
fontSize: 16,
fontWeight: '700',
},
});
Key architectural benefits
- Synchronous tracking. The library tracks IME height on the UI thread, matching the Android keyboard frame by frame.
- Full edge-to-edge support across Android 15, Android 16, and iOS.
- Safe area integration. It combines safe area insets with keyboard padding, preventing clipping at the navigation bar and camera notch.
Production sidebar: Babel plugin ordering gotcha
If you are using react-native-keyboard-controller alongside react-native-reanimated, there is a dangerous trap in babel.config.js that only manifests in release builds:
// babel.config.js
// Crucial Gotcha for react-native-keyboard-controller + Reanimated:
// react-native-reanimated/plugin must ALWAYS be the last plugin in the array.
// Any plugin running after it can mangle worklets in release builds,
// silently breaking keyboard-controller in production while working in debug.
module.exports = api => {
const isProd = api.env('production');
return {
presets: ['module:@react-native/babel-preset'],
plugins: [
'module:react-native-dotenv',
...(isProd ? [['transform-remove-console', { exclude: ['error', 'warn'] }]] : []),
// CRITICAL: react-native-reanimated/plugin MUST BE LAST.
'react-native-reanimated/plugin',
],
};
};
In Jeyad/babel.config.js:48-52, we documented this exact production bug:
react-native-reanimated/plugin must be the very last plugin in the Babel plugins array. If plugins such as transform-remove-console or other AST transforms execute after Reanimated, they can corrupt worklet transformations during minification. This causes react-native-keyboard-controller (which relies on worklets for UI thread keyboard tracking) to silently fail in production release APKs/bundles, even though debug builds run flawlessly.
Verification and test steps
- Android 15 Emulator (API 35):
- Configure an emulator running API 35.
- Verify with
targetSdkVersion = 35and no opt-out: focus a bottom input and verify that the keyboard obscures it. - Add
values-v35/styles.xmlwithwindowOptOutEdgeToEdgeEnforcement="true". Rebuild and confirm that the window resizes and the submit button is fully accessible.
- Android 16 Preview / Inset testing:
- Migrate the screen to
react-native-keyboard-controller. - Remove the opt-out and verify that keyboard transitions animate fluidly without cutting off inputs.
- Migrate the screen to
- Release bundle smoke test:
- Build a production release APK (
./gradlew assembleRelease). - Confirm that keyboard avoidance operates properly and worklets are intact.
- Build a production release APK (
Summary
- Android 15 forces edge-to-edge on apps targeting SDK 35, causing the window to draw behind the keyboard and ignoring
adjustResize. - Fixing per-screen padding in JavaScript is the wrong layer and creates double-padding regressions.
- The immediate window-level fix is
android:windowOptOutEdgeToEdgeEnforcement="true"invalues-v35/styles.xml. - This attribute is removed in SDK 36. Use the time it buys to migrate to
react-native-keyboard-controller. - Always verify that
react-native-reanimated/pluginis the last plugin inbabel.config.js.
What comes next
Next: Why My Form Forgot the Input and Sent It Twice, Day 8, Forms and uploads 2 of 3. Its video link will be added when published.
Comments