iOS Widget Development: Building Useful Home Screen Widgets
Home screen widgets, introduced in iOS 14, have become one of the most effective ways to keep your app visible and useful to users without requiring them to open it. A well-designed widget delivers glanceable information, drives app engagement, and reinforces your brand presence on the user’s most-visited screen.
With iOS 15 refining the widget experience and user adoption growing steadily, 2022 is the right time to add widgets to your iOS app. This guide covers the full implementation from setup to production.
Understanding WidgetKit
WidgetKit is Apple’s framework for building home screen widgets. Key concepts:
- Widgets are read-only: They display information but do not accept input (except taps that deep link into your app)
- Widgets use SwiftUI: All widget UI is built with SwiftUI, regardless of whether your main app uses UIKit
- Widgets are timeline-based: You provide a timeline of entries, and the system decides when to display each one
- Widgets run in a separate process: They are packaged as an app extension, not part of your main app process
Widget Families
Widgets come in three sizes (families):
- systemSmall: 2x2 grid units. Tapping anywhere opens the app.
- systemMedium: 4x2 grid units. Can contain multiple tap targets.
- systemLarge: 4x4 grid units. Room for detailed content.
Setting Up a Widget Extensio
n
Create the Extension
In Xcode, add a new target: File, then New, then Target, then Widget Extension. Name it something like “MyAppWidget”. Xcode generates the boilerplate:
import WidgetKit
import SwiftUI
// The timeline entry
struct WidgetEntry: TimelineEntry {
let date: Date
let title: String
let value: String
let trend: TrendDirection
}
enum TrendDirection {
case up, down, stable
}
Timeline Provider
The timeline provider is the brain of your widget. It tells WidgetKit what data to display and when:
struct MyWidgetProvider: TimelineProvider {
// Placeholder shown while loading
func placeholder(in context: Context) -> WidgetEntry {
WidgetEntry(
date: Date(),
title: "Loading...",
value: "--",
trend: .stable
)
}
// Snapshot for the widget gallery
func getSnapshot(
in context: Context,
completion: @escaping (WidgetEntry) -> Void
) {
let entry = WidgetEntry(
date: Date(),
title: "Daily Active Users",
value: "1,234",
trend: .up
)
completion(entry)
}
// The actual timeline
func getTimeline(
in context: Context,
completion: @escaping (Timeline<WidgetEntry>) -> Void
) {
Task {
do {
let data = try await fetchWidgetData()
let entry = WidgetEntry(
date: Date(),
title: data.metricName,
value: data.formattedValue,
trend: data.trend
)
// Refresh after 30 minutes
let refreshDate = Calendar.current.date(
byAdding: .minute,
value: 30,
to: Date()
)!
let timeline = Timeline(
entries: [entry],
policy: .after(refreshDate)
)
completion(timeline)
} catch {
let entry = WidgetEntry(
date: Date(),
title: "Unable to load",
value: "--",
trend: .stable
)
let timeline = Timeline(
entries: [entry],
policy: .after(
Date().addingTimeInterval(5 * 60)
)
)
completion(timeline)
}
}
}
private func fetchWidgetData() async throws -> WidgetData {
// Fetch from your API or shared data store
let url = URL(string: "https://api.example.com/widget-data")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(WidgetData.self, from: data)
}
}
Widget Views
Build the widget UI with SwiftUI. Support all families you want to offer:
struct MyWidgetEntryView: View {
var entry: WidgetEntry
@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)
@unknown default:
SmallWidgetView(entry: entry)
}
}
}
struct SmallWidgetView: View {
let entry: WidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title)
.font(.caption)
.foregroundColor(.secondary)
Text(entry.value)
.font(.system(size: 32, weight: .bold))
.foregroundColor(.primary)
HStack(spacing: 4) {
Image(systemName: trendIcon)
.foregroundColor(trendColour)
Text(trendLabel)
.font(.caption2)
.foregroundColor(trendColour)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
private var trendIcon: String {
switch entry.trend {
case .up: return "arrow.up.right"
case .down: return "arrow.down.right"
case .stable: return "arrow.right"
}
}
private var trendColour: Color {
switch entry.trend {
case .up: return .green
case .down: return .red
case .stable: return .secondary
}
}
private var trendLabel: String {
switch entry.trend {
case .up: return "Trending up"
case .down: return "Trending down"
case .stable: return "Stable"
}
}
}
Medium Widget with Multiple Sections
struct MediumWidgetView: View {
let entry: WidgetEntry
var body: some View {
HStack(spacing: 16) {
// Left: Main metric
VStack(alignment: .leading, spacing: 8) {
Text(entry.title)
.font(.caption)
.foregroundColor(.secondary)
Text(entry.value)
.font(.system(size: 28, weight: .bold))
HStack(spacing: 4) {
Image(systemName: "arrow.up.right")
.foregroundColor(.green)
Text("+12% this week")
.font(.caption2)
.foregroundColor(.green)
}
}
Divider()
// Right: Quick actions or secondary info
VStack(alignment: .leading, spacing: 12) {
Link(destination: URL(string: "myapp://dashboard")!) {
Label("Dashboard", systemImage: "chart.bar")
.font(.caption)
}
Link(destination: URL(string: "myapp://analytics")!) {
Label("Analytics", systemImage: "chart.line.uptrend.xyaxis")
.font(.caption)
}
Link(destination: URL(string: "myapp://settings")!) {
Label("Settings", systemImage: "gear")
.font(.caption)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
}
Widget Configuration
Register your widget in the widget bundle:
@main
struct MyAppWidgets: WidgetBundle {
var body: some Widget {
MyWidget()
}
}
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: MyWidgetProvider()
) { entry in
MyWidgetEntryView(entry: entry)
}
.configurationDisplayName("My App Stats")
.description("View your key metrics at a glance.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
Deep
Linking
Widgets can deep link into specific parts of your app:
// Small widgets: use widgetURL (one link for the entire widget)
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "myapp://metric/\(entry.metricId)"))
// Medium and large widgets: use Link for multiple tap targets
Link(destination: URL(string: "myapp://dashboard")!) {
DashboardSection()
}
Handle deep links in your main app:
// SwiftUI App
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleWidgetDeepLink(url)
}
}
}
}
// UIKit AppDelegate
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
handleWidgetDeepLink(url)
return true
}
Sharing Data with Your Ap
p
Widgets run in a separate process, so they cannot directly access your app’s data. Use an App Group to share data:
1. Create an App Group
In your Apple Developer account, create an App Group identifier (e.g., group.com.example.myapp). Add this capability to both your main app target and the widget extension target.
2. Share Data via UserDefaults
// In your main app: save data
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
defaults?.set(metricValue, forKey: "widgetMetric")
defaults?.set(Date(), forKey: "widgetLastUpdate")
// In your widget: read data
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
let metric = defaults?.string(forKey: "widgetMetric") ?? "--"
3. Trigger Widget Refresh
When your main app updates shared data, tell WidgetKit to refresh:
import WidgetKit
// After updating shared data
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
Design Best Practices
1. Glanceable Information
Widgets should convey their information in under 3 seconds. Do not cram too much content in. Focus on one to three key pieces of information.
2. Consistent Branding
Use your app’s colour scheme and typography. The widget should feel like a natural extension of your app.
3. Handle Empty and Error States
Users should never see a blank or broken widget:
struct SmallWidgetView: View {
let entry: WidgetEntry
var body: some View {
if entry.isError {
VStack {
Image(systemName: "exclamationmark.triangle")
Text("Tap to refresh")
.font(.caption)
}
} else {
// Normal content
MetricView(entry: entry)
}
}
}
4. Support Dark Mode
Widgets should look good in both light and dark mode. Use semantic colours:
Text(entry.value)
.foregroundColor(.primary) // Adapts to dark mode
.background(Color(.systemBackground)) // Adapts to dark mode
5. Respect Content Margins
WidgetKit automatically applies content margins. Do not fight them. Use padding within the safe area:
VStack {
// Content
}
.padding()
Timeline Strategies
Time-Based Updates
For widgets that show time-sensitive data (weather, stock prices, countdowns):
func getTimeline(
in context: Context,
completion: @escaping (Timeline<WidgetEntry>) -> Void
) {
var entries: [WidgetEntry] = []
let currentDate = Date()
// Create entries for the next 2 hours, every 15 minutes
for minuteOffset in stride(from: 0, to: 120, by: 15) {
let entryDate = Calendar.current.date(
byAdding: .minute,
value: minuteOffset,
to: currentDate
)!
let entry = WidgetEntry(
date: entryDate,
title: "Updated \(minuteOffset) min ago",
value: calculateValue(for: entryDate),
trend: .stable
)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
Event-Driven Updates
For widgets that update based on app activity rather than time:
// In your main app, after a relevant event
func orderCompleted() {
updateSharedData()
WidgetCenter.shared.reloadTimelines(ofKind: "OrderWidget")
}
Testing Widgets
Xcode Previews
Use SwiftUI previews to test widget layouts across all families:
struct MyWidget_Previews: PreviewProvider {
static var previews: some View {
Group {
MyWidgetEntryView(entry: sampleEntry)
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Small")
MyWidgetEntryView(entry: sampleEntry)
.previewContext(WidgetPreviewContext(family: .systemMedium))
.previewDisplayName("Medium")
MyWidgetEntryView(entry: sampleEntry)
.previewContext(WidgetPreviewContext(family: .systemLarge))
.previewDisplayName("Large")
}
}
static var sampleEntry: WidgetEntry {
WidgetEntry(
date: Date(),
title: "Daily Users",
value: "1,234",
trend: .up
)
}
}
On-Device Testing
Build and run the widget extension scheme directly. Long-press the home screen to add your widget during testing.
Conclusion
iOS widgets are a powerful engagement tool that keeps your app present in your users’ daily workflow. The investment in building a useful widget pays dividends in retention and daily active usage.
Focus on delivering genuinely useful glanceable information rather than treating the widget as an advertisement for your app. Users will keep widgets that save them time and remove those that do not.
For help building engaging iOS widgets for your app, contact eawesome. We design and build widgets that drive real user engagement for Australian mobile applications.