Styling di React Native ditulis sebagai object JavaScript, bukan file CSS, dan layout-nya selalu flexbox. Bayangin papan nota di dinding toko Bu Rini. Nota ditempel dari atas ke bawah, satu per satu. Kalau mau beberapa nota berjajar ke samping, kamu harus bilang dulu "jajarkan". Di React Native, tiap View adalah papan seperti itu: anak-anaknya ditumpuk ke bawah (column), kecuali kamu menulis flexDirection: 'row'.
🧪 Tebakan cepat: Di baris order (OrderRow), nama di kiri dan badge status di kanan, dalam
flexDirection: 'row'. Nama pelanggannya panjang, dan HP-nya selebar 360. Tanpaflex: 1di bagian nama, apa yang terjadi dengan badge status? Jawabannya ada di bagian "Flexbox: default yang beda dari web".
StyleSheet: style sebagai object
const styles = StyleSheet.create({
status: { fontSize: 12, lineHeight: 16, fontWeight: '600' },
});
<Text style={[styles.status, { color: '#1d4ed8' }]}>Disetrika</Text>
- Nama properti memakai camelCase:
fontWeight, bukanfont-weight. Angka ditulis tanpa satuan. StyleSheet.createmengumpulkan style di satu tempat. TypeScript langsung menandai nama properti yang salah tulis, dan di mode development object-nya dibekukan supaya gak diubah diam-diam.style={[a, b]}menggabungkan beberapa style. Yang belakang menang, jadi warna dari object kedua menimpa yang pertama.- Gak ada class, selector, atau cascade. Style gak turun ke anak, kecuali
Textdi dalamText.Texttanpa style di React Native 0.86 berwarna hitam dengan ukuran 14.
Satuan: dp, bukan piksel
Semua ukuran di React Native adalah dp (density-independent pixel), di iOS disebut point. 1 dp kira-kira sama besar di layar mana pun. HP Android dengan layar 720 × 1.600 piksel dan density 2 dilaporkan sebagai 360 × 800 dp. iPhone 15 punya layar 1.179 × 2.556 piksel dengan scale 3, jadi lebarnya 393 point.
| Kamu menulis | HP density 2 | iPhone 15 (scale 3) |
|---|---|---|
padding: 16 |
32 piksel fisik | 48 piksel fisik |
width: 96 |
192 piksel fisik | 288 piksel fisik |
PixelRatio.getPixelSizeForLayoutSize(16) menghitungnya untukmu: 48 di iPhone 15. Ukuran huruf juga ikut pengaturan teks di HP pengguna (fontScale), karena allowFontScaling di Text bawaannya true. Persen juga boleh untuk ukuran: width: '50%'.
Flexbox: default yang beda dari web
React Native menghitung layout dengan Yoga, mesin flexbox buatan Meta. Aturannya flexbox, tapi beberapa nilai awalnya berbeda:
| Properti | Web (CSS) | React Native |
|---|---|---|
display |
block, inline, dan lain-lain |
semua View sudah flex |
flexDirection |
row |
column |
flexShrink |
1 (boleh mengecil) |
0 (gak mengecil) |
alignContent |
normal |
flex-start |
position |
static |
relative |
| ukuran kotak | content-box |
border-box (padding ikut dihitung dalam width) |
flex: 1 di React Native artinya: tumbuh mengisi sisa ruang di sumbu utama induknya, dengan basis 0. Itu cara membuat elemen memenuhi ruang yang tersedia, misalnya layar { flex: 1 } di bawah header. height: '100%' mengambil tinggi penuh induknya tanpa peduli saudaranya, jadi bersama header dia kelebihan dan meluber.
Sekarang baris order di HP 360 dp. Padding kiri kanan 16, jarak (gap) 12, badge 96. Nama panjang kalau ditulis satu baris lebarnya, misalnya, 260 dp. Hasil hitungan Yoga:
| Bagian nama | Lebar nama | Badge mulai di | Badge berakhir di |
|---|---|---|---|
tanpa flex |
260 | 16 + 260 + 12 = 288 | 384, lewat 24 dp dari tepi layar |
flex: 1 |
360 - 32 - 12 - 96 = 220 | 248 | 344, pas 16 dari tepi |
Tanpa flex: 1, bagian nama gak mau mengecil (flexShrink 0), jadi lebarnya tetap 260 dan badge status terdorong keluar layar sejauh 24 dp. Itu jawaban tebakannya. Dengan flex: 1, bagian nama mengambil sisa ruang, 220 dp, dan namanya turun ke baris kedua. flexShrink: 1 juga menyelesaikannya.
OrderRow, angka per angka
// src/app/(tabs)/index.tsx
import { router } from 'expo-router';
import { FlatList, Pressable, StyleSheet, Text, View, useColorScheme, useWindowDimensions } from 'react-native';
import { HARGA_PER_KG, ORDERS, rupiah, type Order } from '@/data/orders';
const WARNA = {
light: { latar: '#ffffff', teks: '#111827', redup: '#4b5563', garis: '#e5e7eb', badge: '#dbeafe', badgeTeks: '#1d4ed8' },
dark: { latar: '#111827', teks: '#f9fafb', redup: '#9ca3af', garis: '#374151', badge: '#1e3a8a', badgeTeks: '#bfdbfe' },
};
function OrderRow({ order }: { order: Order }) {
const w = WARNA[useColorScheme() === 'dark' ? 'dark' : 'light'];
return (
<Pressable
style={[styles.row, { backgroundColor: w.latar, borderBottomColor: w.garis }]}
onPress={() => router.push(`/order/${order.id}`)}>
<View style={styles.info}>
<Text style={[styles.nota, { color: w.teks }]}>#{order.id} {order.nama}</Text>
<Text style={[styles.detail, { color: w.redup }]}>
{order.kg} kg · {rupiah(order.kg * HARGA_PER_KG)}
</Text>
</View>
<View style={[styles.badge, { backgroundColor: w.badge }]}>
<Text style={[styles.status, { color: w.badgeTeks }]}>{order.status}</Text>
</View>
</Pressable>
);
}
export default function Pesanan() {
const { width } = useWindowDimensions();
const kolom = width >= 600 ? 2 : 1;
return (
<FlatList
key={kolom}
numColumns={kolom}
data={ORDERS}
keyExtractor={(order) => order.id}
renderItem={({ item }) => <OrderRow order={item} />}
/>
);
}
const styles = StyleSheet.create({
row: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1 },
info: { flex: 1 },
nota: { fontSize: 16, lineHeight: 22, fontWeight: '600' },
detail: { fontSize: 14, lineHeight: 20, marginTop: 2 },
badge: { width: 96, paddingVertical: 4, borderRadius: 12, alignItems: 'center' },
status: { fontSize: 12, lineHeight: 16, fontWeight: '600' },
});
flexDirection: 'row'menjajarkan info dan badge.alignItems: 'center'menaruh badge di tengah secara vertikal.- Tinggi baris: 12 + (22 + 2 + 20) + 12 + garis 1 = 69 dp.
lineHeightyang ditulis jelas membuat tingginya pasti. - Badge selebar 96 supaya semua status sejajar dalam satu kolom. Tingginya 4 + 16 + 4 = 24.
flex: 1dirowmembuat tiap baris berbagi lebar saat ada dua kolom.
Lebar layar: useWindowDimensions
useWindowDimensions() memberi { width, height, scale, fontScale } dan ikut berubah saat HP diputar atau jendela diubah ukurannya. Di tablet selebar 768, kolom jadi 2: tiap baris 384 dp, bagian namanya 244. Dimensions.get('window') yang dipanggil sekali di luar komponen gak ikut berubah.
numColumns gak boleh berubah di FlatList yang sama. Tanpa key={kolom}, saat HP diputar muncul error Changing numColumns on the fly is not supported. Change the key prop on FlatList when changing the number of columns to force a fresh render of the component. Dengan key, FlatList dibuat ulang.
Safe area: poni, Dynamic Island, dan garis home
Safe area adalah bagian layar yang gak tertutup status bar, poni atau Dynamic Island, dan garis home. Di Android, aplikasi Expo SDK 57 selalu digambar sampai tepi layar (edge-to-edge), jadi status bar juga menimpa aplikasimu. Header dari Stack dan Tabs sudah mengurusnya. Masalah muncul saat header disembunyikan:
// src/app/(tabs)/akun.tsx (tab ini memakai headerShown: false)
import { Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function Akun() {
const insets = useSafeAreaInsets();
return (
<View style={{ flex: 1, paddingTop: insets.top + 16, paddingHorizontal: 16 }}>
<Text style={{ fontSize: 24, fontWeight: '700' }}>Halo, Mbak Wulan</Text>
</View>
);
}
Di iPhone dengan Dynamic Island, insets.top bernilai 59 dan insets.bottom 34, jadi sapaannya mulai di 75. react-native-safe-area-context versi 5.7.0 sudah ikut di Expo SDK 57, dan Expo Router sudah memasang SafeAreaProvider-nya. SafeAreaView dari react-native sudah deprecated dan memunculkan peringatan.
Mode gelap
useColorScheme()mengembalikan'light'atau'dark'(kadang'unspecified'), dan komponen render ulang saat pengguna mengganti tema.OrderRowmemilih paletWARNAdari situ.app.jsonharus berisi"userInterfaceStyle": "automatic". Template sudah begitu. Kalau dihapus, bawaannyalight, danuseColorScheme()selalu'light'.- Header dan tab bar ikut tema lewat
ThemeProviderdariexpo-routerdenganDarkThemeatauDefaultTheme, plus<StatusBar style="auto" />dariexpo-status-bar.
⚠️ Jebakan yang sering ketemu
- Teks panjang di dalam row tanpa
flex: 1.flexShrinkbawaannya 0, jadi isinya gak mengecil dan elemen di kanannya terdorong keluar layar. Beriflex: 1(atauflexShrink: 1) di bagian yang boleh menyempit. - Mengandalkan warna bawaan Text di mode gelap.
Texttanpa warna tetap hitam, dan di latar gelap tulisannya hilang. Warnai semua teks dari palet yang ikutuseColorScheme(). - Header disembunyikan tanpa safe area. Isi layar masuk ke bawah Dynamic Island. Pakai
useSafeAreaInsets()darireact-native-safe-area-context, bukanSafeAreaViewdarireact-native. Dimensions.getdi luar komponen. Nilainya diambil sekali dan gak ikut rotasi. PakaiuseWindowDimensions()di dalam komponen.
TL;DR: Style ditulis dengan StyleSheet.create: camelCase, angka tanpa satuan, gabungan [a, b] (yang belakang menang), tanpa cascade. Satuannya dp: padding: 16 jadi 32 piksel di HP density 2 dan 48 di iPhone 15. Flexbox React Native beda dari web: flexDirection bawaannya column, flexShrink 0, alignContent flex-start, position relative. flex: 1 membuat elemen mengisi ruang yang tersedia. Di OrderRow selebar 360, flex: 1 di bagian nama memberinya 220 dp; tanpa itu nama panjang mendorong badge 24 dp keluar layar. useWindowDimensions() untuk lebar yang bisa berubah (dua kolom mulai 600, dengan key), useSafeAreaInsets() saat header disembunyikan (59 di atas iPhone dengan Dynamic Island), dan useColorScheme() plus userInterfaceStyle: "automatic" untuk mode gelap.