React Nativeで `expo-apple-targets`を使ってiOS WidgetとLive Activityの簡易実装

<!-- 以下本文 -->

# 📱 React Native で expo-apple-targets を使って iOS Widget と Live Activity の簡易実装

_2025.05.20 | by Soumyajeet ([github.com/SoumyajeetSarkar](https://github.com/SoumyajeetSarkar)) 開発者 [@menu.inc](https://app.menu.jp/)_

## ✨ はじめに

![Widgets iOS Image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/6a7247e0-471e-49b5-8a11-86375b170ddf.jpeg)

進化し続けるモバイルテクノロジーの時代でも、iOS Widget と Live Activities は、ユーザー体験やエンゲージメントを向上させるために欠かせない存在となっています。特にデリバリーアプリやリアルタイム系のサービスにおいて、以下のような場面で真価を発揮します：

📦 リアルタイム注文追跡：ロック画面上で配達状況を確認でき、アプリを開く必要なし

🔔 エンゲージメント向上：お気に入りの店舗、リピート注文ボタン、限定クーポンなどにホーム画面から即アクセス

🚀 競争力の強化：主要アプリはすでにこれらを活用して、リテンションや満足度を向上中

しかし問題は、これを React Native（Expo）で開発されているアプリにどう組み込むか。Widget や Live Activity は iOS の native 機能であり、これらを実装するために native の知識がないと「無理 ! 」と思ってしまうことがありました。そこで見つけたのが、`expo-apple-targets` です。

## 📦 `expo-apple-targets` とは ?

[expo-apple-targets](https://github.com/EvanBacon/expo-apple-targets) は、Expo の開発者、Evan Bacon 氏によって開発された Expo 用ライブラリで、Expo の Continuous Native Generation (CNG) と iOS の App Extension（ Widget や Live Activities ）をつなぐ橋渡しの役割を果たします。

**なぜ便利なの？**

🚀 Expo Framework の使いやすさと iOS ネイティブ機能の間のギャップを解消するこのライブラリを簡単に導入できる。

👍 Root directory 下に`📂 /targets` で native ターゲットを分離し、プロジェクトをクリーンに保てる

🔄 Expo の Continuous Native Generation (CNG) に完全対応しているため、Xcode の手動設定は不要になる！！

### 📁 プロジェクト構成

Project の構成は単純に、本体と App Extension の組み合わせになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/bec096d5-5ea3-44b3-b694-0c505e25ed74.png)

ここで、`/root/ios/`の内部のファイルに本体と App Extension が紐づいているコードを書く必要がありましたが、このライブラリの導入で、自動化されます。そのため、今回実装するファイルは`/root/targets`の内部だけです。

## 🛠️ プロジェクトのセットアップ方法

### 1️⃣ ライブラリのセットアップ

#### `expo-apple-targets`をインストールする

```bash
npm install @bacons/apple-targets
```

#### `app.json`に plugin を設定

```
"expo": {
  ...
  "plugins": [
      [
        "@bacons/apple-targets",
        {
          "appleTeamId": "XXXXXXXX"
        }
      ],
  ]
}
```

### 2️⃣ widget または live-activity を `📁targets`下に定義

```markdown
📂 Root
📂 targets
📂 widget
📄 expo-target.config.js
📄 Widget.swift
📂live-activity
...
```

`expo-target.config.js`に widget の configuration を設定する

```javascript
module.exports = {
  type: "widget",
  entitlements: {
    "com.apple.security.applicationgroups": ["group.{project-name}.widget"],
  },
};
```

次は、Widget の実装自体は、Swift で行ないます。ここで、swift UI の知識が必要になってくるのです。

`Widget.swift`に簡単な Static Widget を以下のように実装する

```swift
import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), item: "Loading")
    }

    func snapshot(in context: Context) async -> SimpleEntry {
        SimpleEntry(date: Date(), item: "Loading")
    }

    func timeline(in context: Context) async -> Timeline<SimpleEntry> {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, item: "トンカツ")
            entries.append(entry)
        }

        return Timeline(entries: entries, policy: .atEnd)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let item: String
}

struct WidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text(entry.date, style: .time)

            Text("おすすめ")
            if let url = URL(string: "any image url"),
               let imageData = try? Data(contentsOf: url),
               let uiImage = UIImage(data: imageData) {
               Image(uiImage: uiImage)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(height: 50)
            }
            Text(entry.item)
        }
    }
}

struct widget: Widget {
    let kind: String = "widget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
    }
}

@main
struct widgetBundle: WidgetBundle {
    var body: some Widget {
        widget()
    }
}
```

### 3️⃣ Native のファイルを自動で作成

以下のコマンドだけで expo が `📂 ios/`下のファイルを作成し、Widget/Live-Activity に必要なファイルを定義してあげる

```bash
npx expo prebuild -p ios
```

結果

```markdown
📂 Root
📂 targets
📂 widget
📄 expo-target.config.js
📄 Widget.swift
📄 Info.plist //自動作成
📄 target.entitlements //自動作成
📂live-activity
...
```

Xcode の複雑な設定画面を行わず、App Extension のセットアップが完了！！

### 4️⃣ ビルド

以下のコマンドで Xcode にプロジェクトを開き、

```bash
xed ios
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/6af5f077-7bb9-4e5a-9999-02943a8fb4c2.png)

▶️ ボタン押して simulator 上でアプリを動かせる
今回はWidgetを開発してみました。結果 ⬇️

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/b96aa3d2-ba15-4c97-a93d-016669815f1d.png)

### これで、React Native アプリの Widget を作ることができました。Swift も学びになりました。👏👏

---

## 📝 アプリから Widget へデータ送信 (Advanced)

上の手順でハードコードした「トンカツ」だけ表示できる簡単な Widget を作成しました。今からちょっと Advanced の機能に入ります。アプリ本体から Widget にデータを送信してみましょう！！

### 📂 データ送信

データ送信するために、以下の形でアプリからデータを UserDefaults に設定し、Widget 側からそのデータを取得して表示する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/d261b858-f1fb-47b9-bda7-197531224ccf.png)

### UserDefaults は何？

[User Defaults 機能](https://developer.apple.com/documentation/foundation/userdefaults) は Apple が提供する永続的なキーと値の保存機能である。

### 📱 Native Swift コードの例

```swift
UserDefaults(suiteName: "{app group name}")?.set("value", forKey: "key")
```

次に Expo Module を実装する

## Expo Module の実装

Expo Module を簡単に作るため、`create-expo-module` というものを利用します。

```
$ npx create-expo-module --local

Need to install the following packages:
create-expo-module@0.6.7
Ok to proceed? (y) y

The local module will be created in the modules directory in the root of your project. Learn more: https://expo.fyi/expo-module-local-autolinking.md

✔ What is the name of the local module? … samplewidgetmodule
✔ What is the native module name? … Samplewidgetmodule
✔ What is the Android package name? … expo.modules.samplewidgetmodule

✔ Downloaded module template from npm
✔ Created the module from template files

✅ Successfully created Expo module in modules/samplewidgetmodule

You can now import this module inside your application.
For example, you can add this line to your App.js or App.tsx file:
import { hello } from './modules/samplewidgetmodule';

Learn more on Expo Modules APIs: https://docs.expo.dev/modules
Remember you need to rebuild your development client or reinstall pods to see the changes.
```

上記コマンドで以下のようにフォルダーが作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/1414ee3e-7453-4e49-bac0-9cb19fe2de66.png)

次に、`ExpoWidgetModule.swift`で `UserDefaults`を設定する関数を定義します。

```swift
import ExpoModulesCore
import WidgetKit

public class ExpoWidgetModule: Module {
  public func definition() -> ModuleDefinition {

    Name("ExpoWidget")

    Function("set") { (key: String, value: String) in
      let userDefaults = UserDefaults.init(suiteName: "設定したApp group名")
      userDefaults?.set(value, forKey: key)
       if #available(iOS 14.0, *) {
          WidgetCenter.shared.reloadAllTimelines()
      }
    }
  }
}

```

この関数を React Native 側で以下のように呼ぶことができます。

```javascript
import React from "react";
import { View, Button } from "react-native";
import type { RNSC } from "src/navigations/types";

import { requireNativeModule } from "expo-modules-core";

export const ExampleTemplate: RNSC = () => {
  const ExpoWidgetModule = requireNativeModule("ExpoWidget");

  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <Button
        buttonText="Press to set new data"
        onPress={() => ExpoWidgetModule.set("data", "トンカツ")}
      />
    </View>
  );
};
```

## Widget 側で UserDefaults からデータを取得

```swift
import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), item: "Loading")
    }

    func snapshot(in context: Context) async -> SimpleEntry {
        SimpleEntry(date: Date(), item: "Loading")
    }

    func timeline(in context: Context) async -> Timeline<SimpleEntry> {
        var entries: [SimpleEntry] = []
        //ここでUserDefaultsを呼び、データを取得する
        let userDefaults = UserDefaults(suiteName: "設定したApp Group名")
        let data = userDefaults?.string(forKey: "data") ?? "Loading..."
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, item: data)
            entries.append(entry)
        }

        return Timeline(entries: entries, policy: .atEnd)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let item: String
}

struct WidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text(entry.date, style: .time)
            Text(entry.item)
        }
    }
}

struct widget: Widget {
    let kind: String = "widget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
    }
}

@main
struct widgetBundle: WidgetBundle {
    var body: some Widget {
        widget()
    }
}
```

## データ送信の Demo

### 1️⃣ Widget を押してアプリを開く

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/12c0f762-2bcd-402e-af26-da70f9911737.png)


### 2️⃣ アプリの中で作った仮ページにあるボタンを押す

![Screenshot 2025-04-29 at 22.05.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/57e73656-140b-431f-93b2-b9c6a8ae9b79.png)

### 3️⃣ 　データが realtime で送信できました 👏👏

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4064150/19de3cb4-c4af-4232-8ddf-fb0130093814.png)


## 🛑 課題とヒント

ここまでブログを読んでくれた人はもう React Native で Widget を開発できるようになりました。
しかし、開発中で問題が発生する可能性がゼロではないです。そこで、僕からいくつかヒントを教えさせていただきます。

- ✅ OS バージョンの互換性:
  以下のバージョンを注意しながら開発をしてください。

WidgetKit：iOS 14 以降

ActivityKit：iOS 16 以降
バージョンのエラーを避けたい場合は、以下のようにロジックをラップすれば安心です。

```swift
if #available(iOS 14.0, *) {
          //コード
}
```

- 🔍 デバッグのコツ:

Xcode の Debug Preview 機能を使って Widget を確認しましょう。

`Widget.swift` や `LiveActivityControlModule.swift` にブレークポイントを追加して、ネイティブコードをデバッグできます。

🔒 本番運用について:
問題ありません — このライブラリは本番環境でも利用可能です！
実際のアプリでも安心して使えます。

## 💡 まとめ

`expo-apple-targets` は、従来のウィジェット開発で必要だった Xcode プロジェクトの手動設定や複雑なビルド設定を不要にし、Expo 管理下でも手軽に iOS ウィジェットやライブアクティビティを導入できる点が非常に有用でした。
開発体験が大幅に向上し、React Native アプリへの統合もスムーズに行えました。

強くおすすめします:
👉 プロジェクトに Expo CNG（Continuous Native Generation） を導入しましょう。
ネイティブ拡張の統合がとてもスムーズで、保守性も高まります。

📚 参考資料
[expo-apple-targets GitHub リポジトリ](https://github.com/EvanBacon/expo-apple-targets)

[Expo Continuous Native Generation ドキュメント](https://docs.expo.dev/workflow/continuous-native-generation/)

[User Defaults ドキュメント](https://developer.apple.com/documentation/foundation/userdefaults)

[Expo Modules ドキュメント](https://docs.expo.dev/modules/native-module-tutorial/)

<!-- フッター -->

<hr>

### **▼ 採用情報**<br>

レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。

現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。

## https://recruit.reazon.jp/