「ロック解除しなくても、今日の壁紙を変えたい」——これは私が運用している壁紙アプリのひとつ『綺麗な壁紙 』のレビュー欄に、iOS 18 が出た直後から少しずつ増えてきた要望でした。2014年から個人開発でアプリを続けていて、累計で 5,000万ダウンロードほどになりますが、その大半は壁紙・癒し系のユーティリティです。こうしたアプリは「アプリを開く」という一手間が地味に重く、開かれない日はそのまま広告も表示されません。
iOS 18 で、コントロールセンターにサードパーティ製のコントロールを置けるようになりました。アプリを開かずに、画面右上から引き下ろした先のボタンひとつで処理を走らせられます。壁紙アプリにとって、これは「開かれない日」を減らす数少ない正攻法です。今回は、Rork で生成した React Native(Expo)製アプリに、この ControlWidget を後付けする際の実装手順と、つまずいた箇所を残しておきます。
ControlWidget が壁紙アプリと噛み合う理由
iOS 18 のカスタムコントロールは WidgetKit の一部として実装します。ホーム画面ウィジェットの親戚で、ControlWidget というプロトコルに準拠した小さな拡張ターゲットを作ると、ユーザーが「設定 → コントロールを追加」から自分のアプリのコントロールをコントロールセンターに置けるようになります。
ボタン型(ControlWidgetButton)とトグル型(ControlWidgetToggle)の2種類があり、壁紙シャッフルはボタン型が向いています。押した瞬間に AppIntent が走り、次の壁紙を選んで UserDefaults に書き込む、という流れです。AdMob の収益は表示回数に連動しますから、「アプリ起動」という重い導線の手前に、もう一段軽いタッチポイントを作れる意味は小さくありません。月あたりの起動回数が数%でも増えれば、ユーティリティアプリの薄い単価でも効いてきます。
ただし、コントロール自体が壁紙を「設定」できるわけではありません。iOS はサードパーティアプリにロック画面・ホーム画面壁紙の直接変更を許していないからです。ここは正直に設計を割り切る必要があります。私が採った方針は「コントロールで次の1枚を確定 → ショートカット or 共有シートで実際の設定はユーザーが行う」という現実解でした。コントロールが担うのは『今日の1枚を決める』ところまでです。
全体構成 — App Group を背骨にする
Rork(Expo)製アプリの本体は JavaScript 層で動きますが、ControlWidget は別プロセスのネイティブ拡張として動きます。両者は直接メモリを共有できないため、App Group を介した UserDefaults 共有が背骨になります。
構成要素は次の3つです。
メインアプリ(Expo / React Native)— 壁紙のインデックスを App Group の UserDefaults に書く
Control Extension(Swift / WidgetKit)— ControlWidget と AppIntent を持つ新規ターゲット
App Group(group.design.dolice.wallpaper のような識別子)— 両者の共有ストレージ
この順で組むと、データの流れが一方向に整理されます。JS 側が「壁紙の総数」と「現在地」を共有ストレージへ書き、AppIntent 側がそれを読んでインデックスを進める、という分担です。
AppIntent — ボタンが押されたときの本体
まず、ボタンが押されたときに走る AppIntent を書きます。ここがコントロールの実体です。App Group の UserDefaults を開いて、現在のインデックスを次に進めるだけのシンプルな処理にしています。
import AppIntents
import WidgetKit
struct ShuffleWallpaperIntent : AppIntent {
static var title: LocalizedStringResource = "壁紙をシャッフル"
static var description = IntentDescription ( "次の壁紙を選びます" )
// コントロールセンターから直接実行するので、アプリ起動は不要
static var openAppWhenRun: Bool = false
func perform () async throws -> some IntentResult {
let group = "group.design.dolice.wallpaper"
guard let store = UserDefaults ( suiteName : group) else {
return . result ()
}
let total = max (store. integer ( forKey : "wallpaperCount" ), 1 )
let current = store. integer ( forKey : "wallpaperIndex" )
let next = (current + 1 ) % total
store. set (next, forKey : "wallpaperIndex" )
store. set ( Date ().timeIntervalSince1970, forKey : "lastShuffledAt" )
// コントロールの見た目(番号など)を更新したいときに必要
ControlCenter.shared. reloadControls (
ofKind : "design.dolice.wallpaper.shuffle"
)
return . result ()
}
}
ポイントは openAppWhenRun = false です。これを true にすると毎回アプリが前面に出てきてしまい、「開かずに切り替えたい」という要望と真逆の挙動になります。壁紙アプリのコントロールでは原則 false が正解だと考えています。
ControlWidget 本体 — コントロールセンターに並ぶ部分
次に、コントロールセンターに表示されるボタンそのものを定義します。ControlWidgetButton に先ほどの AppIntent を結び付けるだけです。
import WidgetKit
import SwiftUI
import AppIntents
struct WallpaperShuffleControl : ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration (
kind : "design.dolice.wallpaper.shuffle"
) {
ControlWidgetButton ( action : ShuffleWallpaperIntent ()) {
Label ( "壁紙シャッフル" , systemImage : "shuffle" )
}
}
. displayName ( "壁紙シャッフル" )
. description ( "コントロールセンターから次の壁紙を選びます" )
}
}
@main
struct WallpaperControlBundle : ControlWidgetBundle {
var body: some ControlWidget {
WallpaperShuffleControl ()
}
}
kind は AppIntent 内の reloadControls(ofKind:) と必ず一致させます。ここがずれていると、ボタンは表示されるのに reloadControls が空振りし、表示状態が更新されません。私は最初この文字列を別ファイルにベタ書きしていて、片方だけ直したせいで小一時間溶かしました。kind は定数として1か所に定義し、両者から参照するのが安全です。
Rork(Expo) 側との接続 — config plugin で揺らがせない
ここが React Native(Rork / Expo)案件で一番つまずく部分です。expo prebuild を走らせるたびに ios/ ディレクトリが再生成されるため、Xcode で手作業した拡張ターゲットや App Group の設定は毎回吹き飛びます。手で直し続けるのは現実的ではありません。
最初、私は素朴に Xcode 上で直接ターゲットを足していました。
// Before(手作業・prebuild で消える)
1. Xcode で File > New > Target > Widget Extension
2. Signing & Capabilities で App Group を手で追加
3. prebuild を走らせると ios/ が再生成され、1〜2 が消える
4. 毎リリースで同じ作業を繰り返す
これを config plugin に寄せると、再生成しても設定が保たれます。
// After(app.config.js で宣言・prebuild で再現される)
const { withEntitlementsPlist } = require ( "expo/config-plugins" );
const withWallpaperAppGroup = ( config ) => {
return withEntitlementsPlist (config, ( cfg ) => {
cfg.modResults[ "com.apple.security.application-groups" ] = [
"group.design.dolice.wallpaper" ,
];
return cfg;
});
};
module . exports = {
name: "wallpaper" ,
ios: { bundleIdentifier: "design.dolice.wallpaper" },
plugins: [withWallpaperAppGroup],
};
拡張ターゲットそのものの追加は @bacons/apple-targets などのプラグインに任せると、.swift ファイル群と App Group をまとめて prebuild に乗せられます。私は壁紙アプリを6本ほど並行運用していますが、この「設定をコードに寄せる」発想に切り替えてから、OS アップデートのたびに全アプリを手直しする消耗がはっきり減りました。
JS 側からは、壁紙の総数を共有ストレージに書いておきます。ネイティブモジュール越しに UserDefaults(suiteName:) を叩く薄いブリッジを1つ用意し、アプリ起動時やライブラリ更新時に呼びます。
// 壁紙ライブラリを読み込んだ直後に総数を共有
import { NativeModules } from "react-native" ;
const { WallpaperBridge } = NativeModules;
export async function syncWallpaperCount ( count ) {
// App Group の UserDefaults に総数を書く
await WallpaperBridge. setSharedInt ( "wallpaperCount" , count);
}
つまずいた箇所 — 「押しても無反応」の3つの原因
実機で組んでいて、ボタンは出るのに押しても何も起きない、という状態に何度かはまりました。原因はだいたい次の3つに収束しました。
1つ目は App Group 識別子の不一致です。メインアプリの entitlements と拡張ターゲットの entitlements で group.design.dolice.wallpaper が1文字でも違うと、UserDefaults(suiteName:) が nil を返して何も書けません。両方を同じ定数から生成するのが確実です。
2つ目は reloadControls(ofKind:) の呼び忘れです。AppIntent 内で値は書けていても、コントロールの表示が更新されないと「動いていないように見える」だけのことがあります。番号や状態をラベルに出している場合は、書き込み直後に必ず ControlCenter.shared.reloadControls を呼びます。
3つ目はシミュレータ特有の挙動です。コントロールセンターのカスタムコントロールはシミュレータだと反映が不安定で、私の環境では実機でしか正しく検証できませんでした。Crashlytics に上がってくるのも実機由来のものばかりなので、ここは早めに実機確認へ切り替えるのが結局は近道でした。テストの段階で時間を溶かさないために、最初から実機を1台確保しておくことをお勧めします。
設定の最後のひと押しをどう渡すか
先ほど触れたとおり、コントロールは壁紙を直接設定できません。そこで「次の1枚を確定したあと、実際の設定までどう案内するか」が体験の質を左右します。私が落ち着いた形は、コントロールで確定したインデックスを App Group に書いておき、ユーザーがアプリを開いたときに『その1枚をプレビュー表示し、共有シートから設定写真として保存する導線をそっと出す』というものでした。
ここで欲張って「コントロールから直接ショートカット経由で壁紙設定まで自動化する」ことも技術的には可能ですが、ショートカットの許可ダイアログが挟まるぶん、初回の離脱が増える傾向がありました。壁紙アプリのユーザーは気軽さを求めて入ってくる層が多いので、最初の体験は『ボタンを押すと、次に開いたときに今日の1枚が決まっている』くらいの軽さに留めるほうが、結果的に定着が良かったです。自動化の度合いは、コア層が増えてから段階的に上げていけば十分だと感じています。
App Group に書き込む値は、インデックスだけでなく「確定した時刻(lastShuffledAt)」も併記しておくと、アプリ側で『前回から24時間以上経っていたら新しい1枚を提案する』といった日替わり演出を後付けしやすくなります。小さな工夫ですが、毎日開いてもらう習慣づくりには地味に効きました。
実際に置いてみての所感と判断
2週間ほど自分の壁紙アプリ1本に仕込んで様子を見ました。コントロールを実際に追加してくれたユーザーは多くありませんでした。コントロールセンターのカスタマイズ自体が、一般ユーザーにはまだ馴染みの薄い操作だからです。ただ、追加してくれた層の起動頻度ははっきり高く、いわゆるコア層の定着には効いている手応えがあります。
費用対効果で言えば、新規アプリでいきなり優先実装するほどではありません。一方で、すでにホーム画面ウィジェットを出しているアプリなら、WidgetKit の土台がそのまま流用できるので追加コストは小さく、入れておく価値は十分あります。私の判断は「ウィジェット実装済みのアプリには足す、未実装のアプリではウィジェットを先に整える」でした。
もうひとつ実務的な注意として、App Store の審査では「コントロールが何をするか」が説明と一致していることが見られます。シャッフルと書いておきながらアプリを開くだけ、のような実装は指摘の対象になりやすいので、openAppWhenRun の設計は最初に決めておくのが安全です。
次に試すなら、まずは手元のウィジェット対応済みアプリで kind 文字列を定数に切り出し、AppIntent と ControlWidget を最小構成で1つ繋いでみてください。表示と無反応の切り分けができれば、あとは壁紙以外のワンタップ機能にも素直に応用できます。同じようにユーティリティアプリを育てている方の参考になれば嬉しいです。