🖼️

Expoアプリで「共有用画像」を生成してSNSシェアする実装 — react-native-view-shot + expo-sharing

に公開

はじめに

家計管理アプリを作っていると、「比較カードやバケットリストをそのまま画像にしてSNSにシェアしたい」という要件がよく出てきます。

本記事では react-native-view-shotexpo-sharing を使って、以下の2つのシェアパターンを実装した経験を元に解説します。

  • パターンA — 通常UIと共有UIを同一コンポーネント内に持つ(比較カード)
  • パターンB — 共有UIを別コンポーネントに分離する(バケットリスト)

また「金額を表示/非表示に切り替える」「共有画像は常にライトテーマで出す」「Androidで collapsable={false} が必要」など、実際にハマりやすいポイントも含めて書きます。

ライブラリ

npx expo install react-native-view-shot expo-sharing
  • react-native-view-shot — RN Viewをキャプチャして一時ファイルのURIを返す
  • expo-sharing — OS標準のシェアシートを呼び出す

基本的な流れ

ViewShot(ref付き) → capture() → tmpfile URI → Sharing.shareAsync()

ViewShotref をつけることで ref.current.capture() を呼べるようになります。capture() は端末の一時ディレクトリに PNG を保存し、そのパス(URI)を返します。OS標準のシェアシートにそのまま渡せます。

パターンA: 同一ファイルで通常UI + 共有UI を持つ

比較カードのように「通常UIは画面に表示しつつ、共有用の固定幅カードは画面外にレンダリングしておく」パターンです。

通常UIとは異なるレイアウト・サイズで共有カードを作りたいため、画面外に hidden ViewShot を置いておき、シェアボタンを押したときだけキャプチャします。

ViewShot を画面外に隠す

// 通常UIの下に、画面外 hidden 領域として置く
<View style={styles.hiddenShareContainer} pointerEvents="none">
  <ViewShot
    ref={shareShotRef}
    options={{ format: 'png', quality: 1, result: 'tmpfile' }}
    style={styles.hiddenShareMount}
  >
    {/* ポイント: collapsable={false} を忘れるとAndroidでキャプチャが空になる */}
    <View collapsable={false} style={styles.hiddenShareInner}>
      {/* 共有専用レイアウト */}
      <ShareCard ... />
    </View>
  </ViewShot>
</View>
// スタイル側: 画面外に追い出す
hiddenShareContainer: {
  position: 'absolute',
  top: -9999,  // opacity: 0 は使わない(理由は後述)
  left: 0,
},

pointerEvents="none" でタッチ操作を受け付けず、top: -9999 で画面外に追い出します。

ref と state の定義

// useRef で ViewShot インスタンスを保持し、capture() を呼び出せるようにする
const shareShotRef = useRef<ViewShot | null>(null);
const [showShareAmount, setShowShareAmount] = useState(false);
const [shareModalVisible, setShareModalVisible] = useState(false);
const [isSharing, setIsSharing] = useState(false);

金額の表示/非表示

共有カード内に金額を出すかどうかは showShareAmount state で切り替え、props 経由でグラフコンポーネントに渡します。

<PeerComparisonGraph
  showBubbleAmount={showShareAmount}
  ...
/>

共有モーダル内にトグルボタンを置いて、ユーザーが選択できるようにします。

<Pressable onPress={() => setShowShareAmount(v => !v)}>
  <ThemedText>金額を{showShareAmount ? '非表示' : '表示'}</ThemedText>
</Pressable>

シェア実行

const handleShare = useCallback(async () => {
  if (!shareShotRef.current || !metrics || isSharing) return;
  setIsSharing(true);
  try {
    const canShare = await Sharing.isAvailableAsync();
    if (!canShare) {
      // 本番環境でも一部端末で false になるケースがある
      // 実際のアプリではユーザーへのアラート表示を追加することを推奨
      console.warn('Sharing is not available in this environment.');
      return;
    }
    const uri = await shareShotRef.current.capture?.();
    if (!uri) {
      console.warn('Failed to capture card.');
      return;
    }
    await Sharing.shareAsync(uri, {
      mimeType: 'image/png',
      dialogTitle: '同世代比較をシェア',
    });
  } catch (error) {
    console.warn('Failed to share', error);
  } finally {
    setIsSharing(false);
  }
}, [isSharing, metrics]);

isAvailableAsync()Expo Go では必ず false になります。開発中の動作確認には EAS Build の development build か、端末への直接インストール(npx expo run:ios / npx expo run:android)が必要です。開発中に capture() だけ確認したい場合はこのチェックを一時的に外すと楽です。

capture?.() と optional chaining にしているのは、ref の型が ViewShot | null だからです。

パターンB: 共有UIを別コンポーネントに分離する

バケットリストのシェアは、レイアウトが通常UIと全く異なるため、共有カードを ShareableBucketCard、シェアモーダルを BucketShareModal として分離しています。

パターンAと異なり、Modal の内部に ViewShot を置きます。Modal は visible={true} になったときのみレンダリングされ、通常の画面レイアウトには影響しないため、top: -9999 で画面外に隠す必要はありません。

モーダル側 (BucketShareModal)

export function BucketShareModal({ item, visible, onClose }: BucketShareModalProps) {
  const [showAmount, setShowAmount] = useState(false);
  const [isSharing, setIsSharing] = useState(false);
  const viewShotRef = useRef<ViewShot | null>(null);

  // モーダルを開くたびに金額非表示にリセット
  useEffect(() => {
    if (visible) {
      setShowAmount(false);
    }
  }, [visible]);

  // amount が null のときはトグルを無効化
  const amountToggleDisabled = item.amount === null;
  const effectiveShowAmount = showAmount && !amountToggleDisabled;

  // ※ useCallback は今回省略。必要に応じてパフォーマンス最適化を追加してください
  const handleShare = async () => {
    if (!viewShotRef.current || isSharing) return;
    setIsSharing(true);
    try {
      const canShare = await Sharing.isAvailableAsync();
      if (!canShare) return;
      const uri = await viewShotRef.current.capture?.();
      if (!uri) return;
      await Sharing.shareAsync(uri, {
        mimeType: 'image/png',
        dialogTitle: 'バケットリストをシェア',
      });
    } catch (error) {
      console.warn('Failed to share bucket item', error);
    } finally {
      setIsSharing(false);
    }
  };

  return (
    <Modal transparent animationType="fade" visible={visible}>
      <ViewShot
        ref={viewShotRef}
        options={{ format: 'png', quality: 1, result: 'tmpfile' }}
      >
        <ShareableBucketCard item={item} showAmount={effectiveShowAmount} />
      </ViewShot>
      {/* シェアボタン・金額トグルなど */}
    </Modal>
  );
}

共有カード側 (ShareableBucketCard)

showAmount を props で受け取り、レイアウトを切り替えます。

type ShareableBucketCardProps = {
  item: ShareableBucketItem;
  showAmount: boolean;
};

export function ShareableBucketCard({ item, showAmount }: ShareableBucketCardProps) {
  return (
    <View style={styles.card}>
      <ThemedText style={styles.title}>{item.title}</ThemedText>
      {showAmount && item.amount !== null && (
        <ThemedText style={styles.amount}>
          {formatAmount(item.amount)}
        </ThemedText>
      )}
    </View>
  );
}

共有画像はライトテーマ固定にする

アプリがダークモード対応でも、SNSに投稿する画像は視認性のためライトテーマ固定にすることを推奨します。

パターンAの比較カードでは、共有カード内のグラフに直接ライトテーマ用の色を渡しています。

<PeerComparisonGraph
  isDark={false}                          // ← 強制ライト(useColorScheme() は使わない)
  mutedTextColor="rgba(11, 32, 51, 0.68)"
  accentColor="#0284C7"
  ...
/>

パターンBでも同様に、ShareableBucketCardisDark={false} を渡すか、共有カード専用の定数カラーを直接指定します。ThemedText などのテーマ対応コンポーネントをそのまま使うと親テーマを引き継いでしまうため、共有カード内は独立したスタイルで定義するのが確実です。

2つのパターンの使い分け

パターンA(同一ファイル) パターンB(別コンポーネント)
向いている状況 通常UIと共有UIでデータを多く共有する 通常UIと共有UIのレイアウトが別物
ファイル構造 1ファイル内に hidden ViewShot を追記 Modal + Card + ViewShot を分離
ViewShot の配置 画面外(top: -9999)に常時レンダリング Modal 内にレンダリング(visible 時のみ)
スクロールやアニメーション 影響なし(画面外に追い出す) Modal内にレンダリングするので素直

ハマりどころまとめ

1. collapsable={false} を忘れると Androidでキャプチャが空になる

Android は内部最適化で、コンテンツがないと判断した View を描画対象から除外することがあります(View の collapse)。ViewShot のキャプチャ対象 View に collapsable={false} を指定することで、この最適化を無効化できます。パターンAの hidden ViewShot では必須です。

2. opacity: 0 では画像も透明になる

画面外に隠す際は opacity: 0 ではなく top: -9999 で画面外に追い出してください。opacity: 0 はレンダリング自体は行われますが、キャプチャされた画像も透明になります。

3. isAvailableAsync() が Expo Go では false になる

Expo Go ではシェアシートが利用できません。動作確認には EAS Build の development build か npx expo run:ios / npx expo run:android での端末インストールが必要です。開発中に capture() だけ確認したい場合はこのチェックを一時的にスキップするのが楽です。

4. モーダルが開くたびに金額表示をリセットする

useEffect(() => { if (visible) setShowAmount(false); }, [visible]) で対応します。前回シェア時の状態が残ると意図しない金額露出につながります。

5. tmpfile は OS 管理だが大量生成には注意

capture() が返す tmpfile は端末の一時ディレクトリに保存されます。通常は OS が管理・クリーンアップしますが、大量に生成するケースでは expo-file-systemFileSystem.deleteAsync(uri) で明示的に後処理することも検討してください。

まとめ

  • ViewShot + expo-sharing の組み合わせはシンプルで導入コストが低い
  • パターンA(画面外 hidden)とパターンB(Modal 内)のどちらを選ぶかは「通常UIとデータを共有するか、レイアウトが別物か」で決める
  • 金額などのセンシティブ情報は useState で切り替え、モーダルを開くたびリセットする
  • 共有画像はライトテーマ固定にするとSNS映えが良く、ThemedText を直接使わず専用スタイルを定義する
  • Android の collapsable={false} だけは絶対に忘れない
  • Expo Go では isAvailableAsync()false になる。動作確認は development build で

Discussion