Week 3: Platform-Specific Code & Responsive Layouts

"Write once, run everywhere" is the pitch — the reality is that iOS and Android disagree on notches, status bars, back gestures, and even how tall a "safe" screen area is. This week covers branching cleanly by platform, keeping content out from under a notch or system bar, and building layouts that hold up on a phone, a tablet, and a foldable without three separate codebases.

Phase 2 of 8 Week 3 of 22 ~3 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Branch behavior and styles cleanly with Platform.OS and Platform.select()
  • Split a component into .ios.tsx/.android.tsx files when the divergence is bigger than a style tweak
  • Keep content clear of notches and system bars with safe area insets
  • Build a layout that reacts correctly to rotation, tablets, and foldable resizing

1. Platform.OS & Platform.select()

Platform.OS is a string — 'ios', 'android', or 'web' if you're also targeting web with the same codebase — read at runtime to branch behavior. For anything beyond a single value, Platform.select() is the cleaner tool: it takes an object keyed by platform and returns the matching value directly.

a quick branch vs. Platform.select()
import { Platform } from 'react-native';

// Fine for a single value
const iconName = Platform.OS === 'ios' ? 'ios-share' : 'md-share';

// Cleaner for style objects with several platform-specific properties
const styles = StyleSheet.create({
  card: {
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 8 },
      android: { elevation: 4 },
      default: {}, // web, or any platform not explicitly listed
    }),
    borderRadius: 12,
    backgroundColor: '#fff',
  },
});

That shadow example is a real, common necessity: iOS renders shadows with shadowColor/shadowOpacity/shadowRadius, while Android ignores those entirely and uses elevation instead — there's no single cross-platform "drop shadow" property, so Platform.select() is doing real work here, not just tidying syntax.

2. Platform-Specific Files

When a component's implementation diverges by more than a style value — different structure, different libraries, genuinely different behavior — Metro (React Native's bundler) supports splitting it into separate files by platform extension, and picks the right one automatically at build time.

file structure
components/
  ShareButton.ios.tsx
  ShareButton.android.tsx
imported exactly the same way, no extension needed
import ShareButton from './components/ShareButton';
// Metro resolves this to ShareButton.ios.tsx on iOS,
// ShareButton.android.tsx on Android -- automatically

Reach for this only when Platform.select() genuinely isn't enough — two small style differences don't justify two separate files, but a share sheet that uses a completely different native API on each platform usually does.

3. Safe Areas & Notches

A screen's usable area isn't the same as its physical bounds — an iPhone's notch or Dynamic Island, and Android's status bar and gesture navigation area, all carve out space that content shouldn't render under. react-native-safe-area-context is the standard library for handling this correctly.

wrapping the app root
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';

export default function App() {
  return (
    <SafeAreaProvider>
      <SafeAreaView style={{ flex: 1 }}>
        {/* your app */}
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

SafeAreaView handles the common case automatically — but for a custom header that needs to sit flush with the status bar while its content stays below the notch, useSafeAreaInsets() gives you the raw inset values to apply exactly where you need them:

a custom header respecting the top inset
import { useSafeAreaInsets } from 'react-native-safe-area-context';

function Header({ title }: { title: string }) {
  const insets = useSafeAreaInsets();
  return (
    <View style={{ paddingTop: insets.top, backgroundColor: '#7c5cff' }}>
      <Text style={{ fontSize: 18, fontWeight: '700', padding: 16, color: '#fff' }}>
        {title}
      </Text>
    </View>
  );
}

4. Responsive Dimensions

Dimensions.get('window') returns the screen size once, at the moment you call it — it does not update if the device rotates or a foldable unfolds. The useWindowDimensions() hook is the reactive alternative, and should be the default choice for anything responsive.

a layout that reacts to width
import { useWindowDimensions, View } from 'react-native';

function ProductGrid({ products }: { products: Product[] }) {
  const { width } = useWindowDimensions();
  const columns = width >= 600 ? 3 : width >= 400 ? 2 : 1;

  return (
    <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
      {products.map((p) => (
        <View key={p.id} style={{ width: `${100 / columns}%`, padding: 8 }}>
          {/* card content */}
        </View>
      ))}
    </View>
  );
}

Because useWindowDimensions() re-renders the component on every dimension change, this grid automatically reflows from 1 to 2 to 3 columns as the window gets wider — a phone in portrait, that same phone rotated to landscape, and a tablet all get the right column count with zero extra code.

5. Tablets & Foldables

Two things make tablets and foldables genuinely different from "a bigger phone": width breakpoints deserve real layout changes, not just bigger fonts, and a foldable's window can resize while the app is running — unfolding the device mid-session, not just at launch.

This is exactly why Dimensions.get() isn't enough

A foldable device can go from phone-width to tablet-width in the middle of a session as the user unfolds it. Code that reads dimensions once at mount and never again will render a stale, wrong layout after that happens — useWindowDimensions()'s reactivity isn't a nice-to-have here, it's the difference between a layout that keeps working and one that visibly breaks.

A common, pragmatic pattern: define two or three named breakpoints (compact, medium, expanded) and switch entire layout structures — not just column counts — at each one. A detail screen might be a full-screen push on a phone but a side-by-side master-detail pane once the window crosses the tablet breakpoint.

6. Hands-on Exercise

Hands-on

Build a responsive, safe-area-aware settings screen

Apply platform branching, safe area handling, and reactive breakpoints to one real screen.

Requirements:

  1. Build a custom header (not the default navigation header) that uses useSafeAreaInsets() to pad correctly under the status bar/notch on both platforms.
  2. Give the header a platform-specific shadow using Platform.select()shadowColor/shadowOpacity/shadowRadius on iOS, elevation on Android.
  3. Build a settings list below the header using useWindowDimensions(), rendering as a single column under 500px wide and a two-column grid at 500px or wider.
  4. Test by resizing the simulator window (or rotating a physical device) and confirm the layout reflows live, without a reload.
  5. Add one component split into .ios.tsx/.android.tsx files — even something as simple as a platform-appropriate "Open Settings" button using each OS's real settings-deep-link approach.
Hint

If your grid doesn't reflow when you resize the simulator, double-check you're reading width from useWindowDimensions() and not a value captured once from Dimensions.get('window') outside the component — the latter won't trigger a re-render on resize.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a card's drop shadow need Platform.select() instead of one shared set of style properties?

iOS and Android use entirely different properties to render a shadow — iOS reads shadowColor/shadowOpacity/shadowRadius and ignores elevation, while Android does the reverse. There's no single cross-platform shadow property, so Platform.select() is providing genuinely different values per platform, not just tidying up syntax.

Q2

When is splitting a component into .ios.tsx/.android.tsx files the better choice over Platform.select()?

When the divergence is structural — different component trees, different libraries, genuinely different behavior — not just a handful of differing style values. A couple of style differences fit cleanly inside Platform.select(); a share sheet built on a completely different native API per platform usually justifies two separate files instead.

Q3

What problem does useSafeAreaInsets() solve that a plain View with fixed padding doesn't?

Notch size, Dynamic Island, and status bar height vary across devices and platforms — a fixed padding value that looks right on one device will render content under a notch, or leave too much empty space, on another. useSafeAreaInsets() returns the actual inset values for the current device, so padding adapts correctly everywhere instead of being hardcoded to one screen.

Q4

Why is useWindowDimensions() preferred over a one-time call to Dimensions.get('window') for a responsive layout?

Dimensions.get('window') reads the size once and never updates, so a layout built from it goes stale the moment the device rotates or a foldable unfolds mid-session. useWindowDimensions() is a hook that re-renders the component whenever the window size actually changes, keeping breakpoint-based layouts correct through rotation and foldable resizing.