Einleitung: iOS Widgets und Live Activities kommen endlich in die React-Native-Welt
Mal ehrlich – seit Apple 2020 mit iOS 14 die Home Screen Widgets vorgestellt hat (und später mit iOS 16.1 die Live Activities dazukamen), hat sich wohl jeder React-Native-Entwickler gefragt: Wann können wir das endlich auch nutzen? Widgets geben Nutzern die Möglichkeit, wichtige Infos direkt auf dem Home Screen zu sehen, ohne die App überhaupt öffnen zu müssen. Live Activities gehen sogar noch einen Schritt weiter und zeigen Echtzeit-Informationen auf dem Sperrbildschirm und in der Dynamic Island – etwa den Lieferstatus, einen Spielstand oder den Fortschritt eines Timers.
Für uns React-Native-Entwickler waren diese Features bisher ziemlich schwer zugänglich. Klar, es gab Community-Lösungen wie react-native-widget-extension oder @bacons/apple-targets, aber die setzten ordentliches Wissen über SwiftUI und die native iOS-Entwicklungsumgebung voraus. Mit Expo SDK 55 ändert sich das grundlegend: Das neue Paket expo-widgets macht es erstmals möglich, iOS Home Screen Widgets und Live Activities direkt aus einer React-Native-Codebasis heraus zu erstellen – ohne eine einzige Zeile nativen Swift-Code.
Das Besondere daran? Die nahtlose Integration mit @expo/ui. Statt SwiftUI direkt zu verwenden, nutzt man vertraute JSX-Komponenten wie VStack, HStack und Text, die zur Laufzeit auf echte SwiftUI-Ansichten abgebildet werden. Performance und Systemkonformität bleiben erhalten, aber die Entwicklung findet in der gewohnten React-Umgebung statt.
In diesem Artikel gehen wir Schritt für Schritt durch die Erstellung von iOS Home Screen Widgets und Live Activities mit Expo SDK 55. Wir starten mit der Konfiguration, arbeiten uns über Widget-Layouts und Datenaktualisierungen vor und bauen am Ende ein komplettes Wetter-Widget als Praxisbeispiel. Dabei beleuchten wir sowohl die Stärken als auch die aktuellen Einschränkungen dieser noch jungen Technologie – ganz ehrlich und ohne Schönfärberei.
Voraussetzungen und Setup
Bevor wir loslegen, ein paar wichtige Voraussetzungen. Das expo-widgets-Paket ist derzeit im Alpha-Status – es kann also noch Breaking Changes geben. Folgende Anforderungen müssen erfüllt sein:
- Expo SDK 55 oder höher (basierend auf React Native 0.83.1 und React 19.2.0)
- Development Build erforderlich: Widgets funktionieren nicht in Expo Go. Man braucht einen vollständigen nativen Build über EAS Build oder eine lokale Prebuild-Konfiguration.
- iOS 17.0+ als Mindestversion für Live Activities mit Dynamic Island
- Xcode 16 oder höher
- Ein gültiger Apple Developer Account fürs Testen auf physischen Geräten
Installation von expo-widgets
Die Installation ist erfreulich unkompliziert:
npx expo install expo-widgets
Dieses Kommando installiert sowohl expo-widgets als auch die nötigen Abhängigkeiten, einschließlich @expo/ui für die SwiftUI-Komponenten. Falls ihr ein bestehendes React-Native-Projekt ohne Expo verwendet, müsst ihr zuerst expo als Abhängigkeit installieren:
# Falls Expo noch nicht im Projekt vorhanden ist
npx expo install expo
npx expo install expo-widgets
Nach der Installation muss ein neuer nativer Build erstellt werden, da Widget-Erweiterungen eine Anpassung des nativen Xcode-Projekts erfordern:
# Lokaler Build
npx expo prebuild -p ios --clean
npx expo run:ios
# Oder über EAS Build
eas build --platform ios --profile development
Wichtiger Hinweis: Jede Änderung an der Widget-Konfiguration in der app.json erfordert einen erneuten prebuild-Vorgang. Hot Reload funktioniert nur für Änderungen innerhalb der Haupt-App, nicht für Widget-Code. Das hat mich anfangs ein paar Mal in den Wahnsinn getrieben, also seid gewarnt.
Grundlagen: Widget-Konfiguration in app.json
Die zentrale Konfiguration von expo-widgets erfolgt über das Config-Plugin-System in der app.json (oder app.config.js). Hier legt man grundlegende Eigenschaften wie Bundle Identifier, Gruppenbezeichner und die einzelnen Widget-Definitionen fest.
Vollständige Konfiguration
{
"expo": {
"name": "MeineApp",
"slug": "meine-app",
"version": "1.0.0",
"scheme": "meineapp",
"ios": {
"bundleIdentifier": "com.beispiel.meineapp",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.beispiel.meineapp"
]
}
},
"plugins": [
[
"expo-widgets",
{
"bundleIdentifier": "com.beispiel.meineapp.widgets",
"groupIdentifier": "group.com.beispiel.meineapp",
"enablePushNotifications": true,
"widgets": [
{
"name": "WetterWidget",
"displayName": "Wetter",
"description": "Zeigt aktuelle Wetterdaten an",
"supportedFamilies": [
"systemSmall",
"systemMedium",
"systemLarge"
]
},
{
"name": "AktivitaetWidget",
"displayName": "Aktivitaeten",
"description": "Zeigt taegliche Aktivitaeten an",
"supportedFamilies": [
"systemSmall",
"accessoryCircular",
"accessoryRectangular",
"accessoryInline"
]
}
]
}
]
]
}
}
Was die einzelnen Parameter bedeuten
Kurzer Überblick über die wichtigsten Konfigurationsoptionen:
- bundleIdentifier: Die eindeutige Kennung für die Widget-Erweiterung. Standardmäßig wird
<app-bundle>.ExpoWidgetsTargetverwendet, man kann aber auch einen eigenen Bezeichner angeben. Wichtig: Dieser muss ein Unterbezeichner des Haupt-App-Bundles sein. - groupIdentifier: Der App-Gruppen-Bezeichner für die Kommunikation zwischen Haupt-App und Widget-Erweiterung. Standardwert ist
group.<app-bundle>. Beide Targets müssen derselben Gruppe angehören, damit sie Daten austauschen können. - enablePushNotifications: Aktiviert Push-Benachrichtigungen für Live Activities. Wenn auf
truegesetzt, wird die nötige Push-Notification-Berechtigung der Widget-Erweiterung hinzugefügt. - widgets: Ein Array von Widget-Definitionen, wobei jedes Widget einen eindeutigen Namen, einen Anzeigenamen und unterstützte Größen hat.
Unterstützte Widget-Familien
iOS bietet verschiedene Widget-Größen an, und die Wahl der richtigen Größe ist entscheidend. Hier eine Übersicht:
- systemSmall: Das kleinste Home-Screen-Widget (2x2 Raster). Perfekt für eine einzelne Kennzahl oder einen kompakten Status.
- systemMedium: Ein breiteres Widget (4x2 Raster) mit mehr Platz für Details oder kurze Listen.
- systemLarge: Das größte reguläre Widget (4x4 Raster), geeignet für Diagramme oder ausführliche Listen.
- systemExtraLarge: Nur auf iPads verfügbar (6x4 Raster). Nutzt den zusätzlichen Bildschirmplatz.
- accessoryCircular: Ein rundes Sperrbildschirm-Widget für kompakte Informationen wie Icons mit Zahlen.
- accessoryRectangular: Ein rechteckiges Sperrbildschirm-Widget mit mehr Platz für Text.
- accessoryInline: Ein einzeiliges Text-Widget für den Sperrbildschirm, das neben dem Datum angezeigt wird.
Ein Tipp aus der Praxis: Nicht jedes Widget muss in jeder Größe existieren. Überlegt euch, welche Informationsdichte für euren Use Case sinnvoll ist, und implementiert nur die Größen, die echten Mehrwert bieten. Ein halbherziges Large-Widget schadet mehr als es nützt.
Widget-Layouts mit @expo/ui
Die Erstellung von Widget-Layouts erfolgt über @expo/ui/swift-ui-Komponenten, die zur Kompilierzeit in echte SwiftUI-Ansichten übersetzt werden. Das sorgt dafür, dass Widgets dem nativen iOS-Design entsprechen und performant bleiben.
Grundlegende Komponenten
Die wichtigsten Layout-Bausteine sind:
- VStack: Vertikaler Stapel – ordnet Kindelemente untereinander an
- HStack: Horizontaler Stapel – ordnet Kindelemente nebeneinander an
- Text: Textdarstellung mit umfangreichen Formatierungsoptionen
- Image: Bilddarstellung für Icons und Grafiken
- Spacer: Flexibler Abstandshalter für die Verteilung von Platz
Wer schon mal mit SwiftUI gearbeitet hat, wird sich hier sofort zu Hause fühlen. Und wer nicht – keine Sorge, die Konzepte sind schnell verinnerlicht.
Ein einfaches Widget erstellen
// widgets/ZaehlerWidget.tsx
import { HStack, Text, VStack, Spacer } from '@expo/ui/swift-ui';
import { updateWidgetSnapshot, WidgetBase } from 'expo-widgets';
type ZaehlerWidgetProps = {
titel: string;
wert: number;
einheit: string;
};
const ZaehlerWidget = (props: WidgetBase<ZaehlerWidgetProps>) => {
const { titel, wert, einheit, family } = props;
if (family === 'systemSmall') {
return (
<VStack
modifier={{
padding: 16,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
background: { color: '#1a1a2e' },
}}
>
<Text
modifier={{
font: { style: 'caption' },
foregroundStyle: { color: '#8892b0' },
}}
>
{titel}
</Text>
<Spacer />
<Text
modifier={{
font: { style: 'largeTitle', weight: 'bold' },
foregroundStyle: { color: '#ccd6f6' },
}}
>
{wert}
</Text>
<Text
modifier={{
font: { style: 'caption2' },
foregroundStyle: { color: '#8892b0' },
}}
>
{einheit}
</Text>
</VStack>
);
}
// systemMedium und größer
return (
<HStack
modifier={{
padding: 16,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
background: { color: '#1a1a2e' },
}}
>
<VStack modifier={{ alignment: 'leading' }}>
<Text
modifier={{
font: { style: 'headline' },
foregroundStyle: { color: '#ccd6f6' },
}}
>
{titel}
</Text>
<Spacer />
<HStack modifier={{ alignment: 'firstTextBaseline', spacing: 4 }}>
<Text
modifier={{
font: { style: 'largeTitle', weight: 'bold' },
foregroundStyle: { color: '#64ffda' },
}}
>
{wert}
</Text>
<Text
modifier={{
font: { style: 'body' },
foregroundStyle: { color: '#8892b0' },
}}
>
{einheit}
</Text>
</HStack>
</VStack>
<Spacer />
</HStack>
);
};
export default ZaehlerWidget;
Responsives Design basierend auf Widget-Größe
Die props.family-Eigenschaft ist euer bester Freund, wenn es darum geht, das Layout je nach Widget-Größe anzupassen. Das ist ein fundamentales Muster, da jede Größe unterschiedliche Anforderungen an die Informationsdichte stellt:
// widgets/AdaptivesWidget.tsx
import { HStack, Text, VStack, Spacer, Image } from '@expo/ui/swift-ui';
import { WidgetBase } from 'expo-widgets';
type NachrichtenProps = {
schlagzeile: string;
zusammenfassung: string;
kategorie: string;
bildUrl: string;
zeitpunkt: string;
};
const NachrichtenWidget = (props: WidgetBase<NachrichtenProps>) => {
const { schlagzeile, zusammenfassung, kategorie, bildUrl, zeitpunkt, family } = props;
// Sperrbildschirm-Widgets: Nur Text
if (family === 'accessoryInline') {
return <Text>{kategorie}: {schlagzeile}</Text>;
}
if (family === 'accessoryCircular') {
return (
<VStack modifier={{ padding: 4 }}>
<Text modifier={{ font: { style: 'caption2', weight: 'bold' } }}>
NEWS
</Text>
<Text modifier={{ font: { style: 'caption2' } }}>
{kategorie}
</Text>
</VStack>
);
}
if (family === 'accessoryRectangular') {
return (
<VStack modifier={{ alignment: 'leading', padding: 4 }}>
<Text modifier={{ font: { style: 'caption2', weight: 'bold' } }}>
{kategorie}
</Text>
<Text
modifier={{
font: { style: 'caption' },
lineLimit: 2,
}}
>
{schlagzeile}
</Text>
</VStack>
);
}
// systemSmall: Kompakte Ansicht
if (family === 'systemSmall') {
return (
<VStack
modifier={{
padding: 12,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
}}
>
<Text
modifier={{
font: { style: 'caption2', weight: 'semibold' },
foregroundStyle: { color: '#e63946' },
}}
>
{kategorie.toUpperCase()}
</Text>
<Spacer />
<Text
modifier={{
font: { style: 'subheadline', weight: 'bold' },
lineLimit: 3,
}}
>
{schlagzeile}
</Text>
<Text
modifier={{
font: { style: 'caption2' },
foregroundStyle: { color: '#6c757d' },
}}
>
{zeitpunkt}
</Text>
</VStack>
);
}
// systemMedium und systemLarge: Ausführliche Ansicht
return (
<HStack
modifier={{
padding: 16,
spacing: 12,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
}}
>
<VStack modifier={{ alignment: 'leading', spacing: 4 }}>
<Text
modifier={{
font: { style: 'caption2', weight: 'semibold' },
foregroundStyle: { color: '#e63946' },
}}
>
{kategorie.toUpperCase()}
</Text>
<Text
modifier={{
font: { style: 'headline', weight: 'bold' },
lineLimit: 2,
}}
>
{schlagzeile}
</Text>
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: '#495057' },
lineLimit: family === 'systemLarge' ? 5 : 2,
}}
>
{zusammenfassung}
</Text>
<Spacer />
<Text
modifier={{
font: { style: 'caption2' },
foregroundStyle: { color: '#6c757d' },
}}
>
{zeitpunkt}
</Text>
</VStack>
<Image
source={{ uri: bildUrl }}
modifier={{
frame: { width: 80, height: 80 },
cornerRadius: 8,
}}
/>
</HStack>
);
};
export default NachrichtenWidget;
Schaut euch an, wie jede Widget-Größe ein maßgeschneidertes Layout bekommt. Bei Sperrbildschirm-Widgets verzichten wir auf Farben und Bilder, weil diese Widgets vom System eingefärbt werden. Home-Screen-Widgets können dagegen volle Farbpaletten nutzen – was deutlich mehr Spaß macht.
Widget-Daten aktualisieren: Snapshots und Timelines
So, jetzt wird es richtig interessant. Die Aktualisierung von Widget-Inhalten gehört zu den wichtigsten (und zugegebenermaßen auch etwas kniffligsten) Konzepten bei der Widget-Entwicklung. iOS kontrolliert streng, wann und wie oft Widgets aktualisiert werden, um die Akkulaufzeit zu schonen. expo-widgets bietet zwei Hauptmethoden dafür.
updateWidgetSnapshot: Einzelne Aktualisierung
Die einfachste Methode ist updateWidgetSnapshot(), die das Widget sofort mit neuen Daten aktualisiert. Sie erstellt einen einzelnen Timeline-Eintrag für den aktuellen Zeitpunkt:
// In eurer Haupt-App (z.B. nach einem API-Abruf)
import { updateWidgetSnapshot } from 'expo-widgets';
import ZaehlerWidget from './widgets/ZaehlerWidget';
async function aktualisiereSchrittWidget() {
const schritte = await fetchSchritteVonAPI();
updateWidgetSnapshot('ZaehlerWidget', ZaehlerWidget, {
titel: 'Heutige Schritte',
wert: schritte.anzahl,
einheit: 'Schritte',
});
}
// Aufruf beim App-Start oder nach Datenänderung
aktualisiereSchrittWidget();
Der erste Parameter ist der Widget-Name – der muss exakt mit dem name-Feld in der app.json-Konfiguration übereinstimmen. Der zweite ist die Widget-Komponente, und der dritte enthält die Daten, die an das Widget übergeben werden. Simpel, oder?
updateWidgetTimeline: Geplante Aktualisierungen
Für Widgets, die sich über die Zeit ändern sollen, gibt es updateWidgetTimeline(). Damit könnt ihr mehrere Einträge mit festgelegten Zeitstempeln definieren, und das System wechselt dann automatisch zwischen ihnen:
import { updateWidgetTimeline } from 'expo-widgets';
import WetterWidget from './widgets/WetterWidget';
async function aktualisiereWetterTimeline() {
const vorhersage = await fetchWetterVorhersage();
const jetzt = new Date();
// Timeline-Einträge für die nächsten 6 Stunden erstellen
const eintraege = [];
for (let i = 0; i < 6; i++) {
const zeitpunkt = new Date(jetzt.getTime() + i * 60 * 60 * 1000);
eintraege.push({
date: zeitpunkt,
props: {
temperatur: vorhersage[i].temperatur,
zustand: vorhersage[i].zustand,
icon: vorhersage[i].icon,
ort: 'Berlin',
luftfeuchtigkeit: vorhersage[i].luftfeuchtigkeit,
},
});
}
updateWidgetTimeline('WetterWidget', eintraege, WetterWidget);
}
aktualisiereWetterTimeline();
Jeder Eintrag im Array enthält ein date-Feld (ab wann dieser Eintrag angezeigt werden soll) und ein props-Objekt mit den Widget-Daten. Das System wählt automatisch den passenden Eintrag basierend auf der aktuellen Zeit.
Strategien für die Timeline-Gestaltung
Die optimale Timeline-Strategie hängt vom Anwendungsfall ab:
- Statische Daten (z.B. Tageszitat):
updateWidgetSnapshot()einmal täglich reicht völlig. - Stündliche Aktualisierungen (z.B. Wetter): Eine Timeline mit 12-24 Einträgen erstellen und alle 4-6 Stunden erneuern.
- Ereignisbasierte Updates (z.B. Sportergebnisse):
updateWidgetSnapshot()bei jedem Ereignis, kombiniert mit Hintergrund-Updates.
Wichtig zu wissen: iOS begrenzt die Anzahl der Widget-Aktualisierungen pro Tag auf ein Budget von etwa 40-70 Aktualisierungen. Das klingt erstmal viel, kann aber bei häufigen Updates schnell knapp werden. Plant eure Aktualisierungsstrategie also entsprechend – ein Widget, das ab nachmittags keine Updates mehr bekommt, ist nicht gerade ideal.
Live Activities erstellen und verwalten
Live Activities sind ehrlich gesagt eine der coolsten iOS-Funktionen der letzten Jahre. Sie zeigen zeitkritische Informationen prominent auf dem Sperrbildschirm und in der Dynamic Island an. Mit expo-widgets könnt ihr Live Activities direkt aus eurer React-Native-App heraus starten, aktualisieren und beenden.
Die ExpoLiveActivityEntry-Struktur
Live Activities verwenden eine spezielle Layout-Struktur mit verschiedenen Sektionen, die jeweils einen anderen Bereich der Dynamic Island oder des Sperrbildschirms darstellen:
- banner: Die Hauptansicht auf dem Sperrbildschirm
- bannerSmall: Eine kompaktere Version für CarPlay oder Apple Watch
- compactLeading: Der linke Bereich der kompakten Dynamic Island
- compactTrailing: Der rechte Bereich der kompakten Dynamic Island
- expandedLeading: Der linke Bereich der erweiterten Dynamic Island
- expandedCenter: Der mittlere Bereich der erweiterten Dynamic Island
- expandedTrailing: Der rechte Bereich der erweiterten Dynamic Island
- expandedBottom: Der untere Bereich der erweiterten Dynamic Island
- minimal: Die kleinste Darstellung (wenn mehrere Activities aktiv sind)
Das mag erstmal nach viel aussehen, aber man gewöhnt sich schnell daran. Nicht alle Sektionen müssen ausgefüllt werden – konzentriert euch auf die, die für euren Use Case relevant sind.
Implementierung einer Lieferverfolgung
// widgets/LieferungActivity.tsx
import { HStack, Text, VStack, Image, Spacer } from '@expo/ui/swift-ui';
import { ExpoLiveActivityEntry } from 'expo-widgets';
type LieferungProps = {
restaurantName: string;
status: string;
voraussichtlicheAnkunft: string;
fahrerName: string;
fortschritt: number; // 0.0 bis 1.0
};
const LieferungActivity: ExpoLiveActivityEntry<LieferungProps> = (props) => {
const {
restaurantName,
status,
voraussichtlicheAnkunft,
fahrerName,
fortschritt,
} = props;
return {
// Hauptansicht auf dem Sperrbildschirm
banner: (
<VStack modifier={{ padding: 16, spacing: 8 }}>
<HStack>
<Text
modifier={{
font: { style: 'headline', weight: 'bold' },
}}
>
{restaurantName}
</Text>
<Spacer />
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: '#34c759' },
}}
>
{voraussichtlicheAnkunft}
</Text>
</HStack>
<Text
modifier={{
font: { style: 'body' },
foregroundStyle: { color: '#8e8e93' },
}}
>
{status}
</Text>
<HStack modifier={{ spacing: 4 }}>
<Text modifier={{ font: { style: 'caption' } }}>
Fahrer: {fahrerName}
</Text>
</HStack>
</VStack>
),
// Kompakte Dynamic Island - links
compactLeading: (
<Image
source={require('../assets/lieferung-icon.png')}
modifier={{ frame: { width: 20, height: 20 } }}
/>
),
// Kompakte Dynamic Island - rechts
compactTrailing: (
<Text
modifier={{
font: { style: 'caption', weight: 'semibold' },
}}
>
{voraussichtlicheAnkunft}
</Text>
),
// Erweiterte Dynamic Island - links
expandedLeading: (
<VStack modifier={{ alignment: 'leading' }}>
<Text
modifier={{
font: { style: 'headline', weight: 'bold' },
}}
>
{restaurantName}
</Text>
<Text
modifier={{
font: { style: 'caption' },
foregroundStyle: { color: '#8e8e93' },
}}
>
{fahrerName}
</Text>
</VStack>
),
// Erweiterte Dynamic Island - rechts
expandedTrailing: (
<VStack modifier={{ alignment: 'trailing' }}>
<Text
modifier={{
font: { style: 'title2', weight: 'bold' },
foregroundStyle: { color: '#34c759' },
}}
>
{voraussichtlicheAnkunft}
</Text>
</VStack>
),
// Erweiterte Dynamic Island - unten
expandedBottom: (
<VStack modifier={{ spacing: 4 }}>
<Text
modifier={{
font: { style: 'subheadline' },
}}
>
{status}
</Text>
</VStack>
),
// Minimale Darstellung
minimal: (
<Image
source={require('../assets/lieferung-icon.png')}
modifier={{ frame: { width: 16, height: 16 } }}
/>
),
};
};
export default LieferungActivity;
Live Activity starten und aktualisieren
// In eurer Haupt-App
import {
startLiveActivity,
updateLiveActivity,
} from 'expo-widgets';
import LieferungActivity from './widgets/LieferungActivity';
// Live Activity starten
async function starteBestellungsverfolgung(bestellung: Bestellung) {
const deepLinkUrl = `meineapp://bestellung/${bestellung.id}`;
const aktivitaetId = startLiveActivity(
'LieferungActivity',
LieferungActivity,
{
restaurantName: bestellung.restaurant,
status: 'Bestellung wird zubereitet',
voraussichtlicheAnkunft: '18:45',
fahrerName: 'Max M.',
fortschritt: 0.2,
},
deepLinkUrl
);
// Die aktivitaetId für spätere Updates speichern
await speichereAktivitaetId(bestellung.id, aktivitaetId);
return aktivitaetId;
}
// Live Activity aktualisieren (z.B. bei Statuswechsel)
async function aktualisiereBestellstatus(
bestellungId: string,
neuerStatus: string,
fortschritt: number
) {
const aktivitaetId = await ladeAktivitaetId(bestellungId);
if (aktivitaetId) {
updateLiveActivity(aktivitaetId, 'LieferungActivity', LieferungActivity, {
restaurantName: 'Pizza Napoli',
status: neuerStatus,
voraussichtlicheAnkunft: '18:45',
fahrerName: 'Max M.',
fortschritt: fortschritt,
});
}
}
// Beispielhafte Nutzung
await starteBestellungsverfolgung(meineBestellung);
// Später, wenn die Bestellung unterwegs ist:
await aktualisiereBestellstatus(
meineBestellung.id,
'Fahrer ist auf dem Weg zu Ihnen',
0.7
);
Beachtet, dass startLiveActivity() eine eindeutige Aktivitäts-ID zurückgibt. Diese ID müsst ihr speichern – ohne sie könnt ihr die Activity weder aktualisieren noch beenden. Der optionale vierte Parameter ist ein Deep-Link-URL, der geöffnet wird, wenn der Nutzer auf die Live Activity tippt.
Benutzerinteraktionen behandeln
Widgets können interaktive Elemente wie Buttons und Toggles enthalten. Die Funktion addUserInteractionListener() ermöglicht es, auf diese Interaktionen in der Haupt-App zu reagieren.
Listener einrichten
// App.tsx oder ein zentraler Initialisierungspunkt
import { addUserInteractionListener } from 'expo-widgets';
import { useEffect } from 'react';
import { router } from 'expo-router';
export function useWidgetInteraktionen() {
useEffect(() => {
const abonnement = addUserInteractionListener((event) => {
console.log('Widget-Interaktion empfangen:', event);
const { widgetName, actionId, payload } = event;
switch (actionId) {
case 'aufgabe_erledigt':
markiereAufgabeAlsErledigt(payload.aufgabeId);
break;
case 'oeffne_detail':
router.push(`/detail/${payload.elementId}`);
break;
case 'aktualisieren':
aktualisiereWidgetDaten(widgetName);
break;
default:
console.warn('Unbekannte Widget-Aktion:', actionId);
}
});
// Aufräumen beim Unmounten
return () => {
abonnement.remove();
};
}, []);
}
Deep Linking von Widgets
Neben Button-Interaktionen können Widgets auch Deep Links verwenden, um bestimmte Bildschirme in der App zu öffnen. Das wird über die Widget-URL konfiguriert und mit Expo Router verarbeitet:
// app.json - Schema für Deep Links
{
"expo": {
"scheme": "meineapp"
}
}
// In Expo Router: app/bestellung/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function BestellungDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View>
<Text>Bestellung #{id}</Text>
{/* ... */}
</View>
);
}
Wenn ein Nutzer auf eine Live Activity oder ein Widget tippt und der Deep-Link-URL meineapp://bestellung/12345 konfiguriert ist, öffnet sich die App direkt auf der entsprechenden Detailseite. Das sorgt für ein nahtloses Nutzererlebnis.
Datenaustausch zwischen App und Widget
Eines der Konzepte, das man bei der Widget-Entwicklung wirklich verstehen muss, ist der Datenaustausch. Da Widgets als separate Prozesse laufen, können sie nicht direkt auf den Speicher der Haupt-App zugreifen. Die Lösung: gemeinsame Speicherung über App Groups.
ExtensionStorage API
expo-widgets stellt die ExtensionStorage-API bereit, die eine einfache Schnittstelle zur gemeinsamen Datenspeicherung bietet:
// utils/widgetSpeicher.ts
import { ExtensionStorage } from 'expo-widgets';
// Initialisierung mit dem App-Gruppen-Bezeichner
const speicher = new ExtensionStorage('group.com.beispiel.meineapp');
// Daten in den gemeinsamen Speicher schreiben
export function speichereWidgetDaten(schluessel: string, daten: any) {
speicher.set(schluessel, JSON.stringify(daten));
}
// Daten aus dem gemeinsamen Speicher lesen
export function ladeWidgetDaten<T>(schluessel: string): T | null {
const wert = speicher.get(schluessel);
if (wert) {
try {
return JSON.parse(wert) as T;
} catch {
return null;
}
}
return null;
}
// Einzelnen Eintrag entfernen
export function entferneWidgetDaten(schluessel: string) {
speicher.remove(schluessel);
}
// Widgets nach Datenänderung neu laden
export function ladeWidgetsNeu() {
ExtensionStorage.reloadControls();
}
Praktisches Beispiel: Synchronisierung von Benutzerpräferenzen
// In der Haupt-App: Kategorien für ein Nachrichten-Widget speichern
import { speichereWidgetDaten, ladeWidgetsNeu } from '../utils/widgetSpeicher';
import { updateWidgetSnapshot } from 'expo-widgets';
import NachrichtenWidget from '../widgets/NachrichtenWidget';
async function aktualisiereNachrichtenWidget(kategorien: string[]) {
// 1. Präferenzen im gemeinsamen Speicher ablegen
speichereWidgetDaten('ausgewaehlteKategorien', kategorien);
// 2. Aktuelle Nachrichten für die Kategorien abrufen
const nachrichten = await fetchNachrichtenFuerKategorien(kategorien);
// 3. Die neueste Nachricht ans Widget übergeben
if (nachrichten.length > 0) {
updateWidgetSnapshot('NachrichtenWidget', NachrichtenWidget, {
schlagzeile: nachrichten[0].titel,
zusammenfassung: nachrichten[0].auszug,
kategorie: nachrichten[0].kategorie,
bildUrl: nachrichten[0].bild,
zeitpunkt: formatiereDatum(nachrichten[0].datum),
});
}
// 4. Widgets neu laden
ladeWidgetsNeu();
}
Die ExtensionStorage-API basiert intern auf NSUserDefaults mit einer App-Gruppen-Suite. Daten, die darüber gespeichert werden, sind sowohl für die Haupt-App als auch für die Widget-Erweiterung zugänglich. Eine Einschränkung: Es können nur Strings gespeichert werden, komplexe Objekte müssen also über JSON.stringify() serialisiert werden.
Tipp: Haltet die Daten im gemeinsamen Speicher so klein wie möglich. Widgets sollten nur das bekommen, was sie für die Darstellung tatsächlich brauchen. Große Datenmengen können die Widget-Ladezeit spürbar beeinflussen.
Push-Benachrichtigungen für Live Activities
Live Activities können nicht nur aus der App heraus aktualisiert werden, sondern auch über Remote Push Notifications. Das ist besonders nützlich, wenn sich der Zustand auf dem Server ändert, während die App geschlossen ist – etwa bei einer Lieferverfolgung oder einem Sportergebnis.
Konfiguration
Die Push-Benachrichtigungsfunktion muss zunächst in der app.json aktiviert werden:
{
"expo": {
"plugins": [
[
"expo-widgets",
{
"enablePushNotifications": true,
"widgets": [
{
"name": "LieferungActivity",
"displayName": "Lieferverfolgung",
"description": "Zeigt den Status Ihrer Lieferung an"
}
]
}
]
]
}
}
Server-seitige Push-Updates
Wenn eine Live Activity gestartet wird, erhaltet ihr ein Push-Token, das ihr an euren Server senden könnt. Der Server kann dann Updates über den Apple Push Notification Service (APNs) senden:
// Client-seitig: Push-Token bei Start der Activity erfassen
import { startLiveActivity } from 'expo-widgets';
import LieferungActivity from './widgets/LieferungActivity';
async function starteMitPushToken(bestellung: Bestellung) {
const aktivitaetId = startLiveActivity(
'LieferungActivity',
LieferungActivity,
{
restaurantName: bestellung.restaurant,
status: 'Bestellung wird zubereitet',
voraussichtlicheAnkunft: '18:45',
fahrerName: 'Max M.',
fortschritt: 0.2,
},
`meineapp://bestellung/${bestellung.id}`
);
// Push-Token an den Server senden
await fetch('https://api.meineapp.de/live-activity/registrieren', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bestellungId: bestellung.id,
aktivitaetId: aktivitaetId,
}),
});
return aktivitaetId;
}
// Server-seitig (Node.js): Push-Update senden
const apn = require('apn');
const apnProvider = new apn.Provider({
token: {
key: './AuthKey.p8',
keyId: 'ABCDE12345',
teamId: 'FGHIJ67890',
},
production: false,
});
async function sendeLiveActivityUpdate(pushToken, neueDaten) {
const notification = new apn.Notification();
notification.expiry = Math.floor(Date.now() / 1000) + 3600;
notification.topic = 'com.beispiel.meineapp.push-type.liveactivity';
notification.pushType = 'liveactivity';
notification.payload = {
'content-state': {
restaurantName: neueDaten.restaurant,
status: neueDaten.status,
voraussichtlicheAnkunft: neueDaten.ankunftszeit,
fahrerName: neueDaten.fahrer,
fortschritt: neueDaten.fortschritt,
},
'timestamp': Math.floor(Date.now() / 1000),
'event': 'update', // oder 'end' zum Beenden
};
const ergebnis = await apnProvider.send(notification, pushToken);
console.log('Push-Ergebnis:', ergebnis);
}
Wichtig: Push-Updates für Live Activities verwenden einen speziellen APNs-Push-Typ (liveactivity). Der Topic muss das Format <bundleIdentifier>.push-type.liveactivity haben. Stellt sicher, dass eure APNs-Zertifikate korrekt konfiguriert sind.
Best Practices und Einschränkungen
Bei der Entwicklung von Widgets und Live Activities mit expo-widgets gibt es einige Punkte, die über den reinen Code hinausgehen. Und ehrlich gesagt sind es genau diese Punkte, die den Unterschied zwischen einem guten und einem frustrierenden Widget-Erlebnis ausmachen.
Aktuelle Einschränkungen
- Nur iOS:
expo-widgetsunterstützt derzeit ausschließlich iOS. Android-Widget-Unterstützung ist geplant, aber noch nicht verfügbar. - Alpha-Status: Die Bibliothek befindet sich im Alpha-Stadium. Breaking Changes zwischen Versionen sind möglich – also feste Versionsangaben in der
package.jsonverwenden. - Kein Expo Go: Widgets können nicht in Expo Go getestet werden. Ein vollständiger nativer Build ist erforderlich.
- Kein Hot Reload für Widgets: Änderungen an Widget-Layouts erfordern einen erneuten Build. Nur Daten-Updates über
updateWidgetSnapshotfunktionieren zur Laufzeit. - Begrenzte Interaktivität: Widgets unterstützen nur einfache Interaktionen (Taps, Toggles). Komplexe Gesten wie Scrollen oder Wischen sind nicht möglich.
Design-Richtlinien
- Haltet es einfach: Widgets sollen auf einen Blick erfassbar sein. Überladene Layouts schrecken Nutzer ab.
- Respektiert die Systemrichtlinien: Systemschriftarten und -farben nutzen, wo immer möglich. Widgets, die sich nahtlos ins iOS-Design einfügen, werden bevorzugt.
- Plant für alle Größen: Wenn ihr mehrere Widget-Familien unterstützt, muss jede Größe sinnvoll gestaltet sein. Einfach hochskalieren reicht nicht.
- Vorsicht mit Hintergrundfarben: Sperrbildschirm-Widgets werden vom System eingefärbt. Dort keine benutzerdefinierten Farben verwenden.
- Fehlerbehandlung nicht vergessen: Euer Widget sollte auch dann etwas Sinnvolles anzeigen, wenn keine aktuellen Daten vorliegen.
Performance-Tipps
- Datengrößen minimieren: Nur die Daten übergeben, die das Widget tatsächlich braucht. Große JSON-Objekte verlangsamen das Rendering.
- Bilder optimieren: Kleine, für die Widget-Größe optimierte Bilder verwenden. Keine Netzwerk-Bilder direkt im Widget laden.
- Timeline-Einträge begrenzen: Nicht mehr Einträge als nötig erstellen. 24 pro Tag sind in den meisten Fällen ausreichend.
- Hintergrundaktualisierungen sparsam nutzen: iOS hat ein strenges Budget. Verteilt eure Updates sinnvoll über den Tag.
Teststrategie
Das Testen von Widgets verdient besondere Aufmerksamkeit, weil der Feedback-Loop durch den fehlenden Hot Reload deutlich langsamer ist als bei normalem App-Code:
// Beispiel: Widget-Daten in der Entwicklung testen
import { updateWidgetSnapshot } from 'expo-widgets';
import WetterWidget from './widgets/WetterWidget';
// Verschiedene Zustände zum Testen durchspielen
const testszenarien = [
{
name: 'Sonnig',
daten: { temperatur: 28, zustand: 'Sonnig', icon: 'sun', ort: 'Berlin', luftfeuchtigkeit: 45 },
},
{
name: 'Regnerisch',
daten: { temperatur: 12, zustand: 'Regen', icon: 'rain', ort: 'Hamburg', luftfeuchtigkeit: 89 },
},
{
name: 'Extremwerte',
daten: { temperatur: -15, zustand: 'Schnee', icon: 'snow', ort: 'Muenchen', luftfeuchtigkeit: 95 },
},
{
name: 'Keine Daten',
daten: { temperatur: 0, zustand: '--', icon: 'default', ort: 'Unbekannt', luftfeuchtigkeit: 0 },
},
];
function testeWidgetSzenario(index: number) {
const szenario = testszenarien[index];
console.log(`Teste Widget-Szenario: ${szenario.name}`);
updateWidgetSnapshot('WetterWidget', WetterWidget, szenario.daten);
}
Testet eure Widgets auf verschiedenen Gerätegrößen, in beiden Darstellungsmodi (hell und dunkel) und mit unterschiedlichen Textlängen. Der Xcode Widget Preview ist dabei euer bester Freund, um schnell zwischen verschiedenen Widget-Familien zu wechseln.
Praktisches Beispiel: Ein vollständiges Wetter-Widget
Jetzt wird es ernst – wir bringen alles zusammen und bauen ein vollständiges Wetter-Widget, das aktuelle Temperaturen, Wetterbedingungen und eine Vorhersage in verschiedenen Größen anzeigt. Dieses Beispiel bündelt die wichtigsten Konzepte aus den vorherigen Abschnitten.
Schritt 1: App-Konfiguration
{
"expo": {
"name": "WetterApp",
"slug": "wetter-app",
"version": "1.0.0",
"scheme": "wetterapp",
"ios": {
"bundleIdentifier": "com.beispiel.wetterapp",
"entitlements": {
"com.apple.security.application-groups": [
"group.com.beispiel.wetterapp"
]
}
},
"plugins": [
[
"expo-widgets",
{
"bundleIdentifier": "com.beispiel.wetterapp.widgets",
"groupIdentifier": "group.com.beispiel.wetterapp",
"widgets": [
{
"name": "WetterWidget",
"displayName": "Wetter",
"description": "Aktuelle Wetterdaten und Vorhersage",
"supportedFamilies": [
"systemSmall",
"systemMedium",
"systemLarge",
"accessoryCircular",
"accessoryRectangular"
]
}
]
}
]
]
}
}
Schritt 2: Datentypen definieren
// types/wetter.ts
export interface WetterDaten {
temperatur: number;
gefuehlteTemperatur: number;
zustand: string;
icon: string;
ort: string;
luftfeuchtigkeit: number;
windGeschwindigkeit: number;
vorhersage: VorhersageEintrag[];
}
export interface VorhersageEintrag {
tag: string;
temperaturMax: number;
temperaturMin: number;
zustand: string;
icon: string;
}
export interface WetterWidgetProps {
temperatur: number;
gefuehlteTemperatur: number;
zustand: string;
icon: string;
ort: string;
luftfeuchtigkeit: number;
windGeschwindigkeit: number;
vorhersageTage: string;
vorhersageMax: string;
vorhersageMin: string;
vorhersageIcons: string;
}
Schritt 3: Das Widget-Layout erstellen
// widgets/WetterWidget.tsx
import { HStack, Text, VStack, Spacer, Image } from '@expo/ui/swift-ui';
import { WidgetBase } from 'expo-widgets';
import { WetterWidgetProps } from '../types/wetter';
function wetterIcon(icon: string): string {
const iconMap: Record<string, string> = {
sun: 'sun.max.fill',
cloud: 'cloud.fill',
rain: 'cloud.rain.fill',
snow: 'cloud.snow.fill',
storm: 'cloud.bolt.fill',
fog: 'cloud.fog.fill',
default: 'questionmark.circle',
};
return iconMap[icon] || iconMap.default;
}
const WetterWidget = (props: WidgetBase<WetterWidgetProps>) => {
const { family } = props;
// Sperrbildschirm: Kreisförmig
if (family === 'accessoryCircular') {
return (
<VStack modifier={{ padding: 2 }}>
<Image
systemName={wetterIcon(props.icon)}
modifier={{ frame: { width: 16, height: 16 } }}
/>
<Text
modifier={{
font: { style: 'title3', weight: 'bold' },
}}
>
{props.temperatur}°
</Text>
</VStack>
);
}
// Sperrbildschirm: Rechteckig
if (family === 'accessoryRectangular') {
return (
<VStack modifier={{ alignment: 'leading', spacing: 2, padding: 4 }}>
<HStack modifier={{ spacing: 4 }}>
<Image
systemName={wetterIcon(props.icon)}
modifier={{ frame: { width: 14, height: 14 } }}
/>
<Text
modifier={{ font: { style: 'caption', weight: 'bold' } }}
>
{props.ort}
</Text>
</HStack>
<Text modifier={{ font: { style: 'title2', weight: 'bold' } }}>
{props.temperatur}°C {props.zustand}
</Text>
<Text modifier={{ font: { style: 'caption2' } }}>
Gefühlt: {props.gefuehlteTemperatur}°C
</Text>
</VStack>
);
}
// Home Screen: Klein
if (family === 'systemSmall') {
return (
<VStack
modifier={{
padding: 14,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
background: {
linearGradient: {
colors: ['#4facfe', '#00f2fe'],
startPoint: 'topLeading',
endPoint: 'bottomTrailing',
},
},
}}
>
<HStack>
<Text
modifier={{
font: { style: 'caption', weight: 'medium' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.ort}
</Text>
<Spacer />
<Image
systemName={wetterIcon(props.icon)}
modifier={{
frame: { width: 22, height: 22 },
foregroundStyle: { color: '#ffffff' },
}}
/>
</HStack>
<Spacer />
<Text
modifier={{
font: { style: 'largeTitle', weight: 'bold' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.temperatur}°
</Text>
<Text
modifier={{
font: { style: 'caption' },
foregroundStyle: { color: 'rgba(255,255,255,0.85)' },
}}
>
{props.zustand}
</Text>
<Text
modifier={{
font: { style: 'caption2' },
foregroundStyle: { color: 'rgba(255,255,255,0.7)' },
}}
>
Gefühlt: {props.gefuehlteTemperatur}°
</Text>
</VStack>
);
}
// Vorhersage-Daten parsen (als kommagetrennte Strings gespeichert)
const tage = props.vorhersageTage ? props.vorhersageTage.split(',') : [];
const maxTemps = props.vorhersageMax ? props.vorhersageMax.split(',') : [];
const minTemps = props.vorhersageMin ? props.vorhersageMin.split(',') : [];
const icons = props.vorhersageIcons ? props.vorhersageIcons.split(',') : [];
// Home Screen: Mittel
if (family === 'systemMedium') {
return (
<HStack
modifier={{
padding: 16,
spacing: 16,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
background: {
linearGradient: {
colors: ['#4facfe', '#00f2fe'],
startPoint: 'topLeading',
endPoint: 'bottomTrailing',
},
},
}}
>
<VStack modifier={{ alignment: 'leading', spacing: 4 }}>
<Text
modifier={{
font: { style: 'caption', weight: 'medium' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.ort}
</Text>
<HStack modifier={{ alignment: 'firstTextBaseline', spacing: 4 }}>
<Text
modifier={{
font: { style: 'largeTitle', weight: 'bold' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.temperatur}°
</Text>
<Image
systemName={wetterIcon(props.icon)}
modifier={{
frame: { width: 28, height: 28 },
foregroundStyle: { color: '#ffffff' },
}}
/>
</HStack>
<Text
modifier={{
font: { style: 'caption' },
foregroundStyle: { color: 'rgba(255,255,255,0.85)' },
}}
>
{props.zustand} | {props.luftfeuchtigkeit}% Luftfeuchte
</Text>
</VStack>
<Spacer />
<VStack modifier={{ spacing: 6 }}>
{tage.slice(0, 3).map((tag, index) => (
<HStack key={tag} modifier={{ spacing: 8 }}>
<Text
modifier={{
font: { style: 'caption2' },
foregroundStyle: { color: 'rgba(255,255,255,0.9)' },
frame: { width: 30 },
}}
>
{tag}
</Text>
<Image
systemName={wetterIcon(icons[index])}
modifier={{
frame: { width: 14, height: 14 },
foregroundStyle: { color: '#ffffff' },
}}
/>
<Text
modifier={{
font: { style: 'caption2', weight: 'semibold' },
foregroundStyle: { color: '#ffffff' },
}}
>
{maxTemps[index]}°
</Text>
<Text
modifier={{
font: { style: 'caption2' },
foregroundStyle: { color: 'rgba(255,255,255,0.6)' },
}}
>
{minTemps[index]}°
</Text>
</HStack>
))}
</VStack>
</HStack>
);
}
// Home Screen: Groß (systemLarge)
return (
<VStack
modifier={{
padding: 16,
spacing: 12,
frame: { maxWidth: 'infinity', maxHeight: 'infinity' },
background: {
linearGradient: {
colors: ['#4facfe', '#00f2fe'],
startPoint: 'topLeading',
endPoint: 'bottomTrailing',
},
},
}}
>
<HStack>
<VStack modifier={{ alignment: 'leading' }}>
<Text
modifier={{
font: { style: 'headline', weight: 'medium' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.ort}
</Text>
<HStack modifier={{ alignment: 'firstTextBaseline', spacing: 6 }}>
<Text
modifier={{
font: { style: 'largeTitle', weight: 'bold' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.temperatur}°C
</Text>
<Image
systemName={wetterIcon(props.icon)}
modifier={{
frame: { width: 32, height: 32 },
foregroundStyle: { color: '#ffffff' },
}}
/>
</HStack>
</VStack>
<Spacer />
<VStack modifier={{ alignment: 'trailing' }}>
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: 'rgba(255,255,255,0.9)' },
}}
>
{props.zustand}
</Text>
<Text
modifier={{
font: { style: 'caption' },
foregroundStyle: { color: 'rgba(255,255,255,0.7)' },
}}
>
Gefühlt: {props.gefuehlteTemperatur}°C
</Text>
</VStack>
</HStack>
<HStack modifier={{ spacing: 16 }}>
<HStack modifier={{ spacing: 6 }}>
<Image
systemName="humidity.fill"
modifier={{
frame: { width: 16, height: 16 },
foregroundStyle: { color: '#ffffff' },
}}
/>
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.luftfeuchtigkeit}%
</Text>
</HStack>
<HStack modifier={{ spacing: 6 }}>
<Image
systemName="wind"
modifier={{
frame: { width: 16, height: 16 },
foregroundStyle: { color: '#ffffff' },
}}
/>
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: '#ffffff' },
}}
>
{props.windGeschwindigkeit} km/h
</Text>
</HStack>
</HStack>
<Spacer />
<Text
modifier={{
font: { style: 'caption', weight: 'semibold' },
foregroundStyle: { color: 'rgba(255,255,255,0.8)' },
}}
>
5-TAGE-VORHERSAGE
</Text>
{tage.map((tag, index) => (
<HStack key={tag} modifier={{ spacing: 12 }}>
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: 'rgba(255,255,255,0.9)' },
frame: { width: 40, alignment: 'leading' },
}}
>
{tag}
</Text>
<Image
systemName={wetterIcon(icons[index])}
modifier={{
frame: { width: 18, height: 18 },
foregroundStyle: { color: '#ffffff' },
}}
/>
<Spacer />
<Text
modifier={{
font: { style: 'subheadline', weight: 'semibold' },
foregroundStyle: { color: '#ffffff' },
}}
>
{maxTemps[index]}°
</Text>
<Text
modifier={{
font: { style: 'subheadline' },
foregroundStyle: { color: 'rgba(255,255,255,0.5)' },
}}
>
{minTemps[index]}°
</Text>
</HStack>
))}
</VStack>
);
};
export default WetterWidget;
Schritt 4: API-Anbindung und Widget-Aktualisierung
// services/wetterService.ts
import { updateWidgetTimeline, updateWidgetSnapshot } from 'expo-widgets';
import { ExtensionStorage } from 'expo-widgets';
import WetterWidget from '../widgets/WetterWidget';
import { WetterDaten, WetterWidgetProps } from '../types/wetter';
const speicher = new ExtensionStorage('group.com.beispiel.wetterapp');
const API_KEY = 'IHR_API_SCHLUESSEL';
async function fetchWetterDaten(ort: string): Promise<WetterDaten> {
const antwort = await fetch(
`https://api.openweathermap.org/data/2.5/forecast?q=${ort}&units=metric&lang=de&appid=${API_KEY}`
);
const daten = await antwort.json();
return {
temperatur: Math.round(daten.list[0].main.temp),
gefuehlteTemperatur: Math.round(daten.list[0].main.feels_like),
zustand: daten.list[0].weather[0].description,
icon: mappeWetterIcon(daten.list[0].weather[0].main),
ort: daten.city.name,
luftfeuchtigkeit: daten.list[0].main.humidity,
windGeschwindigkeit: Math.round(daten.list[0].wind.speed * 3.6),
vorhersage: extrahiereVorhersage(daten.list),
};
}
function mappeWetterIcon(wetterTyp: string): string {
const zuordnung: Record<string, string> = {
Clear: 'sun',
Clouds: 'cloud',
Rain: 'rain',
Drizzle: 'rain',
Snow: 'snow',
Thunderstorm: 'storm',
Mist: 'fog',
Fog: 'fog',
};
return zuordnung[wetterTyp] || 'default';
}
function extrahiereVorhersage(liste: any[]): any[] {
const tage: any[] = [];
const wochentage = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const gesehen = new Set<string>();
for (const eintrag of liste) {
const datum = new Date(eintrag.dt * 1000);
const tagesSchluessel = datum.toISOString().split('T')[0];
if (!gesehen.has(tagesSchluessel) && tage.length < 5) {
gesehen.add(tagesSchluessel);
tage.push({
tag: wochentage[datum.getDay()],
temperaturMax: Math.round(eintrag.main.temp_max),
temperaturMin: Math.round(eintrag.main.temp_min),
zustand: eintrag.weather[0].description,
icon: mappeWetterIcon(eintrag.weather[0].main),
});
}
}
return tage;
}
function erstelleWidgetProps(daten: WetterDaten): WetterWidgetProps {
return {
temperatur: daten.temperatur,
gefuehlteTemperatur: daten.gefuehlteTemperatur,
zustand: daten.zustand,
icon: daten.icon,
ort: daten.ort,
luftfeuchtigkeit: daten.luftfeuchtigkeit,
windGeschwindigkeit: daten.windGeschwindigkeit,
vorhersageTage: daten.vorhersage.map((v) => v.tag).join(','),
vorhersageMax: daten.vorhersage.map((v) => v.temperaturMax).join(','),
vorhersageMin: daten.vorhersage.map((v) => v.temperaturMin).join(','),
vorhersageIcons: daten.vorhersage.map((v) => v.icon).join(','),
};
}
export async function aktualisiereWetterWidget(ort: string) {
try {
const wetterDaten = await fetchWetterDaten(ort);
const widgetProps = erstelleWidgetProps(wetterDaten);
// Daten im gemeinsamen Speicher ablegen
speicher.set('letzteWetterDaten', JSON.stringify(widgetProps));
speicher.set('letzteAktualisierung', new Date().toISOString());
// Widget sofort aktualisieren
updateWidgetSnapshot('WetterWidget', WetterWidget, widgetProps);
console.log('Wetter-Widget erfolgreich aktualisiert');
} catch (fehler) {
console.error('Fehler beim Aktualisieren des Wetter-Widgets:', fehler);
// Bei Fehler: Letzte bekannte Daten verwenden
const gespeicherteDaten = speicher.get('letzteWetterDaten');
if (gespeicherteDaten) {
const fallbackProps = JSON.parse(gespeicherteDaten);
updateWidgetSnapshot('WetterWidget', WetterWidget, fallbackProps);
}
}
}
export async function aktualisiereWetterTimeline(ort: string) {
try {
const wetterDaten = await fetchWetterDaten(ort);
const jetzt = new Date();
// Timeline für die nächsten 8 Stunden
const eintraege = [];
for (let stunde = 0; stunde < 8; stunde++) {
const zeitpunkt = new Date(jetzt.getTime() + stunde * 60 * 60 * 1000);
const widgetProps = erstelleWidgetProps(wetterDaten);
// Temperatur leicht anpassen basierend auf Tageszeit
const stundeDesTages = zeitpunkt.getHours();
const tempAnpassung = stundeDesTages >= 12 && stundeDesTages <= 16 ? 2 : -1;
widgetProps.temperatur += tempAnpassung;
eintraege.push({
date: zeitpunkt,
props: widgetProps,
});
}
updateWidgetTimeline('WetterWidget', eintraege, WetterWidget);
console.log('Wetter-Timeline erfolgreich aktualisiert');
} catch (fehler) {
console.error('Fehler beim Aktualisieren der Wetter-Timeline:', fehler);
}
}
Schritt 5: Integration in die Haupt-App
// app/index.tsx
import { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
aktualisiereWetterWidget,
aktualisiereWetterTimeline,
} from '../services/wetterService';
import { useWidgetInteraktionen } from '../hooks/useWidgetInteraktionen';
export default function HomeScreen() {
const [ort, setOrt] = useState('Berlin');
const [letzteAktualisierung, setLetzteAktualisierung] = useState<Date | null>(null);
// Widget-Interaktionen abhören
useWidgetInteraktionen();
useEffect(() => {
handleAktualisierung();
}, [ort]);
async function handleAktualisierung() {
await aktualisiereWetterWidget(ort);
await aktualisiereWetterTimeline(ort);
setLetzteAktualisierung(new Date());
}
return (
<View style={styles.container}>
<Text style={styles.titel}>Wetter-Widget Steuerung</Text>
<Text style={styles.info}>Aktueller Ort: {ort}</Text>
{letzteAktualisierung && (
<Text style={styles.info}>
Letzte Aktualisierung: {letzteAktualisierung.toLocaleTimeString('de-DE')}
</Text>
)}
<Pressable style={styles.button} onPress={handleAktualisierung}>
<Text style={styles.buttonText}>Widget jetzt aktualisieren</Text>
</Pressable>
<Pressable
style={styles.button}
onPress={() => setOrt(ort === 'Berlin' ? 'Muenchen' : 'Berlin')}
>
<Text style={styles.buttonText}>
Ort wechseln zu {ort === 'Berlin' ? 'Muenchen' : 'Berlin'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
titel: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
info: {
fontSize: 16,
marginBottom: 8,
color: '#666',
},
button: {
backgroundColor: '#4facfe',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
marginTop: 12,
},
buttonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
});
Dieses vollständige Beispiel zeigt den gesamten Ablauf von der Konfiguration über die Datenbeschaffung bis zur Widget-Aktualisierung. Besonders wichtig ist die Fallback-Strategie: Wenn die API nicht erreichbar ist, werden die zuletzt gespeicherten Daten aus dem gemeinsamen Speicher verwendet. Damit zeigt das Widget immer etwas Sinnvolles an – und das ist bei Widgets enorm wichtig.
Ausblick: Wohin geht die Reise mit expo-widgets?
Das expo-widgets-Paket befindet sich noch in einem frühen Entwicklungsstadium, aber die Richtung ist klar: Expo möchte Widgets und Live Activities zu einem erstklassigen Bestandteil des React-Native-Ökosystems machen.
Android-Widget-Unterstützung
Die am häufigsten nachgefragte Funktion ist – wenig überraschend – die Unterstützung von Android-Widgets. Während iOS-Widgets auf SwiftUI basieren, verwenden Android-Widgets Jetpack Glance (eine Compose-basierte API). Das Expo-Team arbeitet an der @expo/ui-Bibliothek, um Jetpack-Compose-Unterstützung zu erreichen. Eine erste Beta-Version wird voraussichtlich Ende des SDK-55-Zyklus oder mit SDK 56 erwartet.
@expo/ui 1.0
Die @expo/ui-Bibliothek strebt eine stabile 1.0-Version für Mitte 2026 an. Damit werden API-Änderungen seltener und die Komponentenbibliothek vollständiger. Es kommen unter anderem erweiterte SwiftUI-Modifier wie Animationen, benutzerdefinierte Formen und fortgeschrittene Layout-Optionen dazu.
Verbesserte Entwicklererfahrung
Langfristig plant das Expo-Team einige Verbesserungen, die den Arbeitsalltag deutlich erleichtern werden:
- Widget-Vorschau im Simulator: Schnellere Iterationszyklen durch integrierte Vorschaumöglichkeiten, ohne den vollständigen Build durchlaufen zu müssen.
- Gemeinsame Logik: Möglichkeit, Geschäftslogik zwischen Haupt-App und Widget zu teilen, ohne Code zu duplizieren.
- Deklarative Konfiguration: Noch einfachere Widget-Konfiguration mit Unterstützung für dynamische Parameter und Intents.
- Plattformübergreifende Abstraktion: Eine einheitliche API, die auf beiden Plattformen funktioniert.
Das wachsende Ökosystem
Neben dem offiziellen expo-widgets-Paket gibt es weiterhin aktive Community-Projekte wie react-native-widget-extension und @bacons/apple-targets. Diese Vielfalt ist ein gutes Zeichen für die Reife des Ökosystems und treibt die Innovation voran.
Für Entwickler, die heute mit Widgets anfangen möchten, ist expo-widgets trotz Alpha-Status schon eine gute Wahl – besonders wenn ihr bereits im Expo-Ökosystem unterwegs seid. Die API ist durchdacht, die Dokumentation wächst stetig, und das Expo-Team hat eine starke Erfolgsbilanz. Mein Rat: Fangt mit einem einfachen Widget an, sammelt Erfahrungen und erweitert eure Implementierung schrittweise.
Übrigens zeigen Statistiken, dass über 80 Prozent der iOS-Nutzer mindestens ein Widget installiert haben. Das unterstreicht, welches Potenzial Widgets für die Nutzerbindung und die Sichtbarkeit eurer App bieten. Mit expo-widgets steht uns React-Native-Entwicklern jetzt endlich ein zugänglicher Weg offen, dieses Potenzial voll auszuschöpfen.