●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates●PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16●VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to miss●EXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration plan●APPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spent●EXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInput●EAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
Putting a Wallpaper Shuffle in Control Center — a ControlWidget in a Rork (Expo) App, Rebuilt for iOS 26
Field notes on bolting a Control Center control (ControlWidget) onto a Rork-built React Native wallpaper app — App Group sharing, AppIntent, the Expo config plugin that survives prebuild, and what changed when I rebuilt it for iOS 26 accented rendering.
"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. The trouble with wallpaper and calm-utility apps 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 let 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.
I retrofitted a ControlWidget onto a React Native (Expo) app generated with Rork back then, and recently rebuilt it for the iOS 26 generation. These are the original notes with my current judgment layered on top.
Why a ControlWidget fits a wallpaper app
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.
Pull the identifiers into constants. That was the very first thing I did during the rebuild.
enum WallpaperControlKind { static let appGroup = "group.design.dolice.wallpaper" static let shuffle = "design.dolice.wallpaper.shuffle" static let dailyShuffle = "design.dolice.wallpaper.dailyShuffle"}
✦
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, with the kind constant factored out
✦The three-part recipe for stateful controls — ControlWidgetToggle, ControlValueProvider, SetValueIntent — and a table for choosing between them
✦An App Group spine for sharing state between the Rork (Expo) JS layer and the native extension, plus a config plugin that survives every prebuild
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 { 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") // Needed when the control's appearance (a number, etc.) should update ControlCenter.shared.reloadControls( ofKind: WallpaperControlKind.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: WallpaperControlKind.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. WallpaperControlKind above exists because of that afternoon.
Stateful controls need a Toggle and a ControlValueProvider
This is the section that grew the most during the rebuild. Alongside the "advance one image per tap" button, I wanted an on/off setting for automatic daily shuffling. Anything with real state belongs in a toggle, not a button.
A toggle is three parts: a ControlValueProvider that reports the current value, a SetValueIntent that writes it, and a ControlWidgetToggle that binds them together.
import AppIntentsimport SwiftUIimport WidgetKitstruct DailyShuffleValueProvider: ControlValueProvider { // Shown in the controls gallery and Action button settings 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 = "Toggle Daily Shuffle" // SetValueIntent requires a @Parameter literally named value @Parameter(title: "On") 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( "Daily Shuffle", isOn: isOn, action: SetDailyShuffleIntent() ) { isOn in Label( isOn ? "On" : "Off", systemImage: isOn ? "shuffle.circle.fill" : "shuffle.circle" ) } } }}
Do not treat previewValue as a throwaway. It is the first thing a user sees in the gallery. currentValue() resolves asynchronously, so at the moment the gallery opens the real value has not arrived yet and previewValueis the control's first impression. I originally set mine to false, which meant the gallery showed a row of dimmed off-state icons that told nobody what the control was for.
Choosing between the shapes:
Shape
Good for
Parts required
ControlWidgetButton
One-shot actions per tap (next wallpaper, log one entry)
AppIntent only
ControlWidgetToggle
Settings with a clear on/off (auto shuffle, pause notifications)
ControlValueProvider + SetValueIntent
Button with a value provider
A tap action whose label shows live data (images left, today's number)
ControlValueProvider + AppIntent
When in doubt, start with the button. The moment a control holds state, you inherit the whole synchronization question: when currentValue() gets called, and where you fire reloadControls.
What iOS 26 changed — design for a single color
iOS 26 moved the system to Liquid Glass, and widget and control rendering moved with it. Backgrounds are replaced by the system's glass material, and content is tinted through accented rendering mode.
The practical consequence is blunt: multicolor symbols and carefully composed full-color artwork will not render the way you intended. Wallpaper apps are especially tempted to show a thumbnail, so how quickly you accept this determines how much rework you do. I moved control labels to a single-color SF Symbol plus a short string, and left the artwork itself to the app and the home-screen widget.
If you ship a home-screen widget alongside the control, you can branch on the rendering mode.
struct WallpaperWidgetView: View { @Environment(\.widgetRenderingMode) private var renderingMode let image: Image var body: some View { switch renderingMode { case .accented: // Color is overridden by the system, so communicate with shape Label("Today's pick", systemImage: "photo.on.rectangle.angled") default: image.resizable().scaledToFill() } }}
The other shift worth designing around is how many surfaces now mount the same AppIntent: Control Center, the Lock Screen, the Action button, and Control Center on watchOS 26. Rather than writing a separate implementation per entry point, build one AppIntent correctly and reference it from every surface. Then the next OS that adds a surface costs you almost nothing. That framing was the real payoff of the rebuild.
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. The same applies to toggles: change the setting from inside the app without that call, and Control Center keeps showing the stale state.
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.
Handing off the last step
As noted, the control cannot set the wallpaper. So the quality of the experience comes down to how you guide the user from "next image decided" to "wallpaper actually set." What I settled on: write the confirmed index into the App Group, and when the user next opens the app, preview that image and quietly surface a path to save it through the share sheet.
Automating all the way through a Shortcut is technically possible, but the permission dialog it introduces raised first-run drop-off in my testing. Wallpaper users arrive looking for something easy, so keeping the first experience at "tap the button, and next time you open the app today's pick is waiting" retained better. You can ratchet up the automation later, once you have a core audience.
Write more than the index into the App Group — storing the timestamp (lastShuffledAt) too makes it easy to add "suggest a fresh pick if more than 24 hours have passed." A small touch, but it quietly helped build a daily habit.
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." With iOS 26 multiplying the surfaces a single AppIntent can reach, that math tilts further in favor of adding it.
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.