記事

Expo ManagedでネイティブiOSウィジェットを作る

Config Plugins、App Groups、共有ローカル優先ストレージを使い、Xcodeを手動設定せずにExpo ManagedアプリでネイティブiOSウィジェットを公開した方法です。

Expo ManagedでネイティブiOSウィジェットを作る

現代のユーティリティアプリでは、アプリ内の美しいUIだけでは不十分です。ユーザーに使い続けてもらうための本当の舞台はホーム画面です。

ミニマルな水分補給トラッカーWaterPawを開発する際、ネイティブiOSウィジェットは必須機能でした。ホーム画面をちらっと見るだけで水分補給の進捗を確認できるようにしたかったのです。しかしReact Native / Expo環境では、iOSウィジェットにSwiftのネイティブ実装、カスタムApp Extension、Xcodeでのコード署名ターゲット管理が必要です。手動で行うと、Expo Managedのスムーズなワークフローが崩れてしまいます。

Expo Config Pluginsと共有ローカル優先ストレージを使い、ネイティブiOSウィジェットを公開した自動化アーキテクチャを紹介します。

設計上の課題:アプリとウィジェットのサンドボックス

WaterPawはローカル優先でプライバシーを重視しているため、データは端末内に保存されます。一方、iOSウィジェットは独立したバックグラウンドプロセスで動作するため、アプリの標準AsyncStorageやドキュメントディレクトリ内のファイルを読み取れません。

クラウドバックエンドを用意せずにこの問題を解決するには、App GroupというAppleのサンドボックスコンテナを設定します。

[ WaterPaw React Native App ]
    → (Writes to Shared Suite)
        → [ iOS App Group Container ]
            → [ iOS Native Home Widget ] ← (Reads via Swift)

ステップ1:React Nativeからデータを書き込む

JavaScript側でユーザーが水分摂取を記録したら、共有App Groupにデータを送ります。react-native-shared-group-preferencesを使ってネイティブブリッジを連携できます。

import SharedGroupPreferences from 'react-native-shared-group-preferences';

const APP_GROUP = 'group.com.yourgroupname';

export const syncDataToWidget = async (currentMl: number, goalMl: number) => {
  const dataForWidget = {
    progress: currentMl / goalMl,
    displayString: `${currentMl} / ${goalMl} ml`,
    updatedAt: new Date().toISOString()
  };

  try {
    await SharedGroupPreferences.setItem('WidgetData', dataForWidget, APP_GROUP);
  } catch (error) {
    console.error('Failed to sync data to iOS App Group Suite', error);
  }
};

ステップ2:Swiftで共有データを読み込む

ネイティブSwiftウィジェットのディレクトリでは、UserDefaults(suiteName:)を使って同じApp Groupスイートからすぐにデータを読み取れます。すべてローカルで即時に反映され、完全にプライベートです。

import WidgetKit
import SwiftUI

struct HydrationEntry: TimelineEntry {
    let date: Date
    let progress: Double
    let displayString: String
}

struct WaterPawWidgetEntryView: View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("WaterPaw")
                .font(.caption)
                .foregroundColor(.secondary)
            Text(entry.displayString)
                .font(.title2)
                .bold()

            ProgressView(value: entry.progress)
                .progressViewStyle(LinearProgressViewStyle(tint: .blue))
        }
        .padding()
    }
}

ステップ3:Expo Config PluginsでXcodeを自動化 🛠️

ここで開発者はよくつまずきます。npx expo prebuildを実行すると、Expoは手動で追加したXcodeの変更やウィジェット用ターゲットをすべて削除してしまいます。

Managed Workflowを維持するため、Swiftコードを独立したtargets/ディレクトリに置き、expo-target-extensionのようなConfig Pluginでネイティブ構成を自動化します。これにより、Expoはウィジェットターゲットの作成、Swiftのコンパイルフェーズの連携、App Group権限の追加を毎回自動で行います。

{
  "expo": {
    "name": "WaterPaw",
    "slug": "waterpaw",
    "plugins": [
      [
        "expo-target-extension",
        {
          "targetName": "WaterPawWidget",
          "dir": "targets/widgets",
          "entitlements": {
            "com.apple.security.application-groups": ["group.com.yourgroupname"]
          }
        }
      ]
    ]
  }
}

この設定をapp.jsonに追加し、ios/ディレクトリを削除してnpx expo prebuild --cleanを実行すれば、Xcodeのネイティブ環境が自動的に構成されます。

まとめ:Ejectせず、自動化する

ネイティブ拡張のワークフローをConfig Pluginsにまとめれば、モバイルプロジェクトをクリーンで将来にわたって保守しやすい状態に保てます。Expoの素早い反復開発を続けながら、深く統合された遅延のないiOSシステム機能をユーザーに提供できます。

ユーティリティアプリでは、ホーム画面でデータをひと目で確認できるようにする工夫が、長期的な利用につながります。この構成をWaterPawの進捗リングとマスコット追跡機能に活用しました。