iOS vs Android & Platform APIs
By the end of this lesson you will be able to write one codebase that still feels at home on both iPhone and Android. You will detect the platform, pick different values or whole files per platform, handle the Android back button, and open the dialler or maps from your app.
React Native shares most code between platforms, often well over nine tenths of it. But iPhone and Android users have different habits, like the difference between driving in India and driving in the USA. The road rules are similar, but the steering wheel is on the other side.
The differences that matter most
| Area | iOS | Android |
|---|---|---|
| Going back | Swipe from the left edge, back button in the header | System back gesture or button |
| Default font | San Francisco | Roboto |
| Touch feedback | Fading or dimming | Ripple effect |
| Design guide | Apple Human Interface Guidelines | Google Material Design |
| Permission dialogs | Usually asked once; then only Settings | Can often ask again; "Don't ask again" after denials |
| Toast messages | No built-in toast | ToastAndroid |
| Testing on Windows | Expo Go on an iPhone, or cloud builds | Emulator and real phones |
Rule of thumb: keep your app's logic and structure the same, and adapt small details (feedback, icons, gestures) so each platform feels natural.
Step 1: Platform.OS and Platform.select
Platform.OS is "ios", "android" or "web". Platform.select picks a value from an object based on the current platform, with default as a fallback.
import { Platform, StyleSheet, Text } from "react-native";
export function Title({ children }) {
return <Text style={styles.title}>{children}</Text>;
}
const styles = StyleSheet.create({
title: {
fontSize: 22,
fontWeight: Platform.OS === "ios" ? "600" : "700",
...Platform.select({
ios: { letterSpacing: 0.3 },
android: { fontFamily: "sans-serif-medium" },
default: {},
}),
},
});What you should see: on iPhone the title is semi-bold with slightly wider letters. On Android it uses the system's medium sans-serif font. Same component, different finishing.
Platform.Version gives the OS version: a number on Android (the API level, such as 35) and a string on iOS (such as "18.2"). Use it only when a feature exists on newer versions.
Practise the logic: how select works
Platform.select is a small function you could write yourself. This browser version takes the platform as an argument, so you can switch between them and see what each phone would get:
import { useState } from "react";
import { createRoot } from "react-dom/client";
function select(os, options) {
if (os in options) return options[os];
if ((os === "ios" || os === "android") && "native" in options) return options.native;
return options.default;
}
const settings = {
backLabel: { ios: "< Back", android: "←", default: "Back" },
shareTitle: { android: "Share via", ios: "Share", default: "Copy link" },
haptics: { native: true, default: false },
};
function Preview() {
const [os, setOs] = useState("android");
return (
<div style={{ fontFamily: "sans-serif" }}>
{["ios", "android", "web"].map((p) => (
<button key={p} onClick={() => setOs(p)} style={{ fontWeight: p === os ? "bold" : "normal" }}>{p}</button>
))}
<ul>
{Object.entries(settings).map(([key, options]) => (
<li key={key}>{key}: {String(select(os, options))}</li>
))}
</ul>
</div>
);
}
console.log(select("ios", settings.shareTitle), "|", select("web", settings.haptics));
createRoot(document.getElementById("root")).render(<Preview />);Share | false
Click "ios", "android" and "web" and watch every value change. React Native's real Platform.select also supports the native key, which matches both phones but not the web.
Step 2: platform-specific files
When a component is very different on each platform, split it into files instead of filling it with if statements. Metro, the bundler, picks the right file by its extension:
src/components/
date-field.ios.jsx used on iPhone
date-field.android.jsx used on Android
date-field.web.jsx used on the web (optional)// anywhere in the app: no extension in the import
import DateField from "@/components/date-field";What happens: the iPhone build only contains the .ios.jsx code, and the Android build only the .android.jsx code. You can also use .native.jsx for "both phones" with a plain .jsx file for the web.
Step 3: the Android back button
Android users press back constantly. Expo Router and React Navigation already handle it for screens: back pops the stack. Sometimes you want to intercept it, for example to confirm before leaving an unsaved form. Use BackHandler:
import { useEffect } from "react";
import { Alert, BackHandler } from "react-native";
import { router } from "expo-router";
export function useConfirmLeave(hasUnsavedChanges) {
useEffect(() => {
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
if (!hasUnsavedChanges) return false; // let the normal back action happen
Alert.alert("Discard changes?", "Your expense is not saved yet.", [
{ text: "Stay", style: "cancel" },
{ text: "Discard", style: "destructive", onPress: () => router.back() },
]);
return true; // we handled it; do not go back yet
});
return () => sub.remove();
}, [hasUnsavedChanges]);
}What you should see on Android: with unsaved changes, pressing back shows a dialog with "Stay" and "Discard". "Discard" leaves the screen. Without changes, back works normally. BackHandler does nothing on iOS, where the swipe-back gesture is handled by the stack (you can turn it off with the screen option gestureEnabled: false if needed).
Alert.alert itself works on both platforms and shows a native dialog. Android shows at most three buttons in it.
Step 4: open other apps with Linking
Linking.openURL hands a URL to the system, which opens the right app. This is how "Call shop", "Email us" and "Get directions" buttons work.
import { Linking, Platform, Pressable, Text, View } from "react-native";
const shop = { phone: "+919876543210", lat: 26.9196, lng: 75.8235, label: "Johari Bazaar Sweets" };
export function ShopActions() {
const mapsUrl = Platform.select({
ios: `maps:0,0?q=${encodeURIComponent(shop.label)}@${shop.lat},${shop.lng}`,
android: `geo:0,0?q=${shop.lat},${shop.lng}(${encodeURIComponent(shop.label)})`,
});
return (
<View style={{ flexDirection: "row", gap: 12, padding: 16 }}>
<Pressable onPress={() => Linking.openURL(`tel:${shop.phone}`)}><Text>Call</Text></Pressable>
<Pressable onPress={() => Linking.openURL("mailto:orders@example.com?subject=Order")}><Text>Email</Text></Pressable>
<Pressable onPress={() => Linking.openURL(mapsUrl)}><Text>Directions</Text></Pressable>
</View>
);
}What you should see: "Call" opens the dialler with the number filled in (it does not call by itself). "Email" opens the mail app. "Directions" opens Apple Maps on iPhone or Google Maps on Android with a pin on the shop. On a simulator without these apps, openURL may fail, so wrap it in try/catch in production code.
Common mistakes
- Copying iOS design onto Android (or the reverse). Back arrows, tabs and feedback should feel native to each platform.
- Big
if (Platform.OS === ...)blocks everywhere. UsePlatform.selectfor values and platform files for whole components. - Fighting the back button. Only intercept it for a real reason, such as unsaved data, and always let the user leave.
- Assuming
Platform.Versionis a number. It is a string on iOS. - Testing only on your own phone. Cheap Android phones and older iPhones reveal performance and layout problems.
Key points
Platform.OStells you the platform;Platform.selectpicks values withios,android,nativeanddefaultkeys..ios.jsx,.android.jsx,.native.jsxand.web.jsxfiles let Metro choose whole components per platform.BackHandlerintercepts the Android back button; returntrueto stop the default action.Alert.alertshows native dialogs on both platforms.Linking.openURLopens the dialler, email, maps and other apps.- Follow each platform's design conventions and test on both.
Exercise
Write a ShareRow component with two buttons. "Share on WhatsApp" opens whatsapp://send?text=MESSAGE (URL-encode the message "Join our cricket match in Kochi at 6 PM"). "Copy link" is shown only on Android and logs "Copied". On iOS, add extra horizontal padding to the row using Platform.select. If WhatsApp cannot be opened, show an Alert saying "WhatsApp is not installed".
Show solution
import { Alert, Linking, Platform, Pressable, Text, View } from "react-native";
const message = "Join our cricket match in Kochi at 6 PM";
export function ShareRow() {
async function shareWhatsApp() {
try {
await Linking.openURL(`whatsapp://send?text=${encodeURIComponent(message)}`);
} catch {
Alert.alert("WhatsApp is not installed");
}
}
return (
<View style={{ flexDirection: "row", gap: 12, paddingVertical: 12, paddingHorizontal: Platform.select({ ios: 24, default: 12 }) }}>
<Pressable onPress={shareWhatsApp}><Text>Share on WhatsApp</Text></Pressable>
{Platform.OS === "android" ? (
<Pressable onPress={() => console.log("Copied")}><Text>Copy link</Text></Pressable>
) : null}
</View>
);
}openURL rejects when no app can handle the URL, so the catch shows the alert. encodeURIComponent turns spaces into %20 so the message survives inside the URL.
Quick Quiz
1. What does Platform.select({ ios: 1, android: 2, default: 3 }) return on Android?
Platform.select returns the value for the current platform, falling back to default.
2. You have card.ios.jsx and card.android.jsx. How do you import the component?
Metro picks the platform file automatically when you import without the extension.
3. Inside a hardwareBackPress listener, what does returning true mean?
Returning true stops the default back behaviour; false lets it continue.
4. Which call opens the phone dialler with a number filled in?
The tel: URL scheme opens the dialler with the number ready to call.
Learn React Native with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
