「ロック解除しなくても、今日の壁紙を変えたい」——これは私が運用している壁紙アプリのひとつ『綺麗な壁紙 』のレビュー欄に、iOS 18 が出た直後から少しずつ増えてきた要望でした。壁紙や癒し系のユーティリティは「アプリを開く」という一手間が地味に重く、開かれない日はそのまま広告も表示されません。
iOS 18 で、コントロールセンターにサードパーティ製のコントロールを置けるようになりました。アプリを開かずに、画面右上から引き下ろした先のボタンひとつで処理を走らせられます。壁紙アプリにとって、これは「開かれない日」を減らす数少ない正攻法です。
当時 Rork で生成した React Native(Expo)製アプリに後付けした実装を、先日 iOS 26 世代に合わせて作り直しました。当時のメモに、今の判断を重ねて残しておきます。
ControlWidget が壁紙アプリと噛み合う理由
カスタムコントロールは 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 側がそれを読んでインデックスを進める、という分担です。
識別子は必ず定数へ切り出します。作り直しのときに最初にやったのがこれでした。
enum WallpaperControlKind {
static let appGroup = "group.design.dolice.wallpaper"
static let shuffle = "design.dolice.wallpaper.shuffle"
static let dailyShuffle = "design.dolice.wallpaper.dailyShuffle"
}
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 {
guard let store = UserDefaults (
suiteName : WallpaperControlKind.appGroup
) 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 : WallpaperControlKind.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 : WallpaperControlKind.shuffle
) {
ControlWidgetButton ( action : ShuffleWallpaperIntent ()) {
Label ( "壁紙シャッフル" , systemImage : "shuffle" )
}
}
. displayName ( "壁紙シャッフル" )
. description ( "コントロールセンターから次の壁紙を選びます" )
}
}
@main
struct WallpaperControlBundle : ControlWidgetBundle {
var body: some ControlWidget {
WallpaperShuffleControl ()
}
}
kind は AppIntent 内の reloadControls(ofKind:) と必ず一致させます。ここがずれていると、ボタンは表示されるのに reloadControls が空振りし、表示状態が更新されません。私は最初この文字列を別ファイルにベタ書きしていて、片方だけ直したせいで小一時間溶かしました。先ほどの WallpaperControlKind はその反省から生まれたものです。
状態を持たせるなら Toggle と ControlValueProvider
作り直しで一番大きく増えたのがここです。「押すたびに1枚進める」ボタンとは別に、「日替わりで自動シャッフルするかどうか」というオン・オフを置きたくなりました。この手の状態を持つコントロールは、ボタン型ではなくトグル型で組みます。
トグル型は3つの部品でできています。現在値を返す ControlValueProvider、値を書き換える SetValueIntent、そしてそれらを束ねる ControlWidgetToggle です。
import AppIntents
import SwiftUI
import WidgetKit
struct DailyShuffleValueProvider : ControlValueProvider {
// コントロールギャラリーやアクションボタン設定に出る見本の値
let previewValue = true
func currentValue () async throws -> Bool {
let store = UserDefaults ( suiteName : WallpaperControlKind.appGroup)
return store ? . bool ( forKey : "dailyShuffleEnabled" ) ?? false
}
}
struct SetDailyShuffleIntent : SetValueIntent {
static var title: LocalizedStringResource = "日替わりシャッフルを切り替え"
// SetValueIntent は value という名前の @Parameter が必須
@Parameter (title : "オン" ) var value: Bool
func perform () async throws -> some IntentResult {
UserDefaults ( suiteName : WallpaperControlKind.appGroup) ?
. set (value, forKey : "dailyShuffleEnabled" )
return . result ()
}
}
struct DailyShuffleControl : ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration (
kind : WallpaperControlKind.dailyShuffle,
provider : DailyShuffleValueProvider ()
) { isOn in
ControlWidgetToggle (
"日替わりシャッフル" ,
isOn : isOn,
action : SetDailyShuffleIntent ()
) { isOn in
Label (
isOn ? "オン" : "オフ" ,
systemImage : isOn ? "shuffle.circle.fill" : "shuffle.circle"
)
}
}
}
}
previewValue を軽視しないでください。ギャラリー上でユーザーが最初に見るのはこの値です。currentValue() は非同期に呼ばれるため、ギャラリーを開いた瞬間はまだ実値が来ておらず、previewValue が「そのコントロールの印象」になります。私は最初これを false にしていて、ギャラリーで常にオフ表示のくすんだアイコンが並び、何のコントロールか伝わりにくくなっていました。
使い分けは次のとおりです。
型 向いている操作 必要な部品
ControlWidgetButton
押すたびに1回走る処理(次の壁紙へ、記録を1件足す)
AppIntent のみ
ControlWidgetToggle
オン・オフが明確な設定(自動シャッフル、通知の一時停止)
ControlValueProvider + SetValueIntent
ValueProvider 付きボタン
押す操作だが最新値をラベルに出したい(残り枚数、今日の番号)
ControlValueProvider + AppIntent
迷ったらボタン型から始めるのが安全です。状態を持った瞬間、currentValue() がいつ呼ばれるか・reloadControls をどこで打つかという同期の話が全部乗ってきます。
iOS 26 で変わったこと — 単色前提で作り直す
iOS 26 でシステム全体の見た目が Liquid Glass に切り替わり、ウィジェットとコントロールの描画も変わりました。背景はシステム側のガラス素材に置き換えられ、中身は accented rendering mode で単色に色づけされます。
実務上の影響は単純で、多色のシンボルや作り込んだフルカラーの画像を渡しても、意図した色では出ない ということです。壁紙アプリはサムネイルを出したくなる誘惑が強い領域なので、ここは早めに諦めがつくかどうかで手戻りが変わります。私はコントロールのラベルを単色 SF Symbol と短いテキストだけに寄せ、作品の絵はアプリ本体とホーム画面ウィジェットで見せる、という分担に切り替えました。
ホーム画面ウィジェットを併設している場合は、描画モードを見てレイアウトを分けられます。
struct WallpaperWidgetView : View {
@Environment (\.widgetRenderingMode) private var renderingMode
let image: Image
var body: some View {
switch renderingMode {
case .accented :
// 色は system 側で塗り替えられるので、形で伝わる表現に寄せる
Label ( "今日の1枚" , systemImage : "photo.on.rectangle.angled" )
default:
image. resizable (). scaledToFill ()
}
}
}
もうひとつ、同じ AppIntent が載るサーフェスが増え続けている点も設計に効いてきます。コントロールセンター、ロック画面、アクションボタン、そして watchOS 26 のコントロールセンター——入口ごとに別の実装を書くのではなく、AppIntent を1つ正しく作り、それを複数のサーフェスから参照する という形に寄せておくと、次の OS でサーフェスが増えたときの追加コストがほぼゼロになります。作り直しで得た一番の収穫はこの構えでした。
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 の土台がそのまま流用できるので追加コストは小さく、入れておく価値は十分あります。私の判断は「ウィジェット実装済みのアプリには足す、未実装のアプリではウィジェットを先に整える」でした。iOS 26 で AppIntent の載る先が増えたぶん、この判断はより有利側に振れています。
もうひとつ実務的な注意として、App Store の審査では「コントロールが何をするか」が説明と一致していることが見られます。シャッフルと書いておきながらアプリを開くだけ、のような実装は指摘の対象になりやすいので、openAppWhenRun の設計は最初に決めておくのが安全です。
次に試すなら、まずは手元のウィジェット対応済みアプリで kind 文字列を定数に切り出し、AppIntent と ControlWidget を最小構成で1つ繋いでみてください。表示と無反応の切り分けができれば、あとは壁紙以外のワンタップ機能にも素直に応用できます。同じようにユーティリティアプリを育てている方の参考になれば嬉しいです。