Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 098 / 100

App widgets with Glance: real-world lessons and examples

Home screen widgets in Compose style.

Expona Admin7 min read

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:

kotlin
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:

kotlin
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:

kotlin
<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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
// ❌ BAD: Blocking calls in Compose
@Composable
fun HeavyWidgetContent() {
    val stockPrice = getStockPriceSync() // Blocks the UI thread
    Text(text = "$${stockPrice}")
}

Instead, pre-fetch and cache:

kotlin
// ✅ 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:

kotlin
@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:

kotlin
@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:

kotlin
<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:

kotlin
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles