Blog'a Dön
React NativeResponsiveDesign SystemMobileTypeScript

Üçüncü Parti Kütüphane Olmadan React Native İçin Duyarlı Tasarım Sistemi

Umut Korkmaz2025-03-158 min read

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:

typescript
function 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:

typescript
function 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:

typescript
function 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.