Back to all articles

Mobile App Widgets: Complete Guide for iOS and Android 2025

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.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.