●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Adding a Wallpaper Shuffle to the iOS 18 Control Center in a Rork App
A field guide to bolting an iOS 18 Control Center control (ControlWidget) onto a Rork-built React Native wallpaper app — App Group sharing, AppIntent, and the Expo config plugin that keeps it from breaking on every prebuild.
"Let me change today's wallpaper without unlocking the phone." That request started trickling into the reviews of one of my wallpaper apps (Beautiful Wallpapers) right after iOS 18 shipped. I have been building apps solo since 2014 — around 50 million downloads in total, most of them wallpaper and calm-utility apps. The trouble with this category is that opening the app is a surprisingly heavy step, and on the days nobody opens it, no ads are served either.
iOS 18 finally lets third-party apps place a control in Control Center, so a single button pulled down from the corner can run logic without launching the app. For a wallpaper app, that is one of the few legitimate ways to claw back the "never opened today" days. Here are my notes on retrofitting a ControlWidget onto a React Native (Expo) app generated with Rork, including the parts that tripped me up.
Why a ControlWidget fits a wallpaper app
iOS 18 custom controls are built as part of WidgetKit. They are cousins of home-screen widgets: you create a small extension target conforming to the ControlWidget protocol, and users can then add your control through Settings or by customizing Control Center.
There are two shapes — ControlWidgetButton and ControlWidgetToggle. Wallpaper shuffle wants the button: tapping it fires an AppIntent that picks the next wallpaper and writes the index to UserDefaults. Because AdMob revenue tracks impressions, adding a lighter touch point in front of the heavy "launch the app" path matters. Even a few percent more sessions per month adds up at the thin per-unit economics of a utility app.
One honest caveat: the control cannot actually set the wallpaper. iOS does not let third-party apps change the lock or home screen wallpaper directly. So I split the design cleanly: the control decides "today's next image," and the user finishes the actual set through a Shortcut or the share sheet. The control owns the decision, not the act.
Architecture — the App Group is the spine
The body of a Rork (Expo) app runs in the JavaScript layer, but a ControlWidget runs as a separate native extension process. They cannot share memory directly, so UserDefaults backed by an App Group becomes the spine.
Three pieces:
The main app (Expo / React Native) writes the wallpaper index into the App Group UserDefaults.
The control extension (Swift / WidgetKit) holds the ControlWidget and AppIntent.
The App Group (an identifier like group.design.dolice.wallpaper) is the shared store.
Built in that order, the data flow stays one-directional: JS writes "total count" and "current position," and the AppIntent reads them to advance the index.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A working ControlWidget + AppIntent that shuffles the wallpaper from Control Center in one tap (~40 lines of Swift)
✦A concrete pattern for sharing state between the Rork (Expo) JS layer and the native extension via an App Group
✦The three reasons a Control Center button shows up but does nothing — and how to avoid each
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The AppIntent is the real body of the control. It opens the App Group UserDefaults and advances the current index by one.
import AppIntentsimport WidgetKitstruct ShuffleWallpaperIntent: AppIntent { static var title: LocalizedStringResource = "Shuffle Wallpaper" static var description = IntentDescription("Pick the next wallpaper") // Runs straight from Control Center, so no app launch 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") // Needed when the control's appearance (a number, etc.) should update ControlCenter.shared.reloadControls( ofKind: "design.dolice.wallpaper.shuffle" ) return .result() }}
The key line is openAppWhenRun = false. Set it to true and the app jumps to the foreground every time — exactly the opposite of "change it without opening." For a wallpaper control, false is the right default.
The ControlWidget — the part that sits in Control Center
Next, define the button that shows up in Control Center. It just binds the AppIntent above to a ControlWidgetButton.
import WidgetKitimport SwiftUIimport AppIntentsstruct WallpaperShuffleControl: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( kind: "design.dolice.wallpaper.shuffle" ) { ControlWidgetButton(action: ShuffleWallpaperIntent()) { Label("Shuffle", systemImage: "shuffle") } } .displayName("Wallpaper Shuffle") .description("Pick the next wallpaper from Control Center") }}@mainstruct WallpaperControlBundle: ControlWidgetBundle { var body: some ControlWidget { WallpaperShuffleControl() }}
The kind string must match the reloadControls(ofKind:) call inside the AppIntent. If it drifts, the button renders but reloadControls misses and the displayed state never updates. I first hardcoded this string in two files, fixed only one, and lost the better part of an hour. Define kind once as a constant and reference it from both sides.
Wiring it to Rork (Expo) — don't let prebuild erase it
This is where React Native (Rork / Expo) projects hurt the most. Every expo prebuild regenerates the ios/ directory, so any extension target or App Group you set up by hand in Xcode is wiped each time. Fixing it by hand forever is not realistic.
At first I naively added the target directly in Xcode.
// Before (manual; erased by prebuild)1. Xcode: File > New > Target > Widget Extension2. Signing & Capabilities: add the App Group by hand3. Run prebuild; ios/ regenerates and steps 1-2 vanish4. Repeat the same chore every release
Move it into a config plugin and the setup survives regeneration.
Adding the extension target itself is best handed to a plugin like @bacons/apple-targets, which carries the .swift files and the App Group through prebuild together. I run about six wallpaper apps in parallel as an indie developer, and since I moved this configuration into code, the grind of hand-patching every app on each OS update dropped sharply.
From the JS side, write the wallpaper count into the shared store. A thin native bridge that calls UserDefaults(suiteName:) is enough; call it on launch and whenever the library updates.
// Share the total right after loading the wallpaper libraryimport { NativeModules } from "react-native";const { WallpaperBridge } = NativeModules;export async function syncWallpaperCount(count) { // Write the total into the App Group UserDefaults await WallpaperBridge.setSharedInt("wallpaperCount", count);}
What tripped me up — three causes of "tap does nothing"
On device, the button appeared but tapping it did nothing several times. The causes converged on three things.
First, a mismatched App Group identifier. If group.design.dolice.wallpaper differs by even one character between the main app and the extension entitlements, UserDefaults(suiteName:) returns nil and nothing is written. Generate both from the same constant.
Second, forgetting reloadControls(ofKind:). The value can be written correctly inside the AppIntent, but if the control's display never refreshes it merely looks dead. If you surface a number or state in the label, call ControlCenter.shared.reloadControls right after the write.
Third, simulator quirks. Custom Control Center controls behave inconsistently in the simulator; in my setup only a real device verified correctly. The Crashlytics reports that matter come from devices anyway, so switching to on-device testing early was the shortcut. Keep one physical device on hand from the start so you don't burn time during testing.
What I think after shipping it
I ran it on a single wallpaper app for about two weeks. Not many users actually added the control — customizing Control Center is still an unfamiliar gesture for most people. But the ones who did add it open the app noticeably more often, so it seems to help retention among the core audience.
On cost/benefit: it is not worth front-loading on a brand-new app. But if your app already ships a home-screen widget, the WidgetKit foundation is reusable and the marginal cost is small, so it is worth adding. My rule became "add it to apps that already have widgets; for apps that don't, get the widget right first."
One practical review note: App Store review checks that the control does what its description says. A control labeled "shuffle" that merely opens the app invites a rejection, so decide the openAppWhenRun behavior up front.
If you want to try it next, take an app that already supports widgets, extract the kind string into a constant, and wire one AppIntent to one ControlWidget in the smallest possible form. Once you can tell "displayed" apart from "unresponsive," the same shape applies cleanly to one-tap features beyond wallpapers.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.