Expo と NativeWind で質の高い UI を構築する

Expo、NativeWind、Reanimated を使って、コードから直接 React Native アプリを設計する方法を学びます。再利用可能なコンポーネントを作り、テーマを実装し、Figma は使いません。

日本語
コピー
How to build high-quality UIs with Expo and NativeWind

この記事は Thomino によるゲスト投稿です。彼は Native Templates の作者で、X でフォローする価値もあります。

私が Web 開発を始めたのは IE6 の時代です。画像を切ってボックスシャドウを作り、透明 GIF で角丸を再現し、テーブルを入れ子にしてレイアウトを組んでいました。フロントエンド開発の「暗黒時代」です。

幸い、時代は変わりました。ExpoNativeWindReanimated を組み合わせれば、アプリのデザインは驚くほど簡単です。実際、私はもう Figma を使っていません。コードを直接書いてデザインし、Expo Go でリアルタイムにプレビューしています。

ここからは、私がワークフローで使っている再利用可能なコンポーネントでアプリをどう組み立てているかを紹介します。これらのコンポーネントがあれば、エディタ上でそのままデザインできます。テーマの扱い方と、アニメーション付きのテーマ切り替えスイッチの作り方も説明します。

再利用可能なコンポーネント

アプリはそれぞれ違いますが、多くの画面、フロー、コンポーネントは共通しています。そこで私はテンプレートを一式用意し、新規プロジェクトを始めたら必要に応じて土台として使えるようにしています。そのために重要なのは、コンポーネントが再利用でき、かつアプリごとにスタイルを簡単に変えられることです。

以下は NativeTemplates で実装している例です。

NativeWind で柔軟な header コンポーネントを作る

おそらく最も重要で基本的なコンポーネントです。header は必要に応じて簡単に調整できるだけの柔軟性がなければなりません。

import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useThemeColors } from "app/contexts/ThemeColors";
import { Link, router } from "expo-router";
import Icon, { IconName } from "./Icon";
import { useSafeAreaInsets } from "react-native-safe-area-context";

type HeaderProps = {
  title: string,
  showBackButton?: boolean,
  rightComponents?: React.ReactNode[],
};

const Header: React.FC<HeaderProps> = ({
  title,
  showBackButton = false,
  rightComponents = [],
}) => {
  const colors = useThemeColors();
  const insets = useSafeAreaInsets();

  const handleBackPress = () => {
    router.back();
  };

  return (
    <View
      style={{ paddingTop: insets.top }}
      className="w-full pb-2 flex-row justify-between px-6 bg-background"
    >
      <View className="flex-row items-center flex-1">
        {showBackButton && (
          <TouchableOpacity onPress={handleBackPress} className="mr-4 py-4">
            <Icon name="ArrowLeft" size={24} color={colors.icon} />
          </TouchableOpacity>
        )}

        <View className="py-4">
          <Text className="text-lg font-bold" style={{ color: colors.text }}>
            {title}
          </Text>
        </View>
      </View>

      {rightComponents.length > 0 && (
        <View className="flex-row items-center justify-end flex-1">
          {rightComponents.map((component, index) => (
            <View key={index} className="ml-6">
              {component}
            </View>
          ))}
        </View>
      )}
    </View>
  );
};

export default Header;
// Basic header with title
<Header title="Home" />
 
// Header with back button
<Header 
  title="Details" 
  showBackButton 
/>
 
// Header with action icons
<Header 
  title="Messages" 
  rightComponents={[
    <HeaderIcon key="search" icon="Search" href="/search" />,
    <HeaderIcon key="settings" icon="Settings" href="/settings" />
  ]}
/>
基本的なヘッダー

React Native でアンカー付き tab ナビゲーションを作る

アイコン、アニメーション、アバターから選べます。

<TabTrigger name="home" href="/" asChild>
  <TabButton labelAnimated={true} icon="Home">Home</TabButton>
</TabTrigger>
<TabTrigger name="profile" href="/profile" asChild>
  <TabButton labelAnimated={true} avatar={require('@/assets/img/thomino.jpg')}>Profile</TabButton>
</TabTrigger>

マルチステップフォームと onboarding フローを構築する

これは非常によく使います。onboarding やその他のユーザーフローに特に向いています。onboarding はユーザーをアクティブ化し、初期離脱を減らすために欠かせません。

<MultiStep
  onComplete={handleComplete}
  onClose={handleClose}
  headerTitle="Get Started"
>
  <Step title="Profile">
    <Profile />
  </Step>
  <Step title="Role">
    <Role />
  </Step>
  <Step title="Capabilities">
    <Capabilities />
  </Step>
  <Step title="Review">
    <Review />
  </Step>
</MultiStep>;
多段階タブ

フィルタとフォームのための再利用可能な chip コンポーネント

基本的なものですが、フィルタやフォームでよく使います。

<Chip 
  label="Custom" 
  className="my-2 mx-1" 
  size="xl"
/>

すべてのコンポーネントは私のドキュメントで確認できます。

NativeWind でテーマ変数を実装する

NativeWind はテーマの扱いを非常に強力にします。vars のおかげで、1 つのファイルでアプリ全体のテーマを切り替えられます。これにより、ライトモードとダークモードが後付けではなく、デザインシステムの一部であることが保証されます。

テーマのカスタマイズ
import { vars } from "nativewind";

export const themes = {
  light: vars({
    "--color-primary": "#000000", 
    "--color-invert": "#ffffff", 
    "--color-secondary": "#ffffff",
    "--color-background": "#F4F4F5",
    "--color-darker": "#F4F4F5",
    "--color-text": "#000000",     
    "--color-highlight": "#7E55D8",
    "--color-border": "rgba(0, 0, 0, 0.15)",
  }),
  dark: vars({
    "--color-primary": "#ffffff",
    "--color-invert": "#000000",
    "--color-secondary": "#1e1e1e",
    "--color-background": "#141414",
    "--color-darker": "#000000",
    "--color-text": "#ffffff",  
    "--color-highlight": "#7E55D8",
    "--color-border": "rgba(255, 255, 255, 0.15)",
  }),
};

Reanimated で動くダークモードスイッチを作る

X の投稿

import { Pressable, View } from "react-native";
import { useTheme } from "@/app/contexts/ThemeContext";
import Feather from "@expo/vector-icons/Feather";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from "react-native-reanimated";
import { useEffect } from "react";

const ThemeToggle = () => {
  const { theme, toggleTheme } = useTheme();
  const isDark = theme === "dark";
  const translateX = useSharedValue(isDark ? 36 : 3.5);

  useEffect(() => {
    translateX.value = withSpring(isDark ? 36 : 3.5, {
      damping: 15,
      stiffness: 150,
    });
  }, [isDark]);

  const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: translateX.value }],
    };
  });

  return (
    <Pressable
      onPress={toggleTheme}
      className="w-20 h-10 p-1 bg-secondary relative flex-row rounded-full items-center justify-between"
    >
      <Icon icon="sun" />
      <Icon icon="moon" />
      <Animated.View
        style={[animatedStyle]}
        className="w-9 h-9 bg-background rounded-full items-center justify-center flex flex-row absolute"
      />
    </Pressable>
  );
};

const Icon = (props: any) => {
  const { theme } = useTheme();
  const isDark = theme === "dark";

  return (
    <View className="w-9 h-9 relative z-50 rounded-full items-center justify-center flex flex-row">
      <Feather
        name={props.icon}
        size={16}
        color={`${isDark ? "white" : "black"}`}
      />
    </View>
  );
};

export default ThemeToggle;

すぐ使えるログイン、オンボーディング、設定画面のテンプレート

しっかりしたコンポーネントの土台があれば、テンプレートを組むのは驚くほど速いです。ほぼどのアプリにも共通する画面がいくつかあります。ログイン、サインアップ、オンボーディング、プロフィール設定、プライバシーなどです。そしてここで、すべてのコンポーネントが生きてきます。

画面
export default function ProfileScreen() {
  return (
    <View className="flex-1 bg-light-primary dark:bg-dark-primary">
      <Header
        leftComponent={<ThemeToggle />}
        rightComponents={[
          <HeaderIcon icon="ChartBar" href="/screens/analytics" />,
        ]}
      />
      <View className="flex-1 bg-light-primary dark:bg-dark-primary">
        <ThemedScroller>
          <AnimatedView className="pt-4" animation="scaleIn">
            <SubscriptionCard />
            <View className="gap-1 bg-secondary rounded-3xl">
              <ListLink
                className="px-4 py-2 border-b border-border"
                showChevron
                title="Account settings"
                icon="Settings"
                href="/screens/settings"
              />
              <ListLink
                className="px-4 py-2 border-b border-border"
                showChevron
                title="Billing"
                icon="CreditCard"
                href="/screens/billing"
              />
            </View>
          </AnimatedView>
        </ThemedScroller>
      </View>
    </View>
  );
}

Expo がプラットフォームを与え、NativeWind がスタイル言語を与えてくれる。そうして作られたアプリは、動くだけでなく見た目も良いのです。

次のプロジェクトを素早く立ち上げたいなら、ここにあるすべての React Native Expo テンプレートか、GitHub の Expo Playground を覗いてみてください。

出典: Expo Blog← ホームへ戻る