DEV Community

Yoshiaki Hirokawa
Yoshiaki Hirokawa

Posted on

Sharing Data Between Your iOS App and Its Widget with App Groups

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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() returns nil before the first save, so ?? .placeholder keeps 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 UserDefaults suite across app + extension.
  • Put the shape and the save/load logic in one Codable type 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)