ksg

ksg@software_engineer

Portfolio Blog

reactnative2026/07/23

Expo(React Native) × WidgetKit でiOSホーム画面ウィジェットを実装する(ハマりどころ全部乗せ)

Expo(React Native) × WidgetKit でiOSホーム画面ウィジェットを実装する(ハマりどころ全部乗せ)

個人開発で歩数連動の育成ゲームアプリ(Expo + React Native、iOS)を運用しています。ホーム画面に現在地の天気を表示するウィジェットを追加しようとしたのですが、React Native側の知識だけではまったく歯が立たず、Xcodeのビルド設定・Swift・WidgetKitの世界を行き来しながら何度もハマりました。この記事はそのときの知見をまとめたものです。

同じようにExpo/React NativeアプリにWidgetKitを組み込もうとしている人向けに、つまずきやすいポイントを中心に書きます。

この記事はExpo Goでは動きません。 Widget Extension用のTargetを追加してXcodeプロジェクト自体を編集するため、expo prebuildでネイティブプロジェクトを生成した状態(prebuild / bare ワークフロー、またはカスタムDevelopment Client)が前提です。管理ワークフローのままExpo Goで確認しようとしている場合は、先にprebuildが必要になります。

結論

先に全体像と、特にハマったポイントを挙げておきます。

[React Native アプリ (JS)]
    ↓ NativeModule経由
[App Group UserDefaults]  ← 共有ストレージ
    ↓ Swift から読み込み
[Widget Extension (Swift/SwiftUI)]
    ↓
[iOS ホーム画面ウィジェット]

Widget Extensionはメインアプリとは別プロセスで動きます。つまりJSのコードやReact Nativeのライブラリはウィジェット側では一切使えません。データのやり取りはApp Group UserDefaultsという共有ストレージを介して行う必要があり、この前提を理解していないと設計の時点でつまずきます。

その上で、実際に一番時間を溶かしたのは次の3つでした。

  • Xcodeが自動生成するEmbed Foundation Extensionsビルドフェーズの順序が悪いと、ExpoプロジェクトのAppIntentsメタデータ抽出処理と循環依存を起こしてビルドが通らない
  • SwiftUI側でcontainerBackgroundを使わずに背景を敷くと、iOS 17以降はウィジェットの端に謎の余白が出る
  • ウィジェットが灰色になったとき、原因(画像サイズ/API/コード)の切り分け方法がわからず手探りになる

以降、順を追って解説します。

1. Xcodeセットアップでハマったこと

Widget Extension Targetの追加

File → New → Target → Widget Extensionから追加します。このとき「Include Configuration Intent」はチェックを外しました(静的なウィジェットのみで、ユーザーが設定を変更できるタイプは不要だったため)。

設定

Product Name

YourAppWidget

Bundle ID

com.yourapp.widget

Include Configuration Intent

チェックなし(静的ウィジェット)

Deployment TargetはWidget Extensionだけ17+にする

背景の敷き方で使うcontainerBackground(for:)がiOS 17以降のAPIなので、Widget ExtensionのDeployment Targetは17.0以上にします。メインアプリ側のDeployment Targetを引き上げる必要はありません。ここを揃えて考えてしまうと、無駄にサポート範囲を狭めることになります。

App Groupを両方のTargetに設定する

メインアプリ・Widget Extensionの両方のTargetでSigning & Capabilities → + App Groupsから同じグループ(例: group.com.yourapp)を追加します。entitlementsファイルには以下が入ります。

<key>com.apple.security.application-groups</key>
<array>
    <string>group.com.yourapp</string>
</array>

片方のTargetにしか設定していないと、次のNativeModuleの実装まで進んでからハマることになります。

ビルドフェーズの順序で循環依存エラーになる

ここが一番苦労した部分です。Expo/React Nativeプロジェクトでは、Xcodeが自動生成するEmbed Foundation Extensionsビルドフェーズが、ExpoのExtractAppIntentsMetadata処理と循環依存を起こしてビルドが失敗することがあります。

これは私の環境で効いた回避策で、Expo SDKやXcodeのバージョンによって症状・対処が変わる可能性がある点は前提として書いておきます。自分の環境での対策は、Embed Foundation ExtensionsSourcesよりに移動することでした。

[CP] Check Pods Manifest.lock
[Expo] Configure project
Embed Foundation Extensions   ← ここ(Sourcesより前)
Sources
Frameworks
Resources
Bundle React Native code and images
...

あわせて、自分の環境では[CP-User] [RNGoogleMobileAds] Configurationの「Based on dependency analysis」チェックを外し、project.pbxprojのメインアプリTargetのbuildSettingsに以下を追加することで解消しました。

SWIFT_EMIT_CONST_VALUE_PROTOCOLS = NO;

いずれも「これをやれば必ず直る」という一般的な設定というより、特定のバージョンの組み合わせで発生した循環依存に対する回避策です。同じ症状に当たったら試す価値がある、くらいの位置づけで読んでください。エラーメッセージからは意図が読み取りにくく、「循環依存」というワード自体にたどり着くまでに時間がかかったポイントでした。

2. JS↔Swiftのデータ連携(NativeModule)

WidgetはApp Group UserDefaultsからしかデータを読めないので、JS側で取得した情報(APIキーや位置情報など)をNativeModule経由でUserDefaultsに書き込みます。

Swift側(YourAppSharedData.swift

import Foundation
import WidgetKit

@objc(YourAppSharedData)
final class YourAppSharedData: NSObject {
    private static let suiteName = "group.com.yourapp"

    @objc(setApiKey:resolver:rejecter:)
    func setApiKey(_ key: String, resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) {
        UserDefaults(suiteName: Self.suiteName)?.set(key, forKey: "api_key")
        WidgetCenter.shared.reloadAllTimelines()  // ← データ更新のたびに呼ぶ(更新要求。反映タイミングはOS側の裁量)
        resolver(true)
    }

    @objc(setLocation:resolver:rejecter:)
    func setLocation(_ location: NSDictionary, resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) {
        guard let defaults = UserDefaults(suiteName: Self.suiteName) else {
            rejecter("error", "App Group unavailable", nil); return
        }
        if let lat = location["latitude"] as? NSNumber { defaults.set(lat.doubleValue, forKey: "latitude") }
        if let lon = location["longitude"] as? NSNumber { defaults.set(lon.doubleValue, forKey: "longitude") }
        WidgetCenter.shared.reloadAllTimelines()
        resolver(true)
    }

    @objc static func requiresMainQueueSetup() -> Bool { false }
}

WidgetCenter.shared.reloadAllTimelines()をデータ更新のたびに呼んでいるのがポイントです。これを忘れると、UserDefaultsの値は更新されているのにウィジェットの表示だけが古いまま、という状態になります。ただしこれは「タイムラインの再取得をOSに要求する」API であり、呼べば即座に再描画される保証はありません。実機では体感ほぼ即時でしたが、システムの負荷状況によっては反映が遅延・間引きされることもある点は留意してください。

Obj-Cブリッジ(YourAppSharedDataBridge.m

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(YourAppSharedData, NSObject)

RCT_EXTERN_METHOD(setApiKey:(NSString *)key
                  resolver:(RCTPromiseResolveBlock)resolver
                  rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(setLocation:(NSDictionary *)location
                  resolver:(RCTPromiseResolveBlock)resolver
                  rejecter:(RCTPromiseRejectBlock)rejecter)

@end

JS側(sharedData.ts

import { NativeModules, Platform } from 'react-native';

const { YourAppSharedData } = NativeModules;

export async function syncApiKeyToWidget(key: string): Promise<void> {
    if (Platform.OS !== 'ios' || !YourAppSharedData) return;
    await YourAppSharedData.setApiKey(key).catch(console.warn);
}

export async function syncLocationToWidget(lat: number, lon: number): Promise<void> {
    if (Platform.OS !== 'ios' || !YourAppSharedData) return;
    await YourAppSharedData.setLocation({ latitude: lat, longitude: lon }).catch(console.warn);
}

呼び出しタイミング

同期はアプリのライフサイクルに合わせて仕込みます。APIキーは起動時、位置情報は取得できたタイミングでそれぞれ同期します。

// App.tsx: 起動時にAPIキーを同期
useEffect(() => {
    syncApiKeyToWidget(process.env.EXPO_PUBLIC_API_KEY ?? '');
}, []);

// useLocation.ts: 位置情報取得後に同期
setLocation(locationData);
syncLocationToWidget(locationData.latitude, locationData.longitude);

失敗時にアプリ本体をクラッシュさせたくないので、catch(console.warn)でfail-silentにしている点も意識しました。ウィジェットの同期に失敗してもアプリの主機能には影響させない、という方針です。

3. Widget側のSwiftUI実装

TimelineProvider

struct WeatherEntry: TimelineEntry {
    let date: Date
    let condition: String
    let temperature: Int
}

struct WeatherProvider: TimelineProvider {
    private static let suiteName = "group.com.yourapp"

    func placeholder(in context: Context) -> WeatherEntry {
        defaultEntry()
    }

    private func defaultEntry() -> WeatherEntry {
        WeatherEntry(date: .now, condition: "clear", temperature: 20)
    }

    func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> Void) {
        if context.isPreview {
            completion(placeholder(in: context))
            return
        }
        Task { completion(await loadEntry()) }
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<WeatherEntry>) -> Void) {
        Task {
            let entry = await loadEntry()
            let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
            completion(Timeline(entries: [entry], policy: .after(next)))
        }
    }

    private func loadEntry() async -> WeatherEntry {
        guard let defaults = UserDefaults(suiteName: Self.suiteName),
              let apiKey = defaults.string(forKey: "api_key"), !apiKey.isEmpty,
              defaults.object(forKey: "latitude") != nil,
              defaults.object(forKey: "longitude") != nil else {
            // App Group未設定・初回起動未同期など、必要な値が揃っていない場合はプレースホルダーにフォールバック
            return defaultEntry()
        }

        let latitude = defaults.double(forKey: "latitude")
        let longitude = defaults.double(forKey: "longitude")

        do {
            // 実際にはここで天気APIを呼び出し、レスポンスをデコードする
            let (condition, temperature) = try await fetchWeather(
                apiKey: apiKey, latitude: latitude, longitude: longitude
            )
            return WeatherEntry(date: .now, condition: condition, temperature: temperature)
        } catch {
            // API呼び出し失敗時もクラッシュさせず、直近の値かプレースホルダーを返す
            return defaultEntry()
        }
    }

    private func fetchWeather(
        apiKey: String, latitude: Double, longitude: Double
    ) async throws -> (condition: String, temperature: Int) {
        // 天気APIのエンドポイント・パースはサービスに合わせて実装する
        fatalError("Implement your weather API call here")
    }
}

30分ごとに更新するTimelineにしています。天気情報なのでリアルタイム性はそこまで求めず、バッテリー消費とのバランスを取った形です。

containerBackgroundの抜け漏れが余白の原因になる

一番わかりにくかったハマりどころがこれです。iOS 17以降ではcontainerBackgroundを使わないと、意図せずウィジェットの端に余白が出ます。

// ✅ 正しい(iOS 17+)
.containerBackground(for: .widget) {
    Image("bg").resizable().scaledToFill()
    // または
    LinearGradient(colors: [...], startPoint: .topLeading, endPoint: .bottomTrailing)
}

// ❌ これだと端に余白が出る(iOS 17+)
ZStack {
    LinearGradient(...)  // コンテンツ内にグラデーション
    VStack { ... }
}
.containerBackground(for: .widget) { Color.clear }

見た目の崩れ方が「余白ができる」というくらいの微妙な変化なので、最初は他の設定ミスを疑ってしまい、原因特定に時間がかかりました。iOS 16以前もサポートする場合は、以下のように分岐させます。

private extension View {
    @ViewBuilder
    func widgetBackground() -> some View {
        if #available(iOSApplicationExtension 17.0, *) {
            self.containerBackground(for: .widget) {
                Image("bg").resizable().scaledToFill()
            }
        } else {
            self.background(Image("bg").resizable().scaledToFill())
        }
    }
}

4. 背景画像の設定で気をつけたこと

Asset Catalogは通常のアプリと同じ構成です。

YourWidget/Assets.xcassets/
├── Contents.json
└── bg.imageset/
    ├── bg.png
    └── Contents.json

bg.imageset/Contents.jsonは、Xcodeで画像をAsset Catalogにドラッグ&ドロップすれば自動生成されます(1x/2x/3xの解像度別ファイルを手動で用意する場合は、それぞれのfilenameを実ファイル名に変える)。中身のイメージは次の通りです。

{
  "images": [
    { "filename": "bg.png", "idiom": "universal", "scale": "1x" },
    { "filename": "bg@2x.png", "idiom": "universal", "scale": "2x" },
    { "filename": "bg@3x.png", "idiom": "universal", "scale": "3x" }
  ],
  "info": { "author": "xcode", "version": 1 }
}

注意点が2つあります。

  1. 元画像が大きすぎる(1MB超)と、ウィジェットが実行時にクラッシュすることがある。400px以下にリサイズして使うのが安全です(macOSならsips -Z 400 path/to/bg.pngでリサイズできます)。
  2. Assets.xcassetsをフォルダに置いただけでは認識されません。Widgetターゲットの Resources build phaseに追加する必要があります。

5. デバッグTips

ビルドに変更が反映されないとき

ウィジェット側のSwiftファイルを変更してもビルドに反映されないことがありました。DerivedDataが古いキャッシュを掴んでいるケースです。

# DerivedData を完全削除(核オプション)
rm -rf ~/Library/Developer/Xcode/DerivedData/YourApp-*

# その後ビルド
npx expo run:ios --device

インストール後にウィジェットが更新されないとき

  1. アプリを開いて、データ更新につながる操作をする(位置情報取得ボタンを押すなど)→ 内部でWidgetCenter.shared.reloadAllTimelines()が呼ばれる
  2. それでもダメならウィジェットを一度削除して再追加する
  3. 30分待つ(Timelineの自動更新タイミング)

ウィジェットが灰色になるとき

灰色表示は多くの場合ウィジェットのレンダリングがクラッシュしているサインですが、それだけが原因とは限りません。placeholder/getSnapshot/getTimelineの実装漏れや、非対応のウィジェットファミリーを選んでいる、画像アセットが解決できていない、Redacted(プライバシー表示)のまま止まっているなど、見た目が似た別の原因も候補になります。自分が遭遇したケースでは、次の表を目安に切り分けました。

原因

対策

画像が大きすぎる(実行時クラッシュ)

400px以下にリサイズ

containerBackgroundの誤用

ZStack内に画像を置かない

Image("name")が見つからない

Asset CatalogのTarget Membershipを確認

Timelineが返す前にクラッシュしている

placeholder/getSnapshot/getTimelineの実装漏れ・強制アンラップを確認

なお、IDE上でCannot find type 'WeatherEntry' in scopeのようなエラーが出ることがありますが、これはSourceKitがウィジェットExtensionのコンテキストを正しく認識できていないだけで、Xcodeでのビルド自体は通ります。焦って余計な修正を入れないよう注意が必要です。

まとめ

Expo(React Native)アプリにWidgetKitを組み込む作業は、React Native側の知識だけでは完結せず、Xcodeのビルド設定とSwiftUIの両方に踏み込む必要がありました。特に、ビルドフェーズの順序問題とcontainerBackgroundの抜け漏れは、エラーメッセージから原因が読み取りにくく、ハマりやすいポイントです。

新しいアプリでウィジェットを実装する際のチェックリストとして、最後にまとめておきます。

  • Widget Extension TargetをXcodeで追加
  • Widget ExtensionのDeployment TargetをiOS 17.0に設定
  • App Groupを両Targetに設定(entitlementsファイルを確認)
  • Embed Foundation Extensions build phaseがメインアプリTargetにある
  • Embed Foundation ExtensionsSourcesより前に並んでいる
  • SWIFT_EMIT_CONST_VALUE_PROTOCOLS = NOをメインTargetのbuildSettingsに追加
  • 依存ライブラリのConfigurationフェーズで「dependency analysis」を無効化(必要な場合)
  • NativeModule(.swift + .m)をメインTargetのSourcesに追加
  • WidgetKit.frameworkをメインTargetにリンク
  • Asset CatalogをWidgetターゲットのResourcesに追加
  • 背景画像は400px以下にリサイズ
  • JS側で起動時・データ取得後に適切にUserDefaultsへ同期
  • WidgetCenter.shared.reloadAllTimelines()をデータ更新後に呼ぶ

おすすめの記事