App widgets with Glance: real-world lessons and examples
Home screen widgets have been a staple of Android for over a decade, but building them has traditionally meant wrestling with RemoteViews, limited UI flexibility, and frustrating layout constraints. Jetpack Glance changes this by letting us write widgets using Compose-style code, dramatically reducing boilerplate and making our widgets feel like first-class citizens in the modern Android ecosystem. In this article, we'll walk through real-world lessons and examples from shipping production widgets, covering everything from the fundamentals of Glance to keeping your widgets performant and responsive.
Understanding Jetpack Glance for widgets
Glance is a declarative UI toolkit specifically designed for app widgets. It abstracts away RemoteViews and gives us a Compose-like DSL that compiles down to the legacy widget system. The key insight is that Glance isn't full Compose—it's a constrained, widget-optimized subset that works within the RemoteViews model.
Let's start with a basic setup. First, add Glance to your build.gradle.kts:
dependencies {
implementation("androidx.glance:glance-appwidget:1.0.0")
implementation("androidx.glance:glance-material3:1.0.0")
implementation("androidx.work:work-runtime-ktx:2.8.1")
}Now, create your first widget. You'll need a GlanceAppWidget subclass:
class WeatherWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
WeatherWidgetContent()
}
}
}
@Composable
fun WeatherWidgetContent() {
val prefs = currentContext().getSharedPreferences("weather", Context.MODE_PRIVATE)
val temp = prefs.getInt("temperature", 0)
val condition = prefs.getString("condition", "Unknown") ?: "Unknown"
Column(
modifier = Modifier
.fillMaxWidth()
.background(ColorProvider(day = Color.Blue, night = Color.DarkGray))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "$temp°C",
style = TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = condition,
style = TextStyle(fontSize = 14.sp)
)
}
}Next, register your widget in AndroidManifest.xml:
<receiver
android:name=".widget.WeatherWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/weather_widget_info" />
</receiver>And create your receiver class:
class WeatherWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WeatherWidget()
}Updating widgets with WorkManager
Widgets need to refresh periodically, but using setAlarmManager or implementing your own polling logic leads to battery drain and complexity. WorkManager, combined with Glance's updateAll mechanism, is the production-tested approach.
Set up a periodic work request to update your widget every 30 minutes:
class WeatherUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
return@withContext try {
val weatherData = fetchWeatherFromApi()
val prefs = applicationContext.getSharedPreferences("weather", Context.MODE_PRIVATE)
prefs.edit {
putInt("temperature", weatherData.temperature)
putString("condition", weatherData.condition)
}
WeatherWidget().updateAll(applicationContext)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
private suspend fun fetchWeatherFromApi(): WeatherData {
// Call your API here
return WeatherData(temperature = 22, condition = "Sunny")
}
}
data class WeatherData(val temperature: Int, val condition: String)Schedule this work in your app initialization or in a ViewModel:
fun scheduleWeatherUpdates(context: Context) {
val weatherUpdateRequest = PeriodicWorkRequestBuilder<WeatherUpdateWorker>(
repeatInterval = 30,
repeatIntervalTimeUnit = TimeUnit.MINUTES
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather_update",
ExistingPeriodicWorkPolicy.KEEP,
weatherUpdateRequest
)
}In production, we've found that 30-minute intervals balance user experience with battery consumption. Shorter intervals feel responsive but drain batteries; longer intervals hurt engagement. Also, always use ExistingPeriodicWorkPolicy.KEEP to prevent duplicate work registrations on app updates.
Keeping widgets lightweight
Widgets run on a constrained budget. Glance automatically serializes your UI tree and sends it to the system, so every additional view, computation, or API call counts. We've learned this the hard way in production.
Here's a common anti-pattern: don't fetch data inside the composable:
// ❌ BAD: Blocking calls in Compose
@Composable
fun HeavyWidgetContent() {
val stockPrice = getStockPriceSync() // Blocks the UI thread
Text(text = "$${stockPrice}")
}Instead, pre-fetch and cache:
// ✅ GOOD: Data fetched and cached separately
@Composable
fun LightweightWidgetContent() {
val prefs = currentContext().getSharedPreferences("stocks", Context.MODE_PRIVATE)
val cachedPrice = prefs.getString("AAPL_price", "---") ?: "---"
Text(text = "AAPL: $$cachedPrice")
}Also, limit the number of elements. A widget with 20+ views will cause performance issues and may not render at all on older devices. Aim for under 10 views:
@Composable
fun OptimizedPortfolioWidget() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text("Portfolio", style = TextStyle(fontWeight = FontWeight.Bold))
// Show only top 3 holdings
repeat(3) { index ->
PortfolioRow(index)
}
Text("Updated 2 min ago", style = TextStyle(fontSize = 10.sp))
}
}
@Composable
fun PortfolioRow(index: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Stock $index")
Text("+2.5%", color = Color.Green)
}
}Deep linking into the app
A widget that just displays data is only half the story. Users tap widgets to dive deeper into your app. Glance makes deep linking straightforward with clickable and launchApp.
Here's a real-world example from a trading app: tapping a stock in the widget opens that stock's detail screen:
@Composable
fun ClickableStockRow(symbol: String, price: String) {
val deepLinkUri = Uri.Builder()
.scheme("https")
.authority("trading.app")
.appendPath("stock")
.appendQueryParameter("symbol", symbol)
.build()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = actionStartActivity(
Intent(Intent.ACTION_VIEW, deepLinkUri)
)
)
.padding(8.dp)
) {
Text(symbol, modifier = Modifier.weight(1f))
Text(price)
}
}
@Composable
fun TradingWidgetContent() {
Column(modifier = Modifier.padding(12.dp)) {
ClickableStockRow("AAPL", "$189.50")
ClickableStockRow("GOOGL", "$140.25")
}
}And ensure your MainActivity (or detail activity) handles the deep link in its manifest:
<activity
android:name=".ui.StockDetailActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="trading.app"
android:path="/stock" />
</intent-filter>
</activity>In your activity, parse the intent:
class StockDetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val symbol = intent.data?.getQueryParameter("symbol") ?: "UNKNOWN"
// Load and display stock details
}
}Common mistakes
- Forgetting to use context correctly: In Glance, always use
currentContext()inside composables. Direct context references won't serialize. - Over-updating: Calling
updateAll()too frequently drains batteries. Stick to 15-30 minute intervals unless you have a compelling reason. - Ignoring size constraints: Widgets have size limits (usually 4x4 grid cells). Test on real devices and include adaptive layouts for different sizes.
- Not handling missing data: If an API call fails in your background worker, your widget shows stale data. Always provide sensible defaults and "last updated" timestamps.
- Using expensive operations in the widget tree: Parsing JSON, decoding images, or doing database queries inside the composable will freeze the widget rendering. Pre-process everything in WorkManager.
Key takeaways
- Glance gives us Compose-like syntax for widgets, eliminating RemoteViews boilerplate and making widgets easier to maintain.
- Use WorkManager with periodic work requests to update widgets; 30-minute intervals are a good starting point for most use cases.
- Keep widget layouts simple and data pre-fetched. Aim for under 10 views and cache all data in SharedPreferences or a local database.
- Deep linking from widgets requires careful URI construction and activity intent filters, but dramatically improves user experience and engagement.
- Test your widgets on real devices and across multiple Android versions. Glance handles most of the heavy lifting, but edge cases still exist.






Comments
Loading comments…