A widget runs in a separate process from your main app. It has its own memory and its own sandbox, so you can't just hand it an object. If your widget needs data your app computed, you have to ship that data across the process boundary.
In FishGo, an iOS app that tells you whether the sea conditions are good enough to go fishing today, the widget is the product: the whole point is to glance at your home screen and see 🟢 / 🟡 / 🔴 without opening the app. So getting data into the widget reliably mattered a lot.
Here's the pattern we landed on.
The problem
The main app fetches weather, computes a verdict (go / caution / no_go), and renders the detail screen. The widget needs that same verdict — but it can't call the app's view model. The two targets don't share memory.
The solution: one App Group + one Codable type
An App Group gives multiple targets (app + widget extension) access to a shared UserDefaults suite. We define a single Codable struct that owns both the shape of the data and the read/write logic:
struct WidgetFishingData: Codable {
let spotName: String
let verdict: String // "go", "caution", "no_go"
let windSpeed: Double
let waveHeight: Double
let windDirection: String
let tideType: String
let updatedAt: Date
static let appGroupID = "group.jp.netflowers.FishGo"
static func save(_ data: WidgetFishingData) {
guard let defaults = UserDefaults(suiteName: appGroupID) else { return }
if let encoded = try? JSONEncoder().encode(data) {
defaults.set(encoded, forKey: "widgetFishingData")
}
}
static func load() -> WidgetFishingData? {
guard let defaults = UserDefaults(suiteName: appGroupID),
let data = defaults.data(forKey: "widgetFishingData"),
let decoded = try? JSONDecoder().decode(WidgetFishingData.self, from: data) else {
return nil
}
return decoded
}
}
The key design choice: both targets import the same type, so there's exactly one definition of what "widget data" means. No drift between what the app writes and what the widget reads.
Writing from the app
After computing a forecast, the app encodes a WidgetFishingData and asks WidgetKit to refresh:
let widgetData = WidgetFishingData(
spotName: spot.name,
verdict: result.verdict.rawValue,
windSpeed: weather.windSpeed,
waveHeight: weather.waveHeight,
windDirection: weather.windDirectionJapanese,
tideType: weather.tideType,
updatedAt: Date()
)
WidgetFishingData.save(widgetData)
WidgetCenter.shared.reloadAllTimelines()
reloadAllTimelines() tells the system the widget's data changed and it should rebuild its timeline.
Reading from the widget
The widget's TimelineProvider just calls load(). If nothing's there yet (fresh install), it falls back to a placeholder so the widget never renders blank:
func getTimeline(in context: Context, completion: @escaping (Timeline<FishGoEntry>) -> Void) {
let data = WidgetFishingData.load() ?? .placeholder
let entry = FishGoEntry(date: Date(), data: data)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
Note the .after(nextUpdate) policy: even without the app running, the widget asks the system to refresh roughly every 30 minutes — a reasonable cadence that respects the battery.
Pitfalls we hit
-
The App Group ID must match exactly in both targets' entitlements and in code (
group.jp.netflowers.FishGo). A typo fails silently —UserDefaults(suiteName:)returns a suite that simply doesn't share anything. -
reloadAllTimelines()is a request, not a command. WidgetKit batches and budgets refreshes. Don't expect instant updates on every call; design for eventual consistency. -
Always have a placeholder.
load()returnsnilbefore the first save, so?? .placeholderkeeps the widget from looking broken on a fresh install. -
Keep the shared payload small and flat. It's
UserDefaults, not a database. We store only what the widget renders, not the full forecast model.
Takeaways
- Use an App Group to share a
UserDefaultssuite across app + extension. - Put the shape and the save/load logic in one
Codabletype that both targets import — single source of truth. - Write from the app, then call
WidgetCenter.shared.reloadAllTimelines(). - Read in the
TimelineProvider, always with a placeholder fallback.
This pattern is small, but it's the backbone of any app whose widget shows live, app-computed data.
FishGo is on the App Store: https://apps.apple.com/app/id6774428559
Top comments (0)