Platform-Specific Code — Mobile

Kode Spesifik per Platform Meskipun React Native cross-platform, kadang kamu perlu perilaku berbeda di iOS dan Android. Ada beberapa cara menangani perbedaan…

Kode platform-specific adalah bagian kecil kode yang sengaja dibedakan untuk iOS dan Android, di tengah kode yang selebihnya dipakai bersama. Bayangin Bu Rini membuka cabang kedua di mal. Menu, harga, dan cara kerjanya sama persis dengan toko di ruko. Yang beda cuma hal yang diatur gedungnya: pintu geser di mal, pintu dorong di ruko. Bu Rini gak menulis buku panduan kedua, cukup satu halaman "khusus cabang mal". Begitu juga aplikasi Laundry Bu Rini: hampir semua kodenya sama, dan cuma beberapa baris yang tahu dia jalan di mana.

🧪 Tebakan cepat: Layar "Minta jemput" sudah berisi catatan untuk kurir, dan ada BackHandler yang menanyakan "Batal minta jemput?" sebelum layar ditutup. Mbak Wulan memakai iPhone dan menggeser dari tepi kiri layar. Apakah pertanyaan itu muncul? Jawabannya ada di bagian "Back: tombol Android dan geser iOS".

Platform.OS dan Platform.select

import { Platform } from 'react-native';

Platform.OS;      // 'ios' atau 'android' ('web' kalau dijalankan di browser)
Platform.Version; // Android: API level berupa angka, misalnya 35. iOS: string, misalnya '18.2'

const perluIzinNotif = Platform.OS === 'android' && Platform.Version >= 33; // Android 13 ke atas
const warnaLink = Platform.select({ ios: '#007aff', android: '#1a73e8', default: '#2563eb' });

File .ios.tsx dan .android.tsx

Kalau implementasinya beda total, pisahkan per file. Contoh: tombol "Buka di peta" untuk alamat jemput.

// src/lib/bukaPeta.ios.ts: Apple Maps
import { Linking } from 'react-native';
export function bukaPeta(alamat: string) {
  return Linking.openURL(`https://maps.apple.com/?q=${encodeURIComponent(alamat)}`);
}

// src/lib/bukaPeta.android.ts: aplikasi peta bawaan Android
import { Linking } from 'react-native';
export function bukaPeta(alamat: string) {
  return Linking.openURL(`geo:0,0?q=${encodeURIComponent(alamat)}`);
}

// src/lib/bukaPeta.ts: cadangan untuk web, dan file yang dibaca TypeScript
import { Linking } from 'react-native';
export function bukaPeta(alamat: string) {
  return Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(alamat)}`);
}

Semua layar cukup menulis import { bukaPeta } from '@/lib/bukaPeta'. Metro memilih file saat membuat bundle: untuk iOS dia mencari .ios.ts, lalu .native.ts, lalu .ts. Dicek lewat Metro di project Expo SDK 57: bundle iOS cuma berisi maps.apple.com, bundle Android cuma geo:0,0, dan bundle web cuma google.com/maps. Template Expo sendiri memakai cara ini, misalnya app-tabs.web.tsx. File .ts tanpa akhiran tetap perlu, karena TypeScript gak mengenal platform dan mencari file itu.

Pilihannya: beberapa nilai yang beda, pakai Platform.select. Seluruh fungsi atau komponen yang beda, pakai file .ios dan .android.

Bayangan dan font

const styles = StyleSheet.create({
  kartu: {
    padding: 16,
    borderRadius: 12,
    backgroundColor: '#fff',
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 6 },
      android: { elevation: 3 },
    }),
  },
  // atau satu baris yang sama untuk kedua platform:
  kartuBaru: { boxShadow: '0 2px 6px rgba(0, 0, 0, 0.15)' },
});
Style iOS Android
shadowColor, shadowOffset, shadowOpacity, shadowRadius bayangan cuma shadowColor yang dipakai (Android 9 ke atas), untuk mewarnai bayangan elevation
elevation: 3 gak ada efek bayangan bawaan Android
boxShadow bayangan bayangan (Android 9 ke atas)

boxShadow ditulis seperti CSS dan hadir sejak React Native 0.76, khusus New Architecture. Sejak 0.82 New Architecture jadi satu-satunya, jadi di Expo SDK 57 dia jalan di kedua platform.

Font juga beda. Tanpa fontFamily, iOS memakai San Francisco dan Android memakai Roboto. Lebar huruf keduanya gak sama, jadi teks yang sama bisa lebih panjang di salah satu HP. Itu alasan lain memberi flex: 1 di bagian yang boleh menyempit.

Back: tombol Android dan geser iOS

Android punya back sistem (tombol atau gestur). iOS gak punya: kembali lewat tombol di header atau geser dari tepi kiri. BackHandler cuma mendengar back sistem Android.

// src/app/jemput.tsx
import { router, Stack, useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { Alert, BackHandler, TextInput, View } from 'react-native';

export default function MintaJemput() {
  const [catatan, setCatatan] = useState('');
  const adaIsian = catatan.length > 0;

  useFocusEffect(
    useCallback(() => {
      const sub = BackHandler.addEventListener('hardwareBackPress', () => {
        if (!adaIsian) return false; // biarkan navigasi menutup layar
        Alert.alert('Batal minta jemput?', 'Catatan yang belum dikirim akan hilang.', [
          { text: 'Lanjut isi', style: 'cancel' },
          { text: 'Buang', style: 'destructive', onPress: () => router.back() },
        ]);
        return true; // sudah ditangani: layar gak ditutup
      });
      return () => sub.remove();
    }, [adaIsian]),
  );

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Stack.Screen options={{ title: 'Minta jemput', gestureEnabled: !adaIsian }} />
      <TextInput value={catatan} onChangeText={setCatatan} placeholder="Catatan untuk kurir" />
    </View>
  );
}

Di iPhone, geser dari tepi kiri ditangani navigasi native, bukan BackHandler. Jadi tanpa gestureEnabled: false, layarnya langsung tertutup dan catatannya hilang tanpa pertanyaan. Itu jawaban tebakannya. Tombol back di header juga bukan back sistem; navigasi punya event beforeRemove untuk mencegat semua jalan keluar sekaligus.

Status bar dan izin

Mbak Wulan mengetuk "Jangan izinkan" iOS Android
Pertama kali dialog gak akan muncul lagi, canAskAgain: false dialog masih boleh muncul sekali lagi
Kedua kali (dialog sudah gak muncul) sejak Android 11, dialog gak muncul lagi, canAskAgain: false
// src/components/FotoNota.tsx, isi komponennya
const [izin, mintaIzin] = useCameraPermissions(); // dari expo-camera
if (!izin) return null; // status izin belum terbaca
if (izin.granted) return <Text>Kamera siap.</Text>;
if (!izin.canAskAgain) return <Button title="Buka Pengaturan" onPress={() => Linking.openSettings()} />;
return <Button title="Izinkan kamera untuk memotret nota" onPress={mintaIzin} />;

Di iOS, setiap izin juga wajib punya kalimat alasan di Info.plist (misalnya NSCameraUsageDescription). Notifikasi di Android 13 (API 33) ke atas juga butuh izin, itu sebabnya ada perluIzinNotif tadi.

Yang disembunyikan modul Expo: expo-status-bar dan useCameraPermissions memberi satu API untuk dua platform, dan config plugin menulis Info.plist serta AndroidManifest.xml untukmu (pelajaran berikutnya). Yang gak disembunyikan: cara kembali, aturan izin, bayangan, font, dan kebiasaan tampilan masing-masing (Material Design di Android, Human Interface Guidelines di iOS).

⚠️ Jebakan yang sering ketemu

TL;DR: Kode React Native sebagian besar dipakai bersama, dan bagian yang beda ditulis dengan Platform.OS ('ios' atau 'android'), Platform.select({ ios, android, native, default }), atau file .ios.tsx dan .android.tsx yang dipilih Metro saat bundle (plus file tanpa akhiran untuk web dan TypeScript). Bayangan: shadow* di iOS, elevation di Android, atau boxShadow untuk keduanya (sejak 0.76, New Architecture). BackHandler cuma untuk back sistem Android (return true menghentikannya); geser dari tepi kiri iOS dimatikan dengan gestureEnabled: false. expo-status-bar dengan style="auto" mengurus warna teks status bar. Izin: iOS cuma bertanya sekali, Android dua kali sejak Android 11; setelah itu canAskAgain: false dan tinggal Linking.openSettings().

Yang akan kamu pelajari