Platform-Specific Code and Device APIs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Accessibility closed out the pages built entirely on Core Components that render identically — modulo a screen reader’s own platform conventions — on both platforms. This page turns to the opposite end of the spectrum: the surface area where iOS and Android are simply different enough that Places needs to know, at build time or at runtime, exactly which platform it is running on, plus the roster of device- and app-lifecycle-level APIs (AppState, Appearance, Linking, Share, Vibration, and more) that every non-trivial app eventually reaches for regardless of platform. Two adjacent pages cover parts of this surface in more depth than this page does: Device Capabilities (next) covers the full Android/iOS permission model and the specific hardware, sensor, camera, and push-notification APIs that PermissionsAndroid only gets a brief mention here, and Navigation covers `Linking’s deep-link routing and `BackHandler’s hardware-back interception in the detail they deserve, since both live more naturally next to the navigator that actually consumes them.

The Platform Module

Platform, imported from react-native, is the first and most direct way to answer "which platform am I running on right now":

import { Platform } from 'react-native';

Platform.OS;       // 'ios' | 'android' (also 'windows' | 'macos' | 'web' on the respective out-of-tree platforms)
Platform.Version;  // number: the Android API level (e.g. 34) -- string on iOS: the OS version (e.g. '18.0')
Platform.isPad;    // boolean, iOS only: true when running on an iPad rather than an iPhone
Platform.isTV;     // boolean: true on Apple TV or Android TV

Platform.OS is a plain string comparison away from the simplest possible platform branch (Platform.OS === 'ios'), but Platform.select is almost always the better shape for choosing a value rather than writing an if/else around it: it takes an object keyed by platform name plus an optional default, and returns whichever key matches the running platform:

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

const styles = StyleSheet.create({
  card: {
    borderRadius: 10,
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15, shadowRadius: 3 },
      android: { elevation: 3 },
      default: {},
    }),
  },
});

A Platform.select value can also be an entire component or a function rather than a style object, which is exactly how the Places screen header below picks a different back-button icon per platform without an if in the render body at all. Platform.Version’s differing type across platforms (`number on Android, string on iOS) is a common source of a subtle bug — comparing it against a number on iOS silently does nothing useful — so a version-gated branch on iOS parses it explicitly (parseInt(Platform.Version, 10)) rather than comparing the raw value.

Platform-Specific File Extensions

Beyond a runtime Platform.OS check inside one file, Metro (covered in full later in this section) also resolves an import to a different file on disk automatically, based on four recognized suffixes before the ordinary extension:

  • .ios.tsx — used only when bundling for iOS.

  • .android.tsx — used only when bundling for Android.

  • .native.tsx — used when bundling for iOS or Android, but not for a web build; the escape hatch for code that needs to diverge from a web build without needing two near-identical .ios.tsx/.android.tsx copies.

  • .web.tsx — used only when bundling for web, via react-native-web.

Metro picks the most specific file present for the current build target and silently ignores the rest — there is no explicit import './Header.ios', just import './Header', with the suffix resolved automatically the same way index.ts is resolved automatically inside a directory import. The precedence order when more than one candidate exists for the same base name is: the platform-specific file (.ios.tsx/.android.tsx) first, then .native.tsx, then the plain .tsx as the universal fallback — and, separately, for a web build: .web.tsx first, then the plain .tsx (a web build never considers .native.tsx at all, since that suffix exists specifically to be excluded from web bundles).

Diagram of Metro’s file-extension resolution order for import './Header': bundling for iOS resolves Header.ios.tsx first

This mechanism keeps a component’s call sites identical across platforms (import { Header } from './Header' never changes) while letting its implementation diverge exactly where — and only where — the platforms genuinely differ; a component with no platform divergence at all simply never grows an .ios.tsx/.android.tsx pair, and Metro resolves the plain .tsx for every target unchanged.

Device and App-State APIs

The rest of `react-native’s platform-adjacent surface is a set of independent modules, each answering a narrow question about the device or the app’s own lifecycle rather than about layout or rendering.

Dimensions and useWindowDimensions

Already covered in full on Styling's units section: Dimensions.get('window') reads the window size once, synchronously, outside of a component, while the useWindowDimensions() Hook subscribes to rotation and split-screen/multi-window size changes and re-renders automatically. Nothing about either API is platform-specific beyond the values they report, which is why they are only linked here rather than re-explained.

AppState

AppState reports which of three lifecycle states the app is currently in, and notifies a listener when that changes:

  • 'active' — the app is in the foreground and receiving user input.

  • 'background' — the app is not visible at all (the user switched to another app, or, on Android, pressed the home button).

  • 'inactive' (iOS only) — a transient state entered during a system-level interruption while still partially visible — an incoming phone call, Control Center, or the app switcher overlay being dragged into view — that resolves back to 'active' or on to 'background' almost immediately; Android apps never observe 'inactive', moving directly between 'active' and 'background'.

import { AppState } from 'react-native';

AppState.currentState;  // 'active' | 'background' | 'inactive', read synchronously at any time

const subscription = AppState.addEventListener('change', (nextState) => {
  console.log('App state changed to', nextState);
});

subscription.remove(); // when the listener is no longer needed
stateDiagram-v2 [*] --> active active --> background : app moved to background\n(home button, app switcher, Android) background --> active : app returned to foreground active --> inactive : transient interruption begins\n(iOS only: call, Control Center, app switcher) inactive --> active : interruption ends, app stays foregrounded inactive --> background : interruption continues into a full backgrounding background --> inactive : rare iOS transitional case on the way back to active

Places uses this transition, not the states themselves, as the trigger for pausing and resuming background work — shown below with the pending-writes sync worker.

Appearance

Also already covered on Styling's dark-mode section: Appearance.getColorScheme() and Appearance.addChangeListener(callback) are the imperative APIs the useColorScheme() Hook (and Places's own ThemeProvider) are built on top of, useful directly only outside of a component — a non-React module that needs to react to an appearance change without a Hook.

Linking

Linking.openURL(url) opens an arbitrary URL — https://, tel:, mailto:, or the app’s own custom scheme — in the OS’s own handler, and Linking.getInitialURL()/the 'url' event answer "did this app just get opened by a URL, and if so, which one". Both are the foundation deep linking is built on, but the routing side of that — matching an incoming URL against RootStackParamList and navigating to PlaceDetails with the right placeId — belongs to, and is covered in full by, Navigation; this page only names the module so the "share this place" button below can build on it.

Share

Share.share({ message, url, title }), which opens each platform’s native share sheet, was already introduced on Modals, Alerts, and Progress for sharing a place’s name and notes as plain text. The Places: Platform-Aware Code section below extends that same call with a url field carrying a deep link back into the app, rather than repeating the plain-text version.

Vibration

Also introduced on Modals, Alerts, and Progress: Vibration.vibrate(pattern) for raw device vibration, with expo-haptics and Places's own HapticTap Turbo Native Module (Native Modules and Components) as the progressively more nuanced alternatives. Not repeated here.

Keyboard

Covered in full on Text Input and Forms: Keyboard.dismiss() closes the on-screen keyboard imperatively, and Keyboard.addListener('keyboardDidShow', handler) / 'keyboardDidHide' subscribe to its show/hide lifecycle. Mentioned here only for completeness of the device-API roster.

I18nManager and Right-to-Left Layout

I18nManager.isRTL reports whether the app is currently laying out right-to-left (Arabic, Hebrew, and a handful of other locales); React Native’s own Flexbox implementation flips flexDirection: 'row', marginStart/marginEnd, and absolute left/right automatically in that mode, so most layouts built with the logical Start/End properties rather than physical left/right need no additional code at all. I18nManager.allowRTL(true) opts the app into following the device’s RTL locale setting in the first place, and I18nManager.forceRTL(true) forces RTL layout regardless of locale — both take effect only after the next app restart on iOS and Android (there is no live-toggle equivalent to `useColorScheme()’s instant update), which is why either call is made once at startup, not from a settings screen expecting an immediate visual change.

PermissionsAndroid

PermissionsAndroid.request(permission, rationale) and PermissionsAndroid.check(permission) request and inspect a single Android runtime permission (location, camera, and similar dangerous-permission-group entries), returning one of PermissionsAndroid.RESULTS.GRANTED/DENIED/NEVER_ASK_AGAIN. iOS has no equivalent module — permission prompts there are driven by Info.plist usage-description strings and fire automatically the first time a permission-gated API is called. The full permission model on both platforms, including Expo’s declarative permissions config as the alternative to calling PermissionsAndroid directly, is Device Capabilities's subject, not this page’s.

BackHandler

BackHandler.addEventListener('hardwareBackPress', handler) intercepts Android’s hardware/gesture back action — handler returning true marks the event as handled (staying on the current screen, e.g. to close an open modal first), and false lets the default behavior (usually popping the navigation stack, or exiting the app from the root screen) proceed; the module has no effect at all on iOS, which has no hardware back button. Places's own usage — closing `AddPlaceScreen’s photo picker sheet on back before letting back navigate away from the screen itself — is covered alongside the rest of the navigation stack on Navigation.

Settings (iOS)

Settings.get(key) and Settings.set({ key: value }) read and write iOS’s native NSUserDefaults store directly — the same underlying storage UserDefaults reads in Swift — useful specifically when a value needs to be visible to, or shared with, native iOS code (a widget, a Siri Shortcut, or a value set by the native side before the JavaScript runtime even starts) rather than staying entirely inside React Native’s own JavaScript-side storage. It has no Android equivalent; a cross-platform key/value need is Storage and Offline's subject (AsyncStorage, MMKV, and expo-secure-store), not Settings.

DevSettings

DevSettings is a development-only module, stripped from release builds, for extending the Dev Menu introduced on Getting Started with React Native:

import { DevSettings } from 'react-native';

DevSettings.addMenuItem('Clear pending writes', () => {
  // e.g. wipe placesRepository's pending_writes queue during development
});

DevSettings.reload(); // programmatically reloads the app, the same effect as the Dev Menu's "Reload"

A custom Dev Menu item like the one above is a convenient way to reach a debug-only action — clearing a local queue, forcing a particular API error, seeding fixture data — without wiring a real UI control for something no end user should ever see.

DOM-Like Ref Node APIs (Since React Native 0.82)

Since React Native 0.82, a ref attached to a Core Component additionally exposes a small set of read-only, web-Element-shaped methods directly on the native component instance, layered on top of the New Architecture’s Fabric shadow tree: getBoundingClientRect() returns the component’s current layout rectangle in the same { x, y, width, height, top, left, bottom, right } shape a web Element.getBoundingClientRect() returns, and parentNode walks up to the component’s rendered parent, mirroring the DOM property of the same name. Both give a component web-like layout introspection — "where exactly is this view on screen right now, and what contains it" — as a lighter-weight, synchronous alternative to the older measure()/measureInWindow() ref methods, without a separate native measurement round trip; a team already comfortable reasoning about layout the way react-dom exposes it can reach for these instead of relearning React Native’s own imperative measurement callbacks.

Places: Platform-Aware Code

(a) A Platform.select-Styled Screen Header

PlaceDetailsScreen’s header renders a platform-appropriate back-button glyph and a platform-appropriate shadow, both chosen with `Platform.select rather than an inline Platform.OS branch:

// src/features/places/screens/PlaceDetailsScreen.tsx (excerpt -- header)
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../../theme/ThemeProvider';

const backIcon = Platform.select({ ios: '‹', android: '←', default: '←' });

function ScreenHeader({ title, onBack }: { title: string; onBack: () => void }) {
  const theme = useTheme();
  const styles = createStyles(theme);

  return (
    <View style={styles.header}>
      <Pressable onPress={onBack} hitSlop={12} accessibilityRole="button" accessibilityLabel="Go back">
        <Text style={styles.backIcon}>{backIcon}</Text>
      </Pressable>
      <Text style={styles.title} numberOfLines={1}>{title}</Text>
      <View style={styles.headerSpacer} />
    </View>
  );
}

function createStyles(theme: ReturnType<typeof useTheme>) {
  return StyleSheet.create({
    header: {
      flexDirection: 'row',
      alignItems: 'center',
      paddingHorizontal: theme.spacing.md,
      paddingVertical: theme.spacing.sm,
      backgroundColor: theme.colors.surface,
      ...Platform.select({
        ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.12, shadowRadius: 2 },
        android: { elevation: 4 },
        default: {},
      }),
    },
    backIcon: { fontSize: 26, color: theme.colors.primary, width: 32 },
    title: { flex: 1, fontSize: theme.typography.size.lg, fontWeight: theme.typography.weight.bold, color: theme.colors.text, textAlign: 'center' },
    headerSpacer: { width: 32 },
  });
}

android’s single `elevation number and ios’s four separate `shadow* properties are not interchangeable one for one — elevation also affects Android’s touch z-ordering, and iOS has no elevation concept at all — which is exactly the kind of divergence Platform.select exists to localize to one spot instead of scattering Platform.OS checks through the render body.

(b) Pausing the Sync Worker in the Background

Places's offline-first persistence (src/features/places/storage/placesRepository.ts, established in full on Storage and Offline) queues local mutations in a pending_writes table and drains it through a sync worker whenever src/lib/netinfo.ts reports connectivity has returned. Running that drain while the app sits in the background wastes battery and risks losing to the OS’s own background-task time limit mid-write, so an AppState listener pauses it the moment the app leaves 'active' and resumes it — with an immediate drain attempt, in case connectivity or pending writes changed while backgrounded — the moment it returns:

// src/features/places/storage/syncWorker.ts
import { AppState } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { drainPendingWrites, startPendingWritesAutoSync, stopPendingWritesAutoSync } from './placesRepository';

export function startAppStateAwareSync(): () => void {
  // netinfo.ts's connectivity listener already triggers a drain on reconnect; this only adds
  // "don't run that drain -- or its polling -- while the app is not in the foreground".
  startPendingWritesAutoSync();

  const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
    if (nextState === 'active') {
      startPendingWritesAutoSync();
      void drainPendingWrites(); // catch up immediately, don't wait for the next NetInfo event
    } else if (nextState === 'background') {
      stopPendingWritesAutoSync();
    }
    // 'inactive' (iOS-only, transient) is deliberately ignored: pausing/resuming for it would thrash
    // the worker during a brief interruption that resolves back to 'active' almost immediately.
  });

  return () => subscription.remove();
}

startAppStateAwareSync() is called once from App.tsx alongside ThemeProvider and the font-loading gate from Styling, and its returned cleanup function is wired into that root component’s own unmount path. Ignoring 'inactive' above is deliberate, matching the mermaid diagram: treating every momentary interruption as a full pause/resume cycle would start and stop the worker far more often than the underlying network condition it reacts to actually changes.

Building on Share.share() as already introduced on Modals, Alerts, and Progress, the details screen’s share button adds a url pointing back into Places itself — a universal/app link that, once tapped from whatever app receives the share (Messages, Mail, a notes app), opens Places directly to that same place via the Linking-driven routing Navigation covers in full:

// src/features/places/screens/PlaceDetailsScreen.tsx (excerpt -- share button)
import { Pressable, Share, Text } from 'react-native';
import type { Place } from '../types';

function buildPlaceDeepLink(placeId: string): string {
  return `https://places.irurueta.com/place/${placeId}`; // resolves to PlaceDetails via Navigation's linking config
}

function SharePlaceButton({ place }: { place: Place }) {
  const onShare = async () => {
    await Share.share({
      message: `${place.name}\n${place.notes}`,
      url: buildPlaceDeepLink(place.id), // iOS attaches `url` alongside `message`; Android folds it into the text
      title: place.name,
    });
  };

  return (
    <Pressable onPress={onShare} hitSlop={8} accessibilityRole="button" accessibilityLabel="Share this place">
      <Text>Share</Text>
    </Pressable>
  );
}

Share.share()’s `url field is honored as a distinct attachment on iOS’s share sheet but simply appended to message’s text on Android, which is why `message alone (the name and notes) still reads sensibly on Android even though the deep link only shows up there as trailing text rather than a separate rich preview.