Keep SizeMode.Exact: Single and Responsive never reflow on resize (#214)

GlanceAppWidget.resize returns early for both, so the grid was never recomposed
for a new size and a resized widget clipped instead of reflowing. Exact costs one
serialised copy per host size, which the view cuts have made affordable again.

Drops the options-derived width that went with Single; LocalSize is exact here.
This commit is contained in:
2026-08-17 19:56:20 +02:00
parent a0a559648e
commit 7431a7390e

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -20,7 +19,6 @@ import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.LocalAppWidgetOptions
import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
@@ -95,31 +93,33 @@ private fun yearMonthOf(index: Int): YearMonth =
* event bars and titled single-day pills (the in-app lane layout via * event bars and titled single-day pills (the in-app lane layout via
* [layoutMonthWeeks]), and prev/next/today navigation. * [layoutMonthWeeks]), and prev/next/today navigation.
* *
* Columns are sized explicitly from [gridWidth] so a multi-day span renders as a * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
* single Box spanning its columns — connected, no inter-cell seam, with rounded * multi-day span renders as a single Box spanning its columns — connected, no
* end caps. The displayed month lives in Glance state and is read reactively in * inter-cell seam, with rounded end caps. The displayed month lives in Glance
* the composition ([currentState]) so the arrows move it via plain recomposition, * state and is read reactively in the composition ([currentState]) so the arrows
* not a (here-unreliable) widget session reload. * move it via plain recomposition, not a (here-unreliable) widget session reload.
* *
* Everything here is written to keep the published `RemoteViews` small. A widget * Everything here is written to keep the published `RemoteViews` small. A widget
* update reaches the launcher as a *oneway* binder call, and the host's async * update reaches the launcher as a *oneway* binder call, and the host's async
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and * buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
* the system drops the whole host, freezing every widget on the home screen until * the system drops the whole host, freezing every widget on the home screen until
* the launcher rebinds. A 6x7 grid is close enough to that ceiling to have hit it * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
* (#214), so the grid emits no view it does not need. * host reports, so the ceiling arrives at half the view count you would expect: a
* grid that emitted a view per cell blew past it after three taps (#214). Every
* row here emits only what it fills.
*/ */
class MonthWidget : GlanceAppWidget() { class MonthWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
/** /**
* [SizeMode.Exact] would serialise the whole grid once per size the host * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* reports — two full copies of ~700 views on any launcher that offers a * return early from `GlanceAppWidget.resize` — under those the grid is never
* portrait and a landscape size, which is what pushed the payload over the * recomposed for a new size and a resized widget clips instead of reflowing.
* binder ceiling (#214). One composition, sized from [gridWidth], which the * Exact costs one serialised copy per host size, which is affordable now that
* host re-runs on resize and rotation via `onAppWidgetOptionsChanged`. * a rendering is ~190 views rather than ~740.
*/ */
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val source = context.loadMonthWidgetSource() val source = context.loadMonthWidgetSource()
@@ -188,7 +188,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index) val ym = yearMonthOf(index)
// Column width from the live widget size, minus our H padding. // Column width from the live widget size, minus our H padding.
val colW = (gridWidth() - GRID_HPADDING * 2) / 7 val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
MonthHeader(label = monthLabel(ym, source.today.year)) MonthHeader(label = monthLabel(ym, source.today.year))
@@ -210,28 +210,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
} }
} }
/**
* The widget's current width. Under [SizeMode.Single] `LocalSize` is the provider's
* declared minimum rather than the placed size, so read the host's own numbers —
* min width in portrait, max width in landscape, the same pair Glance itself picks
* from — and keep `LocalSize` only as a fallback for a host that reports neither.
*/
@Composable
private fun gridWidth(): Dp {
val options = LocalAppWidgetOptions.current
val landscape = LocalContext.current.resources.configuration.orientation ==
Configuration.ORIENTATION_LANDSCAPE
val width = options.getInt(
if (landscape) {
AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
} else {
AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH
},
0,
)
return if (width > 0) width.dp else LocalSize.current.width
}
@Composable @Composable
private fun MonthHeader(label: String) { private fun MonthHeader(label: String) {
val context = LocalContext.current val context = LocalContext.current