Apps with widgets see 2-3x daily opens and better retention. Widgets keep your app visible and useful throughout the day. This guide covers implementation for iOS and Android.
Why Widgets Matter
- Constant visibility on home screen
- 2-3x increase in daily active users
- Quick glanceable information
- No need to open app for common tasks
- Better user retention
- Competitive advantage
iOS Widgets (WidgetKit)
Widget Sizes
Available sizes:
- Small: 2×2 grid (155×155 pt)
- Medium: 4×2 grid (329×155 pt)
- Large: 4×4 grid (329×345 pt)
- Extra Large: iPad only (6×4)
Most used: Small (67%), Medium (28%), Large (5%)
Creating Widget Extension
Xcode:
1. File → New → Target
2. Widget Extension
3. Name: "MyAppWidget"
4. Include Configuration Intent (for customization)
Structure:
MyApp/
MyApp/ # Main app
MyAppWidget/ # Widget extension
MyAppWidget.swift
Assets.xcassets
Info.plist
Widget Implementation
import WidgetKit
import SwiftUI
// 1. Data Model
struct WidgetData {
let title: String
let value: String
let lastUpdated: Date
}
// 2. Timeline Entry
struct SimpleEntry: TimelineEntry {
let date: Date
let data: WidgetData
}
// 3. Data Provider
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(
date: Date(),
data: WidgetData(title: "Loading", value: "...", lastUpdated: Date())
)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
let entry = SimpleEntry(
date: Date(),
data: fetchData()
)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
var entries: [SimpleEntry] = []
let currentDate = Date()
// Generate timeline: update every 15 minutes
for hourOffset in 0..<4 {
let entryDate = Calendar.current.date(byAdding: .minute, value: hourOffset * 15, to: currentDate)!
let data = fetchData()
entries.append(SimpleEntry(date: entryDate, data: data))
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
private func fetchData() -> WidgetData {
// Fetch from UserDefaults (shared with app) or network
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp")
let title = sharedDefaults?.string(forKey: "widgetTitle") ?? "Default"
let value = sharedDefaults?.string(forKey: "widgetValue") ?? "0"
return WidgetData(title: title, value: value, lastUpdated: Date())
}
}
// 4. Widget View
struct MyAppWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(data: entry.data)
case .systemMedium:
MediumWidgetView(data: entry.data)
case .systemLarge:
LargeWidgetView(data: entry.data)
@unknown default:
SmallWidgetView(data: entry.data)
}
}
}
struct SmallWidgetView: View {
let data: WidgetData
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(data.title)
.font(.caption)
.foregroundColor(.secondary)
Text(data.value)
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
Text(data.lastUpdated, style: .relative)
.font(.caption2)
.foregroundColor(.secondary)
}
.padding()
.background(Color("WidgetBackground"))
}
}
// 5. Widget Configuration
@main
struct MyAppWidget: Widget {
let kind: String = "MyAppWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MyAppWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("Shows your latest stats")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
Sharing Data (App ↔ Widget)
1. Enable App Groups:
Xcode → Target → Signing & Capabilities → App Groups
Add group: group.com.yourapp (both app and widget)
2. Write data in app:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp")
sharedDefaults?.set("5,234 steps", forKey: "widgetValue")
sharedDefaults?.set("Today", forKey: "widgetTitle")
3. Read in widget:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp")
let value = sharedDefaults?.string(forKey: "widgetValue")
4. Trigger widget update from app:
import WidgetKit
// After data changes
WidgetCenter.shared.reloadAllTimelines()
// Or specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "MyAppWidget")
Deep Linking from Widget
Widget view:
Link(destination: URL(string: "myapp://details/123")!) {
WidgetContentView()
}
App:
.onOpenURL { url in
// Handle: myapp://details/123
if url.scheme == "myapp" {
let id = url.pathComponents[2]
navigateToDetails(id: id)
}
}
Android Widgets (App Widgets)
Widget Sizes
Size classes (dp):
- Small: 1×1 (40×40 dp minimum)
- Medium: 2×2 (110×110 dp minimum)
- Large: 4×2 (180×110 dp minimum)
- Extra Large: 4×4 (180×180 dp minimum)
Most common: 4×1 horizontal bar
Widget Implementation
1. Widget XML Layout (res/layout/widget_layout.xml):
2. Widget Provider (MyAppWidgetProvider.kt):
class MyAppWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
appWidgetIds.forEach { widgetId ->
updateWidget(context, appWidgetManager, widgetId)
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: Int
) {
// Fetch data
val data = fetchWidgetData(context)
// Create RemoteViews
val views = RemoteViews(context.packageName, R.layout.widget_layout)
views.setTextViewText(R.id.widget_title, data.title)
views.setTextViewText(R.id.widget_value, data.value)
views.setTextViewText(R.id.widget_updated, "Updated ${data.relativeTime}")
// Set click intent
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("openPage", "details")
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_value, pendingIntent)
// Update widget
appWidgetManager.updateAppWidget(widgetId, views)
}
private fun fetchWidgetData(context: Context): WidgetData {
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
return WidgetData(
title = prefs.getString("title", "Steps") ?: "Steps",
value = prefs.getString("value", "0") ?: "0",
relativeTime = "just now"
)
}
}
data class WidgetData(
val title: String,
val value: String,
val relativeTime: String
)
3. Widget Info (res/xml/widget_info.xml):
4. Manifest (AndroidManifest.xml):
Updating Widget from App
// Save data
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
prefs.edit()
.putString("title", "Steps Today")
.putString("value", "5,234")
.apply()
// Trigger widget update
val intent = Intent(context, MyAppWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
}
val ids = AppWidgetManager.getInstance(context)
.getAppWidgetIds(ComponentName(context, MyAppWidgetProvider::class.java))
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
context.sendBroadcast(intent)
Background Updates (WorkManager)
class WidgetUpdateWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// Fetch fresh data
val data = fetchDataFromAPI()
// Save to SharedPreferences
applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
.edit()
.putString("value", data.value)
.apply()
// Update widget
val intent = Intent(applicationContext, MyAppWidgetProvider::class.java)
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
applicationContext.sendBroadcast(intent)
return Result.success()
}
}
// Schedule periodic updates (every 15 minutes)
val updateRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"widget_update",
ExistingPeriodicWorkPolicy.KEEP,
updateRequest
)
Widget Design Best Practices
Content Guidelines
- Glanceable: Info in < 3 seconds
- Concise: Minimal text
- Focused: One primary metric
- Updated: Show freshness ("2 min ago")
- Actionable: Tap to open relevant screen
Visual Design
iOS:
- Use SF Symbols (built-in icons)
- Support light/dark mode
- Padding: 16pt minimum
- Corner radius: 20pt (system default)
- No interactive elements (except tap)
Android:
- Material Design principles
- Padding: 16dp minimum
- Corner radius: 16dp
- Support Android 12+ dynamic theming
- Limited interactivity (buttons allowed)
Widget Types
1. Informational:
- Weather forecast
- Stock prices
- Calendar events
- Fitness stats
2. Quick Actions:
- Music controls
- Timer/stopwatch
- Quick notes
- Reminders
3. Collections:
- Photo gallery
- News headlines
- Task list
- Contact shortcuts
Widget Performance
Update Frequency
iOS:
- Minimum: 15 minutes between updates
- Budget: ~70 updates per day
- System decides actual frequency
- Use Timeline with multiple entries
Android:
- Minimum: 30 minutes (updatePeriodMillis)
- Use WorkManager for frequent updates
- Battery optimization kicks in if too frequent
Data Loading
- Keep updates fast (< 5 seconds)
- Cache data locally
- Use background tasks
- Show placeholder while loading
- Handle network failures gracefully
Widget Interactions
iOS Interactions
Tap actions only:
- Link to specific screen
- Open app to relevant content
- Use URL schemes
Button-like elements (iOS 17+):
Button(intent: RefreshIntent()) {
Label("Refresh", systemImage: "arrow.clockwise")
}
App Intents Framework:
struct RefreshIntent: AppIntent {
static var title: LocalizedStringResource = "Refresh"
func perform() async throws -> some IntentResult {
// Perform action without opening app
await DataManager.shared.refresh()
return .result()
}
}
Android Interactions
Multiple buttons allowed:
val refreshIntent = Intent(context, RefreshAction::class.java)
val refreshPendingIntent = PendingIntent.getBroadcast(
context, 0, refreshIntent, PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.refresh_button, refreshPendingIntent)
val openIntent = Intent(context, MainActivity::class.java)
val openPendingIntent = PendingIntent.getActivity(
context, 0, openIntent, PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
Widget Configuration
Customizable Widgets (iOS)
IntentConfiguration (configurable widget):
struct MyAppWidget: Widget {
var body: some WidgetConfiguration {
IntentConfiguration(
kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()
) { entry in
MyAppWidgetEntryView(entry: entry)
}
.configurationDisplayName("Customizable Widget")
.description("Choose what to display")
}
}
// Define parameters in .intentdefinition file
// User can customize via long-press → Edit Widget
Resizable Widgets (Android)
res/xml/widget_info.xml:
Testing Widgets
iOS Testing
1. Run widget scheme in simulator
2. Long-press home screen → Add Widget
3. Test all sizes
4. Test light/dark mode
5. Test timeline updates
Debug timeline:
widgetPerformUpdate() // Force update
XCTest:
func testWidgetContent() {
let entry = SimpleEntry(date: Date(), data: testData)
let view = MyAppWidgetEntryView(entry: entry)
// Snapshot testing
}
Android Testing
1. Run app
2. Long-press home screen → Widgets
3. Find your widget and drag to home
4. Test updates
5. Test interactions
Debug updates:
adb shell am broadcast -a android.appwidget.action.APPWIDGET_UPDATE
Widget Analytics
Track Usage
Metrics:
- Widget installs
- Widget taps/interactions
- Update frequency
- Error rates
- Sizes used
iOS:
func widgetPerformUpdate() {
Analytics.logEvent("widget_updated", parameters: [
"widget_family": family.description
])
}
Android:
override fun onUpdate(...) {
Analytics.logEvent("widget_updated")
}
Common Mistakes
- ❌ Too much information (keep it simple)
- ❌ Tiny unreadable text
- ❌ No tap action (wasted opportunity)
- ❌ Stale data (show update time)
- ❌ Complex interactions (widgets are glanceable)
- ❌ Ignoring dark mode
- ❌ Slow updates (< 5 seconds)
- ❌ Battery drain (too frequent updates)
Widget Promotion
Onboarding
Educate users:
- Show widget gallery during onboarding
- "Add to Home Screen" tutorial
- Highlight widget features
- Show before/after examples
In-app prompt:
"Add our widget to your home screen for quick access!"
[Show Me How] [Maybe Later]
App Store Assets
- Screenshot showing widget on home screen
- Mention widgets in description
- Widget preview video
- "Includes widgets" badge
Conclusion
Widgets dramatically increase engagement by keeping your app visible and useful throughout the day. Focus on glanceable, frequently-updated information, design for different sizes, and ensure fast performance. Widgets are a powerful retention tool when done right.