onebox

Guide 16 of 30

For your agent: .md · all guides

On this page
  1. What it costs
  2. Steps
  3. Where the values go
  4. Check it works
  5. Common errors

Live Activities

Runs on: your Mac (the app, Swift and the builds).

A Live Activity shows live data on the lock screen and in the Dynamic Island: a workout clock, a delivery on its way, a timer. The app starts it, and it stays there while the app is closed. The view is SwiftUI in a widget extension. You cannot write it in JavaScript. This guide uses react-native-widget-extension, an Expo config plugin that turns a folder of Swift files into the widget extension during prebuild. Add a Live Activity only when the app has something that changes while the user is away from it.

What it costs

Nothing. ActivityKit is part of iOS. The plugin is free and open source.

A Live Activity is native code. It reaches users only in a new build, never in an EAS Update (ship-an-update.md, step 1).

Apple’s limits:

Checked 2026-10-15 at https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities.

Steps

1. Install the plugin

npx expo install react-native-widget-extension

Add it to plugins in app.json:

["react-native-widget-extension", { "widgetsFolder": "widgets" }]

The options you may need:

OptionDefaultWhat it does
widgetsFolderwidgetsThe folder with your Swift files, from the project root
deploymentTarget16.2The lowest iOS version for the app
frequentUpdatesfalseSets NSSupportsLiveActivitiesFrequentUpdates
groupIdentifiernoneAn App Group (group.com.example.myapp), if the app and the widget share data

The plugin also sets NSSupportsLiveActivities in the app’s Info.plist. It names the extension target <AppName>Widgets, with the bundle ID com.example.myapp.<AppName>Widgets. It adds the target to the EAS credentials list, so EAS makes a provisioning profile for it too.

Checked 2026-10-15 at https://github.com/bndkt/react-native-widget-extension (version 0.3.0).

2. The Swift folder

The plugin reads fixed file names in widgets/:

3. Send times, not countdowns

Put absolute times in the data (epoch milliseconds), never “seconds left”. SwiftUI’s Text(timerInterval:) counts on its own from a date range. A running clock then needs no updates at all. The app sends an update only when the state changes, for example from “working” to “resting”.

The app cannot run code while it is suspended. If the content stops being true at a known time, give that time as the staleDate. iOS draws the view again then, and the view reads context.isStale and shows the next state.

4. Call it from JavaScript, safely

The library calls requireNativeModule when it loads. In a build without the module (Android, or an older build that gets your JavaScript through an EAS Update) that throws, and a top-level import crashes the app at start. Load it lazily and catch the error:

import { Platform } from "react-native";

let mod: any;
function liveActivity() {
  if (mod !== undefined) return mod;
  try {
    mod = Platform.OS === "ios" ? require("react-native-widget-extension") : null;
  } catch {
    mod = null;
  }
  return mod;
}

export function startLiveActivity(...args: unknown[]): boolean {
  try { return liveActivity()?.startActivity(...args) ?? false; } catch { return false; }
}

A missing Live Activity must never stop the main feature.

5. Do not start a second one after a relaunch

iOS keeps a Live Activity alive when the app is killed. After a relaunch the app has no handle to it, but it is still on screen. Starting again leaves two, and nothing can end the old one. In Module.swift, look up the running one with Activity<YourAttributes>.activities.first before you start a new one.

6. Give the timer a fixed width

A timer Text reserves room for its widest value. Where it shares a row with other text, set an explicit width that fits the longest time you expect:

Text(timerInterval: start...end, countsDown: false)
  .monospacedDigit()
  .frame(width: 64, alignment: .trailing)   // fits "1:59:59"; measure yours

Two fixes that look right and break:

7. Prebuild after every Swift edit

The plugin copies widgets/ into ios/ during prebuild. Nothing else copies it. npx expo run:ios runs prebuild only when app.json or package.json changed, and a Swift edit changes neither. So the app builds and installs cleanly, with the old widget inside.

After any edit in widgets/:

npx expo prebuild --platform ios

Not sure which version is in the build? Compare the two copies:

diff widgets/MyLiveActivity.swift ios/<AppName>Widgets/MyLiveActivity.swift

Prebuild is slow, because it runs pod install again. While you only change views, you may copy the view files into ios/<AppName>Widgets/ by hand (not Module.swift) and build again in Xcode. Run a real prebuild before you trust the result.

8. Keep the fingerprint stable

The plugin also copies the Swift files into its own folder in node_modules during prebuild. The fingerprint runtime version reads that folder. So a fresh checkout and the same checkout after a prebuild give different runtime versions. An EAS Update published from the wrong one reaches no build, and nothing tells you.

Fingerprint the source folder and ignore the copies, in fingerprint.config.js next to app.json:

/** @type {import('expo/fingerprint').Config} */
module.exports = {
  extraSources: [{ type: "dir", filePath: "widgets", reasons: ["liveActivity"] }],
  ignorePaths: ["node_modules/react-native-widget-extension/ios/*.swift"],
};

In a monorepo, point ignorePaths at the node_modules where the package really is, for example ../../node_modules/....

Where the values go

ValueWhere
The plugin and its optionsplugins in app.json
The Swift codewidgets/ (in git); ios/ is generated
The App Group, if you use onegroupIdentifier in the plugin options
Fingerprint rulesfingerprint.config.js

No secrets.

Check it works

  1. Prebuild, then build to a simulator or a phone. Start the activity from the app.

  2. Lock the screen. The activity shows on the lock screen. On a phone with a Dynamic Island, go to the home screen: it shows in the island.

  3. Kill the app and open it again. There is still exactly one activity.

  4. The runtime version is the same before and after a prebuild. In a fresh clone, after npm ci:

    npx expo-updates runtimeversion:resolve --platform ios
    npx expo prebuild --platform ios
    npx expo-updates runtimeversion:resolve --platform ios   # must print the same value

Check a fix on the surface and in the state where the bug showed. A bug on the lock screen is not fixed because the compact island looks right.

Common errors

The timer shows dashes, like 1:--, on the lock screen. This is iOS, not your layout. While the screen is idle, iOS shows the seconds of a timer as dashes. The progress bar keeps moving, so it looks like a text bug. Tap the screen and take the screenshot again. Digits now: iOS was idling. Still dashes: the frame really is too narrow (step 6).

“The fix did not take.” The build has the old widget. Prebuild (step 7) and use the diff to check.

The timer is empty in the expanded Dynamic Island. .fixedSize() on the timer. Use .frame(width:) (step 6).

The other text on the lock screen is gone. .layoutPriority on the timer. Use .frame(width:) (step 6).

The app crashes at start after an EAS Update. A top-level import of the library in a build that has no module. Load it lazily (step 4).

Two Live Activities, and one never ends. The app started a new one after a relaunch. Adopt the running one (step 5).

An EAS Update does not reach the build. The runtime version changed with the prebuild. Add fingerprint.config.js (step 8).

startActivity returns false. The user turned off Live Activities for the app in Settings, or the device runs iOS below 16.2. Give Module.swift an areActivitiesEnabled() that returns ActivityAuthorizationInfo().areActivitiesEnabled. Call it first, and hide the feature when it says no.

Wrong or out of date? Fix it on GitHub.