EduFlow

Section 2 · Layout and Style

Screen Sizes, Safe Areas and Platform Code

19 min3 min readCode playground

Lesson at a glance

Make Trailhead’s screens fit any phone with useWindowDimensions and safe-area insets, and write platform-specific code with Platform.select or per-platform files only where the platforms really differ.

  1. 1Reading the screen size the right way
  2. 2Safe areas on both platforms
  3. 3When the platforms differ
  4. 4A tablet-friendly grid

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The phones your users carry range from small Android devices around 360 points wide to large iPhones over 430, plus tablets, foldables that change width mid-session, and landscape. On top of that, the screen isn’t a clean rectangle: there’s a notch or Dynamic Island, rounded corners, a home indicator, and on Android, status and navigation bars that your app now draws behind. Three tools make Trailhead fit all of that.

Reading the screen size the right way

useWindowDimensions() returns the window’s width, height, scale and fontScale, and re-renders your component whenever they change:

TSX
import { useWindowDimensions } from 'react-native';

export function PhotoGrid({ photos }: { photos: string[] }) {
  const { width } = useWindowDimensions();
  const columns = width >= 700 ? 4 : 2;
  // …render photos in `columns` columns
}

You’ll find older code calling Dimensions.get('window') at module level. That reads the size once, when the file loads, and never again: rotate the phone, open the app in iPad split view, or unfold a foldable, and the layout keeps using the old width. Use the hook inside components.

Prefer flex over measurements when you can. Most layouts never need the width at all; reach for useWindowDimensions when the structure changes with size (two columns become four), not to compute pixel values flexbox already handles.

Safe areas on both platforms

The safe area is the part of the screen not covered by hardware cutouts or system UI. Navigation headers and tab bars from Expo Router already respect it. Screens without them, like a full-screen map or an onboarding page, must handle it themselves.

The library for this is react-native-safe-area-context. Expo Router already includes it and provides the SafeAreaProvider at the root, so you can use the hook straight away:

src/app/track.tsx
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

export default function TrackScreen() {
  const insets = useSafeAreaInsets();

  return (
    <View style={styles.screen}>
      {/* the map fills the whole screen, edge to edge */}
      <View style={[styles.topBar, { top: insets.top + 8 }]}>
        <Text style={styles.timer}>01:24:10</Text>
      </View>
      <View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
        <Text>Stop and save</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1 },
  topBar: { position: 'absolute', left: 16, right: 16, alignItems: 'center' },
  timer: { fontSize: 20, fontWeight: '700' },
  bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, paddingTop: 12, alignItems: 'center' },
});

The map can draw under the status bar, which looks good, while the controls sit exactly below the unsafe area. insets has top, bottom, left and right, so landscape works too. The library also exports a SafeAreaView component that applies padding for you, which is handy for simple screens.

When the platforms differ

React Native shares code across iOS and Android, but the platforms aren’t identical, and pretending they are produces an app that feels foreign on both. Platform gives you the current OS:

TSX
import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  title: {
    fontSize: 17,
    fontWeight: Platform.OS === 'ios' ? '600' : '500',
  },
  card: Platform.select({
    ios: { boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)' },
    android: { elevation: 3 },
    default: {},
  }),
});

Platform.select returns the value for the current platform, falling back to default. It’s the right tool for small differences: a font weight, a shadow, a hit slop.

When a whole component differs, split it into files. Create DatePicker.ios.tsx and DatePicker.android.tsx, then import ./DatePicker with no extension; Metro picks the right file per platform. The same works with .native.tsx and .web.tsx if Trailhead ever runs on the web.

Where do the platforms really differ? Navigation conventions (iOS swipes back from the left edge; Android has a system back gesture), pickers and date inputs, typography defaults, haptics, and permission flows. Where they don’t, such as your hike list, your forms and your business logic, share everything.

A tablet-friendly grid

Putting the pieces together: the photo grid should show as many columns as fit, with a minimum card width, consistent gaps and screen padding. That’s a small calculation, run every time the width changes, and it’s exactly the kind of pure function worth getting right once. You’ll write it in the exercise below.

Next up: lists. Trailhead will soon hold hundreds of hikes, and rendering them all at once is the fastest way to make a phone feel slow.

Key takeaways

  • useWindowDimensions() re-renders on rotation and resizing; Dimensions.get() is a one-time snapshot that goes stale.
  • Screens without a navigation header or tab bar must pad themselves with safe-area insets from react-native-safe-area-context.
  • Android draws apps edge to edge behind the system bars, so insets matter on Android as much as on iOS.
  • Use Platform.select for small differences and .ios.tsx / .android.tsx files when a whole component differs.

Further reading

Check your understanding

3 questions

Question 1 of 3

Trailhead’s photo grid computes its columns once with Dimensions.get('window').width at the top of the file. What breaks?

Question 2 of 3

Trailhead’s full-screen map has no header. Its top buttons sit under the iPhone’s Dynamic Island. What’s the right fix?

Question 3 of 3

Trailhead’s date picker needs completely different components on iOS and Android, while the rest of the form is shared. What’s the cleanest structure?

Practice

Code playground

Fit the photo grid to any screen

Trailhead’s photo grid calls gridLayout(width) with the value from useWindowDimensions(). Fix gridLayout(screenWidth, options) so it returns { columns, cardWidth } where:

  • the screen has padding on the left and right, and cards have gap points between them (no gap at the outer edges),
  • columns is the largest number of cards that are each at least minCardWidth wide, and never less than 1,
  • cardWidth is the width each card gets when the leftover space is shared equally, rounded down to a whole point.

Defaults: minCardWidth = 160, gap = 12, padding = 16. For example, a 390-point iPhone gives { columns: 2, cardWidth: 173 }.

// Work out how many photo cards fit across the screen, and how wide each one is.
function gridLayout(screenWidth, { minCardWidth = 160, gap = 12, padding = 16 } = {}) {
  const columns = Math.floor(screenWidth / minCardWidth);
  const cardWidth = screenWidth / columns;
  return { columns, cardWidth };
}

console.log(gridLayout(390)); // iPhone in portrait
console.log(gridLayout(820)); // iPad in portrait
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • A 390-point phone gets 2 columns of 173
  • A 320-point phone gets 1 column of 288
  • An 820-point tablet gets 4 columns of 188
  • Custom minCardWidth, gap and padding are respected
  • A screen narrower than one card still gets 1 column