When you want Flutter data on an iOS home screen widget, most articles point you at the home_widget package. That works fine — until you want the widget itself to animate, at which point the package stops being enough.

This article covers the setup used to build a pixel-art pet that walks around inside a home screen widget, following the actual code. Three things matter: sharing data through an App Group, signalling updates over a MethodChannel, and designing the timeline when the widget has to move.

The third has almost no documentation, and the obvious implementation is guaranteed to break. Never animate by frame index is the single most useful thing in this article.

The overall structure

Flutter only hands over data and asks for a reload. All rendering happens in Swift, through WidgetKit.

▼How data flows

① Flutter passes data to native code over a MethodChannel

② The AppDelegate writes it as JSON into the App Group’s UserDefaults

WidgetCenter.shared.reloadAllTimelines() asks the widget to reload

④ The Widget Extension reads from the App Group and draws

The app and the Widget Extension are separate processes. They share no memory, so an App Group sits between them as shared storage. Holding that fact in mind makes every design decision below follow naturally.

The Widget Extension entry point

A WidgetBundle registers both the home screen widget and the Live Activity, which drives the Dynamic Island.

import SwiftUI
import WidgetKit

@main
struct MyWidgetBundle: WidgetBundle {
  var body: some Widget {
    MyLiveActivity()
    MyHomeWidget()
  }
}

One extension can host several widgets. There is no need for separate targets.

Sharing data through an App Group

Give both targets the same App Group identifier and read and write through UserDefaults(suiteName:).

In Xcode, add the App Groups capability to both the main app target and the Widget Extension target, registering the same identifier on each. Setting it on only one means writes never become readable.

// App side (AppDelegate.swift)
let appGroupId = "group.com.example.myapp"

// Widget side (Widget Extension)
private enum WidgetShared {
  static let appGroupId = "group.com.example.myapp"
  static let itemsKey = "items"
}

A mismatch in this string is the single most common reason nothing appears. Rather than defining it once and copying it, leave a comment on both sides saying it must match the other.

Always assume the read can fail

UserDefaults(suiteName:) returns nil when the App Group is not configured. Swallowing that in the extension produces a blank widget with no explanation.

guard let defaults = UserDefaults(suiteName: WidgetShared.appGroupId),
      let data = defaults.data(forKey: WidgetShared.itemsKey)
else { return [] }

The app side does the same, returning false and continuing normally when the App Group is unavailable. An app that crashes because a widget is misconfigured is a bad trade.

Signalling updates over a MethodChannel

Call reloadAllTimelines() immediately after writing. Without it, the widget waits for whenever iOS feels like refreshing.

private func registerWidgetChannel(messenger: FlutterBinaryMessenger) {
  let channel = FlutterMethodChannel(
    name: "com.example.myapp/widget", binaryMessenger: messenger)
  channel.setMethodCallHandler { call, result in
    switch call.method {
    case "updateItems":
      guard let defaults = UserDefaults(suiteName: appGroupId) else {
        result(false)  // App Group not configured
        return
      }
      let args = call.arguments as? [String: Any] ?? [:]
      let items = args["items"] as? [[String: Any]] ?? []
      if let data = try? JSONSerialization.data(withJSONObject: items) {
        defaults.set(data, forKey: itemsKey)
      }
      if #available(iOS 14.0, *) {
        WidgetCenter.shared.reloadAllTimelines()
      }
      result(true)
    // ...
    }
  }
}

On the Dart side, wrap MethodChannel('com.example.myapp/widget') in a small service class.

Checking whether a widget is actually installed

If no widget is placed on the home screen, running the update path is wasted work. WidgetCenter.shared.getCurrentConfigurations reports what is installed.

One caveat: its completion handler is not guaranteed to run on the main thread. If your other cases return synchronously, hop back to the main thread explicitly so the behaviour is consistent.

WidgetCenter.shared.getCurrentConfigurations { configResult in
  DispatchQueue.main.async {
    // call result(...) here
  }
}

Flutter platform channels expect results delivered from the main thread. Getting this wrong produces crashes that are very hard to reproduce.

Designing a widget that moves

Compute the appearance from the TimelineEntry‘s own date, never from a frame index. This is the trap.

A WidgetKit widget is not continuously rendered. You hand iOS an array of entries saying “at this time, look like this,” and the system swaps between them whenever it chooses.

The implementation that does not work

The obvious approach looks like this.

// Wrong: animating by frame index
for i in 0..<300 {
  entries.append(Entry(date: now + i * 5, frame: i))
}
// then pick a pose from frame % 4 when drawing

This breaks. You cannot control the rate at which iOS consumes the timeline. Depending on power state and visibility, entries get skipped or arrive later than planned. The assumption that frames advance in order collapses, and the result stutters or appears frozen.

The implementation that does work

Derive the appearance deterministically from the entry’s own date.

func getTimeline(in context: Context,
                 completion: @escaping (Timeline<MyEntry>) -> Void) {
  let items = loadItems()
  let now = Date()
  let step: TimeInterval = 5   // one entry every 5 seconds
  let entryCount = 300         // roughly 25 minutes

  var entries: [MyEntry] = []
  entries.reserveCapacity(entryCount)
  for i in 0..<entryCount {
    let date = now.addingTimeInterval(Double(i) * step)
    entries.append(MyEntry(date: date, items: items,
                           isNight: isNight(at: date)))
  }
  let refresh = now.addingTimeInterval(Double(entryCount) * step)
  completion(Timeline(entries: entries, policy: .after(refresh)))
}

The drawing code then takes entry.date as the current time and computes positions from it.

▼Why this holds up

▶ Whichever entry iOS shows, and whenever it shows it, the position is correct for that moment

▶ Skipped entries do not break the motion

▶ Nothing depends on reload timing, so the seams between timelines are invisible

Do not use randomness

Never call a random number generator while drawing. If the same entry looks different each time it is redrawn, the widget flickers.

This app originally switched between four discrete poses built from jump and smile states. With only four options, displaying five or six characters guaranteed duplicates, and it read as “everyone is doing the same thing.”

The fix was continuous parameters with a different phase and speed per character. Offsetting the phase of a trigonometric function by the individual’s index is enough to keep any number of characters moving independently. Variety without randomness.

Choosing the entry count and interval

Five seconds times 300 entries is about 25 minutes. That figure is a compromise with battery use.

Finer entries look smoother, but there is a practical ceiling on how many fit in one timeline, and each reload spends part of your refresh budget. Smoothness and reload frequency trade against each other, so the right number depends on the app.

How Live Activities differ

A Live Activity’s state is limited to roughly 4KB by ActivityKit. Images cannot be carried in it.

Even living in the same extension, the two have different constraints.

Home screen widget Live Activity
How data arrives App Group (UserDefaults) ActivityKit ContentState
State size No practical limit About 4KB
Images Read from Assets Not carried; reference assets instead
Updating reloadAllTimelines() Activity update()

Send only short scalar values in a Live Activity’s state. Send a name, a key describing the current stage, and a few numeric values. Look up the artwork from the stage key on the widget side.

/// MethodChannel arguments. No images (ActivityKit's 4KB limit).
Map<String, dynamic> toArgs() => {
      'name': name,
      'stage': stage,
      'valueA': valueA,
      'valueB': valueB,
      'valueC': valueC,
      // ...
    };

Bugs that actually happened

▼Three real ones

Settings not reaching the widget: changing the app’s quiet hours left the widget sleeping on a fixed schedule, because the setting was never written to the App Group

Every character moving identically: only four discrete poses existed, so they collided. Fixed with continuous parameters

Horizontal flipping had no effect: the sprites were generated symmetrically about the centre line, so mirroring changed nothing

The first is the archetypal widget bug. Changing a setting in the app tells the widget nothing unless you write it to the App Group. Every piece of state the widget needs must be sent explicitly. Forget that these are separate processes and this is what happens.

The third is more specific, but the lesson generalises: keep the assumptions of the drawing code and the asset pipeline in sync. In this app the sprites are produced by a Python script that mirrors the left half.

Why not use home_widget

Because the widget had to animate continuously. For displaying a value, the package is entirely sufficient.

home_widget wraps the App Group write and the reloadAllTimelines() call. If you are showing today’s step count or a remaining task count, there is no reason to write that yourself.

But when the structure of the timeline itself is part of the design, you end up writing a TimelineProvider in Swift anyway. At that point writing the data transfer by hand removes a dependency and makes the whole thing easier to follow.

▼How to choose

Displaying a value → home_widget is enough

The widget moves, or changes with the clock → write the TimelineProvider yourself

You also want a Live Activity → you are writing Swift regardless, so write all of it

Related reading

Pixel Islet — the app this implementation belongs to, available free on the App Store with no in-app purchases

Building a 3D dice game with Three.js and Ammo.js — another build log from this site

 

Summary

Building an iOS widget for a Flutter app in Swift comes down to five points.

▼Key points

① The app and the extension are separate processes. Share through an App Group

② After writing data, call reloadAllTimelines() explicitly

③ For animation, compute the appearance from entry.date, not from a frame index

Never use randomness when drawing. Get variety from phase and speed offsets

⑤ A Live Activity’s state is about 4KB. Send scalars and reference assets for artwork

Point three consumed the most time. The question is whether you can let go of the assumption that the timeline advances at your pace. Pass a timestamp rather than a frame number — one sentence to write down, but it took a full redesign to arrive at.

Written against WidgetKit on iOS 14 and later. Implementation details reflect the code at the time of writing.

ABOUT ME
りん
On this blog, I mainly share information about web development and programming, along with my daily thoughts and what I’ve learned. I aim to create a blog that lets readers enjoy both technology and everyday life, so I also include topics about daily experiences, books, and gourmet. I’d be delighted if you could drop by casually and find something useful or enjoyable here.