Home screen widgets
widget() keeps a home screen widget in step with a signal, and hands the app the taps on the
widget's buttons, including taps made while the app was not running. The widget itself comes from
expo-widgets.
Install
npx expo install expo-widgets @expo/uiList the widget in the expo-widgets plugin in app.json, then run npx expo prebuild -p ios:
{
"expo": {
"plugins": [
"@ng-native/metro",
[
"expo-widgets",
{
"widgets": [
{
"name": "Score",
"displayName": "Score",
"description": "The score, with a button for each side.",
"supportedFamilies": ["systemSmall", "systemMedium"]
}
]
}
]
]
}
}import { widget } from '@ng-native/expo/widget';The layout
The widget extension draws the widget, not your app, so its layout is a component of its own,
compiled at build time to the source the extension runs, as for
Live Activities. It reads props(), and environment() for what
the widget is drawn in, such as its widgetFamily.
A <ui-button> needs a target. A tap runs in the extension while the app may be suspended, so
it records the target in the props, as taps, for widget() to hand to the app. Its
(buttonPress) is an object of the props to change at once, so the widget shows the tap before
the app has seen it:
// src/app/live/score-widget.ts
import { Component, input } from '@angular/core';
import { UiButton, UiHStack, UiText, UiVStack } from '@ng-native/expo/expo-ui-components';
import { createWidget } from '@ng-native/expo/live-activity';
export interface Score {
us: number;
them: number;
}
@Component({
selector: 'score-widget',
imports: [UiButton, UiHStack, UiText, UiVStack],
template: `
<ui-vstack>
<ui-text>{{ props().us }} - {{ props().them }}</ui-text>
<ui-hstack>
<ui-button target="us" (buttonPress)="{ us: props().us + 1 }">
<ui-text>Us</ui-text>
</ui-button>
<ui-button target="them" (buttonPress)="{ them: props().them + 1 }">
<ui-text>Them</ui-text>
</ui-button>
</ui-hstack>
</ui-vstack>
`,
})
class ScoreWidget {
readonly props = input.required<Score>();
}
export const scoreWidget = createWidget('Score', ScoreWidget);The name passed to createWidget is the one in app.json. A Live Activity has nowhere to record
a tap, so a button there is a build error.
Keep it in step from Angular
import { Component, computed, inject } from '@angular/core';
import { Text } from '@ng-native/components';
import { widget } from '@ng-native/expo/widget';
import { scoreWidget } from './live/score-widget.ts';
import { Match } from './match.ts';
@Component({
selector: 'app-scoreboard',
imports: [Text],
template: `<text>{{ match.us() }} - {{ match.them() }}</text>`,
})
export class Scoreboard {
protected readonly match = inject(Match);
protected readonly homeScreen = widget(
scoreWidget,
computed(() => ({ us: this.match.us(), them: this.match.them() })),
{ onTaps: (taps) => taps.forEach((side) => this.match.point(side)) },
);
}Call it in an injection context, such as a field of a component or service.
What it does
- The widget follows the signal. A write replaces the props the widget holds,
tapsincluded, so each one comes after the taps are read, and nothing is written while they cannot be. - Taps reach
onTapsonce each, oldest first, after the write that clears them: at once while the app is running, when it comes back to the foreground, and as it starts. A handler that throws goes to theErrorHandler, and its taps are still cleared. errorholds why the last sync did not happen: the taps could not be read, or the widget not written. It also goes to theErrorHandler, and is null again once a sync works.- iOS redraws a widget on its own schedule, so the home screen can show the last version for a moment after the app changed it. Going to the background asks iOS to redraw it.
sync()collects the taps now, andreload()asks iOS to redraw the widget.
The app stays the one source of truth: a widget's own change to what it shows lasts until the app collects the taps and writes its value back.
Only on iOS
Home screen widgets need iOS 16.4 or newer, the oldest version expo-widgets and Expo build for. On
Android and the web, expo-widgets answers with a stand-in, and widget() does nothing.
Testing
Pass a stand-in for the widget, an object with updateSnapshot, getTimeline and reload, and
provide WIDGET_EVENTS to tap it. In Node, createWidget answers a stand-in that draws nothing,
so a test can import the layout file as it is.