コンテンツにスキップ
Webフロントエンドエンジニアのための React Native 実践入門
Esc
navigateopen⌘Jpreview
このページの内容

第16章 再利用コンポーネントとインタラクションを実装する

繰り返し使う部品を切り出しつつ、スワイプ操作や触覚フィードバックといったモバイルらしい手触りを加えます。

前章で3画面の骨格ができました。ここからは、画面の中で繰り返し使う部品(TodoItemFilterBarFabEmptyState)と、スワイプ操作・触覚フィードバックといったモバイルらしいインタラクションを実装していきます。

再利用するコンポーネント

ここからはcomponents/配下に置く部品を見ていきます。それぞれ独立した責務を持ち、画面側からはpropsで動かせる形にしておきます。

TodoItem

一覧の1行ぶんを描画するコンポーネントです。チェックボックスと、タップで編集に飛ぶ行全体の2つのPressableを持ちます。

// src/components/todo-item/todo-item.tsx
import { Pressable, StyleSheet, Text, View } from "react-native";

import { useTheme } from "@/hooks/use-theme";
import type { ThemeColors } from "@/theme/colors";
import type { Todo } from "@/types/todo";

type TodoItemProps = {
  todo: Todo;
  onPress: () => void;
  onToggle: () => void;
};

export function TodoItem({ todo, onPress, onToggle }: TodoItemProps) {
  const { colors } = useTheme();
  const styles = createStyles(colors);

  return (
    <Pressable
      onPress={onPress}
      style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
      android_ripple={{ color: colors.divider }}
    >
      <Pressable
        onPress={onToggle}
        hitSlop={12}
        style={[styles.checkbox, todo.completed && styles.checkboxChecked]}
      >
        {todo.completed && <Text style={styles.checkmark}>✓</Text>}
      </Pressable>
      <View style={{ flex: 1 }}>
        <Text
          style={[styles.title, todo.completed && styles.titleCompleted]}
          numberOfLines={2}
        >
          {todo.title}
        </Text>
      </View>
    </Pressable>
  );
}

入れ子のPressableはReact Nativeで普通に書けますが、押下時の伝播に注意が必要です。チェックボックスをタップしたとき、その背後の行のonPressまで走らせたくないので、子のPressableが押されたイベントが親に伝わらないようになっています。これはReact Nativeのデフォルト挙動で、特別な実装は要りません。

hitSlop={12}は、見た目より広い範囲をタップ可能にする指定です。チェックボックスは見た目26×26ですが、12px外側まで反応するようにしておけば、指で押しやすくなります。アクセシビリティの観点からもおすすめです。

android_rippleはAndroidで「タップ波紋」を出すための指定です。iOSではプロパティが無視されるだけなので、両方書いておくのが楽です。

numberOfLines={2}を付けると、長すぎるタイトルが2行で省略されます。これがないと、長文のTODOで行の高さがバラバラになって一覧が崩れます。

TodoItemにスワイプ操作を加える

ここまでが基本形です。サンプルアプリでは、ネイティブアプリらしい操作感を出すために、行を左右にスワイプして「完了切り替え」「削除」を呼び出せるようにしてあります。react-native-gesture-handlerReanimatedSwipeableを使えば、自前でアニメーションを書かずに済みます。

// src/components/todo-item/todo-item.tsx (要点のみ)
import { useRef } from "react";
import ReanimatedSwipeable, {
  type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";

import { hapticImpact, hapticToggle, hapticWarning } from "@/lib/haptics";

type TodoItemProps = {
  todo: Todo;
  onPress: () => void;
  onToggle: () => void;
  onDelete?: () => void;
  /** Swipeable を無効化したい時に使う (Storybook など) */
  swipeEnabled?: boolean;
};

export function TodoItem({
  todo,
  onPress,
  onToggle,
  onDelete,
  swipeEnabled = true,
}: TodoItemProps) {
  const swipeRef = useRef<SwipeableMethods | null>(null);

  const handleToggle = () => {
    void hapticToggle(!todo.completed);
    onToggle();
  };

  const handleSwipeToggle = () => {
    void hapticImpact();
    onToggle();
    swipeRef.current?.close();
  };

  const handleSwipeDelete = () => {
    void hapticWarning();
    onDelete?.();
  };

  const renderLeftActions = () => (
    <Pressable onPress={handleSwipeToggle} style={styles.leftAction}>
      <Text style={styles.actionLabel}>
        {todo.completed ? "未完了に戻す" : "完了"}
      </Text>
    </Pressable>
  );

  const renderRightActions = onDelete
    ? () => (
        <Pressable onPress={handleSwipeDelete} style={styles.rightAction}>
          <Text style={styles.actionLabel}>削除</Text>
        </Pressable>
      )
    : undefined;

  const row = (/* 先ほどの Pressable を中身に */);

  if (!swipeEnabled) return row;

  return (
    <ReanimatedSwipeable
      ref={swipeRef}
      friction={2}
      leftThreshold={64}
      rightThreshold={64}
      overshootLeft={false}
      overshootRight={false}
      renderLeftActions={renderLeftActions}
      renderRightActions={renderRightActions}
    >
      {row}
    </ReanimatedSwipeable>
  );
}

スワイプ実装で押さえるポイントは3つです。

ひとつめは、renderLeftActions / renderRightActionsが左右のスワイプで現れる「中身」を返す点です。Reanimated側がアニメーションを担当してくれるので、関数で要素を返すだけで済みます。leftThreshold / rightThresholdは、その距離以上スワイプしないと確定しない閾値です。

ふたつめは、スワイプ操作後にswipeRef.current?.close()で閉じることです。完了トグルのように「行を残したまま状態だけ変える」操作では、ユーザーが指を離した後にアクション領域を閉じてあげる必要があります。削除のように行ごと消える場合は、自然と消えるので閉じる必要はありません。

3つめは、Storybookでの扱いです。Storybookでは1コンポーネントだけを切り出して見るので、ジェスチャの親(GestureHandlerRootView)が無い状態でレンダーされ、エラーになります。swipeEnabledプロパティを足してStorybookではfalseにすると、ジェスチャを無効化したまま見た目だけ確認できます。

触覚フィードバック ― 自前ヘルパーで挙動を揃える

第11章でexpo-hapticsの存在に触れましたが、実アプリで使うときは「どの操作にどの触覚を返すか」を一箇所にまとめておくと便利です。サンプルではsrc/lib/haptics.tsに薄いヘルパーを置いています。

// src/lib/haptics.ts (抜粋)
import * as Haptics from "expo-haptics";
import { Platform } from "react-native";

const isAndroid = Platform.OS === "android";

/** チェックボックスなどトグル切替 */
export function hapticToggle(nextOn: boolean): Promise<void> {
  if (isAndroid) {
    return Haptics.performAndroidHapticsAsync(
      nextOn
        ? Haptics.AndroidHaptics.Toggle_On
        : Haptics.AndroidHaptics.Toggle_Off,
    );
  }
  return Haptics.selectionAsync();
}

/** スワイプアクション発火など、はっきりした手応え */
export function hapticImpact(): Promise<void> {
  if (isAndroid) {
    return Haptics.performAndroidHapticsAsync(
      Haptics.AndroidHaptics.Long_Press,
    );
  }
  return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}

/** 警告・破壊的操作 */
export function hapticWarning(): Promise<void> {
  if (isAndroid) {
    return Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Reject);
  }
  return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}

iOSとAndroidでは触覚フィードバックのAPIが分かれていて、AndroidはperformAndroidHapticsAsyncを使うとVIBRATE権限なしで触覚エンジン側のフィードバックが出せるのがポイントです。Vibrator API を直接叩く実装よりも体験が良く、AndroidManifest.xmlへの権限追加もいりません。

「トグル」「インパクト」「警告」のように意味で名前を付けたヘルパーを作っておくと、画面側のコードはhapticToggle(!todo.completed)のようにそのまま読める形になります。プラットフォーム差を画面コードに持ち込まずに済むのが、こうした薄いラッパーの効きどころです。

Fab

画面右下に置く追加ボタンです。Material DesignのFAB(Floating Action Button)から名前を取りました。

// src/components/fab/fab.tsx
import { Pressable, StyleSheet, Text, View } from "react-native";

import { useTheme } from "@/hooks/use-theme";
import type { ThemeColors } from "@/theme/colors";

type FabProps = {
  onPress: () => void;
  label?: string;
  bottom?: number;
};

export function Fab({ onPress, label = "+", bottom = 24 }: FabProps) {
  const { colors } = useTheme();
  const styles = createStyles(colors);

  return (
    <View style={[styles.wrapper, { bottom }]} pointerEvents="box-none">
      <Pressable
        onPress={onPress}
        style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
        accessibilityRole="button"
        accessibilityLabel="TODOを追加"
      >
        <Text style={styles.label}>{label}</Text>
      </Pressable>
    </View>
  );
}

ポイントはpointerEvents="box-none"です。FABを覆う透明なViewは画面いっぱいに広がっているわけではないものの、絶対配置のラッパーが下のリストのスクロールやタップを邪魔しないように、自分自身はタッチを受け取らず子のPressableだけが反応するようにします。

accessibilityRoleaccessibilityLabelは、第6章で触れたスクリーンリーダー(VoiceOver/TalkBack)向けの指定です。Fabのようにアイコンだけのボタンは、目的を表す文言を渡しておかないと読み上げが不親切になるので、必ず入れておきます。

FilterBar

3つのチップで「すべて / 未完了 / 完了」を切り替える部品です。値とコールバックを受け取るだけの素直な制御コンポーネントにしておきます。

// src/components/filter-bar/filter-bar.tsx
export type TodoFilter = "all" | "active" | "completed";

type FilterBarProps = {
  value: TodoFilter;
  onChange: (value: TodoFilter) => void;
  counts: Record<TodoFilter, number>;
};

const OPTIONS: { value: TodoFilter; label: string }[] = [
  { value: "all", label: "すべて" },
  { value: "active", label: "未完了" },
  { value: "completed", label: "完了" },
];

export function FilterBar({ value, onChange, counts }: FilterBarProps) {
  // ... 各 option を Pressable で並べて、選択中はアクセントカラーで塗る
}

(完全版はsrc/components/filter-bar/filter-bar.tsxを参照してください)

カウント数もcounts: Record<TodoFilter, number>で受け取ります。FilterBar側でTODOストアを参照しに行ってもいいのですが、画面側で計算して渡す形にすると、ストアの形が変わってもFilterBarには手を入れずに済みます。

このコンポーネントはテストも書いてあります。Testing LibraryのfireEvent.pressでチップを押し、onChangeが想定どおり呼ばれるかを確認するだけの簡素なものですが、こうしたインタラクションテストは破綻に気付くのが早くなる効果があります。

EmptyState

リストが空のときに表示する案内です。アイコン・タイトル・説明文の3点だけで、見た目は一定です。

// src/components/empty-state/empty-state.tsx
type EmptyStateProps = {
  title?: string;
  description?: string;
  icon?: string;
};

export function EmptyState({
  title = "まだ TODO がありません",
  description = "右下のボタンから最初のタスクを追加してみましょう。",
  icon = "📝",
}: EmptyStateProps) {
  // 中央寄せで縦に並べる
}

Webだと意外と作るのを忘れがちですが、初めて開いたときに「使い方が分からない」と感じさせない案内は、モバイルでは特に重要です。

画面の繋ぎ込みを確認する

ここまでの実装で、npx expo startしてiOSシミュレータかAndroidエミュレータで開けば、追加・編集・削除・フィルター切り替えが一通り動くはずです。

TODO一覧→編集モーダル→保存→通知予約の操作シーケンス

保存時にsetReminderが呼ばれ、内部で「既存通知のキャンセル→新しい通知の予約→ストア更新」が行われます。画面側はストアを購読しているだけなので、この更新を受けて自動的に再描画されます。

ここまでで気付いた人もいるかもしれませんが、画面のあちこちでuseThemeからcolorsを取り出してスタイルを組み立てています。次章ではこのuseThemeの中身と、ライト/ダークの切り替え周りを掘り下げます。

最終更新 2026年7月24日