WidgetKit Widgets & Live Activities
When to Use
- Adding a home screen or lock screen widget (reading streak, current chapter, recently added manga)
- Building a Live Activity for active manga download progress
- Making widget buttons interactive (iOS 17+ AppIntent)
- Providing glanceable stats from the manga reader on the home screen
Core Rules
- Widgets have NO persistent state of their own — all data comes through
TimelineEntry; fetch from App Group UserDefaults or shared SwiftData store. TimelineProvider.getTimeline()must complete fast — do NOT make network calls in the critical path; use cached data.- Widget views must be pure SwiftUI with no gestures, no scroll views, no animations (beyond
.widgetAccentableand system transitions). - Interactive widgets (buttons/toggles) require
AppIntentconformance, iOS 17+. UseButton(intent:)/Toggle(isOn:intent:). - Always implement all three sizes in
supportedFamiliesor explicitly declare the subset you support. - Deep link from widget taps via
.widgetURL(url)orLink(destination:)— the app handles navigation inonOpenURL. TimelineReloadPolicy.atEndre-fetches after the last entry expires;after(date:)andneverare the other options.- Live Activities use
ActivityKit, not WidgetKit directly — but the UI is a WidgetKitActivityConfiguration. - Preview widgets with
#Preview(as:)macro (Xcode 15+) — much faster than running in simulator. - App Group is mandatory for sharing data between the app and widget extension targets.
Basic Timeline Widget
import WidgetKit
import SwiftUI
// Entry: the data snapshot at a moment in time
struct ReadingEntry: TimelineEntry {
let date: Date
let seriesTitle: String
let currentChapter: Int
let readingStreak: Int
let coverColor: Color
}
// Provider: generates the timeline of entries
struct ReadingProvider: TimelineProvider {
// Placeholder shown while real data loads (should be fast, use dummy data)
func placeholder(in context: Context) -> ReadingEntry {
ReadingEntry(date: .now, seriesTitle: "One Piece", currentChapter: 1085,
readingStreak: 7, coverColor: .blue)
}
// Snapshot for widget gallery preview
func getSnapshot(in context: Context, completion: @escaping (ReadingEntry) -> Void) {
completion(placeholder(in: context))
}
// The real timeline — build from cached app data
func getTimeline(in context: Context, completion: @escaping (Timeline<ReadingEntry>) -> Void) {
let shared = UserDefaults(suiteName: "group.com.mangareader")
let title = shared?.string(forKey: "currentSeriesTitle") ?? "No manga"
let chapter = shared?.integer(forKey: "currentChapter") ?? 0
let streak = shared?.integer(forKey: "readingStreak") ?? 0
let entry = ReadingEntry(date: .now, seriesTitle: title,
currentChapter: chapter, readingStreak: streak,
coverColor: .indigo)
// Refresh every hour
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
Widget Views (All Sizes)
struct ReadingWidgetEntryView: View {
var entry: ReadingEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall: SmallWidgetView(entry: entry)
case .systemMedium: MediumWidgetView(entry: entry)
case .systemLarge: LargeWidgetView(entry: entry)
case .accessoryCircular: AccessoryCircularView(entry: entry) // lock screen
case .accessoryRectangular: AccessoryRectangularView(entry: entry)
default: SmallWidgetView(entry: entry)
}
}
}
struct SmallWidgetView: View {
let entry: ReadingEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("📖").font(.title2)
Spacer()
Text(entry.seriesTitle)
.font(.caption2).bold()
.lineLimit(2)
Text("Ch. \(entry.currentChapter)")
.font(.caption2)
.foregroundStyle(.secondary)
HStack(spacing: 2) {
Image(systemName: "flame.fill").foregroundStyle(.orange)
Text("\(entry.readingStreak)d")
.font(.caption2).bold()
}
}
.padding()
.widgetURL(URL(string: "mangareader://continue")) // tap → deep link
.containerBackground(entry.coverColor.gradient, for: .widget)
}
}
Widget Configuration
@main
struct MangaWidget: Widget {
let kind = "com.mangareader.readingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: ReadingProvider()) { entry in
ReadingWidgetEntryView(entry: entry)
}
.configurationDisplayName("Reading Progress")
.description("Shows your current manga and streak.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular])
}
}
// Xcode 15+ preview
#Preview(as: .systemSmall) {
MangaWidget()
} timeline: {
ReadingEntry(date: .now, seriesTitle: "Berserk", currentChapter: 364,
readingStreak: 12, coverColor: .brown)
}
Interactive Widget with AppIntent (iOS 17+)
import AppIntents
// The action that runs when user taps button (runs in widget process)
struct MarkChapterReadIntent: AppIntent {
static var title: LocalizedStringResource = "Mark Chapter Read"
@Parameter(title: "Series ID") var seriesID: String
@Parameter(title: "Chapter Number") var chapterNumber: Int
func perform() async throws -> some IntentResult {
// Persist to App Group shared store
let defaults = UserDefaults(suiteName: "group.com.mangareader")
defaults?.set(chapterNumber + 1, forKey: "currentChapter_\(seriesID)")
// Trigger widget reload
WidgetCenter.shared.reloadTimelines(ofKind: "com.mangareader.readingWidget")
return .result()
}
}
// In your entry view:
struct InteractiveChapterView: View {
let entry: ReadingEntry
var body: some View {
VStack {
Text("Ch. \(entry.currentChapter)")
// Button triggers AppIntent without opening the app
Button(intent: MarkChapterReadIntent(
seriesID: "op",
chapterNumber: entry.currentChapter)
) {
Label("Done", systemImage: "checkmark.circle.fill")
}
.tint(.green)
}
.containerBackground(.fill, for: .widget)
}
}
Live Activity (Download Progress)
import ActivityKit
// Shared between app and widget extension (put in shared framework or copy)
struct DownloadActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var downloadedPages: Int
var totalPages: Int
var chapterTitle: String
var progress: Double { Double(downloadedPages) / Double(max(totalPages, 1)) }
}
var seriesTitle: String
var chapterNumber: Int
}
// In your widget extension — Live Activity UI
struct DownloadLiveActivityView: View {
let context: ActivityViewContext<DownloadActivityAttributes>
var body: some View {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(context.attributes.seriesTitle).font(.caption).bold()
Text("Ch. \(context.attributes.attributes.chapterNumber): \(context.state.chapterTitle)")
.font(.caption2)
ProgressView(value: context.state.progress)
}
Text("\(context.state.downloadedPages)/\(context.state.totalPages)")
.font(.caption2).monospacedDigit()
}
.padding()
}
}
// Starting a Live Activity from the app
func startDownloadActivity(series: MangaSeries, chapter: Chapter) throws {
let attributes = DownloadActivityAttributes(
seriesTitle: series.title,
chapterNumber: chapter.number
)
let initialState = DownloadActivityAttributes.ContentState(
downloadedPages: 0,
totalPages: chapter.pageCount,
chapterTitle: chapter.title ?? ""
)
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil)
)
print("Started Live Activity: \(activity.id)")
}
// Updating progress
func updateDownloadActivity(_ activity: Activity<DownloadActivityAttributes>, page: Int) async {
let state = DownloadActivityAttributes.ContentState(
downloadedPages: page,
totalPages: activity.content.state.totalPages,
chapterTitle: activity.content.state.chapterTitle
)
await activity.update(.init(state: state, staleDate: nil))
}
// Ending it
func endDownloadActivity(_ activity: Activity<DownloadActivityAttributes>) async {
await activity.end(nil, dismissalPolicy: .immediate)
}
App Group Setup (Required for Data Sharing)
- In both app target and widget extension target → Signing & Capabilities → + App Groups →
group.com.mangareader - Write from app:
UserDefaults(suiteName: "group.com.mangareader")?.set(series.title, forKey: "currentSeriesTitle")
WidgetCenter.shared.reloadAllTimelines() // call after data change
- Read from widget: same
UserDefaults(suiteName:)access
Timeline Reload Triggers
// After user finishes a chapter — call from app target
WidgetCenter.shared.reloadTimelines(ofKind: "com.mangareader.readingWidget")
// Reload all widgets (use sparingly — impacts battery)
WidgetCenter.shared.reloadAllTimelines()
// Check current configurations
WidgetCenter.shared.getCurrentConfigurations { result in
guard case .success(let widgets) = result else { return }
print("Active widgets: \(widgets.map(\.kind))")
}
Common Patterns
Lock Screen Accessory Circular (Streak Counter)
struct AccessoryCircularView: View {
let entry: ReadingEntry
var body: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Image(systemName: "flame.fill").font(.caption)
Text("\(entry.readingStreak)")
.font(.system(size: 14, weight: .bold, design: .rounded))
.widgetAccentable()
}
}
}
}
Configurable Widget (User Picks Series)
- Use
AppIntentConfiguration+WidgetConfigurationIntentinstead ofStaticConfiguration - The intent provides a series picker; Xcode 16 generates most boilerplate
Related Skills
ios-swiftui-expert— SwiftUI for widgetsswift-concurrency-expert— async widget updatesios-architecture— widget architecture
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal. Index path: /Users/localuser/.claude/skills/widgetkit/.gitnexus Last indexed: 2026-05-23