Back to writing

Mobile engineering

The same screen on iOS and Android: where differences appear

TextInput, keyboards, safe areas and modals: documented React Native differences with six implementation examples.

2026-09-2812 minReact NativeiOSAndroid

Likes and bookmarks are saved only in this browser.

A contact screen, two operating systems

Imagine a screen with a phone number, notes and a continue button. It opens correctly on both iPhone and Android. Differences appear when someone starts filling it out: the keyboard covers an action, text sits in a different position or a back gesture closes something unexpected.

To investigate this screen, distinguish three situations: a platform-exclusive API, a shared API with different behavior and behavior determined by native configuration. Each calls for a different decision. This article uses a fictional form to explore those decisions, without reproducing code from professional projects.

Sources and references

Differences worth recognizing before debugging

This map helps check your hypothesis before adding a platform condition. Documented behavior can look like a bug when a screen has only been exercised on one operating system.

The same API, different support or results
API / configurationiOSAndroid
TextInput multilineTop-aligned text by defaultVertically centered text by default
ScrollView: keyboardDismissMode="interactive"Keyboard follows the dragBehaves like "none"
InputAccessoryViewKeyboard accessory barNo equivalent implementation in this component
Button.colorText colorBackground color
BackHandlerNot the iOS navigation APIAPI for the system back action

1. TextInput: the field renders, but interaction changes

For notes, textAlignVertical: "top" makes the intended alignment explicit. The phone field has a more consequential limitation: on iOS, onSubmitEditing does not fire with keyboardType="phone-pad".

The example keeps a visible continue action and shares its callback between both entry points. Phone validation belongs in onContinue; the keyboard type alone does not validate the value.

ContactFields.tsxtsxDownload example
import { useState } from 'react';
import { Button, Keyboard, Text, TextInput, View } from 'react-native';

type Props = {
  onContinue: (values: { phone: string; notes: string }) => void;
};

export function ContactFields({ onContinue }: Props) {
  const [phone, setPhone] = useState('');
  const [notes, setNotes] = useState('');
  const continueForm = () => {
    Keyboard.dismiss();
    onContinue({ phone, notes });
  };

  return (
    <View style={{ gap: 12 }}>
      <Text>Phone</Text>
      <TextInput
        accessibilityLabel="Phone"
        keyboardType="phone-pad"
        value={phone}
        onChangeText={setPhone}
        onSubmitEditing={continueForm}
        style={{ borderWidth: 1, padding: 12 }}
      />
      <Text>Notes</Text>
      <TextInput
        accessibilityLabel="Notes"
        multiline
        value={notes}
        onChangeText={setNotes}
        underlineColorAndroid="transparent"
        style={{
          minHeight: 120, borderWidth: 1,
          padding: 12, textAlignVertical: 'top',
        }}
      />
      <Button title="Continue" onPress={continueForm} />
    </View>
  );
}

2. Keyboard: position, scrolling and taps are separate decisions

KeyboardAvoidingView can adjust height, position or padding, and its documentation highlights differences between iOS and Android. The example uses padding on iOS and height on Android as a starting point. Evaluate this combination against Android window configuration and the space occupied by the header.

keyboardDismissMode controls dismissal while dragging: interactive stays on iOS; on-drag is the explicit Android choice. keyboardShouldPersistTaps="handled" lets a child button handle a tap while the keyboard is open. The default, "never", can consume that tap to dismiss the keyboard.

Place KeyboardForm in a bounded area, such as a View with flex: 1, and pass ContactFields as its content. keyboardVerticalOffset represents the distance to the start of that area: measure the layout before hardcoding a number or adding a header height that has already been accounted for.

KeyboardForm.tsxtsxDownload example
import type { PropsWithChildren } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';

type Props = PropsWithChildren<{
  keyboardVerticalOffset?: number;
}>;

export function KeyboardForm({
  children,
  keyboardVerticalOffset = 0,
}: Props) {
  return (
    <KeyboardAvoidingView
      style={{ flex: 1 }}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      keyboardVerticalOffset={keyboardVerticalOffset}
    >
      <ScrollView
        contentContainerStyle={{ padding: 16, gap: 16 }}
        keyboardShouldPersistTaps="handled"
        keyboardDismissMode={
          Platform.OS === 'ios' ? 'interactive' : 'on-drag'
        }
      >
        {children}
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

3. InputAccessoryView: an iOS-specific feature

InputAccessoryView places a toolbar above the keyboard on iOS, connected to a field through nativeID and inputAccessoryViewID. It does not provide an equivalent Android toolbar.

Here, iOS gets a Done button next to the keyboard. A Close keyboard action remains on the screen on both platforms. The toolbar is an extra convenience; the flow does not depend on a component unsupported by the other system. Put this block in the previous keyboard container to keep the screen action reachable.

KeyboardToolbar.tsxtsxDownload example
import { useId } from 'react';
import {
  Button, InputAccessoryView, Keyboard,
  Platform, TextInput, View,
} from 'react-native';

export function KeyboardToolbar() {
  const accessoryId = useId();
  return (
    <View style={{ padding: 16, gap: 12 }}>
      <TextInput
        accessibilityLabel="Reference number"
        keyboardType="number-pad"
        inputAccessoryViewID={
          Platform.OS === 'ios' ? accessoryId : undefined
        }
        style={{ borderWidth: 1, padding: 12 }}
      />
      <Button title="Close keyboard" onPress={Keyboard.dismiss} />
      {Platform.OS === 'ios' && (
        <InputAccessoryView nativeID={accessoryId}>
          <View style={{ backgroundColor: '#fff', padding: 8 }}>
            <Button title="Done" onPress={Keyboard.dismiss} />
          </View>
        </InputAccessoryView>
      )}
    </View>
  );
}

4. Safe areas: decide which layer applies each inset

The SafeAreaView exported by React Native itself is deprecated and documented as iOS-only. Do not confuse it with the identically named component from react-native-safe-area-context. That library provides a provider and consumers for obtaining and applying insets.

On Android 15 or later, apps targeting SDK 35 or later display edge-to-edge: content can occupy the area behind system bars. A fixed paddingTop does not describe that space across every configuration.

This example is a standalone root without a navigator header or tab bar. Its provider wraps the screen and the hook applies all four insets, including the sides. In a React Navigation app, keep the existing provider and apply only the spacing that has not already been handled. The navigation library’s guidance for screens and transitions should inform that decision.

InsetScreen.tsxtsxDownload example
import type { PropsWithChildren } from 'react';
import { View } from 'react-native';
import {
  SafeAreaProvider, useSafeAreaInsets,
} from 'react-native-safe-area-context';

function InsetContent({ children }: PropsWithChildren) {
  const insets = useSafeAreaInsets();
  return (
    <View style={{
      flex: 1,
      paddingTop: insets.top + 16,
      paddingBottom: insets.bottom + 16,
      paddingLeft: insets.left + 16,
      paddingRight: insets.right + 16,
    }}>
      {children}
    </View>
  );
}

// Standalone root, without a navigator header or tab bar.
export function InsetScreen({ children }: PropsWithChildren) {
  return (
    <SafeAreaProvider>
      <InsetContent>{children}</InsetContent>
    </SafeAreaProvider>
  );
}

5. Modal: back should close the right layer

While a Modal is open, React Native does not publish BackHandler events. On Android, its close request arrives through onRequestClose. Routing dismissal through that callback and a visible button avoids relying on a listener outside the modal.

The example uses pageSheet on iOS with swipe dismissal enabled. onRequestClose synchronizes state when the system requests or completes dismissal. Close filters calls the same function. The provider inside the modal serves its separate native surface.

This differs from preventing removal of a route with unsaved changes. With React Navigation, use a route-removal prevention API such as usePreventRemove to cover navigator actions and the back gesture. A standalone BackHandler does not solve that iOS flow.

FiltersModal.tsxtsxDownload example
import { useState } from 'react';
import { Button, Modal, Platform, Text } from 'react-native';
import {
  SafeAreaProvider, SafeAreaView,
} from 'react-native-safe-area-context';

export function FiltersModal() {
  const [visible, setVisible] = useState(false);
  const close = () => setVisible(false);

  return (
    <>
      <Button title="Open filters" onPress={() => setVisible(true)} />
      <Modal
        visible={visible}
        onRequestClose={close}
        presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
        allowSwipeDismissal={Platform.OS === 'ios'}
      >
        <SafeAreaProvider>
          <SafeAreaView style={{ flex: 1, padding: 24, gap: 16 }}>
            <Text>Filters</Text>
            <Button title="Close filters" onPress={close} />
          </SafeAreaView>
        </SafeAreaProvider>
      </Modal>
    </>
  );
}

6. Button: the same color paints a different part

On React Native’s Button, color sets text color on iOS and background color on Android. That difference can be appropriate when preserving native appearance. A design that requires the same filled surface needs an explicitly composed control.

This example combines Pressable and Text to specify the background, spacing and pressed state. The ripple remains Android-specific. Taking ownership of appearance also means providing an accessible name, disabled state and touch feedback; those details are part of the code.

PrimaryButton.tsxtsxDownload example
import { Pressable, Text } from 'react-native';

type Props = {
  label: string;
  disabled?: boolean;
  onPress: () => void;
};

export function PrimaryButton({ label, disabled = false, onPress }: Props) {
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityState={{ disabled }}
      disabled={disabled}
      onPress={onPress}
      android_ripple={{ color: '#ffffff33' }}
      style={({ pressed }) => ({
        minHeight: 48,
        alignItems: 'center',
        justifyContent: 'center',
        paddingHorizontal: 20,
        paddingVertical: 12,
        borderRadius: 12,
        overflow: 'hidden',
        backgroundColor: disabled ? '#585868' : '#5140b8',
        opacity: pressed && !disabled ? 0.85 : 1,
      })}
    >
      <Text style={{ color: '#fff', fontWeight: '600' }}>{label}</Text>
    </Pressable>
  );
}

Where platform differences belong in the code

For a prop or a small adjustment, Platform.OS keeps the decision close to the component. When an entire implementation differs, files such as KeyboardToolbar.ios.tsx and KeyboardToolbar.android.tsx can preserve the same import for callers.

A useful boundary is to share what the user wants to do and isolate how each platform supports that action. A caller can request closing a picker or continuing a form without knowing every native detail involved.

Sources and references

A verification sequence for this screen

A screenshot with the keyboard closed does not cover form behavior. To test the composition inside an application, I would use the following sequence as a guide. This is a proposed verification plan, not a report of tests performed for this article.

  • Open the screen and focus the last field. Confirm that the field, error message and primary action remain reachable by scrolling.
  • Switch between numeric and text keyboards; tap the button while the keyboard is open and check whether the first interaction performs the intended action.
  • Drag the list, dismiss the keyboard and open it again. Look for jumps, residual spacing and lost scroll position.
  • Open the modal and dismiss it through every available path. Reopen it and check that its state remains correct.
  • Repeat in portrait and landscape, with larger text and the Android navigation configurations supported by the app.
  • Record the app version, React Native version, OS, target SDK, device model and keyboard. Separate simulator results from physical-device results.

About the examples and validation

The six TSX files are independent examples with complete imports and a download beside each block. ContactFields can be passed as a child of KeyboardForm; the others demonstrate specific decisions and should not automatically be stacked as wrappers around one screen.

TypeScript checking used React Native 0.87.0, react-native-safe-area-context 5.10.0 and React 19 types, outside the portfolio’s dependencies. It checks the snippets’ APIs and types. No native builds, simulator sessions, physical-device tests or screen-reader tests were run. Navigation, window and keyboard configuration must be verified in the application receiving the code.

The same screen on iOS and Android: where differences appear — Tiago Pinto