Üçüncü Parti Kütüphane Olmadan React Native İçin Duyarlı Tasarım Sistemi
React Native'de duyarlı (responsive) tasarım çözümlerini aradığımda, her cevap bir kütüphaneye işaret ediyordu: react-native-responsive-screen, react-native-size-matters veya react-native-responsive-dimensions. Ancak her biri bir bağımlılık daha, bir soyutlama katmanı daha ve bankacılık uygulamamızda güvenlik açısından denetlenecek bir paket daha ekliyordu. Ben de ihtiyacımız olan her şeyi 200 satırdan az kodla halleden özel bir useResponsive() hook'u geliştirdim.
Sorun Alanı
React Native'de CSS medya sorguları (media queries) yoktur. Bir iPhone 15 Pro Max'te (430x932) mükemmel görünen bir düğme, iPhone SE'de (375x667) sıkışık ve iPad Pro'da (1024x1366) gülünç derecede küçük görünür. Telefon ile tablet düzenlerini, dikey ile yatay yönelimi (orientation), ekran boyutlarına göre dinamik yazı tipi ölçeklendirmeyi ve tüm cihaz boyutlarında tutarlı boşlukları (spacing) halleden bir sisteme ihtiyacımız vardı.
useResponsive() Hook'u
typescript// hooks/useResponsive.ts
import { useWindowDimensions, PixelRatio, Platform } from 'react-native';
import { useMemo } from 'react';
// Base design dimensions (iPhone 14 Pro)
const BASE_WIDTH = 393;
const BASE_HEIGHT = 852;
interface DeviceInfo {
isTablet: boolean;
isLandscape: boolean;
screenWidth: number;
screenHeight: number;
fontScale: number;
}
interface ResponsiveHelpers {
wp: (percentage: number) => number;
hp: (percentage: number) => number;
fs: (size: number) => number;
sp: (size: number) => number;
device: DeviceInfo;
select: <T>(options: { phone?: T; tablet?: T; landscape?: T }) => T;
}
export function useResponsive(): ResponsiveHelpers {
const { width, height, fontScale } = useWindowDimensions();
return useMemo(() => {
const screenWidth = Math.min(width, height); // Always portrait width
const screenHeight = Math.max(width, height); // Always portrait height
const isLandscape = width > height;
const isTablet = screenWidth >= 600;
// Width percentage — responsive to screen width
const wp = (percentage: number): number => {
const value = (percentage / 100) * width;
return PixelRatio.roundToNearestPixel(value);
};
// Height percentage — responsive to screen height
const hp = (percentage: number): number => {
const value = (percentage / 100) * height;
return PixelRatio.roundToNearestPixel(value);
};
// Font size — scales relative to base design, respects accessibility
const fs = (size: number): number => {
const widthRatio = width / BASE_WIDTH;
const heightRatio = height / BASE_HEIGHT;
const scale = Math.min(widthRatio, heightRatio);
// Clamp scaling between 0.8x and 1.4x to prevent extremes
const clampedScale = Math.max(0.8, Math.min(1.4, scale));
const scaledSize = size * clampedScale;
// Respect system font scale for accessibility, but cap at 1.3x
const accessibilityScale = Math.min(fontScale, 1.3);
const finalSize = scaledSize * accessibilityScale;
return PixelRatio.roundToNearestPixel(finalSize);
};
// Spacing — scales relative to base design without font scale
const sp = (size: number): number => {
const widthRatio = width / BASE_WIDTH;
const scale = Math.max(0.8, Math.min(1.5, widthRatio));
return PixelRatio.roundToNearestPixel(size * scale);
};
// Device-conditional rendering helper
const select = <T,>(options: {
phone?: T;
tablet?: T;
landscape?: T;
}): T => {
if (isLandscape && options.landscape !== undefined) {
return options.landscape;
}
if (isTablet && options.tablet !== undefined) {
return options.tablet;
}
return options.phone as T;
};
const device: DeviceInfo = {
isTablet,
isLandscape,
screenWidth: width,
screenHeight: height,
fontScale,
};
return { wp, hp, fs, sp, device, select };
}, [width, height, fontScale]);
}
Her yardımcıyı (helper) açıklayayım.
wp() ve hp(): Yüzde Tabanlı Boyutlar
Piksel değerlerini sabit kodlamak yerine, boyutları ekranın yüzdesi olarak ifade ederiz:
typescript// Before — breaks on different screens
const styles = StyleSheet.create({
card: {
width: 350, // Too wide on iPhone SE, too narrow on iPad
height: 200,
marginHorizontal: 20,
},
});
// After — adapts to any screen
function AccountCard() {
const { wp, hp, sp } = useResponsive();
return (
<View style={{
width: wp(90), // 90% of screen width
height: hp(22), // 22% of screen height
marginHorizontal: sp(16),
borderRadius: sp(12),
padding: sp(16),
}}>
{/* content */}
</View>
);
}
Bir iPhone SE'de (375px genişlik), wp(90) = 337,5px. Bir iPad Pro'da (1024px genişlik), wp(90) = 921,6px. Kart, her iki cihazda da ekranı uygun biçimde doldurur.
fs(): Yazı Tipi Ölçeklendirmesini Doğru Yapmak
Yazı tipi ölçeklendirmesi, çoğu duyarlı sistemin yanlış yaptığı yerdir. Ya doğrusal ölçeklerler (metin küçük ekranlarda okunamayacak kadar küçük ya da tabletlerde gülünç derecede büyük olur) ya da erişilebilirlik (accessibility) ayarlarını tümüyle yok sayarlar.
fs() fonksiyonumuz, temel tasarım boyutlarına göre ölçekler ancak ölçeklendirme faktörünü 0.8x ile 1.4x arasında kıstırır (clamp):
typescript// Typography scale
const typography = {
h1: (fs: Function) => ({ fontSize: fs(28), lineHeight: fs(36) }),
h2: (fs: Function) => ({ fontSize: fs(22), lineHeight: fs(30) }),
body: (fs: Function) => ({ fontSize: fs(16), lineHeight: fs(24) }),
caption: (fs: Function) => ({ fontSize: fs(12), lineHeight: fs(18) }),
};
function TransactionAmount({ amount }: { amount: number }) {
const { fs } = useResponsive();
return (
<Text style={{
fontSize: fs(24),
fontWeight: '700',
lineHeight: fs(32),
}}>
{formatCurrency(amount)}
</Text>
);
}
Kıstırma (clamping), 28px'lik bir başlığın küçük ekranlarda 22px'e (hâlâ okunabilir) veya tabletlerde 39px'e (orantılı, ezici değil) dönüşmesini önler. Ayrıca, daha büyük metne ihtiyaç duyan kullanıcılara saygı duyarken düzen bozulmalarını önlemek için erişilebilirlik yazı tipi ölçeğini 1.3x ile sınırlarız.
select(): Cihaza Bağlı Mantık
select yardımcısı, dağınık üçlü operatörleri (ternary) temiz, bildirimsel (declarative) bir kalıpla değiştirir:
typescriptfunction DashboardLayout({ children }: { children: React.ReactNode }) {
const { select, wp, sp } = useResponsive();
const columns = select({
phone: 1,
tablet: 2,
landscape: 3,
});
const containerPadding = select({
phone: sp(16),
tablet: sp(32),
});
return (
<View style={{ padding: containerPadding }}>
<FlatList
data={items}
numColumns={columns}
key={columns} // Force re-render when columns change
columnWrapperStyle={
columns > 1 ? { gap: sp(16) } : undefined
}
renderItem={({ item }) => (
<View style={{ width: wp(100 / columns - 4) }}>
<DashboardCard item={item} />
</View>
)}
/>
</View>
);
}
Dikey moddaki bir telefonda tek sütunlu bir liste alırsınız. Yatay moda döndürün: üç sütun. Bir tablette açın: iki sütun. Hepsi medya sorguları olmadan.
Duyarlı Bir Tasarım Token Sistemi İnşa Etmek
Hook tek başına yeterli değil — onu tutarlı biçimde kullanan tasarım token'larına ihtiyacınız var:
typescript// theme/useDesignTokens.ts
export function useDesignTokens() {
const { sp, fs, select } = useResponsive();
return useMemo(() => ({
spacing: {
xs: sp(4),
sm: sp(8),
md: sp(16),
lg: sp(24),
xl: sp(32),
xxl: sp(48),
},
fontSize: {
xs: fs(10),
sm: fs(12),
md: fs(14),
lg: fs(16),
xl: fs(20),
xxl: fs(28),
},
borderRadius: {
sm: sp(4),
md: sp(8),
lg: sp(16),
full: sp(999),
},
hitSlop: {
// Minimum 44pt touch targets (Apple HIG)
default: select({
phone: sp(44),
tablet: sp(48),
}),
},
iconSize: {
sm: sp(16),
md: sp(24),
lg: sp(32),
},
}), [sp, fs, select]);
}
Bileşenler, ham sayılar yerine token'ları tüketir:
typescriptfunction PrimaryButton({ label, onPress }: ButtonProps) {
const tokens = useDesignTokens();
return (
<Pressable
onPress={onPress}
style={{
backgroundColor: '#1a73e8',
paddingVertical: tokens.spacing.md,
paddingHorizontal: tokens.spacing.xl,
borderRadius: tokens.borderRadius.md,
minHeight: tokens.hitSlop.default,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{
color: '#ffffff',
fontSize: tokens.fontSize.lg,
fontWeight: '600',
}}>
{label}
</Text>
</Pressable>
);
}
Yönelim Değişikliklerini Ele Almak
React Native'in useWindowDimensions'ı, yönelim değişikliklerinde otomatik olarak yeniden render tetikler. Hook'umuz width, height ve fontScale üzerinde memoize edildiği için, her şey otomatik olarak yeniden hesaplanır:
typescriptfunction App() {
const { device, select } = useResponsive();
return (
<SafeAreaView style={{ flex: 1 }}>
{select({
phone: <MobileNavigation />,
tablet: <TabletSidebarNavigation />,
})}
<MainContent />
{device.isLandscape && <LandscapeToolbar />}
</SafeAreaView>
);
}
Neden Bir Kütüphane Değil?
Özel hook'umuz, sıfır bağımlılıkla 80 satır kod. Popüler alternatifler 500 ila 2.000 satır arasında değişir ve ek paketler çeker. Her bağımlılığın bir güvenlik denetimi yüzeyi olduğu bir bankacılık uygulamasında, küçük olan daha iyidir.
Daha da önemlisi, ölçeklendirme algoritmalarını biz kontrol ediyoruz. Tasarım ekibimiz, yazı tiplerinin küçük cihazlarda asla 0.85x'in altına ölçeklenmemesi gerektiğine karar verdiğinde (önceden 0.8x'ti), bu tek satırlık bir değişiklikti. Bunu bir üçüncü parti kütüphanenin yayın döngüsüyle müzakere etmeyi deneyin.
useResponsive() hook'u bir yılı aşkın süredir üretimde, iPhone SE'den iPad Pro 12.9 inç'e kadar cihazları yönetiyor. Kusursuz değil — hiçbir duyarlı sistem değildir — ama basit, öngörülebilir ve tümüyle bizim kontrolümüzde. Bazen en iyi bağımlılık, kurmadığınızdır.