From f6ec28be2331e3cdfdfd3e4fe3ac11119017c12f Mon Sep 17 00:00:00 2001 From: android-devrel-github-bot Date: Wed, 14 Oct 2026 10:03:30 +0000 Subject: [PATCH] Updates skills (2026-10-14 10:03) --- .claude-plugin/plugin.json | 5 + .codex-plugin/plugin.json | 25 + camera/camerax/SKILL.md | 2 +- cars/android-for-cars-parked-apps/SKILL.md | 279 ++++++ .../car-app-quality/index.md | 787 +++++++++++++++++ device-ai/litert/litert-runtime/SKILL.md | 358 ++++++++ .../android/google/play/on-device-ai.md | 818 ++++++++++++++++++ .../litert-runtime/references/verify.md | 39 + jetpack-compose/adaptive/SKILL.md | 2 +- .../layouts/adaptive/flexbox/get-started.md | 2 +- .../layouts/adaptive/grid/get-started.md | 2 +- .../jetpack-compose-performance/SKILL.md | 193 +++++ .../develop/ui/compose/custom-modifiers.md | 552 ++++++++++++ .../ui/compose/performance/backwards-write.md | 186 ++++ .../performance/stability/strongskipping.md | 137 +++ .../references/animation.md | 290 +++++++ .../references/effects-and-threading.md | 531 ++++++++++++ .../references/graphics-performance.md | 221 +++++ .../references/layouts.md | 176 ++++ .../references/lazy-layouts.md | 366 ++++++++ .../references/state-and-stability.md | 665 ++++++++++++++ navigation/navigation-3/SKILL.md | 2 +- .../navigation-3/recipes/bottomsheet.md | 20 +- .../recipes/multiple-backstacks.md | 2 + navigation/navigation-event/SKILL.md | 2 +- .../navigation/navigation-event/index.md | 2 +- play/engage-sdk-integration/SKILL.md | 2 +- .../references/hints/hints_cpu.md | 30 + .../references/hints/hints_graphics.md | 25 + .../references/hints/hints_io.md | 26 + .../references/hints/hints_ipc.md | 21 + .../references/hints/hints_memory.md | 29 + .../references/hints/hints_power.md | 19 + system/glance-canonical-layouts/SKILL.md | 245 ++++++ .../references/ActionDemonstrationActivity.kt | 47 + .../references/CanonicalLayoutActivity.kt | 371 ++++++++ .../references/CollectionsKtx.kt | 48 + .../collections/ActionListAppWidget.kt | 86 ++ .../collections/CheckListAppWidget.kt | 95 ++ .../collections/ImageGridAppWidget.kt | 96 ++ .../collections/ImageTextListAppWidget.kt | 99 +++ .../data/FakeActionListDataRepository.kt | 129 +++ .../data/FakeCheckListDataRepository.kt | 125 +++ .../data/FakeImageGridDataRepository.kt | 220 +++++ .../data/FakeImageTextListDataRepository.kt | 205 +++++ .../collections/layout/ActionListLayout.kt | 560 ++++++++++++ .../collections/layout/CheckListLayout.kt | 462 ++++++++++ .../collections/layout/EmptyListContent.kt | 23 + .../collections/layout/ImageGridLayout.kt | 405 +++++++++ .../collections/layout/ImageTextListLayout.kt | 497 +++++++++++ .../references/collections/layout/ListItem.kt | 85 ++ .../collections/layout/NoDataContent.kt | 77 ++ .../layout/RoundedScrollingLazyColumn.kt | 79 ++ .../RoundedScrollingLazyVerticalGrid.kt | 127 +++ .../collections/layout/VerticalListItem.kt | 89 ++ .../drawable/four_side_cookie_background.xml | 26 + .../res/drawable/sample_ac_icon.xml | 10 + .../res/drawable/sample_add_icon.xml | 5 + .../res/drawable/sample_app_logo.xml | 26 + .../res/drawable/sample_arrow_right_icon.xml | 5 + .../res/drawable/sample_bulb_icon.xml | 11 + .../res/drawable/sample_camera_icon.xml | 27 + .../drawable/sample_checked_circle_icon.xml | 5 + .../res/drawable/sample_circle_icon.xml | 5 + .../res/drawable/sample_delete_icon.xml | 5 + .../res/drawable/sample_door_icon.xml | 10 + .../res/drawable/sample_edit_icon.xml | 6 + .../res/drawable/sample_file_upload_icon.xml | 26 + .../res/drawable/sample_grid_icon.xml | 5 + .../res/drawable/sample_heart_icon.xml | 3 + .../res/drawable/sample_home_icon.xml | 3 + .../res/drawable/sample_image_icon.xml | 3 + .../res/drawable/sample_info_icon.xml | 5 + .../res/drawable/sample_mic_icon.xml | 25 + .../res/drawable/sample_no_data_icon.xml | 6 + .../res/drawable/sample_pin_icon.xml | 5 + .../drawable/sample_power_settings_icon.xml | 3 + .../res/drawable/sample_refresh_icon.xml | 3 + .../res/drawable/sample_scrim_gradient.xml | 41 + .../res/drawable/sample_search_icon.xml | 25 + .../res/drawable/sample_share_icon.xml | 5 + .../res/drawable/sample_snooze_icon.xml | 5 + .../res/drawable/sample_text_icon.xml | 3 + .../res/drawable/sample_thermostat_icon.xml | 3 + .../res/drawable/sample_videocam_icon.xml | 25 + .../references/res/values/dimens.xml | 146 ++++ .../references/res/values/strings.xml | 77 ++ .../xml/sample_action_list_widget_info.xml | 33 + .../res/xml/sample_check_list_widget_info.xml | 34 + .../sample_expressive_toolbar_widget_info.xml | 28 + .../sample_full_bleed_image_widget_info.xml | 26 + .../res/xml/sample_image_grid_widget_info.xml | 33 + .../sample_image_text_list_widget_info.xml | 33 + .../res/xml/sample_long_text_widget_info.xml | 28 + .../xml/sample_search_toolbar_widget_info.xml | 28 + .../sample_text_with_image_widget_info.xml | 28 + .../res/xml/sample_toolbar_widget_info.xml | 28 + .../text/FullBleedImageAppWidget.kt | 104 +++ .../references/text/LongTextAppWidget.kt | 84 ++ .../references/text/TextWithImageAppWidget.kt | 103 +++ .../text/data/FakeLongTextRespository.kt | 90 ++ .../text/data/FakeTextWithImageRepository.kt | 160 ++++ .../text/layout/FullBleedImageLayout.kt | 222 +++++ .../references/text/layout/LongTextLayout.kt | 347 ++++++++ .../text/layout/TextWithImageLayout.kt | 520 +++++++++++ .../toolbars/ExpressiveToolbarAppWidget.kt | 93 ++ .../toolbars/SearchToolBarAppWidget.kt | 88 ++ .../references/toolbars/ToolBarAppWidget.kt | 90 ++ .../references/toolbars/layout/Buttons.kt | 135 +++ .../layout/ExpressiveToolbarLayout.kt | 411 +++++++++ .../toolbars/layout/LayoutComponents.kt | 223 +++++ .../toolbars/layout/SearchToolBarLayout.kt | 417 +++++++++ .../toolbars/layout/ToolBarLayout.kt | 455 ++++++++++ .../references/ui/theme/Color.kt | 11 + .../references/ui/theme/Theme.kt | 70 ++ .../references/ui/theme/Type.kt | 34 + .../references/utils/ActionUtils.kt | 24 + .../references/utils/FontUtils.kt | 102 +++ .../references/utils/ImageAspectRatio.kt | 45 + .../references/utils/ImageUtils.kt | 81 ++ .../references/utils/PreviewAnnotations.kt | 71 ++ wear/wear-compose-m3/SKILL.md | 9 +- .../wearables/compose/migrate-to-material3.md | 10 +- .../foundation/HierarchicalFocusSample.kt.md | 4 +- .../references/material3/SliderSample.kt.md | 11 + .../material3/TransformationSpecSample.kt.md | 8 +- wear/wear-widgets/SKILL.md | 175 ++++ .../references/previews-and-testing.md | 131 +++ .../references/remote-compose-layouts.md | 166 ++++ .../references/state-and-actions.md | 137 +++ .../references/widget-service-and-manifest.md | 94 ++ .../SKILL.md | 2 +- .../request-hardware-permissions.md | 178 ++-- 133 files changed, 15623 insertions(+), 125 deletions(-) create mode 100644 cars/android-for-cars-parked-apps/SKILL.md create mode 100644 cars/android-for-cars-parked-apps/references/android/docs/quality-guidelines/car-app-quality/index.md create mode 100644 device-ai/litert/litert-runtime/SKILL.md create mode 100644 device-ai/litert/litert-runtime/references/android/google/play/on-device-ai.md create mode 100644 device-ai/litert/litert-runtime/references/verify.md create mode 100644 jetpack-compose/jetpack-compose-performance/SKILL.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/custom-modifiers.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/animation.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/effects-and-threading.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/graphics-performance.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/layouts.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/lazy-layouts.md create mode 100644 jetpack-compose/jetpack-compose-performance/references/state-and-stability.md create mode 100644 profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_cpu.md create mode 100644 profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_graphics.md create mode 100644 profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_io.md create mode 100644 profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_ipc.md create mode 100644 profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_memory.md create mode 100644 profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_power.md create mode 100644 system/glance-canonical-layouts/SKILL.md create mode 100644 system/glance-canonical-layouts/references/ActionDemonstrationActivity.kt create mode 100644 system/glance-canonical-layouts/references/CanonicalLayoutActivity.kt create mode 100644 system/glance-canonical-layouts/references/CollectionsKtx.kt create mode 100644 system/glance-canonical-layouts/references/collections/ActionListAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/collections/CheckListAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/collections/ImageGridAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/collections/ImageTextListAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/collections/data/FakeActionListDataRepository.kt create mode 100644 system/glance-canonical-layouts/references/collections/data/FakeCheckListDataRepository.kt create mode 100644 system/glance-canonical-layouts/references/collections/data/FakeImageGridDataRepository.kt create mode 100644 system/glance-canonical-layouts/references/collections/data/FakeImageTextListDataRepository.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/ActionListLayout.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/CheckListLayout.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/EmptyListContent.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/ImageGridLayout.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/ImageTextListLayout.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/ListItem.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/NoDataContent.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyColumn.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyVerticalGrid.kt create mode 100644 system/glance-canonical-layouts/references/collections/layout/VerticalListItem.kt create mode 100644 system/glance-canonical-layouts/references/res/drawable/four_side_cookie_background.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_ac_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_add_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_app_logo.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_arrow_right_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_bulb_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_camera_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_checked_circle_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_circle_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_delete_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_door_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_edit_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_file_upload_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_grid_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_heart_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_home_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_image_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_info_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_mic_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_no_data_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_pin_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_power_settings_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_refresh_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_scrim_gradient.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_search_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_share_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_snooze_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_text_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_thermostat_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/drawable/sample_videocam_icon.xml create mode 100644 system/glance-canonical-layouts/references/res/values/dimens.xml create mode 100644 system/glance-canonical-layouts/references/res/values/strings.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_action_list_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_check_list_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_expressive_toolbar_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_full_bleed_image_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_image_grid_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_image_text_list_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_long_text_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_search_toolbar_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_text_with_image_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/res/xml/sample_toolbar_widget_info.xml create mode 100644 system/glance-canonical-layouts/references/text/FullBleedImageAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/text/LongTextAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/text/TextWithImageAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/text/data/FakeLongTextRespository.kt create mode 100644 system/glance-canonical-layouts/references/text/data/FakeTextWithImageRepository.kt create mode 100644 system/glance-canonical-layouts/references/text/layout/FullBleedImageLayout.kt create mode 100644 system/glance-canonical-layouts/references/text/layout/LongTextLayout.kt create mode 100644 system/glance-canonical-layouts/references/text/layout/TextWithImageLayout.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/ExpressiveToolbarAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/SearchToolBarAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/ToolBarAppWidget.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/layout/Buttons.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/layout/ExpressiveToolbarLayout.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/layout/LayoutComponents.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/layout/SearchToolBarLayout.kt create mode 100644 system/glance-canonical-layouts/references/toolbars/layout/ToolBarLayout.kt create mode 100644 system/glance-canonical-layouts/references/ui/theme/Color.kt create mode 100644 system/glance-canonical-layouts/references/ui/theme/Theme.kt create mode 100644 system/glance-canonical-layouts/references/ui/theme/Type.kt create mode 100644 system/glance-canonical-layouts/references/utils/ActionUtils.kt create mode 100644 system/glance-canonical-layouts/references/utils/FontUtils.kt create mode 100644 system/glance-canonical-layouts/references/utils/ImageAspectRatio.kt create mode 100644 system/glance-canonical-layouts/references/utils/ImageUtils.kt create mode 100644 system/glance-canonical-layouts/references/utils/PreviewAnnotations.kt create mode 100644 wear/wear-widgets/SKILL.md create mode 100644 wear/wear-widgets/references/previews-and-testing.md create mode 100644 wear/wear-widgets/references/remote-compose-layouts.md create mode 100644 wear/wear-widgets/references/state-and-actions.md create mode 100644 wear/wear-widgets/references/widget-service-and-manifest.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 15e0b2d..6de01c2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -19,12 +19,15 @@ "skills": [ "./build-system/agp/agp-9-upgrade", "./camera/camerax", + "./cars/android-for-cars-parked-apps", "./device-ai/appfunctions", + "./device-ai/litert/litert-runtime", "./device-ai/ml-kit-genai-prompt-api", "./devtools/android-cli", "./identity/restore-credentials", "./identity/verified-email", "./jetpack-compose/adaptive", + "./jetpack-compose/jetpack-compose-performance", "./jetpack-compose/migration/migrate-xml-views-to-jetpack-compose", "./jetpack-compose/theming/styles", "./media/media3-cast-integration", @@ -38,9 +41,11 @@ "./security/android-intent-security", "./security/android-permissions-security", "./system/edge-to-edge", + "./system/glance-canonical-layouts", "./testing/testing-setup", "./tv/leanback-to-compose-tv-migration", "./wear/wear-compose-m3", + "./wear/wear-widgets", "./xr/display-glasses-with-jetpack-compose-glimmer" ] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5b67b40..d71e20c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -25,11 +25,21 @@ "path": "./camera/camerax" } }, + { + "source": { + "path": "./cars/android-for-cars-parked-apps" + } + }, { "source": { "path": "./device-ai/appfunctions" } }, + { + "source": { + "path": "./device-ai/litert/litert-runtime" + } + }, { "source": { "path": "./device-ai/ml-kit-genai-prompt-api" @@ -55,6 +65,11 @@ "path": "./jetpack-compose/adaptive" } }, + { + "source": { + "path": "./jetpack-compose/jetpack-compose-performance" + } + }, { "source": { "path": "./jetpack-compose/migration/migrate-xml-views-to-jetpack-compose" @@ -120,6 +135,11 @@ "path": "./system/edge-to-edge" } }, + { + "source": { + "path": "./system/glance-canonical-layouts" + } + }, { "source": { "path": "./testing/testing-setup" @@ -135,6 +155,11 @@ "path": "./wear/wear-compose-m3" } }, + { + "source": { + "path": "./wear/wear-widgets" + } + }, { "source": { "path": "./xr/display-glasses-with-jetpack-compose-glimmer" diff --git a/camera/camerax/SKILL.md b/camera/camerax/SKILL.md index 3963a12..d3962f7 100644 --- a/camera/camerax/SKILL.md +++ b/camera/camerax/SKILL.md @@ -7,7 +7,7 @@ description: Provide technical guidance for Android camera development with Came license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-24' + last-updated: '2026-10-08' keywords: - recipe - Android diff --git a/cars/android-for-cars-parked-apps/SKILL.md b/cars/android-for-cars-parked-apps/SKILL.md new file mode 100644 index 0000000..51e39e5 --- /dev/null +++ b/cars/android-for-cars-parked-apps/SKILL.md @@ -0,0 +1,279 @@ +--- +name: android-for-cars-parked-apps +description: Adapt existing Android video streaming apps, games, and web browsers + to run as parked apps on Android Auto and Android Automotive OS. Covers manifest + configuration, Google Play hardware feature declarations, compatibility mode, back + navigation affordances, orientation and display continuity, controllable system + bar insets, irregular display cutouts, driver distraction lifecycle handling, MediaSession + playback restrictions with CarUxRestrictionsManager, background audio while driving, + and browser sensitive data protection. Use this skill when migrating, building, + or auditing parked apps for cars to meet Car App Quality guidelines. Don't use for + media audio apps, templated Car App Library navigation, point of interest, internet + of things, weather, or communication apps designed for use while driving. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-10-09' + keywords: + - Android Auto + - Android Automotive OS + - parked apps + - video + - games + - browsers + - adaptive +--- + +Adapt existing Android video apps, games, and web browsers to run as parked +apps on Android Auto and Android Automotive OS. Follow these steps to meet the +car app quality guidelines for Google Play distribution. + +## Prerequisites + +- Begin with an existing adaptive Android app in one of the supported parked app categories: video streaming apps, games, or web browsers. + +## Glossary + +- **Parked apps**: Parked apps run on Android Auto and Android Automotive OS only while the vehicle is parked. The three parked categories are video, games, and browsers; other categories can't be published as parked apps. +- **Android Auto**: Android Auto is a phone-powered projection system. Apps execute on the user's phone and are projected to the in-car display over a wired or wireless connection. Android Auto is a separate platform from Android Automotive OS. +- **Android Automotive OS**: Android Automotive OS, or AAOS, is a variant of Android that runs directly on the vehicle itself. Android Automotive OS is a separate platform from Android Auto. +- **Video category**: Video apps display streaming videos while parked. Generally available on Android Automotive OS; unsupported on Android Auto. +- **Games category**: Game apps provide gameplay while parked. Generally available on Android Automotive OS and Android Auto. +- **Browsers category**: Web browsers let users browse the web while parked. Available in beta on Android Automotive OS; unsupported on Android Auto. +- **Media category**: Not a parked category. Media apps play audio content (music, radio, audiobooks, excluding video) while driving or parked. +- **Android for Cars platform**: Android for Cars is an umbrella term that consists of Android Auto and Android Automotive OS. +- **Generally available categories**: Permitted on internal testing, closed testing, open testing, and production Google Play tracks for a supported platform. +- **Beta categories**: Permitted on internal or closed testing tracks; open testing and production tracks require acceptance into an early access program. +- **Audio while driving**: A beta feature on Android Automotive OS that lets video apps play audio while the vehicle is in motion. +- **Car app quality guidelines** : When auditing an app against the Tier 3 (*Car ready* ), Tier 2 (*Car optimized* ), or Tier 1 (*Car differentiated* ) criteria, **MUST** check [car app quality guidelines](references/android/docs/quality-guidelines/car-app-quality/index.md). +- **Android Automotive OS compatibility mode** : Compatibility mode (`android.software.car.display_compatibility`) is a system feature on select vehicles providing a system back affordance, safe area rendering, density scaling, and opaque driving-mode blocking (moving activities to *Stopped*). Compatibility mode is only relevant to apps that support Android Automotive OS; it isn't relevant for apps that only support Android Auto. + +## Codebase exploration + +Search the codebase for `AndroidManifest.xml` (or create +`Assets/Plugins/Android/AndroidManifest.xml` in Unity projects), +`res/xml/automotive_app_desc.xml`, `build.gradle`, `build.gradle.kts`, +or `gradle/libs.versions.toml` files. Locate existing video players, +`MediaSession` classes, `requestedOrientation` calls, and `WindowInsets` +handlers. Unless instructed otherwise by the user, ask permission before adding +dependencies. + +## Step 1: Configure the app manifest + +Declare the app category, target platforms, hardware feature requirements, and +activity configuration in `AndroidManifest.xml`. + +### 1.1: Identify the app category + +- **Video apps** : Video apps **MUST** set `android:appCategory="video"` on `` and **MUST NOT** include `app/src/main/res/xml/automotive_app_desc.xml` with `uses name="video"`. +- **Games** : Games **MUST** set `android:appCategory="game"` on ``. Declare `android.hardware.gamepad` with `android:required="false"` if controller input is supported by the app. +- **Browsers** : Browsers **MUST** include `` in the `MAIN` `` of an exported `` (`android:exported="true"`). + +### 1.2: Identify target platforms and hardware features + +- **Android Auto** : Apps targeting Android Auto (Android 15+) **MUST** add `` to an `` in the `AndroidManifest.xml` file. Add it to the `MAIN` `` unless the user specifies a different intent filter. **NEVER** add this category if the app is solely targeting Android Automotive OS. +- **Android Automotive OS** : Apps targeting Android Automotive OS **MUST** declare `` (`"false"` for mobile or multi-form-factor tracks; `"true"` only if the app will be published on the dedicated Automotive OS track) in the `AndroidManifest.xml` file. **NEVER** add this feature tag if the app is solely targeting Android Auto. +- **Unrequired hardware features (`DO-1`)** : To satisfy Google Play feature requirements and `DO-1`, declare unsupported features (`android.hardware.wifi`, `android.hardware.screen.portrait`, `android.hardware.screen.landscape`, `android.hardware.camera`) with `android:required="false"`, and remove `android:required="true"` on features absent in cars. +- **Display continuity (`LS-C1`)** : Configure `android:configChanges` for `screenSize`, `smallestScreenSize`, `orientation`, `screenLayout`, and `density` on `` so moving to or from a distant display does not recreate the activity or stutter playback. +- **Compatibility mode and distraction** : Declaring compatibility mode is optional and is only relevant for apps that support Android Automotive OS. **NEVER** add compatibility mode for apps that only support Android Auto. Adding `android.hardware.type.automotive` disables compatibility mode unless `` is added to ``. When an app declares `android.software.car.display_compatibility` in `AndroidManifest.xml`, the app **MUST** check `PackageManager.hasSystemFeature("android.software.car.display_compatibility")` at runtime and hide duplicate in-app back or close affordances when it returns `true`. Parked apps **MUST NOT** include `` on any `` (`DD-2`, `DD-3`). + +## Step 2: Provide back navigation and orientation support + +### 2.1: Add in-app back and close affordances + +Android Automotive OS vehicles aren't required to have a hardware or software +back button or gesture navigation (`AN-1`). Provide explicit UI back and close +controls on every non-root screen, including detail screens and full-screen +players: + +- **If `AndroidManifest.xml` does not declare + `android.software.car.display_compatibility`**: Display explicit in-app back and close controls unconditionally. +- **If `AndroidManifest.xml` declares + `android.software.car.display_compatibility` with `android:value="true"`** : Show in-app back and close controls only when `PackageManager.hasSystemFeature("android.software.car.display_compatibility")` returns `false`. + +### 2.2: Guard runtime orientation requests + +Vehicle displays have fixed orientations. Requesting landscape on a +fixed-portrait car display causes letterboxing or flickering loops (`DO-1`). +Remove hardcoded `android:screenOrientation` manifest attributes and check +`PackageManager.FEATURE_SCREEN_LANDSCAPE` and +`PackageManager.FEATURE_SCREEN_PORTRAIT` before setting `requestedOrientation`. + +## Step 3: Adapt to system bars and irregular display cutouts + +### 3.1: Track controllable system bar insets + +On Android Automotive OS, OEMs are permitted to lock system bars visible so +climate and vehicle controls remain accessible (`AR-1`). Track controllable +insets with `OnControllableInsetsChangedListener`, hide only controllable bars, +and apply `windowInsetsPadding` for uncontrollable `statusBars`, +`navigationBars`, and `displayCutout` insets. + +### 3.2: Render into irregular display cutouts safely + +To meet Tier 1 criterion `AR-2` on curved or non-rectangular car displays, +**NEVER** use `LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT` (`0`) or +`LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES` (`1`). Configure +`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` (`3`) or +`LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER` (`2`) using the `car` resource qualifier +in `res/values-car-v30/integers.xml`: + + +```xml +3 +``` + +
+ +Protect interactive UI, app bars, and scrollable lists with +`WindowInsets.safeDrawing`. + +## Step 4: Meet driver distraction and media playback requirements + +### 4.1: Pause playback on lifecycle transitions and block driving resumption + +When a vehicle begins moving, the OS covers parked activities with a blocking +overlay and triggers `onPause` (and `onStop` on compatibility mode vehicles). To +satisfy `DD-2`, `DD-3`, and `MC-1`: + +1. Pause playback on `ON_PAUSE` or `ON_STOP` using `LifecycleEventEffect` from `androidx.lifecycle:lifecycle-runtime-compose`. +2. Expose a `MediaSession` (`androidx.media3:media3-session`) with play, pause, or stop commands and item metadata (`MC-1`), and release it in `onCleared`. +3. Add `useLibrary("android.car")` in `build.gradle.kts`, wrap `ExoPlayer` in `ForwardingSimpleBasePlayer`, listen to `CarUxRestrictionsManager`, pause playback when `isRequiresDistractionOptimization` becomes `true`, and remove `COMMAND_PLAY_PAUSE` in `getState` so external `MediaSession` controls can't resume playback while driving: + + +```kotlin +@UnstableApi +class CarAwarePlayer(context: Context) : + ForwardingSimpleBasePlayer(ExoPlayer.Builder(context).build()) { + private var shouldPreventPlay = false + private var pausedByUxRestrictions = false + private lateinit var carUxRestrictionsManager: CarUxRestrictionsManager + + init { + if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) { + val car = Car.createCar(context) + carUxRestrictionsManager = + car.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as CarUxRestrictionsManager + shouldPreventPlay = + carUxRestrictionsManager.currentCarUxRestrictions.isRequiresDistractionOptimization + invalidateState() + + carUxRestrictionsManager.registerListener { restrictions: CarUxRestrictions -> + shouldPreventPlay = restrictions.isRequiresDistractionOptimization + if (!shouldPreventPlay && pausedByUxRestrictions) { + handleSetPlayWhenReady(true) + invalidateState() + } else if (shouldPreventPlay && isPlaying) { + pausedByUxRestrictions = true + handleSetPlayWhenReady(false) + invalidateState() + } + } + } + } + + override fun getState(): State { + val state = super.getState() + return state.buildUpon() + .setAvailableCommands( + state.availableCommands.buildUpon() + .removeIf(COMMAND_PLAY_PAUSE, shouldPreventPlay) + .build() + ) + .build() + } + + override fun handleRelease(): ListenableFuture<*> { + if (::carUxRestrictionsManager.isInitialized) { + carUxRestrictionsManager.unregisterListener() + } + return super.handleRelease() + } +} +``` + +
+ +### 4.2: Support background audio while driving (optional beta feature) + +Because this is an optional beta feature, **NEVER** implement this feature +unless the user requests it. To implement the feature: + +1. Declare `` in `AndroidManifest.xml`, along with `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions. +2. Check runtime vehicle support with `CarFeatures.isFeatureEnabled(context, + CarFeatures.FEATURE_BACKGROUND_AUDIO_WHILE_DRIVING)` from `androidx.car.app:app`. Only set `shouldPreventPlay` when `!isBackgroundAudioWhileDrivingSupported && + restrictions.isRequiresDistractionOptimization`. +3. Host the `MediaSession` and player inside an exported `MediaSessionService` with `android:foregroundServiceType="mediaPlayback"`, and connect from the UI using `MediaController.Builder` and `buildAsync`. + +## Step 5: Protect sensitive data in browser apps + +Because vehicles are shared devices, browsers **MUST** block access to +saved passwords and payment methods unless protected by a profile lock (`SD-1`). +Before syncing passwords or payment data to the vehicle, browsers **MUST** +prompt the user to authenticate and display a notice on the car screen stating +that their data will be synchronized to the car (`SD-2`). + +Check whether a PIN, pattern, or password is set using `KeyguardManager`, open +`Settings.ACTION_SECURITY_SETTINGS` if unset, and authenticate with +`BiometricPrompt` using `BiometricManager.Authenticators.DEVICE_CREDENTIAL` +only (vehicles don't have biometric hardware): + + +```kotlin +val keyguardManager = context.getSystemService() +val isDeviceSecure = keyguardManager?.isDeviceSecure == true +lateinit var biometricPrompt: BiometricPrompt + +if (!isDeviceSecure) { + context.startActivity( + Intent(Settings.ACTION_SECURITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) +} else { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.auth_title)) + .setSubtitle(context.getString(R.string.sync_data_to_car_notice)) + .setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .build() + biometricPrompt.authenticate(promptInfo) +} +``` + +
+ +## Step 6: Verify implementation + +Build the app using `./gradlew assembleDebug`. Find and fix any build errors. + +## Post-implementation guidance + +Provide this guidance to the user after implementation: + +- For Android Auto apps, install the app on a phone and test using the Desktop Head Unit (DHU). +- For Android Automotive OS, install the app on the Android Automotive OS emulator in Android Studio. +- If the app is in a beta category for the target platform, acceptance into the early access program is required before submitting the app to an open testing or production track. +- If the app implements the beta audio while driving feature, acceptance into the early access program is required before submitting the app to an open testing or production track. +- Test the app against the relevant criteria in the car app quality guidelines before submitting the app. + +## Antipatterns + +- **NEVER** include `` on any `` in a parked app, or include `res/xml/automotive_app_desc.xml` with `uses name="video"` in a video app. +- **NEVER** assume a hardware or software back button exists on Android Automotive OS. +- **NEVER** assume system bars are positioned only at the top and bottom or can always be hidden. +- **NEVER** use `LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT` or `LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES` on Android Automotive OS. +- **NEVER** infer UX restrictions from vehicle speed or gear state instead of reading `isRequiresDistractionOptimization` from `CarUxRestrictionsManager`. +- **NEVER** use biometric authenticators (`BIOMETRIC_STRONG`, `BIOMETRIC_WEAK`) with `BiometricPrompt` on browser apps on Android Automotive OS; use `DEVICE_CREDENTIAL` only. +- **NEVER** add the `CAR_LAUNCHER` intent category if the user only requests Android Automotive OS support. +- **NEVER** add the `android.hardware.type.automotive` feature tag if the user only requests Android Auto support. + +## Best practices + +- **MUST** declare `android:required="false"` on unsupported hardware features (`wifi`, `screen.portrait`, `screen.landscape`, `camera`) and guard `requestedOrientation` calls by checking `FEATURE_SCREEN_LANDSCAPE` and `FEATURE_SCREEN_PORTRAIT` (`DO-1`). +- **MUST** provide in-app back and close affordances (`AN-1`) for Android Automotive OS when not in compatibility mode and protect all interactive UI and text from system bars and display cutouts using `WindowInsets.safeDrawing` and `OnControllableInsetsChangedListener` (`AR-1`, `AR-2`). +- **MUST** pause playback when UX restrictions become active and strip `COMMAND_PLAY_PAUSE` from `MediaSession` while driving unless audio while driving is supported and enabled (`DD-2`, `DD-4`, `MC-1`). +- **MUST** require profile lock authentication and display an on-screen sync disclosure before saving, accessing, or syncing browser passwords or payment information (`SD-1`, `SD-2`). diff --git a/cars/android-for-cars-parked-apps/references/android/docs/quality-guidelines/car-app-quality/index.md b/cars/android-for-cars-parked-apps/references/android/docs/quality-guidelines/car-app-quality/index.md new file mode 100644 index 0000000..bda3fed --- /dev/null +++ b/cars/android-for-cars-parked-apps/references/android/docs/quality-guidelines/car-app-quality/index.md @@ -0,0 +1,787 @@ +To provide a great experience for users in cars, complete the car compatibility +checklists and tests that follow as you design and develop your app. + +The checklists and tests define an extensive set of quality requirements for the +various app categories supported by Android Auto and Android Automotive OS. Many +requirements apply only to specific categories, so be sure to filter by your +app's category. See the [Car quality tiers](https://developer.android.com/docs/quality-guidelines/car-app-quality#car-quality-tiers) definitions +for details on which guidelines your app must meet in order to be accepted on +the Google Play Store. + +> [!NOTE] +> **September 2026 update:** The Games app category is now generally available. See [September 2026](https://developer.android.com/docs/quality-guidelines/car-app-quality#sep-26) for details. + +## Test your app + +Test your app for the applicable criteria listed on this page before submitting +your app to Google Play for review. As applicable, test both of the ways +users can access your app: + +Android Automotive OS +: Use the [Android Emulator](https://developer.android.com/studio/run/emulator) to validate each checklist + item. For more information, see [Test using the Android Automotive OS + emulator](https://developer.android.com/training/cars/testing/emulator). + +Android Auto +: Use the Android Auto Desktop Head Unit (DHU) to validate each checklist item. + For more information, see [Test using the Desktop Head Unit](https://developer.android.com/training/cars/testing/dhu). + +## App categories + +> [!IMPORTANT] +> **Key Point:** Select your app's categories to filter the guidelines on this page. + +| [Supported categories](https://developer.android.com/training/cars#supported-app-categories) | Android Auto | Android Automotive OS | +|---|---|---| +| Media | ✔ | ✔ | +| Media (templated) labs | ✔ | ✔ | +| Communication - messaging notifications | ✔ | | +| Communication - templated messaging labs | ✔ | | +| Communication - calling labs | ✔ | | +| Navigation | ✔ | ✔ | +| Point of Interest (POI) | ✔ | ✔ | +| Internet of Things (IOT) | ✔ | ✔ | +| Weather | ✔ | ✔ | +| Video | | ✔ | +| Games | ✔ | ✔ | +| Browsers labs | | ✔ | + +## Car quality tiers + +The quality tiers define criteria to help you assess the level of support your +app provides for cars. Each category builds upon those below it. That is, for an +app to be *Car optimized* , it should also meet all of the applicable *Car ready* +requirements. + +In addition to the guidelines specific to cars, each tier has associated +guidelines from [Adaptive app quality guidelines](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality) that +are relevant for activities implemented by your app. + +Levels of support include the following: + +### Tier 3 - Car ready + +Your app is large screen compatible and can be used while the car is parked. +While it may not have any car-optimized features, users can experience the app +just as they would on any other large screen Android-powered device. + +This tier primarily applies to the [parked categories](https://developer.android.com/training/cars/parked) of apps, +which must meet all of the requirements in this tier to be accepted on the +Google Play Store. + +> [!NOTE] +> **Note:** Through the [Car ready mobile apps program](https://developer.android.com/training/cars/car-ready-mobile-apps), mobile apps meeting the requirements in this tier can be made available for download from the Google Play Store on Android Automotive OS vehicles with Google built-in. + +### Tier 2 - Car optimized + +Your app provides a great experience on the car's center stack display. To +accomplish this, your app has some car-specific capabilities that users can +experience across driving or parked modes, depending on your app's category. + +Apps in [categories built for use while driving](https://developer.android.com/training/cars#supported-app-categories) must +meet meet all of the applicable requirements in this tier to be accepted on the +Google Play Store. + +### Tier 1 - Car differentiated + +Your app is built to work across the variety of hardware in cars and can adapt +its experience across driving and parked modes. It provides the best user +experience designed for the different screens in cars such as the center +console, instrument cluster, and additional screens - like panoramic displays +seen in many premium cars. + +### Car ready + +| Criteria | ID | Applicable Categories | Description | +|---|---|---|---| +| Permitted Categories | `PC-1` | All | The app must not include features outside the app types intended for cars. See [Supported app categories](https://developer.android.com/training/cars#supported-app-categories). | +| Expected Performance | `EP-1` | All | The app must work as expected or described in the app's Google Play Store listing. | +| Expected Performance | `EP-2` | Media, Messaging (templated), Calling, Navigation, POI, IOT, Weather, Video, Games, Browsers | When the app is relaunched from the home screen, the app must restore the app state as closely as possible to the previous state. | +| Expected Performance | `EP-3` | Games | The app is responsive to input and does not freeze or stutter during gameplay. | +| Expected Performance | `EP-4` | Media, Messaging (templated), Calling, Navigation, POI, IOT, Weather | **Android Automotive OS only:** The app listing on Google Play Store must include screenshots for the Android Automotive OS form factor. See [Provide Android Automotive OS screenshots](https://developer.android.com/training/cars/distribute#opt-in-aaos). Note: For apps in the parked app categories, screenshots are optional. However, if your app includes features unique to cars, or the app differs significantly from the experience on mobile or tablet, it is recommended to include screenshots in your Google Play Store listing. See [Provide Android Automotive OS screenshots](https://developer.android.com/training/cars/distribute#opt-in-aaos). | +| Display Orientation | `DO-1` | Video, Games, Browsers | **Android Automotive OS only:** The app can be distributed to devices with fixed screen orientations. See [Required Android Automotive OS features](https://developer.android.com/training/cars/parked/automotive-os#required-features). | +| Display Orientation | `DO-2` | Games | **Android Auto only:** The app is not significantly pillarboxed when running on landscape displays. See [Support common Android Auto screen sizes](https://developer.android.com/training/cars/parked/auto#support-screen-sizes). | +| App Rendering | `AR-1` | All categories supported by Android Automotive OS | In activities implemented by the app, interactive UI elements must not be obstructed by system bars or display cutouts. See [Work with window insets and display cutouts](https://developer.android.com/training/cars/parked/automotive-os#insets-and-cutouts). | +| Driver Distraction | `DD-3` | Video, Games, Browsers | The app must not be launchable or usable while driving and must not play any audio. See [Meet driver distraction requirements](https://developer.android.com/training/cars/parked/automotive-os#driver-distraction). Note: If your app plays audio, it must meet the [`DD-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-2) requirement. | +| Irrelevant notifications | `IN-2` | Video, Games, Browsers | The app must not post any [heads up notifications](https://developer.android.com/training/cars/platforms/automotive-os/notifications#hun). | +| App Navigability | `AN-1` | Video, Games, Browsers | Users can navigate through the app without encountering any dead ends. | +| Sensitive Data | `SD-1` | Browsers | Browsers must not save or allow access to passwords or payment information unless the [user can block access to passwords](https://developer.android.com/training/cars/parked/browser#block-sensitive) using a profile lock. | +| Sensitive Data | `SD-2` | Browsers | Before syncing data to the car, browsers that synchronize passwords or payments data must do the following steps: 1. Prompt the user to authenticate. 2. Notify the user on the car screen their data will be synchronized to the car. For details, see [Allow users to block access to sensitive data](https://developer.android.com/training/cars/parked/browser#block-sensitive). | + +#### Associated large screen quality guidelines + +While these guidelines are relevant for all activities implemented by your app, +they are Tier 3 requirements only for [parked apps](https://developer.android.com/training/cars/parked). + +| Criteria | ID | Guidance for cars | +|---|---|---| +| Configuration and continuity | [`LS-C1`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-C1) | Configuration changes in cars are primarily caused by moving an app between displays, such as when moving an app to or from a [distant display](https://developer.android.com/training/cars/testing/emulator/distant-display). | +| Configuration and continuity | [`LS-C2`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-C2) | No additional guidance. | + +### Car optimized + +| Criteria | ID | Applicable Categories | Description | +|---|---|---|---| +| Parked Experiences | `PE-1` | Media, Navigation, POI, IOT, Weather | **Android Automotive OS only:** with the exception of providing setup, settings, and sign-in flows while parked, the app must not provide any functionality through its own activities. | +| Screen Animation | `SA-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | The app must not display animated elements on the screen, such as animated graphics or video. **Exception:** Canvas animations while the user is parked are allowed if they are relevant to the driving task. | +| Visual or Text Ads | `AD-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | The app must not display text-based advertising other than the advertiser's name or the product name. | +| Image Usage | `IU-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | App elements don't display any images on the screen. Exceptions include: - Apps can display a single static image for content context in the background of the consumption screen, such as album art or the advertiser's corporate logo. - Apps can display icons in the content navigation drawer. - Apps can display images and photographs to aid in driving decision-making. - Navigation apps can display an image for lane guidance or junction guidance. | +| Visual Information on Phone | `VI-1` | Media, Messaging (templated), Calling, Navigation, POI, IOT, Weather | **Android Auto only:** If the user must go to the phone screen---for example, to act on a permission request---then the app must display a message instructing the user to only look at their phone screen when it's safe to do so. For more information, see [Handle general errors](https://developer.android.com/training/cars/media/errors) for media, and [Handle user input](https://developer.android.com/training/cars/apps#handle-user-input) for navigation, point of interest, internet of things, and weather apps. | +| App Doesn't Crash | `AC-1` | Messaging (templated), Calling, Navigation, POI, IOT, Weather | Users must be able to complete tasks in the app using five screens or fewer. For more information, see [Template restrictions](https://developer.android.com/training/cars/apps#template-restrictions). | +| Scrolling Text | `ST-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | The app must not display automatically scrolling text. | +| Voice Commands | `VC-1` | Media, Navigation | The app must support Gemini and Google Assistant voice commands. For more information, see [Support voice actions](https://developer.android.com/training/cars/media/voice-actions) for media, and [Support navigation intents](https://developer.android.com/training/cars/apps/navigation#support-navigation-intents) for navigation. | +| Delayed Response | `DR-1` | Media, Messaging (templated), Calling, Navigation, POI, IOT, Weather | App-specific buttons must respond to user actions with no more than a two-second delay. | +| Delayed Response | `DR-2` | Media, Messaging (templated), Calling, Navigation, POI, IOT, Weather | The app must launch in no more than 10 seconds. | +| Delayed Response | `DR-3` | Media, Messaging (templated), Calling, Navigation, POI, IOT, Weather | The app must load content in no more than 10 seconds. | +| Contrast | `VD-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | The app must provide icons and colors that meet Android Auto's contrast requirements. For more information, see [Contrast](https://developers.google.com/cars/design/android-auto/design-system/color#contrast). | +| Contrast | `VD-2` | Media | The app must provide white icon sets that the system colorizes to provide automatic contrast compensation. | +| Contrast | `VD-3` | Media | The app must provide colors that the system can optimize for in-vehicle readability. | +| Theming | `TH-1` | Messaging (templated), Navigation, POI, IOT, Weather | Apps that add custom theming to Car App Library components with version 1.9 or higher must support light and dark theme. See [Contrast](https://developer.android.com/develop/ui/compose/styles/theming)for more information. | +| Grey Buttons | `GB-1` | Media | Interactive elements that are intentionally disabled must be nonfunctional. | +| Driver Distraction | `DD-1` | Navigation | The navigation audio channel can only be used by navigation apps and for navigation instructions. For more information, see [Voice guidance](https://developer.android.com/training/cars/apps/navigation#voice-guidance). | +| Driver Distraction | `DD-2` | Video, Games, Browsers | While driving, the app must not be launchable and the app's UI must not be visible. The app's audio must stop when the user starts driving and cannot be unpaused while driving. For more information, see [Meet driver distraction requirements](https://developer.android.com/training/cars/parked/automotive-os#driver-distraction). **Exception:** Video apps that support audio while driving can continue playback on supported devices. See [`DD-4`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-4) . | +| Payments | `PA-1` | Navigation, POI, IOT, Weather | The app must have simple flows if purchases are enabled, using shortcuts such as recent or favorite purchases. For more information, see [Purchase using an existing payment method](https://developers.google.com/cars/design/create-apps/sample-flows/purchase-with-existing-method). The app must not allow any of the following: - Setup of payment methods - Multiple items to be selected for purchase - Commitment to recurring payments, such as subscriptions | +| Notification Ads | `NA-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | The app must not present advertisements through notifications. | +| Irrelevant Notifications | `IN-1` | Media, Messaging, Calling, Navigation, POI, IOT, Weather | The app must display notifications only when relevant to the driver's needs. Examples: Good: Notifying the user that a new message has arrived. Bad: Notifying the user about a new album release. Bad: For weather apps, your app posts notifications that are not related to imminent alerts for the current location, route, or destination. | +| Media Autoplay | `MA-1` | Media | The app must not autoplay on startup or without user initiated action to select the app or app media. For more information, see [Implement play commands](https://developer.android.com/training/cars/media#playback-commands). | +| Messaging Functionality | `MF-1` | Messaging | The app must receive incoming messages. For more information, see [Extend messaging notifications to Android Auto](https://developer.android.com/training/cars/communication/notification-messaging). | +| Messaging Functionality | `MF-2` | Messaging | Messages must be properly grouped and displayed in the correct order. For more information, see [Extend messaging notifications to Android Auto](https://developer.android.com/training/cars/communication/notification-messaging). | +| Messaging Functionality | `MF-3` | Messaging | The user can reply to a message. For more information, see [Extend messaging notifications to Android Auto](https://developer.android.com/training/cars/communication/notification-messaging). | +| Messaging Functionality | `MF-4` | Messaging | The app must use short-form messaging app design patterns. Long-form messaging apps, such as apps for email, are not permitted. | +| Messaging Functionality | `MF-5` | Messaging | The app must implement a peer-to-peer messaging service and not notification services, such as those for weather, stocks, and sport scores. | +| Navigation Functionality | `NF-1` | Navigation | The app must provide turn-by-turn navigation directions. | +| Navigation Functionality | `NF-2` | Navigation | The app draws only map content on the surface of the navigation templates. Text-based turn-by-turn directions, lane guidance, and estimated arrival time must be displayed on the relevant components of the navigation template. Additional information relevant to the drive, speed limit, road obstructions, etc., can be drawn on the safe area of the map. | +| Navigation Functionality | `NF-3` | Navigation | When the app provides text-based turn-by-turn directions, it must also trigger navigation notifications. For more information, see [Turn-by-turn notifications](https://developer.android.com/training/cars/apps/navigation#turn-by-turn-notifications). | +| Navigation Functionality | `NF-4` | Navigation | When the navigation app provides text-based turn-by-turn directions, it must send next-turn information to the vehicle's cluster display. For more information, see [Navigation metadata](https://developer.android.com/training/cars/apps/navigation#navigation-metadata). | +| Navigation Functionality | `NF-5` | Navigation | The app must not provide turn-by-turn notifications, voice guidance, or cluster information when another navigation app is providing turn-by-turn instructions. For more information, see [Start, end, and stop navigation](https://developer.android.com/training/cars/apps/navigation#starting-ending-stopping-navigation). | +| Navigation Functionality | `NF-6` | Navigation | The app must handle navigation requests from other apps. For more information, see [Support navigation intents](https://developer.android.com/training/cars/apps/navigation#support-navigation-intents). | +| Navigation Functionality | `NF-7` | Navigation | The app must provide a "test drive" mode that simulates driving. For more information, see [Simulate navigation](https://developer.android.com/training/cars/apps/navigation#simulating-navigation). | +| Point of Interest Functionality | `PF-1` | POI | The app must provide meaningful functionality relevant to driving. | +| Internet of Things Functionality | `IT-1` | IOT | The IOT app may allow the following while driving: - **View the current state of devices**. For example: to view if a garage door is open or closed, a light bulb is on or off, a security system is armed or disarmed, or a washer is running or completed. - **Simple, one-touch features that control on and off functions**. For example: the ability to turn various devices on and off or open and close them, including turning lights on and off, turning a thermostat on and off, or opening and closing a garage door or curtain. This also includes turning a pre-programmed scene or routine on and off. - **Notify users about an event in the home or another location**. For example: receiving notifications for a routine or scene, a security alert, or a change in a door's open or closed status. The IOT app must not allow the following while driving: - **Tasks related to app setup of any kind**. For example: the ability to select devices, systems, or locations for use with the IOT app. - **Tasks related to creation, modification, or reordering**. For example: the ability to create, modify, or reorder a scene or routine, such as a sequence of events when departing or leaving a location, including opening and closing a garage door, or turning lights on and off. - **Tasks related to fine-grained device control**. For example: the ability to control certain functions, including adjusting thermostat temperatures or the level of lighting luminescence. | +| Weather Functionality | `WE-1` | Weather | App must include weather related content, which must be relevant to the user's current location or a user specified location. | +| Weather Functionality | `WE-2` | Weather | Weather information on map tiles must be readable and may not include complex legends. Apps may include a maximum of three legends. Apps with multiple legends may have a maximum of three colors, whereas single legend apps may have more than three colors. | +| Weather Functionality | `WE-3` | Weather | Forecast information must include easily readable icons and symbols. | +| Weather Functionality | `WE-4` | Weather | Customization of forecast intervals must not be made possible using templates. | +| Weather Functionality | `WE-5` | Weather | Weather apps must not show more than five unique weather map annotations in a given view (for example: Temperature markers, wind speed markers, humidity, radar overlay, lightning indicators, road conditions all in the same view). | +| Map Rendering | `MR-1` | Navigation, POI, Weather | By default, apps that [draw maps](https://developer.android.com/training/cars/apps#draw-maps) must draw a light-themed or dark-themed map when instructed to do so. For more information, see [Support dark theme](https://developer.android.com/training/cars/apps#dark-theme). Apps can let users choose to always display the app in either light or dark theme. | +| Media Controls | `MC-1` | Video | The app integrates with media session. Depending on the content, the app must support either the play/pause or stop playback commands. Additionally, the app must provide title and thumbnail metadata for every media item. See [Control and advertise playback using a MediaSession](https://developer.android.com/media/media3/session/control-playback). | +| Deep Links | `DL-1` | Video, Games, Browsers | **Android Automotive OS only:** The app supports deep links. See [Handling Android App Links](https://developer.android.com/training/app-links). | +| Media Functionality (templated) | `MFT-1` | Media (templated) | User must be able to navigate to the `MediaPlaybackTemplate` from media browsing views or support the minimized control panel for apps using the Car App Library Version 1.9 or higher. See [Build templated media apps](https://developer.android.com/training/cars/apps/media) for more details. | +| Media Functionality (templated) | `MFT-3` | Media (templated) | Handle `SHOW_MEDIA_PLAYBACK` intents: Templated Media apps must handle the `androidx.car.app.media.action.SHOW_MEDIA_PLAYBACK` intent action to direct the user to the `MediaPlaybackTemplate`. See [handle system media playback intents](https://developer.android.com/training/cars/apps/media#sys-media-playback-intents) for more details. | + +#### Associated large screen quality guidelines + +While these guidelines are relevant for all activities implemented by your app, +they are Tier 2 requirements only for [parked apps](https://developer.android.com/training/cars/parked). + +| Criteria | ID | Guidance for cars | +|---|---|---| +| Multi-window and multi-resume | [`LS-M2`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-M2) | When an activity is on a [distant display](https://developer.android.com/training/cars/testing/emulator/distant-display), it loses the top resumed activity position when the user interacts with an activity on the main display. | +| UX | [`LS-U1`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-U1) | Some cars have landscape screens with much greater aspect ratios than are common on other large screen devices. You can use the [Automotive Ultrawide](https://developer.android.com/training/cars/testing/emulator#bundled-profiles) hardware profile to test on such a display. | +| UX | [`LS-U2`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-U2) | Same as for LS-U1 | +| UX | [`LS-U3`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-U3) | To qualify as a car optimized app, your app must meet the LS-U3 requirement for touch target sizes. To qualify as car differentiated, it must meet the car [`UX-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#UX-1) requirement. | + +### Car differentiated + +| Criteria | ID | Applicable Categories | Description | +|---|---|---|---| +| Navigation Functionality | `NF-9` | Navigation | The app supports rendering on cluster displays, and only renders map tiles on these displays. See [Drawing to the cluster display](https://developer.android.com/training/cars/apps/navigation#drawing_to_the_cluster_display). **Warning:** Navigation apps that also support weather functionality must not include any weather-related information on the cluster display. | +| Templated Messaging Functionality | `TMF-1` | Messaging (templated) | The app displays conversations as described in [Display conversations](https://developer.android.com/training/cars/communication/templated-messaging#display-conversations): - The self property of each conversation is set accurately. - Users can send a message using the button provided for each conversation. - Conversations are refreshed to include new messages as they are sent and received. | +| Calling Functionality | `CF-1` | Calling | The app can be used to initiate, accept, decline, and leave calls. See [Build calling experiences for Android Auto](https://developer.android.com/training/cars/communication/calling#integrate-telecom). | +| Calling Functionality | `CF-2` | Calling | During a call, the app provides the following: - A call name and icon. - For meetings/group calls, the number of participants and the actively speaking participant. - The ability for a user to mute themselves. | +| Calling Functionality | `CF-3` | Calling | Calls initiated while Android Auto is not connected continue when a user connects to Android Auto and are displayed on Android Auto. | +| Deep Links | `DL-2` | Media, Navigation, POI, IOT, Weather | **Android Automotive OS only:** The app supports deep links: - For media apps, see [Add support for Android Automotive OS to your media app](https://developer.android.com/training/cars/media/automotive-os#support-deep-links). - For templated apps, see [Add support for Android Automotive OS to your templated app](https://developer.android.com/training/cars/apps/automotive-os#support-deep-links). | +| Driver distraction | `DD-4` | Video | The app supports audio while driving on capable devices. See [Support audio while driving](https://developer.android.com/training/cars/parked/video#audio-while-driving) for additional details. **Important:** The app must meet [`DD-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-2) on devices that don't support audio while driving. | +| App Rendering | `AR-2` | Video, Games, Browsers | The app renders into display cutouts to fully make use of the screen space available while maintaining the ability for users to continue interacting with all UI elements on the screen. See [Adapt to irregularly shaped displays](https://developer.android.com/training/cars/parked/automotive-os#irregular-displays) for more details. | +| Media Functionality (templated) | `MFT-2` | Media (templated) | App provides a differentiated experience by using templates for scenarios beyond basic browsing and playback. For example, by providing expanded playback controls or by allowing users to adjust driving relevant app settings. **Tip:** Try creating a car version of a feature that's unique to your mobile app. See [Build templated media apps](https://developer.android.com/training/cars/apps/media) for more details. | +| User Experience | `UX-1` | Video, Games, Browsers | Touch targets are at least 64dp. | +| User Experience | `UX-2` | Video, Games, Browsers | Touch targets are at least 24dp apart from each other and 24dp away from screen edges. | +| User Experience | `UX-3` | Video, Games, Browsers | Font sizes should be at least 24sp. | + +#### Associated large screen quality guidelines + +While these guidelines are relevant for all activities implemented by your app, +they are Tier 1 requirements only for [parked apps](https://developer.android.com/training/cars/parked). + +| Criteria | ID | Guidance for cars | +|---|---|---| +| UX | [`LS-U4`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-U4) | Although keyboards are supported by Android Automotive OS, they're less common input methods in cars in comparison to other large screen devices. However, some cars have rotary input devices that rely on the same APIs as tab navigation with a keyboard, so they also require LS-U4 to be met to function properly. | +| Keyboard, mouse, and trackpad | [`LS-I3`](https://developer.android.com/docs/quality-guidelines/large-screen-app-quality#LS-I3) | Same as for LS-U4 | + +## Frequently asked questions + +**What happens after I submit my app?** + +Apps for cars are subject to an additional manual review beyond normal Play +Store review processes. Your app is tested to ensure compliance against the +applicable criteria. + +**After submitting my app, how will I find out if my app does not meet all the +requirements for Android Auto or Android Automotive OS?** + +If your app does not meet the app quality requirements described on this page, +the Play Store team contacts you through the email address specified in the +[Google Play Console](https://play.google.com/console/) account associated with the app. + +> [!NOTE] +> **Note:** For information about how to publish your app in Google Play, see [Distribute to cars](https://developer.android.com/training/cars/distribute). + +**How do I manage policy violations and appeals?** + +You can learn more about +[managing policy violations and appeals](https://support.google.com/googleplay/android-developer/answer/9899142) +in the Google Play policy center. + +**My app targets more than just Android Auto or Android Automotive OS. If my app +does not meet the car requirements, will my new or updated app still appear on +Google Play for other devices?** + +No. When Google begins the approval process, your app undergoes an app quality +review. Any subsequent updates are not available for distribution until the app +is approved. If you need to make updates to your app for other devices, consider +creating a separate release from the updates to your car app. + +> [!CAUTION] +> **Caution:** Due to this restriction, you shouldn't use your production APK for Android Auto support prototyping. + +## Change notes + +### September 2026 + +- The Games parked app category has graduated from beta to general availability. Developers can now publish games to all track types on Google Play. See [Supported app categories](https://developer.android.com/training/cars#supported-app-categories) for more information. + +### August 2026 + +- Expected Performance + + - Categories affected: Media, Messaging (templated), Calling, Navigation, POI, IOT, or Weather + - New criterion: + [`EP-4`](https://developer.android.com/docs/quality-guidelines/car-app-quality#EP-4) + + + **Android Automotive OS only:** The app listing on + Google Play Store must include screenshots for the Android + Automotive OS form factor. See + [Provide Android + Automotive OS screenshots](https://developer.android.com/training/cars/distribute#opt-in-aaos). + + + Note: For apps in the parked app categories, screenshots are + optional. However, if your app includes features unique to cars, + or the app differs significantly from the experience on mobile or + tablet, it is recommended to include screenshots in your Google + Play Store listing. See + [Provide Android + Automotive OS screenshots](https://developer.android.com/training/cars/distribute#opt-in-aaos). + +### May 2026 + +- Voice Commands + + - Categories affected: Media, Media (templated), Navigation + - Updated criterion: [`VC-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#VC-1) + - Previous text: + + The app must support Google Assistant voice commands. For more + information, see [Support voice actions](https://developer.android.com/training/cars/media/voice-actions) for + media, and [Support navigation intents](https://developer.android.com/training/cars/apps/navigation#support-navigation-intents) for + navigation. + - New text: + + The app must support Gemini and Google Assistant voice commands. For + more information, see [Support voice actions](https://developer.android.com/training/cars/media/voice-actions) + for media, and [Support navigation intents](https://developer.android.com/training/cars/apps/navigation#support-navigation-intents) for + navigation. +- Theming + + - Categories affected: Media (templated), Messaging (templated), Navigation, POI, IOT, Weather + - New criterion: + [`TH-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#TH-1) + + Apps that add custom theming to Car App Library components with version + 1.9 or higher must support light and dark theme. See + [Contrast](https://developer.android.com/develop/ui/compose/styles/theming) for more information. +- Media Functionality (templated) + + - Categories affected: Media (templated) + - Updated criterion: [`MFT-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#MFT-1) + - Previous text: + + User must be able to navigate to the `MediaPlaybackTemplate` from media + browsing views. See [Build templated media + apps](https://developer.android.com/training/cars/apps/media) for more details. + - New text: + + User must be able to navigate to the `MediaPlaybackTemplate` from media + browsing views or support the minimized control panel for apps using the + Car App Library Version 1.9 or higher. See [Build templated media + apps](https://developer.android.com/training/cars/apps/media) for more details. + - Categories affected: Media (templated) + + - New criterion: + [`MFT-3`](https://developer.android.com/docs/quality-guidelines/car-app-quality#MFT-3) + + Handle `SHOW_MEDIA_PLAYBACK` intents: Templated Media apps must + handle the `androidx.car.app.media.action.SHOW_MEDIA_PLAYBACK` intent + action to direct the user to the `MediaPlaybackTemplate`. See [handle + system media playback intents](https://developer.android.com/training/cars/apps/media#sys-media-playback-intents) for more + details. + +### May 2025 + +- [App Categories](https://developer.android.com/docs/quality-guidelines/car-app-quality#app-categories): "Communication --- messaging notifications" replaces the "Messaging" category. "Communication --- templated messaging" and "Communication --- calling" have been added, including the related "Templated Messaging Functionality" and "Calling Functionality" criteria. +- Expected Performance + + - Categories affected: Games + - New criterion: + [`EP-3`](https://developer.android.com/docs/quality-guidelines/car-app-quality#EP-3) + + The app is responsive to input and does not freeze or stutter during + gameplay. +- Display Orientation + + - Categories affected: Games, Video, Browser + - Updated criterion: [`DO-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DO-1) + - Previous text: + + The app can be distributed to devices with fixed screen orientations. + See [Required Android Automotive OS features](https://developer.android.com/training/cars/parked#required-features). + - New text: + + Android Automotive OS only: The app can be distributed to devices + with fixed screen orientations. See [Required Android Automotive OS + features](https://developer.android.com/training/cars/parked/automotive-os#required-features). + - Categories affected: Games + + - New criterion: + [`DO-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DO-2) + + Android Auto only: The app is not significantly pillarboxed when + running on landscape displays. See [Add support for Android Auto to your + parked app](https://developer.android.com/training/cars/parked/auto). +- Deep Links + + - Categories affected: Video, Games, Browsers + - New criterion: + [`DL-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DL-1) + + Android Automotive OS only: The app supports deep links. See + [Handling Android App Links](https://developer.android.com/training/app-links). + - Categories affected: Media, Navigation, POI, IOT, Weather + + - New criterion: + [`DL-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DL-2) + + Android Automotive OS only: The app supports deep links: + - For media apps, see [Add support for Android Automotive OS to your + media app](https://developer.android.com/training/cars/media/automotive-os#support-deep-links). + - For templated apps, see [Add support for Android Automotive OS to + your templated app](https://developer.android.com/training/cars/apps/automotive-os#support-deep-links). +- CPU Architectures + + - Categories affected: Video, Games, Browsers + - Removed criterion: `CP-1` + + The app must support both x86_64 and ARM CPUs. +- Driver Distraction + + - Categories affected: Video + - New criterion: + [`DD-4`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-4) + + The app supports audio while driving on capable devices. See [Support + audio while driving](https://developer.android.com/training/cars/parked/video#audio-while-driving) for additional details. + + > [!IMPORTANT] + > **Important:** The app must meet [`DD-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-2) on devices that don't support audio while driving. + +### December 2024 + +- [App Categories](https://developer.android.com/docs/quality-guidelines/car-app-quality#app-categories): "Weather" has been added, including the introduction of the Weather Functionality criteria. +- Map Rendering + + - Categories affected: Navigation, POI, Weather + - New criterion: + [`MR-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#MR-1) + + By default, apps that [draw maps](https://developer.android.com/training/cars/apps#draw-maps) must draw a + light-themed or dark-themed map when instructed to do so. For more + information, see [Support dark theme](https://developer.android.com/training/cars#dark-theme). + + Apps can let users choose to always display the app in either light or + dark theme. +- Navigation Functionality + + - Removed criterion: `NF-8` + - Categories affected: Navigation Replacement of criterion NF-8 by the [`MR-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#MR-1) criterion. + - Updated criterion: [`NF-9`](https://developer.android.com/docs/quality-guidelines/car-app-quality#NF-9) + - Categories affected: Navigation + - Previous text: + + The app supports rendering on cluster displays, and only renders map + tiles on these displays. See + [Drawing to the cluster display](https://developer.android.com/training/cars/apps/navigation#drawing-cluster-display). + - New text: + + The app supports rendering on cluster displays, and only renders map + tiles on these displays. See + [Drawing to the cluster display](https://developer.android.com/training/cars/apps/navigation#drawing-cluster-display). + + Navigation apps that also support weather functionality must not include + any weather-related information on the cluster display. +- Screen Animation + + - Updated criterion: [`SA-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#SA-1) + - Categories affected: Media, Messaging, Navigation, Point of Interest, Weather + - Previous text: + + The app must not display animated elements on the screen, such as + animated graphics or video. + - New text: + + The app must not display animated elements on the screen, such as + animated graphics or video. + + Exception: Canvas animations while the user is parked are allowed if + they are relevant to the driving task. + +### May 2024 + +- Introduction of the [Car quality tiers](https://developer.android.com/docs/quality-guidelines/car-app-quality#car-quality-tiers) +- Driver Distraction + + - Categories affected: Browsers, Games, Video + - New criterion: + [`DD-3`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-3) + + The app must not be launchable or usable while driving and must not play + any audio. See + + [Ensure there are no distraction optimized + activities](https://developer.android.com/training/cars/parked#ensure_there_are_no_distraction-optimized_activities). + + > [!NOTE] + > **Note:** If your app plays audio, it must meet the [`DD-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DD-2) requirement. + +- App Rendering + + - Categories affected: All categories supported by Android Automotive OS + - New criterion: + [`AR-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#AR-1) + + In activities implemented by the app, interactive UI elements must not + be obstructed by system bars or display cutouts. See [Work with window + insets and display cutouts](https://developer.android.com/training/cars/parked/automotive-os#insets-and-cutouts). + - New criterion: + [`AR-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#AR-2) + + The app renders into display cutouts to fully make use of the screen + space available while ensuring interactive UI elements remain + accessible. See + [Adapt to irregularly shaped displays](https://developer.android.com/training/cars/parked/automotive-os#irregular-displays) for + more details. +- Media Controls + + - Categories affected: Video + - New criterion: + [`MC-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#MC-1) + + The app integrates with media session. Depending on the content, the app + must support either the play/pause or stop playback commands. + Additionally, the app must provide title and thumbnail metadata for + every media item. See [Control and advertise playback using a + MediaSession](https://developer.android.com/media/media3/session/control-playback). +- UX + + - Categories affected: Browsers, Games, Video + - New criterion: + [`UX-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#UX-1) + + Touch targets are at least 64dp. + - New criterion: + [`UX-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#UX-2) + + Touch targets are at least 24dp apart from each other and 24dp away from + screen edges. + - New criterion: + [`UX-3`](https://developer.android.com/docs/quality-guidelines/car-app-quality#UX-3) + + Font sizes should be at least 24sp. +- Permitted Experiences + + - Categories affected: Media, Navigation, POI, IOT + - New criterion: + [`PE-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#PE-1) + + Android Automotive OS only: with the exception of providing setup, + settings, and sign-in flows while parked, the app must not provide any + functionality through its own activities. +- Navigation functionality + + - Categories affected: Navigation + - New criterion: + [`NF-9`](https://developer.android.com/docs/quality-guidelines/car-app-quality#NF-9) + + The app supports rendering on cluster displays, and only renders map + tiles on these displays. See + [Drawing to the cluster display](https://developer.android.com/training/cars/apps/navigation#drawing-cluster-display). +- Delayed Response + + - Categories affected: Video + - Removal of criteria [`DR-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DR-1) , [`DR-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DR-2) , and [`DR-3`](https://developer.android.com/docs/quality-guidelines/car-app-quality#DR-3) for the video category + +### July 2023 + +- [App Categories](https://developer.android.com/docs/quality-guidelines/car-app-quality#app-categories): "Browsers" has been added +- Sensitive Data + + - Categories affected: Browsers + - New criterion: + [`SD-1`](https://developer.android.com/docs/quality-guidelines/car-app-quality#SD-1) + + Browsers must not save or allow access to passwords or payment + information unless the user can block access to passwords using a + profile lock. [Learn how](https://developer.android.com/training/cars/parked/browser#block-sensitive). + - New criterion: + [`SD-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#SD-2) + + Before syncing data to the car, browsers that synchronize passwords or + payments data must do the following steps: + 1. Prompt the user to authenticate. + 2. Notify the user on the car screen their data will be synchronized to the car. + +### April 2023 + +- [App Categories](https://developer.android.com/docs/quality-guidelines/car-app-quality#app-categories): "Games" has been added +- Irrelevant Notifications + + - Categories affected: Games, Video + - New criterion: + [`IN-2`](https://developer.android.com/docs/quality-guidelines/car-app-quality#IN-2) + + The app must not provide any heads up notifications. +- Settings Flow + + - Categories affected: Video + - Removed criterion: `SF-1` + + The app must proceed to the home page after sign-in or attempted + sign-in. +- Automotive Functionality + + - Categories affected: Video + - Removed criterion: `AF-1` + + The app must not contain any distraction-optimized activities. + +### March 2023 + +- Grammar and formatting changes. + +### December 2022 + +- Internet of Things Functionality + + - Categories affected: Internet of Things + - New criteria: + + The IOT app may allow the following while driving: + - View the current state of devices. For example: to view if a garage door is open or closed, a light bulb is on or off, a security system is armed or disarmed, or a washer is running or completed. + - Simple, one-touch features that control on and off functions. For example: the ability to turn various devices on and off or open and close them, including turning lights on and off, turning a thermostat on and off, or opening and closing a garage door or curtain. This also includes turning a pre-programmed scene or routine on and off. + - Notify users about an event in the home or another location. For example: receiving notifications for a routine or scene, a security alert, or a change in a door's open or closed status. + + The IOT app must not allow the following while driving: + - Tasks related to app setup of any kind. For example: the ability to select devices, systems, or locations for use with the IOT app. + - Tasks related to creation, modification, or reordering. For example: the ability to create, modify, or reorder a scene or routine, such as a sequence of events when departing or leaving a location, including opening and closing a garage door, or turning lights on and off. + - Tasks related to fine-grained device control. For example: the ability to control certain functions, including adjusting thermostat temperatures or the level of lighting luminescence. + +### May 2022 + +- [App Categories](https://developer.android.com/docs/quality-guidelines/car-app-quality#app-categories): "Parking \& Charging" has been replaced with "Point of Interest" +- Screen Animation + + - Categories affected: Media, Messaging, Navigation, and Point of Interest + - Previous text: + + The app does not display animated elements on the screen such as + animated graphics, video, or progress bars. + - New text: + + The app does not display animated elements on the screen such as + animated graphics or video. +- Image Usage + + - Categories affected: Media, Messaging, Navigation, and Point of Interest + - Previous text: + + App elements don't display any images on the screen. Exceptions + include: + - Apps may display a single static image for content context in the background of the consumption screen, such as album art or the advertiser's corporate logo. + - Apps may display icons in the content navigation drawer. + - Navigation, parking, and charging apps may display images and photographs to aid in driving decision-making. + - Navigation apps may display an image for lane guidance or junction guidance. + - New text: + + App elements don't display any images on the screen. Exceptions + include: + - Apps may display a single static image for content context in the background of the consumption screen, such as album art or the advertiser's corporate logo. + - Apps may display icons in the content navigation drawer. + - Apps may display images and photographs to aid in driving decision-making. + - Navigation apps may display an image for lane guidance or junction guidance. +- Since cars are large screen devices, apps should fill the categories + + - Categories affected: Media, Messaging, Navigation, Point of Interest, and Video + - Previous text: + + The app does not include games or other features outside the app types + intended for cars. For more information, see [Supported app + categories](https://developer.android.com/training/cars#supported-app-categories). + - New text: + + The app does not include features outside the app types intended for + cars. For more information, see + [Supported app categories](https://developer.android.com/training/cars#supported-app-categories). +- Visual Information on Phone + + - Categories affected: Media, Navigation, and Point of Interest + - Previous text: + + Android Auto only: If the user must go to the phone screen --- for + example, to act on a permission request --- then the app must display a + message instructing the user to only look at their phone screen when + it's safe to do so. For more information, see + [Handle general errors](https://developer.android.com/training/cars/media#errors) for media, and [Handle + user input](https://developer.android.com/training/cars/apps#handle-user-input) for navigation, parking, and + charging. + - New text: + + Android Auto only: If the user must go to the phone screen --- for + example, to act on a permission request --- then the app must display a + message instructing the user to only look at their phone screen when + it's safe to do so. For more information, see + [Handle general errors](https://developer.android.com/training/cars/media#errors) for media, and [Handle + user input](https://developer.android.com/training/cars/apps#handle-user-input) for navigation and point of + interest apps. +- Navigation Functionality + + - Categories affected: Navigation + - Previous text: + + The app draws only map content on the surface of the navigation + templates. Text-based turn-by-turn directions, lane guidance, and + estimated arrival time must be displayed on the relevant components of + the Navigation template. Additional information relevant to the drive --- + for example, speed limit and road obstructions --- can be drawn on the + right side of the map. + - New text: + + The app draws only map content on the surface of the navigation + templates. Text-based turn-by-turn directions, lane guidance, and + estimated arrival time must be displayed on the relevant components of + the Navigation template. Additional information relevant to the drive --- + speed limit and road obstructions --- can be drawn on the safe area of the + map. +- Point of Interest Functionality + + - Categories affected: Point of Interest + - Previous text: + + The app must provide meaningful functionality in its category relevant + to driving. + - New text: + + The app must provide meaningful functionality relevant to driving. + - Removed criteria: + + If a parking app is displaying a list of locations next to a map --- for + example, the Place List Map template --- then the locations in the list + can only be parking spots. + + Similarly, for a charging app, the locations must be charging stations. + +### April 2022 + +- Display Orientation + + - Categories affected: Video + - New criteria: + + UIs drawn by the app support both landscape and portrait screens. +- Driver Distraction + + - Categories affected: Video + - New criteria: + + While driving, the video app must not be launchable and the video app's + screen must not be visible. The video app's audio must stop when the + user starts driving. +- Settings Flow + + - Categories affected: Video + - New criteria: + + App must proceed to the home page after (attempted) sign-in. +- Automotive Functionality + + - Categories affected: Video + - New criteria: + + App does not contain driver optimized activities. +- CPU Architectures + + - Categories affected: Video + - New criteria: + + App supports both x86_64 and ARM CPUs. +- App Navigability + + - Categories affected: Video + - New criteria: + + App allows users to navigate through the app without encountering any + dead ends. + +### November 2021 + +- Visual Information on Phone + + - Categories affected: Media, Navigation, and Parking \& Charging + - Previous text: + + While the app is interacting with the car screen and the car is not + parked, the app does not activate the phone screen to present any form + of visual information such as notifications, toasts, video, images, + advertising, or similar. For more information, see [Build a navigation + app](https://developer.android.com/training/cars/apps/navigation#handle-user-input) for navigation, parking, and charging. + Similarly, while the app is running Android Auto UI on the phone screen, + the app does not present any visual information on the phone screen that + is unrelated to Android Auto. + + If the user must go to the phone screen --- for example, to act on a + permission request --- then the app must display a message instructing the + user to only look at their phone screen when it's safe to do so. + - New text: + + Android Auto only: If the user must go to the phone screen --- for + example, to act on a permission request --- then the app must display a + message instructing the user to only look at their phone screen when + it's safe to do so. For more information, see + [Handle general errors](https://developer.android.com/training/cars/media#errors) for media, and [Handle + user input](https://developer.android.com/training/cars/apps#handle-user-input) for navigation, parking, and + charging. +- Payments + + - Categories affected: Navigation, Parking \& Charging + - Previous text: + + The app must have simple flows if purchases are enabled. + - New text: + + The app must have simple flows if purchases are enabled, using shortcuts + such as recent or favorite purchases. For more information, see + [Purchase using existing payment method](https://developers.google.com/cars/design/create-apps/sample-flows/purchase-with-existing-method). + + The app must not allow any of the following: + - Setup of payment methods + - Multiple items to be selected for purchase + - The user to commit to recurring payments --- for example, subscriptions. \ No newline at end of file diff --git a/device-ai/litert/litert-runtime/SKILL.md b/device-ai/litert/litert-runtime/SKILL.md new file mode 100644 index 0000000..3148fc1 --- /dev/null +++ b/device-ai/litert/litert-runtime/SKILL.md @@ -0,0 +1,358 @@ +--- +name: litert-runtime +description: Configures and implements LiteRT (.tflite) on-device inference on Android + in Kotlin using the CompiledModel API on CPU and GPU. Use this skill for any Android + LiteRT or .tflite task, including Gradle setup, image classification or vision/audio/embedding + helpers, TensorBuffer reuse, Bitmap preprocessing, AndroidViewModel GPU-to-CPU fallback + with warm-up on a single-thread dispatcher, migrating from legacy org.tensorflow.lite.Interpreter + or GpuDelegate, and latency benchmarking. Don't use for running generative text + LLMs with .litertlm files or for Python model conversion. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-10-13' + keywords: + - LiteRT + - CompiledModel + - tflite + - Android app + - GPU + - TensorBuffer +--- + +This skill provides step-by-step guidance for building an Android app that runs +a `.tflite` model with the [LiteRT CompiledModel API](https://ai.google.dev/edge/litert/android) +(`com.google.ai.edge.litert:litert` 2.x; [source repository](https://github.com/google-ai-edge/litert/blob/main/README.md)) on the CPU or +the GPU. Don't use the deprecated `org.tensorflow.lite.Interpreter` API. For +open text language models using `.litertlm` files, suggest the `litert-lm` +skill. + +## Prerequisites + +- A Kotlin Android project (`minSdk` must be `24` or higher, as declared by the LiteRT 2.3.0 AARs). +- The dependencies in the app-level `build.gradle.kts` file from Google Maven: `implementation("com.google.ai.edge.litert:litert:2.3.0")` and `implementation("com.google.ai.edge.litert:litert-gpu:2.3.0")`. From 2.3.0 the GPU accelerator is the second artifact: with `litert` alone the app builds, and `CompiledModel.create` with `Accelerator.GPU` throws `LiteRtException` because the APK has no GPU accelerator library (Logcat logs `GPU accelerator + could not be loaded and registered`). No manifest entry is needed: `litert-api`, which `litert` depends on, declares the OpenCL driver libraries (`uses-native-library`) in its own manifest. +- A verified `.tflite` model and its input contract (input dimensions, mean, standard deviation, channel order, and tensor layout). For models with a verified recipe, inspect the [LiteRT model recipes](https://github.com/google-ai-edge/litert-samples/blob/main/models/README.md). + +## Critical mandates + +- **Single-thread confinement** : One single-thread executor or confined coroutine dispatcher must own the `CompiledModel` instance. Creation, inference, and closing must execute on that same background thread, never on the main thread. +- **Buffer lifecycle** : Allocate `TensorBuffer` instances once using `createInputBuffers` and `createOutputBuffers`, reuse them across inferences, and close all input and output buffers before closing the `CompiledModel`. +- **Synchronous timing** : `model.run` enqueues work on the GPU and returns before GPU execution completes. Always measure `model.run` and output buffer readback (`outputs[0].readFloat`) together. +- **Warm-up and exception safety** : Execute one warm-up inference inside the helper constructor's `init` block so first-run GPU shader compilation doesn't block the first user interaction, and call `close` in `catch` before rethrowing so neither `CompiledModel` nor `TensorBuffer` handles leak if buffer allocation or warm-up throws. + +## Detailed steps + +### Step 1: Configure Gradle and place the model file + +Search the project for `libs.versions.toml`, `gradle.properties`, +`app/build.gradle.kts`, and any existing `org.tensorflow.lite` imports before +editing build files. Configure `minSdk` and add the LiteRT runtime and GPU +accelerator dependencies in `app/build.gradle.kts`: + + android { + defaultConfig { + minSdk = 24 + } + } + + dependencies { + implementation("com.google.ai.edge.litert:litert:2.3.0") + implementation("com.google.ai.edge.litert:litert-gpu:2.3.0") + } + +Place bundled models at `app/src/main/assets/model.tflite` (AGP stores `.tflite` +files uncompressed by default). Deliver a model that exceeds the base APK size +limit through [Play for On-Device AI](references/android/google/play/on-device-ai.md) as an AI pack, or download it into +`context.filesDir` (`bundletool --local-testing` installs AI packs locally +without Google Play). Read an install-time AI pack through `AssetManager`. Load +a fast-follow or on-demand AI pack or a downloaded file from its on-disk +`filePath` using `CompiledModel.create(filePath, +CompiledModel.Options(accelerator), environment)` in place of the asset call in +Step 2. Obtain the pack's directory from the AI Delivery library +(`com.google.android.play:ai-delivery`) using +`AiPackManagerFactory.getInstance(context).getPackLocation(name)?.assetsPath()`, +which returns `null` until the pack has finished downloading. + +With 2.3.0 nothing is added to `gradle.properties`: `litert` 2.3.0 declares the +namespace `com.google.ai.edge.litert.impl` and its dependency `litert-api` 2.3.0 +declares `com.google.ai.edge.litert`, so AGP 9.x builds the project. With 2.2.0 +or 2.1.6 both libraries declare `com.google.ai.edge.litert`, and AGP 9.x stops +at `processDebugMainManifest` (AGP 8.x reports it as a warning and builds); for +those two versions add `android.uniquePackageNames=false` to +`gradle.properties`, and remove it when moving to 2.3.0 because it also hides +the same clash between any other two libraries. Excluding `litert-api` instead +doesn't work: `CompiledModel` and `Accelerator` are in `litert-api`, and the +build then fails at `compileDebugKotlin`. + +### Step 2: Implement the inference helper class + +Isolate `CompiledModel` and its `TensorBuffer` instances in a dedicated +`AutoCloseable` helper class free of Android UI types: + + import android.content.Context + import com.google.ai.edge.litert.Accelerator + import com.google.ai.edge.litert.CompiledModel + import com.google.ai.edge.litert.Environment + import com.google.ai.edge.litert.TensorBuffer + + const val INPUT_SIZE = 224 * 224 * 3 + private val environment by lazy { Environment.create() } + + class Classifier(context: Context, accelerator: Accelerator) : AutoCloseable { + private val model = CompiledModel.create( + context.assets, + "model.tflite", + CompiledModel.Options(accelerator), + environment + ) + private val inputs = mutableListOf() + private val outputs = mutableListOf() + + init { + try { + inputs += model.createInputBuffers() + outputs += model.createOutputBuffers() + infer(FloatArray(INPUT_SIZE)) + } catch (e: Exception) { + close() + throw e + } + } + + fun infer(input: FloatArray): FloatArray { + inputs[0].writeFloat(input) + model.run(inputs, outputs) + return outputs[0].readFloat() + } + + override fun close() { + (inputs + outputs).forEach { it.close() } + model.close() + } + } + +`Accelerator.CPU` requires nothing from the device. `Accelerator.GPU` compiles +the graph for the GPU; when the GPU can't execute the model, the constructor +throws `LiteRtException` (from `create` for an operator the GPU doesn't support, +and on the Android emulator from `create` or from buffer allocation). A missing +asset or a file that isn't a valid model also throws `LiteRtException` from +`create`. + +The buffers are created once with the model, reused for every inference, and +closed before the model. One `Environment` serves every model in the process and +stays open, so LiteRT loads the GPU accelerator library once rather than on +every `create` call. The constructor ends with one warm-up inference (the first +GPU run includes shader compilation) and closes the model and its buffers if any +step in `init` throws. `INPUT_SIZE` is the model's input element count: +`writeFloat` throws on a longer array and writes a shorter one without an error, +so a wrong size produces an incorrect result. For `int8` or `uint8` tensors, use +`writeInt8` and `readInt8` on `TensorBuffer` instead of `writeFloat` and +`readFloat`. + +### Step 3: Wire the ViewModel and Compose screen + +Drive the helper from a single-thread executor inside an `AndroidViewModel`, +fall back to `Accelerator.CPU` when GPU compilation fails, and expose immutable +state to the Compose UI: + + import android.app.Application + import androidx.lifecycle.AndroidViewModel + import com.google.ai.edge.litert.Accelerator + import com.google.ai.edge.litert.LiteRtException + import java.util.concurrent.Executors + import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.SupervisorJob + import kotlinx.coroutines.asCoroutineDispatcher + import kotlinx.coroutines.cancel + import kotlinx.coroutines.flow.MutableStateFlow + import kotlinx.coroutines.flow.StateFlow + import kotlinx.coroutines.flow.asStateFlow + import kotlinx.coroutines.flow.update + import kotlinx.coroutines.launch + + data class UiState( + val ready: Boolean = false, + val accelerator: Accelerator = Accelerator.CPU, + val result: List? = null, + val error: String? = null + ) + + class MainViewModel(app: Application) : AndroidViewModel(app) { + private val executor = Executors.newSingleThreadExecutor() + private val scope = CoroutineScope( + SupervisorJob() + executor.asCoroutineDispatcher() + ) + private var classifier: Classifier? = null + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun load(preferred: Accelerator = Accelerator.GPU) { + scope.launch { + if (classifier != null) return@launch + val appContext = getApplication() + val selected = try { + classifier = Classifier(appContext, preferred) + preferred + } catch (e: LiteRtException) { + if (preferred != Accelerator.CPU) { + try { + classifier = Classifier(appContext, Accelerator.CPU) + Accelerator.CPU + } catch (cpuError: LiteRtException) { + _uiState.value = UiState(error = cpuError.message) + return@launch + } + } else { + _uiState.value = UiState(error = e.message) + return@launch + } + } + _uiState.value = UiState(ready = true, accelerator = selected) + } + } + + fun classify(input: FloatArray) { + scope.launch { + try { + classifier?.infer(input)?.let { result -> + _uiState.update { + it.copy(result = result.toList(), error = null) + } + } + } catch (e: LiteRtException) { + _uiState.update { it.copy(error = e.message) } + } + } + } + + override fun onCleared() { + scope.launch { classifier?.close() }.invokeOnCompletion { + scope.cancel() + } + executor.shutdown() + } + } + +One single-thread executor owns the model: creation, inference, and closing +execute only on it, never on the main thread. The `ViewModel` keeps its own +scope because `viewModelScope` is cancelled before `onCleared` runs, so a close +launched there wouldn't run; `onCleared` cancels `scope` once `close` completes. +`load` is a no-op when a model is already loaded across configuration changes; a +failed `load` leaves `classifier` `null`. `classify` reports any +`LiteRtException` (in package `com.google.ai.edge.litert`) in `error` and keeps +the model. + +The following Compose screen takes the picture (`bitmap`) from the photo picker +or CameraX and runs `preprocess` in the click handler (a few milliseconds for +224x224; move `preprocess` onto the executor for images exceeding 224x224). +`viewModel` and `collectAsStateWithLifecycle` come from +`androidx.lifecycle:lifecycle-viewmodel-compose` and +`androidx.lifecycle:lifecycle-runtime-compose` (2.10.0): + + import android.graphics.Bitmap + import androidx.compose.foundation.layout.Column + import androidx.compose.material3.Button + import androidx.compose.material3.Text + import androidx.compose.runtime.Composable + import androidx.compose.runtime.LaunchedEffect + import androidx.compose.runtime.getValue + import androidx.lifecycle.compose.collectAsStateWithLifecycle + import androidx.lifecycle.viewmodel.compose.viewModel + import com.google.ai.edge.litert.Accelerator + + @Composable + fun MainScreen( + bitmap: Bitmap, + viewModel: MainViewModel = viewModel() + ) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { viewModel.load(Accelerator.GPU) } + Column { + state.error?.let { Text(it) } + Button( + onClick = { viewModel.classify(preprocess(bitmap)) }, + enabled = state.ready + ) { + Text("Run (${state.accelerator})") + } + state.result?.let { scores -> + Text("Top class: ${scores.indices.maxBy { i -> scores[i] }}") + } + } + } + +### Step 4: Preprocess inputs to match the model contract + +Convert the `Bitmap` into the model's input with `preprocess`. Match the +model's exact input dimensions, normalization mean and standard deviation, +channel order, and memory layout (this example uses NHWC, RGB, scaled to +`-1.0..1.0`). Mismatched `mean` or `std` values produce corrupted predictions +that look like a broken model: + + import android.graphics.Bitmap + + fun preprocess( + bitmap: Bitmap, + size: Int = 224, + mean: Float = 127.5f, + std: Float = 127.5f + ): FloatArray { + val scaled = checkNotNull( + Bitmap.createScaledBitmap(bitmap, size, size, true) + .copy(Bitmap.Config.ARGB_8888, false) + ) + val pixels = IntArray(size * size).also { + scaled.getPixels(it, 0, size, 0, 0, size, size) + } + val out = FloatArray(size * size * 3) + pixels.forEachIndexed { i, p -> + out[i * 3] = ((p shr 16 and 0xFF) - mean) / std + out[i * 3 + 1] = ((p shr 8 and 0xFF) - mean) / std + out[i * 3 + 2] = ((p and 0xFF) - mean) / std + } + return out + } + +The scaled bitmap is copied to `Bitmap.Config.ARGB_8888` because `ImageDecoder` +returns `Bitmap.Config.HARDWARE` bitmaps by default on Android 9 and higher +(including from a photo picker `Uri`), scaling preserves that config, and +`getPixels` can't read `HARDWARE` bitmaps; the copy is of the 224x224 scaled +bitmap rather than the full image. Scaling doesn't preserve the aspect ratio; +crop first if the model requires center cropping. An audio or embedding model +replaces `preprocess` with its own input encoding and keeps the inference class. + +### Step 5: Verify output on a physical device + +Run the app on a physical device that runs Android from Android Studio. Compare +the app's output with the model's reference output for one fixed input on +`Accelerator.CPU` first and then on `Accelerator.GPU`; when verifying model +outputs on a physical device, you must follow [device verification](references/verify.md). + +## Migrating from legacy TFLite Interpreter + +When upgrading an existing project from `org.tensorflow.lite.Interpreter`, +replace legacy constructs with their `CompiledModel` equivalents: + +- **Gradle dependencies** : Remove all `org.tensorflow:tensorflow-lite*` artifacts (`org.tensorflow:tensorflow-lite`, `org.tensorflow:tensorflow-lite-gpu`, `org.tensorflow:tensorflow-lite-support`, and `org.tensorflow:tensorflow-lite-metadata`). Replace them with `com.google.ai.edge.litert:litert:2.3.0` and `com.google.ai.edge.litert:litert-gpu:2.3.0`. If you still need Support or Metadata utilities (such as `ImageProcessor`, `TensorImage`, or `MetadataExtractor`), use `com.google.ai.edge.litert:litert-support:2.3.0` (or `litert-support-api:2.3.0` alongside `litert:2.3.0`) and `com.google.ai.edge.litert:litert-metadata:2.3.0`, and rename package imports from `org.tensorflow.lite.support.*` to `com.google.ai.edge.litert.support.*` per the [LiteRT Support migration guide](https://github.com/google-ai-edge/LiteRT/blob/main/litert/support_lib/MIGRATION_GUIDE.md). +- **Model loading** : Replace `FileUtil.loadMappedFile` and `Interpreter` with `CompiledModel.create(context.assets, name, options, environment)`. +- **GPU delegation** : Replace `GpuDelegate` on `Interpreter.Options` with `CompiledModel.Options(Accelerator.GPU)` and the `com.google.ai.edge.litert:litert-gpu:2.3.0` dependency. +- **Execution** : Replace `interpreter.run` on `ByteBuffer` inputs with `inputs[0].writeFloat(input)`, `model.run(inputs, outputs)`, and `outputs[0].readFloat`. +- **Label loading** : Replace `FileUtil.loadLabels` with `bufferedReader` and `readLines` on `context.assets.open(name)` (or `com.google.ai.edge.litert.support.common.FileUtil.loadLabels` if using `litert-support:2.3.0`). + +## Antipatterns + +- **Never** create, run, or close `CompiledModel` on the Android main thread. +- **Never** allocate new `TensorBuffer` instances on every inference call; failing to close per-frame `TensorBuffer` objects leaks C++ heap memory. +- **Never** benchmark `model.run` without including output buffer readback (`outputs[0].readFloat`). +- **Never** mix `org.tensorflow:tensorflow-lite*` artifacts (such as `org.tensorflow:tensorflow-lite-support`) with `com.google.ai.edge.litert:*` artifacts; both ship the `org.tensorflow.lite.*` classes and cause duplicate class build failures. +- **Never** use `Accelerator.GPU` on LiteRT 2.3.0 without adding the `com.google.ai.edge.litert:litert-gpu:2.3.0` dependency alongside `com.google.ai.edge.litert:litert:2.3.0`. + +## Checklist + +- \[ \] Is `minSdk` set to `24` or higher and are both `com.google.ai.edge.litert:litert:2.3.0` and `com.google.ai.edge.litert:litert-gpu:2.3.0` added in `app/build.gradle.kts`? +- \[ \] Are all legacy `org.tensorflow:tensorflow-lite*` dependencies and `org.tensorflow.lite.*` imports removed? +- \[ \] Do `CompiledModel.create`, `model.run`, and `close` all execute on the same single-thread background dispatcher? +- \[ \] Are `TensorBuffer` instances allocated once, reused across calls, and closed before `model.close`, including when `init` or warm-up throws? +- \[ \] Does `load` execute one warm-up inference and fall back to `Accelerator.CPU` if `Accelerator.GPU` throws `LiteRtException`? +- \[ \] Does the project compile with `./gradlew assembleDebug`? diff --git a/device-ai/litert/litert-runtime/references/android/google/play/on-device-ai.md b/device-ai/litert/litert-runtime/references/android/google/play/on-device-ai.md new file mode 100644 index 0000000..f781d3b --- /dev/null +++ b/device-ai/litert/litert-runtime/references/android/google/play/on-device-ai.md @@ -0,0 +1,818 @@ +## Introduction + +Play for On-device AI brings the benefits of +[Android App Bundles](https://developer.android.com/guide/app-bundle) and Google Play delivery to custom ML +model distribution so you can improve model performance with less device +ecosystem complexity at no additional cost. It lets you publish a single +artifact to Play containing your code, assets, and ML models and to choose +from a number of delivery modes and targeting options. + +### Benefits + +- Upload a single publishing artifact to Google Play and delegate hosting, delivery, updates, and targeting to Play at no additional cost. +- Deliver your ML models at install-time, fast-follow, or on-demand. + - Install-time delivery can guarantee that a very large model is present when your app is opened. Your model will be installed as an APK. + - Fast-follow delivery occurs automatically in the background after your app has been installed. Users may open your app before your model has been fully downloaded. Your model will be downloaded to your app's internal storage space. + - On-demand delivery lets you request the model at runtime, which is useful if the model is only required for certain user-flows. Your model will be downloaded to your app's internal storage space. +- Deliver variants of your ML models that are targeted to specific devices based on device model, system properties, or RAM. +- Keep app updates small and optimized with Play's automatic patching, which means only the differences in files need to be downloaded. + +### Considerations + +- By using Play for On-device AI you agree to the terms in the [Google Play Developer Distribution Agreement](https://play.google.com/about/developer-distribution-agreement.html) and the [Play Core Software Development Kit Terms of Service](https://developer.android.com/guide/playcore#license). +- Models downloaded by Play for On-device AI should only be used by your apps. Models shouldn't be offered to other apps. +- Individual AI packs can be up to 1.5GB, based on their compressed download sizes. The maximum cumulative app size of any version of your app generated from your app bundle is 4GB. +- Apps over 1GB in size must set min SDK Level to 21 or higher. + +### How to use Play for On-device AI + +Play for On-device AI uses AI packs. You package custom models that are ready +for distribution in AI packs in your app bundle. You can choose whether the AI +pack should be delivered at install-time, fast-follow, or on-demand. + +By packaging AI packs with your app bundle, you can use all of Play's existing +testing and release tools, such as test tracks and staged rollouts to manage +your app's distribution with your custom models. + +AI packs are updated together with the app binary. If your new app release +doesn't make changes to an AI pack, then Play's automatic patching process will +ensure the user doesn't have to re-download it. Play will just download what's +changed when it updates the app. + +AI packs only contain models. Java/Kotlin and native libraries are not allowed. +If you need to ship libraries or code to run your ML model, move it into the +base module or a [feature module](https://developer.android.com/guide/playcore/feature-delivery). You can +configure your feature module so that it has the same download and targeting +settings as the AI pack. + +> [!NOTE] +> **Note:** AI packs don't contain Java/Kotlin or native libraries. You can ship libraries or code to run your ML models in [feature modules](https://developer.android.com/guide/playcore/feature-delivery). + +### Use LiteRT and MediaPipe with AI packs + +You can use LiteRT and MediaPipe with AI packs. Package your model in an AI pack +and then access it using the instructions for +[install-time packs](https://developer.android.com/google/play/on-device-ai#install-time-delivery) or for +[fast-follow and on-demand packs](https://developer.android.com/google/play/on-device-ai#ff-od-delivery). + +Further reading: + +- [Getting started with LiteRT](https://ai.google.dev/edge/litert/android) + - The [sample app](https://developer.android.com/google/play/on-device-ai#example-app) shows how you can package a LiteRT model in an AI pack and load it at runtime. + - There are many [pretrained LiteRT models](https://ai.google.dev/edge/litert/models/trained) that you can use in AI packs to get started. +- [Getting started with MediaPipe](https://ai.google.dev/edge/mediapipe/framework/getting_started/android) + - For fast-follow and on-demand packs, you can use [AssetCache.java](https://github.com/google-ai-edge/mediapipe/blob/master/mediapipe/java/com/google/mediapipe/framework/AssetCache.java) to load your assets (eg. `.binarypb` files) by their file paths. + - For install-time packs, you can use [AndroidAssetUtil.java](https://github.com/google-ai-edge/mediapipe/blob/master/mediapipe/java/com/google/mediapipe/framework/AndroidAssetUtil.java#L56). + +## Get started with AI packs + +At a high level, here's how you can start using Play for On-device AI: + +1. Package your models into AI packs into your Android App Bundle and specify how the AI packs should be delivered. +2. \[Optional\] If you want to deliver different models to different devices, you can [configure device targeting](https://developer.android.com/google/play/on-device-ai#device-targeting-configuration) for your AI packs. For example, you could deliver AI pack A to a specific device model, AI pack B to devices with at least 6GB of RAM, and all other devices could receive no model. +3. \[Optional\] If you're using on-demand or fast-follow delivery, integrate the Play AI Delivery Library into your app to download your AI packs as needed. +4. Test and release your app bundle to Google Play. + +### Check Android Gradle Plugin version + +To use AI packs, ensure that your Android Gradle Plugin (AGP) version is at +least 8.8. This version is packaged with Android Studio Ladybug 2. + +### Extract your model into an AI pack + +Android Studio is not required for the following steps. + +1. In the top-level directory of your project, create a directory for the AI pack. This directory name is used as the AI pack name. AI pack names must start with a letter and can only contain letters, numbers, and underscores. +2. In the AI pack directory, create a `build.gradle` file and add the following + code. Make sure to specify the name of the AI pack and only one delivery + type: + + // In the AI pack's build.gradle file: + plugins { + id 'com.android.ai-pack' + } + + aiPack { + packName = "ai-pack-name" // Directory name for the AI pack + dynamicDelivery { + deliveryType = "[ install-time | fast-follow | on-demand ]" + } + } + +3. In the project's app `build.gradle` file, add the name of every AI pack in + your project as shown below: + + // In the app build.gradle file: + android { + ... + assetPacks = [":ai-pack-name", ":ai-pack2-name"] + } + +4. In the project's `settings.gradle` file, include all AI packs in your + project as shown below: + + // In the settings.gradle file: + include ':app' + include ':ai-pack-name' + include ':ai-pack2-name' + +5. Inside your AI pack, create a `src/main/assets/` directory. + +6. Place your models in the `src/main/assets` directory. You can create + subdirectories in here as well. The directory structure for your app should + now look like the following: + + - `build.gradle` + - `settings.gradle` + - `app/` + - `ai-pack-name/build.gradle` + - `ai-pack-name/src/main/assets/your-model-directories` +7. Add code to load and run your models. How you do this will depend on the + delivery mode of your AI packs. See instructions for + [install-time](https://developer.android.com/google/play/on-device-ai#install-time-delivery) and + [fast-follow/on-demand](https://developer.android.com/google/play/on-device-ai#ff-od-delivery) below. + +8. \[Optional\] [Configure device targeting](https://developer.android.com/google/play/on-device-ai#device-targeting-configuration) + to deliver different models to different devices. + +9. [Build the Android App Bundle with Gradle](https://developer.android.com/studio/build/building-cmdline#build_bundle). + In the generated app bundle, the root-level directory now includes the + following: + + - `ai-pack-name/manifest/AndroidManifest.xml`: Configures the AI pack's identifier and delivery mode + - `ai-pack-name/assets/your-model-directories`: Directory that contains all assets delivered as part of the AI pack + + Gradle generates the manifest for each AI pack and outputs the `assets/` + directory for you. + +### Configure install-time delivery + +AI packs configured as install-time are immediately available at app launch. Use +the Java AssetManager API to access AI packs served in this mode: + +```java +import android.content.res.AssetManager; +... +Context context = createPackageContext("com.example.app", 0); +AssetManager assetManager = context.getAssets(); +InputStream is = assetManager.open("model-name"); +``` + +### Configure fast-follow and on-demand delivery + +To download AI packs with fast-follow or on-demand delivery, use the Play AI +Delivery Library. + +#### Declare dependency on the Play AI Delivery Library + +In your app's `build.gradle` file, declare a dependency on the Play AI Delivery +Library: + + dependencies { + ... + implementation "com.google.android.play:ai-delivery:0.1.1-alpha01" + } + +#### Check status + +Each AI pack is stored in a separate folder in the app's internal storage. Use +the +[`getPackLocation()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#getPackLocation(java.lang.String)) +method to determine the root folder of an AI pack. This method returns the +following values: + +| Return value | Status | +|---|---| +| A valid [`AiPackLocation`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackLocation) object | AI pack root folder is ready for immediate access at [`assetsPath()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackLocation#assetsPath) | +| `null` | Unknown AI pack or AI packs are not available | + +> [!NOTE] +> **Note:** Don't rely on cached AI pack locations between app launches. The app should always check for the existence of AI packs at every launch. AI packs may become invalid due to app updates or if the user clears the app data. + +#### Get download information about AI packs + +Use the + +[`getPackStates()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#getPackStates(java.util.List%3Cjava.lang.String%3E)) +method to determine the size of the download and whether the pack is already +downloading. + +```java +Task getPackStates(List packNames) +``` + +`getPackStates()` is an asynchronous method that returns a `Task`. +The +[`packStates()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackStates#packStates()) +method of an `AiPackStates` object returns a `Map`. This +map contains the state of each requested AI pack, keyed by its name: + +```java +Map AiPackStates#packStates() +``` + +The final request is shown by the following: + +```java +final String aiPackName = "myAiPackName"; + +aiPackManager + .getPackStates(Collections.singletonList(aiPackName)) + .addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(Task task) { + AiPackStates aiPackStates; + try { + aiPackStates = task.getResult(); + AiPackState aiPackState = + aiPackStates.packStates().get(aiPackName); + } catch (RuntimeExecutionException e) { + Log.d("MainActivity", e.getMessage()); + return; + }); +``` + +The following +[`AiPackState`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackState) +methods provide the size of the AI pack, the downloaded amount so far (if +requested), and the amount already transferred to the app: + +- [`totalBytesToDownload()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackState#totalBytesToDownload()) +- [`bytesDownloaded()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackState#bytesDownloaded()) +- [`transferProgressPercentage()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackState#transferProgressPercentage()) + +To get the status of an AI pack, use the +[`status()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackState#status()) +method, which returns the status as an integer that corresponds to a constant +field in the +[`AiPackStatus`](https://developer.android.com/reference/com/google/android/play/core/aipacks/model/AiPackStatus) +class. An AI pack that's not installed yet has the status +`AiPackStatus.NOT_INSTALLED`. + +If a request fails, use the +[`errorCode()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackState#errorCode()) +method, whose return value corresponds to a constant field in the +[`AiPackErrorCode`](https://developer.android.com/reference/com/google/android/play/core/aipacks/model/AiPackErrorCode) +class. + +#### Install + +Use the +[`fetch()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#fetch(java.util.List%3Cjava.lang.String%3E)) +method to download an AI pack for the first time or call for the update of an AI +pack to complete: + +```java +Task fetch(List packNames) +``` + +This method returns an +[`AiPackStates`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackStates) +object containing a list of packs and their initial download states and sizes. +If an AI pack requested via `fetch()` is already downloading, the download +status is returned and no additional download is started. + +> [!NOTE] +> **Note:** In most cases, you implement a `listener` to track the download and installation process as covered in the next section. + +#### Monitor download states + +You should implement an +[`AiPackStateUpdateListener`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackStateUpdateListener) +to track the installation progress of AI packs. The status updates are broken +down per pack to support tracking the status of individual AI packs. You can +start using available AI packs before all other downloads for your request have +completed. + +```java +void registerListener(AiPackStateUpdateListener listener) +void unregisterListener(AiPackStateUpdateListener listener) +``` + +> [!NOTE] +> **Note:** The Play Store automatically triggers the download of any `fast-follow` packs after the user installs or updates the app. However, these packs may not be ready to use immediately. You must check the status of the `fast-follow` packs at every app launch. If the download is in progress, monitor it with a listener. If the download is cancelled or paused, you can resume it by using the `fetch()` method, as covered in the [Install](https://developer.android.com/google/play/on-device-ai#install) section. + +##### Large downloads + +If the download is larger than 200 MB and the user is not on Wi-Fi, the download +does not start until the user explicitly gives their consent to proceed with the +download using a mobile data connection. Similarly, if the download is large and +the user loses Wi-Fi, the download is paused and explicit consent is required to +proceed using a mobile data connection. A paused pack has state +`WAITING_FOR_WIFI`. To trigger the UI flow to prompt the user for consent, use +the +[`showConfirmationDialog()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#showConfirmationDialog(androidx.activity.result.ActivityResultLauncher%3Candroidx.activity.result.IntentSenderRequest%3E)) +method. + +Note that if the app does not call this method, the download is paused and will +resume automatically only when the user is back on a Wi-Fi connection. + +##### Required user confirmation + +If a pack has the `REQUIRES_USER_CONFIRMATION` status, the download won't +proceed until the user accepts the dialog that is shown with +[`showConfirmationDialog()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#showConfirmationDialog(androidx.activity.result.ActivityResultLauncher%3Candroidx.activity.result.IntentSenderRequest%3E)). +This status can occur when the app is not recognized by Play---for example, if the +app was sideloaded. Note that calling +[`showConfirmationDialog()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#showConfirmationDialog(androidx.activity.result.ActivityResultLauncher%3Candroidx.activity.result.IntentSenderRequest%3E)) +in this case will cause the app to be updated. After the update, you will need +to request the AI packs again. + +The following is an example implementation of a listener: + +```java +AiPackStateUpdateListener aiPackStateUpdateListener = new AiPackStateUpdateListener() { + private final ActivityResultLauncher activityResultLauncher = + registerForActivityResult( + new ActivityResultContracts.StartIntentSenderForResult(), + new ActivityResultCallback() { + @Override + public void onActivityResult(ActivityResult result) { + if (result.getResultCode() == RESULT_OK) { + Log.d(TAG, "Confirmation dialog has been accepted."); + } else if (result.getResultCode() == RESULT_CANCELED) { + Log.d(TAG, "Confirmation dialog has been denied by the user."); + } + } + }); + + @Override + public void onStateUpdate(AiPackState aiPackState) { + switch (aiPackState.status()) { + case AiPackStatus.PENDING: + Log.i(TAG, "Pending"); + break; + + case AiPackStatus.DOWNLOADING: + long downloaded = aiPackState.bytesDownloaded(); + long totalSize = aiPackState.totalBytesToDownload(); + double percent = 100.0 * downloaded / totalSize; + + Log.i(TAG, "PercentDone=" + String.format("%.2f", percent)); + break; + + case AiPackStatus.TRANSFERRING: + // 100% downloaded and assets are being transferred. + // Notify user to wait until transfer is complete. + break; + + case AiPackStatus.COMPLETED: + // AI pack is ready to use. Run the model. + break; + + case AiPackStatus.FAILED: + // Request failed. Notify user. + Log.e(TAG, aiPackState.errorCode()); + break; + + case AiPackStatus.CANCELED: + // Request canceled. Notify user. + break; + + case AiPackStatus.WAITING_FOR_WIFI: + case AiPackStatus.REQUIRES_USER_CONFIRMATION: + if (!confirmationDialogShown) { + aiPackManager.showConfirmationDialog(activityResultLauncher); + confirmationDialogShown = true; + } + break; + + case AiPackStatus.NOT_INSTALLED: + // AI pack is not downloaded yet. + break; + case AiPackStatus.UNKNOWN: + Log.wtf(TAG, "AI pack status unknown") + break; + } + } +} +``` + +Alternatively, you can use the +[`getPackStates()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#getPackStates()) +method to get the status of current downloads. +[`AiPackStates`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackStates) +contains the download progress, download status, and any failure error codes. + +#### Access AI packs + +You can access an AI pack using file system calls after the download request +reaches the +[`COMPLETED`](https://developer.android.com/reference/com/google/android/play/core/aipacks/model/AiPackStatus#completed()) +state. Use the +[`getPackLocation()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#getPackLocation(java.lang.String)) +method to get the root folder of the AI pack. + +AI packs are stored in the `assets` directory within the AI pack root directory. +You can get the path to the `assets` directory by using the convenience method +[`assetsPath()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackLocation#assetsPath()). +Use the following method to get the path to a specific asset: + +```java +private String getAbsoluteAiAssetPath(String aiPack, String relativeAiAssetPath) { + AiPackLocation aiPackPath = aiPackManager.getPackLocation(aiPack); + + if (aiPackPath == null) { + // AI pack is not ready + return null; + } + + String aiAssetsFolderPath = aiPackPath.assetsPath(); + // equivalent to: FilenameUtils.concat(aiPackPath.path(), "assets"); + String aiAssetPath = FilenameUtils.concat(aiAssetsFolderPath, relativeAiAssetPath); + return aiAssetPath; +} +``` + +### Configure device targeting + +You can follow the +[device targeting instructions](https://developer.android.com/google/play/on-device-ai#device-targeting-configuration) to specify +devices or groups of devices that should receive your AI packs. + +### Other Play AI Delivery API methods + +The following are some additional API methods you may want to use in your app. + +#### Cancel request + +Use +[`cancel()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#cancel(java.util.List%3Cjava.lang.String%3E)) +to cancel an active AI pack request. Note that this request is a best-effort +operation. + +#### Remove an AI pack + +Use +[`removePack()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#removePack(java.lang.String)) +to schedule the removal of an AI pack. + +#### Get locations of multiple AI packs + +Use +[`getPackLocations()`](https://developer.android.com/reference/com/google/android/play/core/aipacks/AiPackManager#getPackLocations()) +to query the status of multiple AI packs in bulk, which returns a map of AI +packs and their locations. The map returned by `getPackLocations()` contains an +entry for each pack that is currently downloaded and up-to-date. + +## Device targeting + +Device targeting gives you finer control over which parts of your app bundle are +delivered to specific devices. For example, you could ensure that a large model +is only delivered to devices with high RAM or you could deliver different +versions of a model to different devices. + +You can target device properties such as: + +- System on chip +- Device model +- Device RAM +- [System features](https://developer.android.com/guide/topics/manifest/uses-feature-element#features-reference) + +### Overview of required steps + +The following steps are required to enable device targeting: + +1. Define your device groups in an XML file. +2. Specify which parts of your bundle should go to which device groups. +3. \[Optional\] Test your configuration locally. +4. Upload your bundle (containing the XML file) to Google Play. + +### Check Android Gradle Plugin version + +To use device targeting, ensure that your Android Gradle Plugin (AGP) version is +at least 8.10.0. This is packaged with Android Studio (Meerkat 2 and later). +Download the latest [stable version of Android Studio](https://developer.android.com/studio). + +### Enable this feature in Android Gradle Plugin + +Device targeting must be enabled explicitly in your `gradle.properties` file: + + android.experimental.enableDeviceTargetingConfigApi=true + +### Create a device targeting configuration XML file + +The device targeting configuration file is an XML file in which you define your +custom device groups. For example, you could define a device group called +`qti_v79` that contains all devices with the Qualcomm SM8750 system on chip: + + + + + + + + + + + +A **device group** is composed of up to 5 **device selectors**. A device is +included in a device group if it satisfies any of its device selectors. + +A **device selector** can have one or more **device properties**. A device is +selected if it matches all of the selector's device properties. + +If a device matches multiple groups, it will be served the content for the group +that is defined first in the XML file. The order you define groups in the XML +file is your priority order. + +If a device matches no groups, it will receive the default "other" group. This +group is automatically generated and shouldn't be defined explicitly. + +#### Available device properties + +- **device_ram** : Device RAM requirements + - **min_bytes** (*inclusive)*: Minimum required RAM (in bytes) + - **max_bytes** (*exclusive)*: Maximum required RAM (in bytes) +- **included_device_ids** : Device models to be included in this selector **(max of 10000 device_ids per group)** . This property is satisfied if the device matches any device_id in the list. + - **build_brand**: Device manufacturer + - **build_device**: Device model code +- **excluded_device_ids** : Device models to be excluded in this selector **(max of 10000 device_ids per group)** . This property is satisfied if the device matches no device_id in the list. + - **build_brand**: Device manufacturer + - **build_device**: Device model code +- **required_system_features** : Features that a device needs to have to be + included by this selector **(max of 100 features per group)**. A device + needs to have all system features in this list to satisfy this property. + + System + [feature reference](https://developer.android.com/guide/topics/manifest/uses-feature-element#features-reference) + - **name**: A system feature +- **forbidden_system_features** : Features that a device mustn't have to be + included by this selector **(max of 100 features per group)**. If a device + has any of the system features in this list it doesn't satisfy this + property. + + System + [feature reference](https://developer.android.com/guide/topics/manifest/uses-feature-element#features-reference) + - **name**: A system feature +- **system-on-chip**: System on chips to be included in this selector. A + device needs to have any chip in this list to satisfy this property. + + - **manufacturer** : [System on chip manufacturer](https://developer.android.com/reference/android/os/Build#SOC_MANUFACTURER) + - **model** : [System on chip model](https://developer.android.com/reference/android/os/Build#SOC_MODEL) + +> [!TIP] +> **Tip:** Including multiple properties in a single +> selector creates a logical AND, for example: +> +> ```xml +> +> +> +> +> ``` +> +> will create the condition for all devices with \> 7GB of RAM AND it +> is a Pixel 4 - also written as follows: +> +> ![](https://developer.android.com/static/images/google/play/intersect.png) +> +> If you want an OR condition, create separate +> selectors in a single device group, for example: +> +> ```xml +> +> +> +> +> +> ``` +> +> will create the condition for all devices with \> 7GB of RAM OR +> it is a Pixel 4 - also written as follows: +> +> ![](https://developer.android.com/static/images/google/play/union.png) + +Here is an example showing all possible device properties: + + + + + + + + + + + + + + + + + + + + + + + + + + +#### Official device manufacturer and device model codes + +You can find the correct formatting for the device manufacturer and model code +by using the Device Catalog on the Google Play Console, by either: + +- Inspecting individual devices using the Device Catalog, and finding the + manufacturer and model code in the locations as shown in the example below + (For a Google Pixel 4a, the manufacturer is "Google" and the model code is + "sunfish")' + + ![pixel 4a page in the device catalog](https://developer.android.com/static/images/google/play/pixel-4.png) + + ![pixel 4a page in the device catalog](https://developer.android.com/static/images/google/play/pixel-4-2.png) +- Downloading a CSV of supported devices, and using the *Manufacturer* and + *Model Code* for the *build_brand* and *build_device* fields, + respectively. + +#### Include your device targeting configuration file in your app bundle + +Add the following to your main module's `build.gradle` file: + + android { + ... + bundle { + deviceTargetingConfig = file('device_targeting_config.xml') + deviceGroup { + enableSplit = true // split bundle by #group + defaultGroup = "other" // group used for standalone APKs + } + } + ... + } + +`device_targeting_config.xml` is the path of your configuration file relative to +the main module. This ensures that your configuration file is packaged with your +app bundle. + +The `deviceGroup` clause ensures that the APKs generated from your bundle are +split by device groups. + +### Use device targeting for your AI packs + +You can keep size optimized on devices by only delivering your large models to +devices that can run them. + +Subdivide your AI packs by device groups by taking the **existing** AI pack +directories created in the last step, and post-fixing the appropriate folders +(as described below) with #group_myCustomGroup1, #group_myCustomGroup2, etc. +When using the AI packs in your app, you won't need to address folders by +postfix (in other words, the postfix is automatically stripped during the build +process). + +After the previous step, this might look like: + + ... + .../ai-pack-name/src/main/assets/image-classifier#group_myCustomGroup1/ + .../ai-pack-name/src/main/assets/image-classifier#group_myCustomGroup2/ + ... + +In this example, you would reference `ai-pack-name/assets/image-classifier/` +without any postfixes. + +Devices in `myCustomGroup1` will receive all the assets under +`image-classifier#group_myCustomGroup1/`, while devices in `myCustomGroup2` will +receive all the assets under `image-classifier#group_myCustomGroup2/`. + +Devices that don't belong to either `myCustomGroup1` or `myCustomGroup2` will +receive an empty `ai-pack-name` pack. + +This is because devices that don't match any device group will receive the +default variant of your AI pack. This includes anything that is not inside a +directory with a `#group_suffix`. + +Once you have downloaded the AI pack, you can check whether your model is +present by using the [AssetManager](https://developer.android.com/google/play/on-device-ai#install-time-delivery) for install-time +packs or the [AiPackManager](https://developer.android.com/google/play/on-device-ai#access-AI-packs) for fast-follow and on-demand +packs. Examples for doing this are shown for all delivery modes in the +[sample app](https://developer.android.com/google/play/on-device-ai#example-app). + +> [!IMPORTANT] +> **Important:** It's not possible to prevent **any** variant of your AI pack being delivered to certain devices. Non-targeted devices will always receive the default variant. + +### Use device targeting for your feature modules + +You can also use device targeting for feature modules. Instead of subdividing +feature modules by device group, you specify whether the entire module should be +delivered based on device group membership. + +To deliver a feature module to devices that belong to either `myCustomGroup1` or +`myCustomGroup2`, modify its `AndroidManifest.xml`: + + + ... + + + + + + + + + ... + + + + + ... + + +> [!NOTE] +> **Note:** Devices that are not targeted won't receive the feature module at all. This is different from [targeting for AI packs](https://developer.android.com/google/play/on-device-ai#targeting-for-ai-packs), where devices that are not targeted receive a default, empty variant of the AI pack. + +## Test locally + +Before creating a release for your new bundle, you can test locally with either +Internal App Sharing or Bundletool. + +### Internal App Sharing + +Internal App Sharing lets you use an app bundle to quickly generate a URL that +you can tap on a local device to install exactly what Google Play would install +for that device if that version of the app was live in a test or prod track. + +Take a look at the +[internal app sharing instructions](https://support.google.com/googleplay/android-developer/answer/9844679). + +### Bundletool + +Alternatively, you can generate APKs using +[`bundletool`](https://developer.android.com/studio/command-line/bundletool) (1.18.0 or above) and sideload +them onto your device. Follow these steps to test your app locally using +bundletool: + +1. Build your app bundle with Android Studio or bundletool. + +2. Generate APKs with the `--local-testing` flag: + + ``` + java -jar bundletool-all.jar build-apks --bundle=path/to/your/bundle.aab \ + --output=output.apks --local-testing + ``` +3. Connect a device and run `bundletool` to sideload the APKs: + + ``` + # Example without Device Targeting Configuration + java -jar bundletool.jar install-apks --apks=output.apks + ``` + + ``` + # Example with Device Targeting Configuration (you must specify which groups the connected device belongs to) + java -jar bundletool.jar install-apks --apks=output.apks --device-groups=myCustomGroup1,myCustomGroup2 + ``` + +#### Limitations of local testing with bundletool + +The following are limitations of local testing with bundletool: + +- `fast-follow` packs behave as `on-demand` packs. That is, they won't be automatically fetched when the app is sideloaded. Developers need to request them manually when the app starts; this does not require any code changes in your app. +- Packs fetch from external storage instead of Play, so you cannot test how your code behaves in the case of network errors. +- Local testing does not cover the wait-for-Wi-Fi scenario. +- Updates are not supported. Before installing a new version of your build, manually uninstall the previous version. + +### Verify that the correct APKs are being installed + +Use the following method to ensure only the correct APKs are installed on the +device + + adb shell pm path {packageName} + +You should see something like: + + package:{...}/base.apk + package:{...}/split_config.en.apk + package:{...}/split_config.xxhdpi.apk + package:{...}/split_main_ai-pack-name.apk + package:{...}/split_main_ai-pack-name.config.group_myCustomGroup1.apk + +Note that you will only see APKs in this list, which are made from feature +modules and install-time AI packs. On-demand and fast-follow AI packs are not +installed as APKs. + +## Test and release on Google Play + +We recommend that you test your app end to end on Google Play with an +[internal test track](https://support.google.com/googleplay/android-developer/answer/9845334). + +Once you've done this, you can incrementally release your app update to +production with +[staged roll-outs](https://support.google.com/googleplay/android-developer/answer/6346149). + +## Sample app using Play for On-device AI + +Download the +[sample app](https://drive.google.com/drive/folders/1zb2O2mDvEh4vOmv1QbjeLU-O25KHrfKJ). + +It demonstrates how to use each of the delivery modes as well as the device +targeting configuration. See the [local testing](https://developer.android.com/google/play/on-device-ai#local-testing) section to get +started. + +## Related content + +Learn more about [Android App Bundles](https://developer.android.com/guide/app-bundle) and read the +references for the +[AI Delivery SDK](https://developer.android.com/reference/com/google/android/play/core/packages-ai_delivery). \ No newline at end of file diff --git a/device-ai/litert/litert-runtime/references/verify.md b/device-ai/litert/litert-runtime/references/verify.md new file mode 100644 index 0000000..b9272f3 --- /dev/null +++ b/device-ai/litert/litert-runtime/references/verify.md @@ -0,0 +1,39 @@ +## Reference first + +Keep the output of one fixed input from the source model in its own framework, +or from the same `.tflite` file on the workstation using the Python package +`ai-edge-litert` (using the same `CompiledModel` API: +`CompiledModel.from_file(path)`, then write, run, and read). The `qwen3_tts` +recipe in `litert-samples` ships such source-model dumps as `dump_*_ref.py`. +This output serves as the ground truth for every run on a device that runs +Android. + +## Device CPU, then the GPU + +Run the fixed input with `Accelerator.CPU` first and compare with the reference; +a difference here is in the model file or preprocessing, not in the device. +Next, run `Accelerator.GPU` and compare in two ways: + +1. **Numbers**: Compute maximum absolute difference and Pearson correlation against the reference dump. +2. **Task result**: Check task-level agreement (top-1 argmax, segmentation mask, or output text). Because the GPU computes in FP16, the least significant digits move slightly, whereas the task result shouldn't change. + +## Diagnostic indicators + +- **First inference slow**: GPU shader compilation runs during the first execution. The helper constructor's warm-up call absorbs this cost; never include the first run in timing metrics. +- **Timing GPU execution** : On the GPU, measure `model.run` and `readFloat` together, because the output buffer readback is where the app waits for the GPU to finish. + +## Device-only failures + +- **`CompiledModel.create` with `Accelerator.GPU` throws `Failed to compile + model`** : Caused by an operator the GPU doesn't support (Logcat logs `Following operations are not supported by GPU delegate`). Run the model on `Accelerator.CPU`, or re-export the model without that operator. +- **`NaN` or corrupted values appear only on `Accelerator.GPU`** : Caused by an FP16 range overflow in the model. Run the model on `Accelerator.CPU`, or re-export it with an FP16-safe graph rewrite. +- **Process terminates while loading an unquantized FP32 model exceeding 100 + MB**: Insufficient device RAM. Quantize or split the model before loading. + +## Record + +Record the device model, LiteRT version, accelerator, maximum absolute +difference, and task result (one row per device in the app's `README`). When the +`Accelerator.CPU` run differs from the reference output, inspect the model +itself (such as the converted or quantized model accuracy, GPU operator +residency, or device-only failures such as FP16 range overflows). \ No newline at end of file diff --git a/jetpack-compose/adaptive/SKILL.md b/jetpack-compose/adaptive/SKILL.md index 0f8eb7c..64d8059 100644 --- a/jetpack-compose/adaptive/SKILL.md +++ b/jetpack-compose/adaptive/SKILL.md @@ -10,7 +10,7 @@ description: Instructions to make or update an app's UI so that it adapts to dif license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-30' + last-updated: '2026-10-08' keywords: - android - ui diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md index 2f54571..44d6d13 100644 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md +++ b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md @@ -6,7 +6,7 @@ This page describes how to implement basic `FlexBox` layouts. `lib.versions.toml`. [versions] - compose = "1.13.0-alpha03" + compose = "1.13.0-beta01" [libraries] androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" } diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md index dc5bd59..d3e010d 100644 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md +++ b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md @@ -6,7 +6,7 @@ This page describes how to implement basic [`Grid`](https://developer.android.co `lib.versions.toml`. [versions] - compose = "1.13.0-alpha03" + compose = "1.13.0-beta01" [libraries] androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" } diff --git a/jetpack-compose/jetpack-compose-performance/SKILL.md b/jetpack-compose/jetpack-compose-performance/SKILL.md new file mode 100644 index 0000000..3a07a5b --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/SKILL.md @@ -0,0 +1,193 @@ +--- +name: jetpack-compose-performance +description: Diagnose and optimize Android Jetpack Compose UI code. Use this skill + when analyzing performance bottlenecks of existing code to minimize recomposition + overhead and prevent dropped frames and UI lag. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-10-13' + keywords: + - Jetpack Compose + - performance + - UI development + - optimization + - recomposition + - lazy layouts + - graphics + - stability + - memory + - allocation + - jank + - rendering + - state computation + - state derivation + - snapshotFlow + - produceState + - derivedStateOf + - computedStateOf + - sharedElement + - sharedBounds + - CompositionLocal +--- + +## Foundational prerequisites + +Before micro-optimizing composables, ensure standard production flags are in +place: + +1. **Release Build**: Never profile or benchmark debuggable builds; ART runtime verification severely degrades performance. +2. **R8 Minification**: Keep R8 enabled to optimize bytecode and coroutine dispatch. +3. **Baseline Profiles**: Generate baseline profiles for critical user journeys (cold start, key scrolling feeds) so the ART VM pre-compiles and preloads classes ahead of time. + +## Optimization audit workflow + +1. **Exhaustive Scan** : Scan the target code against all 7 core categories in the following sections. Evaluate every `@Composable` function, modifier chain, and lazy-layout data model against all checklist rules rather than stopping after the first issue found. +2. **Mandatory Reference Read Before Flagging or Editing** : Whenever a file matches a rule in Categories 1--7, you **MUST** open and read the corresponding `references/*.md` file for the approved fix pattern and full scope guards **before** reporting a finding or editing code. Never rely on the summary bullets alone to write fixes. + +## 1. State, snapshots, and stability ([references/state-and-stability.md](references/state-and-stability.md)) + +**MANDATORY** : Before flagging or fixing any issue in this category, you +**MUST** read [references/state-and-stability.md](references/state-and-stability.md). + +- **Phase Deferral (Lambdas)** : Wrap frequently changing state reads in lambdas (such as `Modifier.offset` or `Modifier.graphicsLayer`) so reads occur in Layout or Draw phase rather than Composition. +- **Prohibit Backwards Writes (Verify Read-Before-Write Order)** : Never modify state in a later phase or after it has already been read in the same composition pass (`Read -> Write`, or `Composition Read -> Layout or Draw + Write`). Prevent backwards writes across phases to avoid first-frame popping, jank, and infinite recomposition loops ([Backwards Write](references/android/develop/ui/compose/performance/backwards-write.md)). Do **not** flag a state assignment at the start of a composable (`Write -> + Read` or `Composition Write -> Draw Read`) as a backwards write---a backwards write strictly requires a read of that state to precede the write. +- **Avoid Redundant State Wrapping in Composition (`Write -> Read`)** : Do not wrap synchronous values in `remember { mutableStateOf(...) }` only to overwrite them immediately at the top of composition before reading them (for example, `var path by remember { mutableStateOf(Path()) }; path = + viewModel.getPath()`). While not a backwards write, this adds unnecessary slot table and `MutableState` snapshot overhead (and risks becoming a backwards write if a read is later added preceding the assignment); use a plain local `val` or read the value directly inside the target phase (such as `Modifier.drawWithCache`). +- **Explicit `remember` Keys \& Slot Overhead** : Always pass all input variables and composable parameters referenced inside `remember` as calculation keys. Remember that `remember` stores `keys + 1` objects in the slot table and checks key equality on every recomposition; only `remember` when allocations are heavy or identity must be preserved. Do **not** pass snapshot state values/properties (e.g. `state.value`) as keys when wrapping `derivedStateOf` (passing the `State` instance itself is fine if the instance reference can change). +- **`computedStateOf` vs `derivedStateOf` (Compose 1.13+)** : On Compose 1.13+, use `computedStateOf` for simple condition checks and filtered state from fast-changing inputs (such as `scrollState.value > 0`), which provides significantly faster invalidation than `derivedStateOf` (on Compose `< 1.13`, use `remember { derivedStateOf { ... } }`). Reserve `derivedStateOf` for heavy calculations that benefit from multi-read caching, or trivial calculations read frequently enough per composition (roughly `10 / numberOfStatesReadPerCalculation` times without invalidating between reads) to make up for the invalidation cost. +- **Synchronous State Derivation** : Audit for asynchronous `produceState` + `snapshotFlow` blocks observing Compose states (such as scroll positions or collection sizes); replace them with synchronous `remember { derivedStateOf + { ... } }` (or `computedStateOf` on Compose 1.13+). +- **Trivial Primitives** : Prefer plain custom Kotlin getters (`get()`) over `derivedStateOf` or `computedStateOf` for simple boolean expressions and property lookups. +- **Collection State Tracking** : Back whole-list updates with standard `mutableListOf()` and `mutableStateOf(list, neverEqualPolicy())` with load-bearing assignments instead of `mutableStateListOf()`. +- **Snapshot Collections** : Use `SnapshotStateList.toList()` for repeated queries or iterations to avoid per-element read observation overhead. +- **Repeated State Reads** : Extract repeated Compose `State` property reads into a local variable at the top of the block. +- **State in Setters** : Avoid reading Compose `State` inside custom property setters; use a raw backing field when triggering change side-effects and batch multi-property updates. +- **Stability \& Skipping Trade-Offs (Skipping Isn't Free)** : Do not fixate on stability or treat `@Immutable`, `@Stable`, or `ImmutableList` as default solutions. Skipping a composable is not free: each parameter is stored in the slot table as if remembered, and all inputs must be compared on recomposition. [Strong Skipping Mode](references/android/develop/ui/compose/performance/stability/strongskipping.md) already skips composables with unstable parameters (including standard `kotlin.collections.List`) when instance equality (`===`) matches. Annotating models or using `ImmutableList` forces structural equality (`.equals()`), which can be significantly more expensive than recomposition itself for large collections or complex classes. +- **Never Add `@Stable`, `@Immutable`, or `ImmutableList` to Collection + Models** : When writing or optimizing code, never add `@Immutable` or `@Stable` to models containing collections (`List`, `Set`, `Map`), and **never** recommend replacing `kotlin.collections.List` with `ImmutableList` (or adding `ImmutableList` to `stability_config.txt`). Calling `O(N)` `.equals()` on collections on every recomposition pass is more expensive than `O(1)` instance equality (`===`) under Strong Skipping Mode. +- **Scope Guard for Removing Existing `@Stable` / `@Immutable` Annotations + (Large Lazy Datasets Only)** : + - **When to Remove** : **Only** recommend removing existing `@Stable` or `@Immutable` annotations from data models when **both** conditions are met: (1) the model is used within a **lazy layout** (`LazyColumn`, `LazyRow`, `LazyGrid`, `Pager`, `ScalingLazyColumn`) as the input feed or item model, **and** (2) it contains a **large dataset or deep nested + collection hierarchy** (e.g., feed streams, large lists of tags/items, or multi-level nested lists like `Post` with `List` and `List`), where recursive `O(N)` `.equals()` checks degrade scrolling and recomposition performance. + - **When to Leave Untouched** : For all other existing `@Stable`, `@Immutable`, or `ImmutableList` usages in a codebase---such as non-lazy UI models, standalone components, or models with small/bounded collections (e.g., a card with a few badges, thumbnails, or metadata rows) where dataset size is small or unknown---**leave the existing + annotations untouched and do not flag them** . Never blindly grep and flag all `@Stable`/`@Immutable` classes across a codebase without verifying they feed a lazy layout with a large dataset. +- **When to Add `@Stable` / `@Immutable` Under Strong Skipping Mode** : Flat `data class`es with `val` primitive/`String` properties in Compose modules are **already inferred as stable** by the compiler and use `.equals()` automatically---do not add redundant `@Immutable` annotations to them. Only add `@Stable` or `@Immutable` when the compiler cannot infer stability (e.g. classes from non-Compose modules or interfaces) **and** a data source (such as Room or MVI reducers) allocates new object instances for unchanged items on every emission, **provided** the class is small, flat, has no collections (`List`, `Set`, `Map`), and its `.equals()` check is trivial `O(1)`. +- **Make Composables Cheaper Instead of Forcing Skipping**: Do not overindex on recomposition counts. Skipping one composable during an active recomposition pass saves little time once composition is scheduled (leaving the house is the bulk of the cost). Focus on making composables cheap to execute, deferring state reads to Layout/Draw, and verifying with benchmarks rather than assuming skipping improves performance. + +## 2. Layouts and measurement optimization ([references/layouts.md](references/layouts.md)) + +**MANDATORY** : Before flagging or fixing any issue in this category, you +**MUST** read [references/layouts.md](references/layouts.md). + +- **Coordinates \& Placement** : Avoid `onGloballyPositioned`; use `Modifier.onVisibilityChanged` for viewport tracking, `Modifier.onPlaced` or `Modifier.onLayoutRectChanged` for local bounds, and query `requireLayoutCoordinates()` on demand inside `Modifier.Node` event handlers. +- **Anchor Floating Content via Custom `Layout`** : When attaching floating labels or tooltips to a component, use a custom `Layout` measuring and placing both children relative to each other. Never track `onGloballyPositioned` coordinates in `MutableState` to position floating content with `offset`, which causes a 1-frame lag and schedules a second composition pass. +- **Phase Deferral for Offsets** : Wrap dynamic offsets in lambdas (`Modifier.offset { IntOffset(...) }`) to skip Composition straight to Layout. +- **Tree Depth \& Decorative Wrappers** : Avoid redundant wrapper `Box`es and decorative `Canvas` nodes; attach modifiers directly or use `Modifier.drawBehind`. +- **Subcomposition Overhead** : Avoid `BoxWithConstraints` / `SubcomposeLayout` inside hot paths and lazy items; prefer standard modifiers, `Modifier.layout`, or `LayoutModifierNode`. +- **Intrinsic Measurements**: Use intrinsic measurements judiciously and avoid deeply nested intrinsic chains. + +## 3. Lazy layouts optimization ([references/lazy-layouts.md](references/lazy-layouts.md)) + +**MANDATORY** : Before flagging or fixing any issue in this category, you +**MUST** read [references/lazy-layouts.md](references/lazy-layouts.md). + +- **Lazy Layout Scope Guard** : Directives for `key` and `contentType` apply **strictly** to Lazy containers (`LazyColumn`, `LazyRow`, `LazyGrid`). Never flag standard non-lazy containers (`Column`, `Row`, `Box`) iterating over collections. +- **Mandatory `contentType` and Primitive `key`** : Always specify both primitive `key`s (`Int`, `Long`, `String`) **and** `contentType`s for all items in lazy layouts (`LazyColumn`, `LazyRow`, `LazyGrid`). When using `items` or `itemsIndexed`, never omit `contentType`: `contentType = { _, item -> item.type }`. Omitting `contentType` prevents Compose from reusing composition slots during scrolling. +- **In-Item Index Lookups** : Replace `list.indexOf(item)` with `itemsIndexed` to eliminate `O(N²)` lookups during layout passes. +- **Observable Selection \& State Queries** : Convert selection tracking from `mutableStateListOf` to an immutable `Set` in `MutableState` (`mutableStateOf(emptySet())`), `mutableStateSetOf()`, or Map for `O(1)` lookups. Pass lambda providers (`isSelected: () -> Boolean`) to leaf composables to isolate recomposition from parent `items` scopes. +- **Propagate CompositionLocals in Lazy Items** : Avoid querying `CompositionLocal`s (`LocalContext.current`, `LocalDensity.current`, `LocalSharedTransitionScope.current`, scopes, etc.) inside individual lazy item composables; resolve them once in the parent container or screen and propagate the resolved values down as parameters. +- **Defer Shared Transitions in Lazy Items** : Put `Modifier.sharedBounds` or `Modifier.sharedElement` on lazy items behind visibility indicators (such as `Modifier.onVisibilityChanged` or viewport visibility checks) so offscreen or pre-fetched items do not instantiate transition scopes. +- **Item Count Derivations** : Read collection `.size` directly instead of wrapping list counts in `derivedStateOf`. +- **Small \& Bounded Lists** : Use standard `Row` or `Column` with `for` or `forEach` loops for small or bounded collections (e.g. 1 to 10 items) to eliminate lazy virtualization, measuring, and pre-fetching overhead. +- **Hoist Shared Flows** : Collect shared `Flow`s once in the parent container preceding lazy layouts; never call `collectAsStateWithLifecycle()` per visible item. +- **Avoid Side Effects in Pre-Fetched Items** : Never trigger analytics in `LaunchedEffect(Unit)` inside lazy items; use `Modifier.onVisibilityChanged` with dwell thresholds instead. + +## 4. Animation and shared transitions ([references/animation.md](references/animation.md)) + +**MANDATORY** : Before flagging or fixing any issue in this category, you +**MUST** read [references/animation.md](references/animation.md). + +- **Phase Deferral for Animated Properties** : Wrap fast-changing animated state reads (alpha, scale, rotation, color) in `Modifier.graphicsLayer { ... + }` to bypass Composition and evaluate directly in the Draw phase. +- **Phase Deferral for Offsets \& Size** : Wrap animated offsets in `Modifier.offset { IntOffset(...) }` and animate constraints using `Modifier.layout { ... }` or `Modifier.animateBounds` to bypass recomposition during size transitions. +- **Defer Shared Transitions in Fast-Scrolling Lists** : Attach `Modifier.sharedBounds` or `Modifier.sharedElement` only when visible using `Modifier.onVisibilityChanged` to avoid modifier and observation instantiation costs on discarded pre-fetched items. +- **Lambda Providers** : Pass lambda providers (`() -> Float`) instead of raw animated values to child composables to keep state reads deferred to Draw or Layout. +- **Persistent `Animatable` in Nodes** : Store `Animatable` as a persistent member property on `Modifier.Node` and launch in `onAttach()` using `coroutineScope.launch`. + +## 5. Effects, lifecycle, and threading ([effects-and-threading.md](references/effects-and-threading.md)) + +**MANDATORY** : Before flagging or fixing any issue in this category, you +**MUST** read [references/effects-and-threading.md](references/effects-and-threading.md). + +- **Prohibit Direct Side-Effects in Body** : Never perform side-effects directly in the Composable body during composition; wrap them in `SideEffect`, `LaunchedEffect`, or user event callbacks. +- **Prohibit Non-Suspending `LaunchedEffect` \& Empty `DisposableEffect`** : Never use `LaunchedEffect` for synchronous operations (e.g. logging views or updating objects). Never use `DisposableEffect` with an empty `onDispose {}` block as a workaround. Use `SideEffect` instead (in Compose 1.12+, `SideEffect(keys) { ... }` supports keys directly; on Compose `< + 1.12`, `SideEffect` takes no arguments, so check the project's Compose version and track key changes with a `remember`ed holder inside `SideEffect { ... }`). +- **Cache Allocations in Body** : Wrap heavy object allocations (such as date formatters or complex paths) and expensive computations in `remember`. +- **Combine Related `LaunchedEffect`s** : Combine multiple `LaunchedEffect(Unit)` calls into a single `LaunchedEffect` with nested `launch { ... }` blocks to reduce effect node allocations and coroutine launcher overhead. +- **Effect Ordering \& Teardown Costs** : Respect effect execution order (`DisposableEffect` \> `SideEffect` \> Layout and Draw \> `LaunchedEffect` after frame). Note that canceling a `LaunchedEffect` incurs coroutine cancellation and teardown overhead; avoid cycling `LaunchedEffect` keys frequently during animations or scrolling. +- **Main Thread Offloading** : Offload blocking I/O, heavy file parsing, and database operations to `withContext(Dispatchers.IO)` using `produceState`. +- **No Item-Level Impression Effects in Lazy Layouts** : Never use `LaunchedEffect` or `DisposableEffect` inside lazy items for impression logging. Lazy items are pre-composed before entering the viewport, causing premature or false impressions. Use `Modifier.onVisibilityChanged` with dwell thresholds instead. +- **Lifecycle-Aware Flow Collection** : Use `collectAsStateWithLifecycle()` for UI Flow collections that actively produce background work on Android, and `collectAsState()` for lightweight in-memory StateFlows or non-Android scopes. +- **Hoist Receivers \& Listeners** : Register system listeners and `BroadcastReceiver`s once at the screen level using `DisposableEffect`, not inside lazy list items. +- **Guard Optional Effects** : Guard effects with `if (callback != null)` before launching to prevent allocating empty coroutines and effect nodes. +- **Prevent Stale Captures** : Use `rememberUpdatedState` when long-running or looping coroutines in `LaunchedEffect` reference callbacks or parameters. +- **Replace Legacy Timers** : Replace `Handler.postDelayed` or `TimerTask` with `LaunchedEffect` and `delay()`. + +## 6. Graphics and custom drawing ([references/graphics-performance.md](references/graphics-performance.md)) + +**MANDATORY** : Before flagging or fixing any issue in this category, you +**MUST** read [references/graphics-performance.md](references/graphics-performance.md). + +- **Cache Drawing Allocations** : Replace decorative `Canvas` nodes with `Modifier.drawWithCache` to cache `Path`, `Paint`, and `Brush` allocations. +- **Prefer AsyncImage instead of Image** : Use Coil's `AsyncImage` for loading images from the internet and `painterResource` for drawables. `Image` is not optimized for caching and loading images and may cause performance issues. +- **Scope Fast-Changing Uniforms** : `remember` the `RuntimeShader` and `ShaderBrush` in composition, update size uniforms in `drawWithCache`, and per-frame uniforms in `onDrawBehind`. +- **Graphics Path Object Reuse** : Prefer `Path.rewind()` over `reset()` in hot draw loops to retain allocated buffers without reallocation. +- **Value Classes \& Primitive Collections** : Avoid generic tuples (`Pair`, `Triple`) and generic sets (`HashSet`); use `androidx.collection.MutableLongSet` and `androidx.compose.ui.util.packInts`. + +## 7. Custom modifiers: `Modifier.Node` ([Custom modifiers guide](references/android/develop/ui/compose/custom-modifiers.md)) + +- **Modifier.Node Migration** : Migrate legacy `Modifier.composed` to + `ModifierNodeElement` + `Modifier.Node` (or `DelegatingNode` when delegating + to sub-nodes such as `SuspendingPointerInputModifierNode`) with capability + interfaces (`DrawModifierNode`, `LayoutModifierNode`, + `SemanticsModifierNode`, `CompositionLocalConsumerModifierNode` using + `currentValueOf(...)`) to eliminate per-node subcomposition overhead and + enable node reuse (see [Create custom modifiers with Modifier.Node](references/android/develop/ui/compose/custom-modifiers.md)): + + + ```kotlin + // Modifier factory + fun Modifier.circle(color: Color) = this then CircleElement(color) + + // ModifierNodeElement + private data class CircleElement( + val color: Color + ) : ModifierNodeElement() { + override fun create() = CircleNode(color) + + override fun update(node: CircleNode) { + node.color = color + } + } + + // Modifier.Node + private class CircleNode( + var color: Color + ) : DrawModifierNode, Modifier.Node() { + override fun ContentDrawScope.draw() { + drawCircle(color) + } + } + ``` + +
+ +- **On-Demand Coordinates** : Query `requireLayoutCoordinates()` on demand + inside pointer or draw handlers in `Modifier.Node` rather than implementing + `GlobalPositionAwareModifierNode.onGloballyPositioned`. + +**IMPORTANT** : For more examples on how to migrate common modifiers to +Modifier.Node, you must read [Create custom modifiers with Modifier.Node](references/android/develop/ui/compose/custom-modifiers.md). diff --git a/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/custom-modifiers.md b/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/custom-modifiers.md new file mode 100644 index 0000000..a48df35 --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/custom-modifiers.md @@ -0,0 +1,552 @@ +Compose provides many [modifiers](https://developer.android.com/develop/ui/compose/modifiers) for common behaviors right out of the box, +but you can also create your own custom modifiers. + +Modifiers have multiple parts: + +- A modifier factory + - This is an extension function on `Modifier`, which provides an idiomatic API for your modifier and allows modifiers to be chained together. The modifier factory produces the modifier elements used by Compose to modify your UI. +- A modifier element + - This is where you can implement the behavior of your modifier. + +There are multiple ways to implement a custom modifier depending on the +functionality needed. Often, the simplest way to implement a custom modifier is +to implement a custom modifier factory that combines other already defined +modifier factories. If you need more custom behavior, implement the modifier +element using the `Modifier.Node` APIs, which are lower level but provide more +flexibility. + +## Chain existing modifiers together + +It is often possible to create custom modifiers by using existing modifiers. For +example, [`Modifier.clip()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).clip(androidx.compose.ui.graphics.Shape)) is implemented using the `graphicsLayer` +modifier. This strategy uses existing modifier elements, and you provide your +own custom modifier factory. + +Before implementing your own custom modifier, see if you can use the same +strategy. + + +```kotlin +fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true) +``` + +
+ +Or, if you find you are repeating the same group of modifiers often, you can +wrap them into your own modifier: + + +```kotlin +fun Modifier.myBackground(color: Color) = padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color) +``` + +
+ +## Create a custom modifier using a composable modifier factory + +You can also create a custom modifier using a composable function to pass values +to an existing modifier. This is known as a composable modifier factory. + +> [!NOTE] +> **Note:** `composed {}` is no longer recommended due to performance concerns. See [Migrate from `Modifier.composed` to `Modifier.Node`](https://developer.android.com/develop/ui/compose/custom-modifiers-node) for details on how to migrate away from this modifier. + +Using a composable modifier factory to create a modifier also lets you use +higher level compose APIs, such as [`animate*AsState`](https://developer.android.com/develop/ui/compose/animation/value-based#animate-as-state) and other [Compose +state backed animation APIs](https://developer.android.com/develop/ui/compose/animation/choose-api). For example, the following snippet shows a +modifier that animates an alpha change when enabled/disabled: + + +```kotlin +@Composable +fun Modifier.fade(enable: Boolean): Modifier { + val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f) + return this then Modifier.graphicsLayer { this.alpha = alpha } +} +``` + +
+ +> [!WARNING] +> **Warning:** When creating custom modifiers, don't break the modifier chain. You must always reference `this` or else any modifiers previously added will be dropped. You can use `this then Modifier` as in the preceding example or implicitly using `return graphicsLayer { this.alpha = alpha }`. + +If your custom modifier is a convenience method to provide default values from a +`CompositionLocal`, the easiest way to implement this is to use a composable +modifier factory: + + +```kotlin +@Composable +fun Modifier.fadedBackground(): Modifier { + val color = LocalContentColor.current + return this then Modifier.background(color.copy(alpha = 0.5f)) +} +``` + +
+ +This approach has some caveats, which are detailed in the following sections. + +#### `CompositionLocal` values are resolved at the call site of the modifier factory + +When creating a custom modifier using a composable modifier factory, composition +locals take the value from the composition tree where they are created, not +used. This can lead to unexpected results. For example, consider the +composition local modifier example mentioned previously, implemented slightly +differently using a composable function: + + +```kotlin +@Composable +fun Modifier.myBackground(): Modifier { + val color = LocalContentColor.current + return this then Modifier.background(color.copy(alpha = 0.5f)) +} + +@Composable +fun MyScreen() { + CompositionLocalProvider(LocalContentColor provides Color.Green) { + // Background modifier created with green background + val backgroundModifier = Modifier.myBackground() + + // LocalContentColor updated to red + CompositionLocalProvider(LocalContentColor provides Color.Red) { + + // Box will have green background, not red as expected. + Box(modifier = backgroundModifier) + } + } +} +``` + +
+ +If this is not how you expect your modifier to work, use a custom +[`Modifier.Node`](https://developer.android.com/develop/ui/compose/custom-modifiers#implement-custom) instead, as composition locals will be +correctly resolved at the usage site and can be safely hoisted. + +#### Composable function modifiers are never skipped + +Composable factory modifiers are never [skipped](https://developer.android.com/develop/ui/compose/mental-model#skips) because composable functions +that have return values cannot be skipped. This means your modifier function +will be called on every recomposition, which may be expensive if it recomposes +frequently. + +#### Composable function modifiers must be called within a composable function + +Like all composable functions, a composable factory modifier must be called from +within composition. This limits where a modifier can be hoisted to, as it can +never be hoisted out of composition. In comparison, non-composable modifier +factories can be hoisted out of composable functions to allow easier reuse and +improve performance: + + +```kotlin +val extractedModifier = Modifier.background(Color.Red) // Hoisted to save allocations + +@Composable +fun Modifier.composableModifier(): Modifier { + val color = LocalContentColor.current.copy(alpha = 0.5f) + return this then Modifier.background(color) +} + +@Composable +fun MyComposable() { + val composedModifier = Modifier.composableModifier() // Cannot be extracted any higher +} +``` + +
+ +## Implement custom modifier behavior using `Modifier.Node` + +[`Modifier.Node`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier.Node) is a lower level API for creating modifiers in Compose. It +is the same API that Compose implements its own modifiers in and is the most +performant way to create custom modifiers. + +> [!NOTE] +> **Note:** There is another API for creating custom modifiers, [`composed {}`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).composed(kotlin.Function1,kotlin.Function1)). This API is no longer recommended due to the performance issues it created. `Modifier.Node` was designed from the ground up to be far more performant than composed modifiers. For more details on the problems with composed modifiers, see the Android Dev Summit talk [Compose Modifiers Deep Dive](https://www.youtube.com/watch?v=BjGX2RftXsU). + +### Implement a custom modifier using `Modifier.Node` + +There are three parts to implementing a custom modifier using Modifier.Node: + +- A [`Modifier.Node`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt;l=184) implementation that holds the logic and state of your modifier. +- A [`ModifierNodeElement`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ModifierNodeElement.kt;l=39) that creates and updates modifier node instances. +- An optional modifier factory, as detailed previously. + +`ModifierNodeElement` classes are stateless and new instances are allocated each +recomposition, whereas `Modifier.Node` classes can be stateful and will survive +across multiple recompositions, and can even be reused. + +The following section describes each part and shows an example of building a +custom modifier to draw a circle. + +#### `Modifier.Node` + +The `Modifier.Node` implementation (in this example, `CircleNode`) implements +the functionality of your custom modifier. + + +```kotlin +// Modifier.Node +private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() { + override fun ContentDrawScope.draw() { + drawCircle(color) + } +} +``` + +
+ +In this example, it draws the circle with the color passed in to the modifier +function. + +A node implements `Modifier.Node` as well as zero or more node types. There are +different node types based on the functionality your modifier requires. The +preceding example needs to be able to draw, so it implements `DrawModifierNode`, +which lets it override the draw method. + +The available types are as follows: + +|---|---|---| +| **Node** | **Usage** | **Sample Link** | +| [`LayoutModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutModifierNode.kt) | A `Modifier.Node` that changes how its wrapped content is measured and laid out. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/LayoutSample.kt;l=198) | +| [`DrawModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DrawModifierNode.kt) | A `Modifier.Node` that draws into the space of the layout. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierSamples.kt;l=313) | +| [`CompositionLocalConsumerModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/CompositionLocalConsumerModifierNode.kt) | Implementing this interface lets your `Modifier.Node` read composition locals. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierCompositionLocalSample.kt;l=64) | +| [`SemanticsModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt) | A `Modifier.Node` that adds semantics key/value for use in testing, accessibility, and similar use cases. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierSamples.kt;l=338) | +| [`PointerInputModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt) | A `Modifier.Node` that receives [PointerInputChanges.](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.kt) | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierSamples.kt;l=366) | +| [`ParentDataModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ParentDataModifierNode.kt) | A `Modifier.Node` that provides data to the parent layout. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt;l=295) | +| [`LayoutAwareModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutAwareModifierNode.kt) | A `Modifier.Node` which receives `onMeasured` and `onPlaced` callbacks. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierSamples.kt;l=405) | +| [`GlobalPositionAwareModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/GlobalPositionAwareModifierNode.kt) | A `Modifier.Node` which receives an `onGloballyPositioned` callback with the final `LayoutCoordinates` of the layout when the global position of the content may have changed. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierSamples.kt;l=405) | +| [`ObserverModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ObserverModifierNode.kt) | `Modifier.Node`s that implement `ObserverNode` can provide their own implementation of `onObservedReadsChanged` that will be called in response to changes to snapshot objects read within an `observeReads` block. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierCompositionLocalSample.kt;l=64) | +| [`DelegatingNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DelegatingNode.kt) | A `Modifier.Node` which is able to delegate work to other `Modifier.Node` instances. This can be useful to compose multiple node implementations into one. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/ModifierSamples.kt) | +| [`TraversableNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TraversableNode.kt;l=28) | Allows `Modifier.Node` classes to traverse up/down the node tree for classes of the same type or for a particular key. | [Sample](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/modifier/TraverseModifierDemo.kt;l=123) | + +Nodes are automatically invalidated when update is called on their corresponding +element. Because our example is a `DrawModifierNode`, any time update is called +on the element, the node triggers a redraw and its color correctly updates. It +is possible to opt out of auto-invalidation, as detailed in the +[Opt out of node auto-invalidation](https://developer.android.com/develop/ui/compose/custom-modifiers#autoinvalidation) section. + +#### `ModifierNodeElement` + +A `ModifierNodeElement` is an immutable class that holds the data to create or +update your custom modifier: + + +```kotlin +// ModifierNodeElement +private data class CircleElement(val color: Color) : ModifierNodeElement() { + override fun create() = CircleNode(color) + + override fun update(node: CircleNode) { + node.color = color + } +} +``` + +
+ +`ModifierNodeElement` implementations need to override the following methods: + +1. `create`: This is the function that instantiates your modifier node. This gets called to create the node when your modifier is first applied. Usually, this amounts to constructing the node and configuring it with the parameters that were passed in to the modifier factory. +2. `update`: This function is called whenever this modifier is provided in the same spot this node already exists, but a property has changed. This is determined by the `equals` method of the class. The modifier node that was previously created is sent as a parameter to the `update` call. At this point, you should update the nodes' properties to correspond with the updated parameters. The ability for nodes to be reused this way is key to the performance gains that `Modifier.Node` brings; therefore, you must update the existing node rather than creating a new one in the `update` method. In our circle example, the color of the node is updated. + +Additionally, `ModifierNodeElement` implementations also need to implement +`equals` and `hashCode`. `update` will only get called if an equals comparison +with the previous element returns false. + +> [!WARNING] +> **Warning:** Your `ModifierNodeElement` must implement `equals` and `hashCode` correctly and not rely on instance equality. Without this, your modifier node will be updated unnecessarily and perform poorly. Use a data class to achieve this automatically. + +The preceding example uses a data class to achieve this. These methods are used +to check if a node needs updating or not. If your element has properties that +don't contribute to whether a node needs to be updated, or you want to avoid +data classes for binary compatibility reasons, then you can manually implement +`equals` and `hashCode`, for example, the +[padding modifier element](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Padding.kt;l=358). + +#### Modifier factory + +This is the public API surface of your modifier. Most implementations create the +modifier element and add it to the modifier chain: + + +```kotlin +// Modifier factory +fun Modifier.circle(color: Color) = this then CircleElement(color) +``` + +
+ +#### Complete example + +These three parts come together to create the custom modifier to draw a circle +using the `Modifier.Node` APIs: + + +```kotlin +// Modifier factory +fun Modifier.circle(color: Color) = this then CircleElement(color) + +// ModifierNodeElement +private data class CircleElement(val color: Color) : ModifierNodeElement() { + override fun create() = CircleNode(color) + + override fun update(node: CircleNode) { + node.color = color + } +} + +// Modifier.Node +private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() { + override fun ContentDrawScope.draw() { + drawCircle(color) + } +} +``` + +
+ +## Common situations using `Modifier.Node` + +When creating custom modifiers with `Modifier.Node`, here are some common +situations you might encounter. + +### Zero parameters + +If your modifier has no parameters, then it never needs to update and, +furthermore, doesn't need to be a data class. The following is a sample +implementation of a modifier that applies a fixed amount of padding to a +composable: + + +```kotlin +fun Modifier.fixedPadding() = this then FixedPaddingElement + +data object FixedPaddingElement : ModifierNodeElement() { + override fun create() = FixedPaddingNode() + override fun update(node: FixedPaddingNode) {} +} + +class FixedPaddingNode : LayoutModifierNode, Modifier.Node() { + private val PADDING = 16.dp + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + val paddingPx = PADDING.roundToPx() + val horizontal = paddingPx * 2 + val vertical = paddingPx * 2 + + val placeable = measurable.measure(constraints.offset(-horizontal, -vertical)) + + val width = constraints.constrainWidth(placeable.width + horizontal) + val height = constraints.constrainHeight(placeable.height + vertical) + return layout(width, height) { + placeable.place(paddingPx, paddingPx) + } + } +} +``` + +
+ +### Reference composition locals + +`Modifier.Node` modifiers don't automatically observe changes to Compose state +objects, like `CompositionLocal`. The advantage `Modifier.Node` modifiers have +over modifiers that are just created with a composable factory is that they can +read the value of the composition local from where the modifier is used in your +UI tree, not where the modifier is allocated, using [`currentValueOf`](https://developer.android.com/reference/kotlin/androidx/compose/ui/node/CompositionLocalConsumerModifierNode#(androidx.compose.ui.node.CompositionLocalConsumerModifierNode).currentValueOf(androidx.compose.runtime.CompositionLocal)). + +However, modifier node instances don't automatically observe state changes. To +automatically react to a composition local changing, you can read its current +value inside a scope: + +- `DrawModifierNode`: [`ContentDrawScope`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/DrawModifierNode.kt;l=31) +- `LayoutModifierNode`: [`MeasureScope`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutModifierNode.kt;l=64) \& [`IntrinsicMeasureScope`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutModifierNode.kt;l=87) +- `SemanticsModifierNode`: [`SemanticsPropertyReceiver`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt;l=788) + +This example observes the value of `LocalContentColor` to draw a background +based on its color. As `ContentDrawScope` does observe snapshot changes, this +automatically redraws when the value of `LocalContentColor` changes: + + +```kotlin +class BackgroundColorConsumerNode : + Modifier.Node(), + DrawModifierNode, + CompositionLocalConsumerModifierNode { + override fun ContentDrawScope.draw() { + val currentColor = currentValueOf(LocalContentColor) + drawRect(color = currentColor) + drawContent() + } +} +``` + +
+ +To react to state changes outside of a scope and automatically update your +modifier, use an [`ObserverModifierNode`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/ObserverModifierNode.kt). + +For example, [`Modifier.scrollable`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt;l=269) uses this technique to +observe changes in `LocalDensity`. A simplified example is shown in the +following example: + + +```kotlin +class ScrollableNode : + Modifier.Node(), + ObserverModifierNode, + CompositionLocalConsumerModifierNode { + + // Place holder fling behavior, we'll initialize it when the density is available. + val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity)) + + override fun onAttach() { + updateDefaultFlingBehavior() + observeReads { currentValueOf(LocalDensity) } // monitor change in Density + } + + override fun onObservedReadsChanged() { + // if density changes, update the default fling behavior. + updateDefaultFlingBehavior() + } + + private fun updateDefaultFlingBehavior() { + val density = currentValueOf(LocalDensity) + defaultFlingBehavior.flingDecay = splineBasedDecay(density) + } +} +``` + +
+ +### Animate a modifier + +`Modifier.Node` implementations have access to a `coroutineScope`. This allows +for use of the [Compose Animatable APIs](https://developer.android.com/develop/ui/compose/animation/value-based#animatable). For example, this snippet modifies +the `CircleNode` shown previously to fade in and out repeatedly: + +> [!NOTE] +> **Note:** `Modifier.Node` can be reused in a composable. You can set the new state either by setting it in [`onAttach`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier.Node#onAttach()), or by resetting in [`onReset`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier.Node#onReset()). + + +```kotlin +class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode { + private lateinit var alpha: Animatable + + override fun ContentDrawScope.draw() { + drawCircle(color = color, alpha = alpha.value) + drawContent() + } + + override fun onAttach() { + alpha = Animatable(1f) + coroutineScope.launch { + alpha.animateTo( + 0f, + infiniteRepeatable(tween(1000), RepeatMode.Reverse) + ) { + } + } + } +} +``` + +
+ +### Share state between modifiers using delegation + +`Modifier.Node` modifiers can delegate to other nodes. There are many use cases +for this, such as extracting common implementations across different modifiers, +but it can also be used to share common state across modifiers. + +For example, a basic implementation of a clickable modifier node that shares +interaction data: + + +```kotlin +class ClickableNode : DelegatingNode() { + val interactionData = InteractionData() + val focusableNode = delegate( + FocusableNode(interactionData) + ) + val indicationNode = delegate( + IndicationNode(interactionData) + ) +} +``` + +
+ +### Opt out of node auto-invalidation + +`Modifier.Node` nodes automatically invalidate when their corresponding +`ModifierNodeElement` calls update. For complex modifiers, you might want to opt +out of this behavior to gain more fine-grained control over when your modifier +invalidates phases. + +This is particularly useful if your custom modifier modifies both layout and +draw. Opting out of auto-invalidation lets you just invalidate draw when only +draw-related properties, such as `color`, change. This avoids invalidating +layout and can improve your modifier's performance. + +A hypothetical example of this is shown in the following example with a modifier +that has a `color`, `size`, and `onClick` lambda as properties. This modifier +invalidates only what is required, skipping any unneeded invalidation: + + +```kotlin +class SampleInvalidatingNode( + var color: Color, + var size: IntSize, + var onClick: () -> Unit +) : DelegatingNode(), LayoutModifierNode, DrawModifierNode { + override val shouldAutoInvalidate: Boolean + get() = false + + private val clickableNode = delegate( + ClickablePointerInputNode(onClick) + ) + + fun update(color: Color, size: IntSize, onClick: () -> Unit) { + if (this.color != color) { + this.color = color + // Only invalidate draw when color changes + invalidateDraw() + } + + if (this.size != size) { + this.size = size + // Only invalidate layout when size changes + invalidateMeasurement() + } + + // If only onClick changes, we don't need to invalidate anything + clickableNode.update(onClick) + } + + override fun ContentDrawScope.draw() { + drawRect(color) + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + val size = constraints.constrain(size) + val placeable = measurable.measure(constraints) + return layout(size.width, size.height) { + placeable.place(0, 0) + } + } +} +``` + +
\ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md b/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md new file mode 100644 index 0000000..7ee2939 --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md @@ -0,0 +1,186 @@ +Compose executes a frame in three strictly ordered, forward-flowing phases: +![Three forward-flowing phases: 1. Composition, 2. Layout, 3. Drawing](https://developer.android.com/static/develop/ui/compose/performance/images/compose_phases.png) **Figure 1.** The three phases of a Compose frame + +1. **Composition** : Runs `@Composable` functions to build and update the UI tree. +2. **Layout**: Measures children and then places them. +3. **Drawing**: Emits canvas draw commands to render pixels to the screen. + +Whenever a Compose `State` is **read** during any phase, Compose automatically +**records a dependency** between that state and the corresponding phase. + +*** ** * ** *** + +## What makes a write "backwards"? + +A **backwards write** occurs whenever **a state is modified in a later phase** +(or in a downstream scope---meaning a composable scope executed later in order +within the same composition pass) **than where it was read**, forcing Compose to +recompose by scheduling an earlier phase or composable to run again. A backwards +write is an unoptimized recomposition loop. +![Example of backwards write through the recomposition loop phases](https://developer.android.com/static/develop/ui/compose/performance/images/recomposition-loop.png) **Figure 2.** Example of backwards write through the recomposition loop phases + +## Consequences of backwards writes + +Backwards writes aren't necessarily a bad thing, and they don't always trigger +crashes, but they are inefficient and can harm app performance in several ways: + +- **Extra frame rendering and dropped frames**: A backwards write forces Compose to execute redundant composition passes across consecutive frames, wasting CPU and GPU resources and potentially causing jank. +- **First-frame correctness issues**: If your component requires a backwards write to resolve its final dimensions or state, the first frame is rendered with invalid, default, or unsettled data (such as zero size or an incorrect offset). This causes visible visual popping or layout flashing when the second frame renders. +- **Infinite recomposition loops**: If a state change alters layout sizing, and layout sizing continuously writes back a new value to state, you can risk creating an infinite frame loop where the screen constantly recomposes every frame without ever stabilizing. + +## Forward flow through phases + +State changes should always flow **forward** through the phases: + +| **Read Phase** | **Write Context** | **Acceptable?** | **Why** | +|---|---|---|---| +| **Layout** (`Modifier.offset { }`) | **Composition** | Yes | Composition updates the state → Layout reads it later in the same frame without recomposing. | +| **Draw** (`graphicsLayer { }`, `drawBehind { }`) | **Composition** | Yes | Composition updates the state → Draw reads it in the final phase. Composition and Layout are skipped entirely. | +| **Draw** | **Layout** | Yes | Layout updates the state, and draw reads it, valid flow. | +| **Composition** | Event Callback (`onClick`, `onValueChange`) driving state change. **Note**: layout callbacks do not count as events. | Yes | An event mutates state that is used to drive composition. If the event happens out-of-frame (not in Composition, Layout or Draw), this is valid. | +| **Composition** | Coroutine (`LaunchedEffect`) | Yes - with caution | Asynchronously updates state in response to lifecycle/events. Writes from effects can be valid, but can point to inefficient state layering. They should be avoided where possible. | +| **Placement (In Layout)** | **Measure (In Layout)** | Yes | In Layout, updating the state then reading that state in placement is acceptable. | +| **Measure (In Layout)** | **Placement (In Layout)** | **No - backwards write** | Writing to state in placement that's then further in the read state, causes remeasure loop. | +| **Composition** | **Layout** (`onSizeChanged`, `LayoutModifier`) | **No** - **Backwards write** | Layout invalidates Composition → Recomposition loop. | +| **Composition** | **Draw** (`drawWithContent`, `Canvas`) | **No - Backwards write** | Draw invalidates Composition → Recomposition loop. | + +## Phase combinations: Backwards versus forward + +The following are examples of backwards writes within Compose and how you can +resolve them. + +### Backwards: Reading in Composition, writing in Layout + +- **What happens** : Composition reads `componentHeight` to determine what UI to emit. Later in the frame, the Layout phase measures or places views and writes a new value to `componentHeight` (for example, using `onSizeChanged`, `onGloballyPositioned`, or custom `LayoutModifier`). +- **Result** : Modifying `componentHeight` in Layout invalidates the Composition phase that was just completed. Note that `onSizeChanged` reports size after the layout measurement pass completes. If the updated state value stabilizes on the next pass, the recomposition may halt after **one extra + frame** ; however, if the new value continues to alter the size, it results in an infinite frame loop. Furthermore, `onGloballyPositioned` runs after both layout and placement, making state writes inside it even more susceptible to continuous recomposition and relayout loops across consecutive frames. + + +```kotlin +// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged) +@Composable +fun BadAspectRatioImage(painter: Painter) { + var calculatedHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + // State read during COMPOSITION: + Image( + painter = painter, + contentDescription = "Dynamic Image", + modifier = Modifier + .fillMaxWidth() + .height(calculatedHeight) + .onSizeChanged { size -> + // State write during LAYOUT phase! + // Triggers backwards write and recomposition pass + val aspectRatio = 16f / 9f + val widthDp = with(density) { size.width.toDp() } + calculatedHeight = widthDp / aspectRatio + } + ) +} + +// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition +@Composable +fun GoodAspectRatioImage( + painter: Painter, + aspectRatio: Float = 16f / 9f, + modifier: Modifier = Modifier +) { + Layout( + content = { + Image( + painter = painter, + contentDescription = "Dynamic Image" + ) + }, + modifier = modifier + ) { measurables, constraints -> + val width = constraints.maxWidth + val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio() + val imageConstraints = constraints.copy( + minWidth = width, + maxWidth = width, + minHeight = height, + maxHeight = height + ) + val placeable = measurables.first().measure(imageConstraints) + layout(width, height) { + placeable.placeRelative(0, 0) + } + } +} +``` + +
+ +### Backwards: Reading in Composition, writing in Drawing + +- **What happens** : State is read in the Composable body (Composition phase), but mutated inside `Modifier.drawWithContent`, `Modifier.drawBehind`, or `Canvas` (Draw phase). +- **Result**: Draw phase mutates state → Composition invalidated → endless loop. + + +```kotlin +// ❌ BAD: Read in Composition, Written in Draw () +@Composable +fun BadBackwardsWriteDraw() { + var componentHeight by remember { mutableStateOf(0.dp) } + // State read during COMPOSITION: + Text( + text = "Height is: $componentHeight", + modifier = Modifier.drawBehind { + // State write during the DRAW phase! + // Invalidates Composition -> triggers recomposition loop! + componentHeight = size.height.dp + } + ) +} +``` + +
+ +### Backwards: Reading in Composition, writing in Composition (same phase) + +- **What happens** : Reading `count` in composable function, and modifying `count` directly in another composables content slot after reading it. +- **Result**: The snapshot system records the read and subsequent write within the same composition pass, immediately invalidating the current scope. + + +```kotlin +// ❌ BAD: Direct write in Composable body after read +@Composable +fun BadCounter() { + var count by remember { mutableIntStateOf(0) } + Text("Count: $count") // State read in Composition + Button(onClick = {}) { + count++ // State write in Composition (Backwards write!) + } +} +// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read +@Composable +fun OkCounter() { + var count by remember { mutableIntStateOf(0) } + Button(onClick = {}) { + count++ // State write in Composition + } + Text("Count: $count") // State read in Composition +} +``` + +
+ +*** ** * ** *** + +## Key rules to prevent backwards writes + +1. **Don't write to state in `onGloballyPositioned`, `onSizeChanged`, or + `LayoutModifier` if that state is read in Composition** as this causes the first-frame-correctness problem. + - If layout coordinates or sizes are only needed for custom drawing, read them directly in the Draw or Layout phase (for example, using `Modifier.drawWithCache` or `Modifier.layout`). + - **For Window-Level Sizing (`WindowWidthSizeClass`):** Hoist size observation to the window level. Composition branches on window size classes before local measurement occurs. + - **Keep Composition Uniform:** Use a single custom Layout or components like `FlowRow` or `LazyVerticalGrid` that adjust measurement and placement during Phase 2 without recomposing or altering state used in composition. + - **Use Sub-composition:** Use `BoxWithConstraints` or `SubcomposeLayout` when child composables must branch based on local width or height. Use caution: Subcomposition carries a performance cost and can usually be avoided. + - **As a last resort:** Allow the first frame to be wrong, storing size in `onSizeChanged` to trigger a second frame recomposition. This causes visible layout popping, jank, and risks infinite loops. +2. **Don't mutate state after its read the first time in composition** : + - Although you may safely write to `MutableState` objects during composition outside of a`SideEffect`, take special care to ensure that you don't write to a state that you may have previously read in composition. It's recommended to use `rememberUpdatedState` when this need for a state write in composition arises. Writing to a state during composition in another way is usually a sign of a missing effect or an inappropriately designed state or composable. Remember that composition is optimistic and always executes with the latest value of a state, so you may not see all state changes in recompositions. UI updates shouldn't be used as a way of handling one-time events, which makes it uncommon for a state's value to require being updated as a result of recomposing. + - Avoid mutating states that are observed outside of composition (for example, `ViewModel` fields or `isVisible` flags). State writes that affect composition belong in event lambdas (`onClick`), coroutines (`LaunchedEffect`), or side effects (`SideEffect`). `rememberUpdatedState` is an exception as it is designed to mutate state that is only used in `@Composable` body. +3. **Defer state reads to the latest possible phase** : + - Reading states in Draw (`Modifier.graphicsLayer { alpha = ... }`) or Layout (`Modifier.offset { IntOffset(...) }`) ensures that changes only invalidate Phase 2 or 3, completely skipping Phase 1 (Composition). \ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md b/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md new file mode 100644 index 0000000..8dbe2a3 --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md @@ -0,0 +1,137 @@ +Strong Skipping is a mode available in the Compose compiler. When enabled, it +changes the compiler's behavior in two ways: + +- Composables with unstable parameters become [skippable](https://developer.android.com/develop/ui/compose/performance/stability#functions) +- Lambdas with unstable captures are [remembered](https://developer.android.com/develop/ui/compose/state#state-in-composables) + +> [!NOTE] +> **Note:** Strong Skipping is enabled by default in Kotlin 2.0.20. The full list of options are available in the [Compose Compiler Gradle Plugin reference](https://kotlinlang.org/docs/compose-compiler-options.html). + +## Enable strong skipping mode + +Strong skipping is enabled by default in Kotlin 2.0.20. + +To enable strong skipping for a Gradle module in a release prior to 2.0.20, +include the following option in +the `composeCompiler` block of your Gradle configuration: + + android { ... } + + composeCompiler { + enableStrongSkippingMode = true + } + +## Composable skippability + +Strong skipping mode relaxes some of the [stability](https://developer.android.com/develop/ui/compose/performance/stability) rules normally applied +by the Compose compiler when it comes to skipping and composable functions. By +default, the Compose compiler marks a composable function as skippable if all of +its arguments have stable values. Strong skipping mode changes this. + +With strong skipping enabled, all restartable composable functions become +skippable. This applies whether or not they have unstable parameters. +Non-restartable composable functions remain unskippable. + +### When to skip + +To determine whether to skip a composable during recomposition, Compose compares +the value of each parameter with their previous values. The type of comparison +depends on the [stability](https://developer.android.com/develop/ui/compose/performance/stability) of the parameter. + +- Unstable parameters are compared using instance equality (`===`) +- Stable parameters are compared using object equality (`Object.equals()`) + +If all parameters meet these requirements, Compose skips the composable during +recomposition. + +You might want a composable to opt out of strong skipping. That is, you might +want a restartable but non-skippable composable. In this case, use the +`@NonSkippableComposable` annotation. + + @NonSkippableComposable + @Composable + fun MyNonSkippableComposable {} + +### Annotate classes as stable + +If you want an object using object equality instead of instance equality, +continue to annotate the given class with `@Stable`. +An example of when you might +have to do this is when observing an entire list of objects, data sources such +as Room will allocate new objects for every item in the list any time one of +them changes. + +## Lambda memoization + +Strong skipping mode also enables more [memoization](https://en.wikipedia.org/wiki/Memoization) of lambdas +inside composables. With strong skipping enabled, every lambda inside a +composable function will be automatically remembered. + +### Examples + +To achieve memoization of lambdas inside composables when using strong skipping, +the compiler wraps your lambda with a `remember` call. It is keyed with the +captures of the lambda. + +Consider a case where you have a lambda as in the following example: + + @Composable + fun MyComposable(unstableObject: Unstable, stableObject: Stable) { + val lambda = { + use(unstableObject) + use(stableObject) + } + } + +With strong skipping enabled, the compiler memoizes the lambda by wrapping it in +a `remember` call: + + @Composable + fun MyComposable(unstableObject: Unstable, stableObject: Stable) { + val lambda = remember(unstableObject, stableObject) { + { + use(unstableObject) + use(stableObject) + } + } + } + +The keys follow the same comparison rules as composable functions. The runtime +compares unstable keys using instance equality. It compares stable keys using +object equality. + +> [!NOTE] +> **Note:** This is slightly different to a typical `remember` call where the Compose runtime compares all keys using object equality. + +### Memoization and recomposition + +This optimization greatly increases the number of composables that the runtime +skips during recomposition. Without memoization, the runtime is much more likely +to allocate a new lambda to any composable that takes a lambda parameter during +recomposition. As a result, the new lambda has parameters that are not equal to +the last composition. This results in recomposition. + +> [!NOTE] +> **Note:** A common misconception is that lambdas with unstable captures are themselves unstable objects. This is not true. The Compose compiler always considers lambdas to be stable. However, with Strong skipping disabled, the compiler does not memoize them and the runtime allocates them during recomposition. As such, lambdas with unstable captures cause Compose not to skip composables because they have unequal parameters. + +### Avoid memoization + +If you have a lambda that you don't want to memoize, use the `@DontMemoize` +annotation. + + val lambda = @DontMemoize { + ... + } + +## APK Size + +When compiled, skippable Composables result in more generated code than +composables that are not skippable. With strong skipping enabled, the compiler +marks nearly all composables as skippable and wraps all lambdas in a +`remember{...}`. Due to this, enabling strong skipping mode has a very small +impact on the APK size of your application. + +Enabling strong skipping in [Now In Android](https://github.com/android/nowinandroid) increased the APK +size by 4kB. The difference in size largely depends on the number of previously +unskippable composables that were present in the given app but should be +relatively minor. \ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/animation.md b/jetpack-compose/jetpack-compose-performance/references/animation.md new file mode 100644 index 0000000..6fdce35 --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/animation.md @@ -0,0 +1,290 @@ +## 1. Phase deferral for animations (skip Composition to Draw and Layout) + +When animating visual properties (such as alpha, scale, rotation, color, or +translation), never read the animated state directly in the Composable body +during the Composition phase. Reading state during composition causes the entire +composable tree to recompose on every animation tick (60 to 120 times per +second). + +Wrap animated state reads in lambdas so evaluation is deferred to the **Draw** +or +**Layout** phase. + +### A. Alpha, rotation, and scale: defer to Draw with `graphicsLayer` + +- **Bad (Recomposes on every animation frame)**: + + + ```kotlin + val alpha by animateFloatAsState(targetValue = targetAlpha, label = "Alpha") + // Reading alpha here registers a composition dependency on every frame + Box(modifier = Modifier.alpha(alpha)) + ``` + +
+ +- **Optimized (Bypasses Composition and Layout, evaluates in Draw phase)**: + + + ```kotlin + val alpha by animateFloatAsState(targetValue = targetAlpha, label = "Alpha") + // graphicsLayer with lambda skips composition and runs in the draw phase + Box( + modifier = Modifier.graphicsLayer { + this.alpha = alpha + } + ) + ``` + +
+ +### B. Animated offsets: defer to Layout with `Modifier.offset` + +- **Bad (Recomposes on every pixel shift)**: + + + ```kotlin + val animatedOffset by animateDpAsState( + targetValue = targetOffset, + label = "Offset", + ) + Box(modifier = Modifier.offset(x = animatedOffset, y = 0.dp)) + ``` + +
+ +- **Optimized (Skips Composition, goes straight to Layout phase)**: + + + ```kotlin + val animatedOffset by animateIntAsState( + targetValue = targetOffsetPx, + label = "Offset", + ) + Box( + modifier = Modifier.offset { + IntOffset(x = animatedOffset, y = 0) + } + ) + ``` + +
+ +### C. Animated size: defer to Layout with `Modifier.layout` or `animateBounds` + +Animating container dimensions using `animateDpAsState` and `Modifier.size(...)` +directly in the Composable body triggers recomposition of the composable and its +children on every animation tick. + +Use layout-phase constraint animation or Lookahead bounds animation instead: + +- **Bad (Recomposes entire subtree on every animation frame)**: + + + ```kotlin + val animatedSize by animateDpAsState( + targetValue = targetSize, + label = "Size", + ) + Box(modifier = Modifier.size(animatedSize)) + ``` + +
+ +- **Optimized (Phase Deferral with `Modifier.layout`)**: Animate constraints + in the layout pass without re-triggering composition: + + + ```kotlin + val animatedWidth by animateIntAsState( + targetValue = targetWidthPx, + label = "Width", + ) + Box( + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy( + minWidth = animatedWidth, + maxWidth = animatedWidth + ) + ) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + ) + ``` + +
+ +- **Optimized (Lookahead Scope with `animateBounds`)** : Inside a + `LookaheadScope`, animate size changes smoothly using `animateBounds`: + + + ```kotlin + with(lookaheadScope) { + Box(modifier = Modifier.animateBounds(this, Modifier.size(finalSize))) + } + ``` + +
+ +### D. Custom composable parameters (lambda providers) + +When creating custom composables that animate properties internally, pass a +lambda provider (`alphaProvider: () -> Float` or `offsetProvider: () -> +IntOffset`) instead of the raw animated value. This allows the caller to avoid +reading the state in Composition and allows the child to defer the read to +`graphicsLayer` or `offset`. + +- **Bad (Caller must read state in Composition to pass Float)**: + + + ```kotlin + @Composable + fun AnimatedCard(alpha: Float) { + Box(modifier = Modifier.graphicsLayer { this.alpha = alpha }) + } + ``` + +
+ +- **Optimized (Caller passes lambda; read is deferred until child's Draw + phase)**: + + + ```kotlin + @Composable + fun AnimatedCard(alphaProvider: () -> Float) { + Box(modifier = Modifier.graphicsLayer { this.alpha = alphaProvider() }) + } + ``` + +
+ +## 2. Shared transitions and shared elements (`SharedTransitionScope`) + +`SharedTransitionScope` enables coordinated bounds and element transitions +across layouts and navigation destinations. + +### 1. Defer `sharedBounds` and `sharedElement` in fast-scrolling lists + +**Recommendation:** For long or fast-scrolling lists (`LazyColumn`, `LazyRow`, +`LazyVerticalGrid`, `HorizontalPager`), consider attaching +`Modifier.sharedBounds` or `Modifier.sharedElement` only when items enter the +visible viewport using `Modifier.onVisibilityChanged`. + +While spatial coordinate tracking is only enabled when a match is found, +unconditionally attaching shared transition modifiers introduces modifier +instantiation and state observation overhead for **pre-fetched items that may +never end up on screen** during fast scroll flings. Deferring attachment until +visible avoids this instantiation cost for offscreen items. +> **Note:** +> This recommendation applies to current versions of Compose. Upstream platform +> performance optimizations to make `Modifier.sharedBounds` and +> `Modifier.sharedElement` lighter-weight and avoid prefetch overhead are in +> active development. + +- **Before (Instantiation runs on all pre-fetched items even if never + displayed)**: + + + ```kotlin + @Composable + fun FeedItemRow( + item: FeedItem, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + ) { + with(sharedTransitionScope) { + ItemContent( + modifier = Modifier + .fillMaxWidth() + .sharedBounds( + rememberSharedContentState(key = item.id), + animatedVisibilityScope = animatedVisibilityScope, + ) + ) + } + } + ``` + +
+ +- **Optimized (Shared transition modifier deferred until item enters + viewport)**: + + + ```kotlin + @Composable + fun FeedItemRow( + item: FeedItem, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + ) { + var isVisibleInWindow by remember { mutableStateOf(false) } + + with(sharedTransitionScope) { + val sharedBoundsModifier = if (isVisibleInWindow) { + Modifier.sharedBounds( + rememberSharedContentState(key = item.id), + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + Modifier + } + + ItemContent( + modifier = Modifier + .fillMaxWidth() + .onVisibilityChanged( + minFractionVisible = 0.001f, + ) { visible -> + isVisibleInWindow = visible + } + .then(sharedBoundsModifier) + ) + } + } + ``` + +
+ +## 3. Persistent `Animatable` in custom modifiers (`Modifier.Node`) + +When building custom animated modifiers, do not use `composed { ... }` with +`remember { Animatable(...) }` and `LaunchedEffect`. + +Store `Animatable` directly as a member property on the `Modifier.Node` and +launch the animation in `onAttach()` using the node's built-in `coroutineScope`: + + +```kotlin +private class FadeRevealNode( + var targetAlpha: Float, + var durationMillis: Int +) : Modifier.Node(), DrawModifierNode { + // Persistent node property (avoids remember overhead) + private val alpha = Animatable(0f) + + override fun onAttach() { + // Launches animation tied directly to node lifecycle + coroutineScope.launch { + alpha.animateTo(targetAlpha, tween(durationMillis)) + } + } + + override fun ContentDrawScope.draw() { + drawContext.canvas.saveLayer( + bounds = size.toRect(), + paint = Paint().apply { + this.alpha = this@FadeRevealNode.alpha.value + } + ) + drawContent() + drawContext.canvas.restore() + } +} +``` + +
\ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/effects-and-threading.md b/jetpack-compose/jetpack-compose-performance/references/effects-and-threading.md new file mode 100644 index 0000000..46ad67b --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/effects-and-threading.md @@ -0,0 +1,531 @@ +## 1. Main thread blocking, offloading, and caching + +Move non-UI work off the main thread, avoid heavy object allocations directly +inside Composable bodies, and cache expensive calculations across recompositions +using `remember`. + +### 1. Cache expensive computations in composable body + +If a Composable body performs sorting, filtering, or heavy string manipulation +on a collection, wrap the calculation in `remember`: + +- **Bad (Recalculated on every recomposition pass)**: + + + ```kotlin + @Composable + fun FilteredFeed(rawList: List, query: String) { + // Avoid: Heavy filtering and sorting on every recomposition + val filteredList = rawList + .filter { it.title.contains(query) } + .sortedBy { it.timestamp } + PostList(posts = filteredList) + } + ``` + +
+ +- **Optimized (Cached calculation using remember)**: + + + ```kotlin + @Composable + fun FilteredFeed(rawList: List, query: String) { + val filteredList = remember(rawList, query) { + rawList + .filter { it.title.contains(query) } + .sortedBy { it.timestamp } + } + PostList(posts = filteredList) + } + ``` + +
+ +For collections with very few elements (3 to 5 items), wrapping calculations in +`remember` introduces allocation overhead without measurable benefit. Benchmark +caching changes using Macro benchmark. + +### 2. Avoid heavy object allocations in composition + +Never allocate or compute heavy objects (such as `RoundedPolygon`, complex +paths, `Paint`, or date formatters) directly in the Composable body. Wrap them +in `remember { ... }` or hoist them out of the Composable. + +- **Bad (New date formatter allocated on every frame/recomposition)**: + + + ```kotlin + @Composable + fun DateBadge(timestamp: Long) { + val formatter = SimpleDateFormat("MMM dd, yyyy", LocalLocale.current.platformLocale) + Text(text = formatter.format(Date(timestamp))) + } + ``` + +
+ +- **Optimized (Formatter allocated once and reused across timestamp + updates)**: + + + ```kotlin + // Reusable formatter allocated once at top-level or remembered + private val dateFormatter = + SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + + @Composable + fun DateBadge(timestamp: Long) { + val formattedDate = dateFormatter.format(Date(timestamp)) + + Text(text = formattedDate) + } + ``` + +
+ +Conversion functions like `.toShape()` or `painterResource()` are `@Composable` +themselves and internally handle caching. + +### 3. Offload I/O and heavy work to `Dispatchers.IO` with `produceState` + +Never perform blocking operations (like database queries, disk I/O, heavy file +parsing, or blocking binder transactions) directly on the main thread. Offload +them to `Dispatchers.IO` using `produceState`: + +- **Bad (Blocking I/O on main UI thread)**: + + + ```kotlin + @Composable + fun ProfileScreen(fileUri: Uri) { + var state by remember { mutableStateOf(null) } + LaunchedEffect(fileUri) { + val data = parseJsonFromDisk(fileUri) // Blocks main dispatcher! + state = data + } + } + ``` + +
+ +- **Optimized (Offloaded to IO dispatcher via `produceState`)**: + + + ```kotlin + @Composable + fun ProfileScreen(fileUri: Uri) { + val state by produceState(initialValue = null, fileUri) { + value = withContext(Dispatchers.IO) { parseJsonFromDisk(fileUri) } + } + } + ``` + +
+ +## 2. Side effect execution and lifecycle rules + +Structure side effects to be lifecycle-aware, idempotent, and properly scoped. +Prohibit direct side-effects in the Composable body, prefer `SideEffect` for +non-suspending operations, hoist flow or receiver subscriptions out of item +composables, and always use `collectAsStateWithLifecycle()`. + +### 1. Prohibit direct side-effects in composable bodies + +The Composable body must remain idempotent and free of direct side effects. Side +effects placed directly in composition run on every recomposition attempt +(including speculative, interrupted, or discarded passes). + +- **Bad (Side-effect runs directly in composition pass)**: + + + ```kotlin + @Composable + fun UserProfile(userId: String, viewModel: ProfileViewModel) { + // Avoid: Runs on every recomposition and speculative pass + viewModel.trackProfileImpression(userId) + Text(text = "User: $userId") + } + ``` + +
+ +- **Optimized (Wrapped in SideEffect or user event handler)**: + + + ```kotlin + @Composable + fun UserProfile(userId: String, viewModel: ProfileViewModel) { + // Compose 1.12+: keyed SideEffect + SideEffect(userId) { + viewModel.trackProfileImpression(userId) + } + Text(text = "User: $userId") + } + ``` + +
+ +### 2. Prohibit `LaunchedEffect` for non-suspend operations + +Never wrap synchronous, non-suspending operations in `LaunchedEffect`. +`LaunchedEffect` creates a standalone coroutine, allocates coroutine context +machinery, and dispatches to a thread even when no suspension occurs. + +- **Bad (Unnecessary coroutine allocation for synchronous call)**: + + + ```kotlin + LaunchedEffect(itemId) { + analyticsTracker.trackScreenView(itemId) // Non-suspend function! + } + ``` + +
+ +- **Optimized (Use `SideEffect` for synchronous execution on successful + composition)**: + + + ```kotlin + // Compose 1.12+ (supports keys directly): + SideEffect(itemId) { + analyticsTracker.trackScreenView(itemId) + } + ``` + +
+ +- **Warning** : Never use `DisposableEffect(keys) { ... onDispose {} }` with an + empty `onDispose` block to trigger a keyed synchronous effect. Check the + project's Compose version in `gradle/libs.versions.toml` or + `build.gradle(.kts)`: use `SideEffect(keys)` on Compose 1.12+, or + `SideEffect { ... }` with a `remember`ed key check on pre-1.12 versions. + +### 3. Lifecycle-aware flow collection (`collectAsStateWithLifecycle`) + +In Android UI development, use `collectAsStateWithLifecycle()` when collecting +Kotlin `Flow`s that actively produce background work. +`collectAsStateWithLifecycle` (from +`androidx.lifecycle:lifecycle-runtime-compose`) automatically subscribes and +cancels flow collection based on the Android `Lifecycle` state (defaulting to +`Lifecycle.State.STARTED`), preventing background CPU usage, battery drain, +resource leaks, and unnecessary UI updates when the app or screen is stopped. + +- **Performance Nuance \& When to Prefer `collectAsState()`**: + + - **Benchmark Overhead** : `collectAsStateWithLifecycle()` is \~3-4x slower to subscribe and dispatch compared to `collectAsState()` due to Android `Lifecycle` observer registration and lifecycle-aware state machine overhead. + - **In-Memory \& Scoped StateFlows** : For lightweight, in-memory `StateFlow`s (such as UI states emitted by a ViewModel already scoped with `SharingStarted.WhileSubscribed(5000)`), `collectAsState()` is often more performant and avoids this overhead. + - **When to Require `collectAsStateWithLifecycle()`** : Reserve `collectAsStateWithLifecycle()` for cold flows, database observation streams, sensors, location updates, or external polling streams that must halt emission when the screen is not visible. + - **Non-Android platforms** : In Compose Multiplatform (Desktop, Web, iOS) where the Android `Lifecycle` library is not present, use `collectAsState()`. + - **Headless / Unit Test environments** : In non-UI scopes or pure unit tests where no `LifecycleOwner` is available. +- **Bad (Continues collecting in background on Android, leaking resources and + CPU cycles)**: + + + ```kotlin + @Composable + fun HomeFeed(viewModel: FeedViewModel) { + // Avoid on Android: Flow stays active when activity is stopped + val uiState by viewModel.feedState.collectAsState() + FeedContent(uiState = uiState) + } + ``` + +
+ +- **Optimized (Lifecycle-aware: pauses collection when backgrounded)**: + + + ```kotlin + // import androidx.lifecycle.compose.collectAsStateWithLifecycle + + @Composable + fun HomeFeed(viewModel: FeedViewModel) { + val uiState by viewModel.feedState.collectAsStateWithLifecycle() + FeedContent(uiState = uiState) + } + ``` + +
+ +### 4. Hoist `BroadcastReceiver`s and listeners to prevent per-item registration + +Never register a `BroadcastReceiver` or system listener (such as +`ACTION_TIMEZONE_CHANGED` or network connectivity changes) inside list item +Composables. This registers a separate receiver for every visible item on the +main thread, causing severe scrolling latency, memory leaks, and frame drops. + +Register a single receiver in a parent or screen-level Composable using +`DisposableEffect`, and propagate the state down using parameters or +`CompositionLocalProvider`. + +- **Bad (Registers a separate BroadcastReceiver for every visible item in + LazyColumn)**: + + + ```kotlin + @Composable + fun TimeZoneListItem(timeZoneId: String) { + val context = LocalContext.current + DisposableEffect(Unit) { + // Avoid: Multiplied BroadcastReceivers per visible item! + val receiver = object : BroadcastReceiver() { + // ... + } + context.registerReceiver( + receiver, + IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), + ) + onDispose { context.unregisterReceiver(receiver) } + } + ItemRow(timeZoneId) + } + ``` + +
+ +- **Optimized (Single BroadcastReceiver hoisted to parent container)**: + + + ```kotlin + @Composable + fun TimeZoneList(timeZoneIds: List) { + val context = LocalContext.current + var currentTimeZone by remember { + mutableStateOf(TimeZone.getDefault()) + } + + DisposableEffect(context) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + currentTimeZone = TimeZone.getDefault() + } + } + context.registerReceiver( + receiver, + IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), + ) + onDispose { context.unregisterReceiver(receiver) } + } + + LazyColumn { + items(timeZoneIds, key = { it }) { id -> + TimeZoneListItem( + timeZoneId = id, + currentTimeZone = currentTimeZone, + ) + } + } + } + ``` + +
+ +### 5. Guard optional effects and match effect type to work + +Never spawn `LaunchedEffect` or `DisposableEffect` unconditionally if optional +callbacks or trigger handlers are `null`. Guard the effect with `if (callback != +null)` to avoid allocating unnecessary coroutine jobs and snapshot effect +tracking structures. Furthermore, ensure the effect API matches the execution +type: + +- **Synchronous Callbacks (`() -> Unit`)** : Use `SideEffect` or keyed `SideEffect(onInit)` rather than launching a coroutine job. +- **Suspending Callbacks (`suspend () -> Unit`)** : Use + `LaunchedEffect(onInit)` guarded by `if (onInit != null)`. + +- **Bad (Spawns empty coroutine and effect nodes when callback is null or + synchronous)**: + + + ```kotlin + // Avoid: Launches coroutine for synchronous work, runs even if null + LaunchedEffect(onInit) { + onInit?.invoke() + } + ``` + +
+ +- **Optimized (Synchronous work uses SideEffect; guarded if optional)**: + + + ```kotlin + if (onInit != null) { + SideEffect(onInit) { + onInit() + } + } + ``` + +
+ +### 6. Prevent stale captures with `rememberUpdatedState` + +If a long-running, periodic, or looping coroutine inside `LaunchedEffect` +references a Composable parameter or callback, use `rememberUpdatedState` to +ensure it always uses the latest value without restarting the effect: + +- **Bad (Stale capture if onTick parameter changes during loop lifetime)**: + + + ```kotlin + @Composable + fun PeriodicTicker(intervalMs: Long, onTick: () -> Unit) { + LaunchedEffect(intervalMs) { + while (isActive) { + delay(intervalMs) + onTick() // Stale reference if onTick callback instance changes! + } + } + } + ``` + +
+ +- **Optimized (rememberUpdatedState provides latest callback without + cancelling timer loop)**: + + + ```kotlin + @Composable + fun PeriodicTicker(intervalMs: Long, onTick: () -> Unit) { + val currentOnTick by rememberUpdatedState(onTick) + LaunchedEffect(intervalMs) { + while (isActive) { + delay(intervalMs) + currentOnTick() + } + } + } + ``` + +
+ +### 7. Replace legacy timers with coroutine delays + +Avoid using legacy Android handlers like `Handler.postDelayed` or `TimerTask` in +UI code. Use `LaunchedEffect` combined with `delay()` for clean, lifecycle-aware +timing that is automatically cancelled when the Composable leaves the +composition. + +- **Bad (Legacy Handler risks memory leak and out-of-lifecycle execution)**: + + + ```kotlin + DisposableEffect(Unit) { + val handler = Handler(Looper.getMainLooper()) + val runnable = Runnable { showBanner = false } + handler.postDelayed(runnable, 3000L) + onDispose { handler.removeCallbacks(runnable) } + } + ``` + +
+ +- **Optimized (Clean, lifecycle-aware coroutine delay)**: + + + ```kotlin + LaunchedEffect(Unit) { + delay(3000L) + showBanner = false + } + ``` + +
+ +### 8. Combine related `LaunchedEffect`s (reduce number of effects) + +Every `LaunchedEffect` attached to a composable allocates slot table records, +registers coroutine launcher machinery, and requires cancellation tracking +during teardown. + +When multiple asynchronous tasks share the same trigger key (such as `Unit` on +initial screen load), combine them into a single `LaunchedEffect` block with +nested `launch { ... }` calls instead of declaring multiple standalone +`LaunchedEffect`s. + +- **Bad (Multiple separate LaunchedEffects incur redundant effect overhead)**: + + + ```kotlin + @Composable + fun ShoppingCartScreen(viewModel: CartViewModel) { + // Avoid: 3 LaunchedEffects allocate 3 effect nodes and launchers + LaunchedEffect(Unit) { viewModel.loadCart() } + LaunchedEffect(Unit) { viewModel.loadPaymentMethods() } + LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() } + + CartContent() + } + ``` + +
+ +- **Optimized (Single LaunchedEffect manages multiple concurrent + coroutines)**: + + + ```kotlin + @Composable + fun ShoppingCartScreen(viewModel: CartViewModel) { + // Optimized: 1 LaunchedEffect node launches concurrent jobs + LaunchedEffect(Unit) { + launch { viewModel.loadCart() } + launch { viewModel.loadPaymentMethods() } + launch { viewModel.loadDeliveryAddresses() } + } + + CartContent() + } + ``` + +
+ +### 9. Effect execution ordering and teardown costs + +Understanding the execution lifecycle and performance costs of Compose effects +is critical when choosing between them: + +#### A. Execution order relative to frame rendering + +Compose executes effects in a strictly defined order across frame phases: + +1. **`DisposableEffect`**: Executes first, immediately after composition changes are applied, in the exact order declared in the composable. +2. **`SideEffect`** : Executes second, after all `DisposableEffect`s have run, before layout and drawing occur. +3. **Layout \& Draw**: The UI hierarchy measures, places, and draws pixels onto the screen. +4. **`LaunchedEffect`** : Executes **after Draw at the end of the frame**, scheduled on the coroutine dispatcher. + +> **Warning:** +> If your code implicitly relies on `LaunchedEffect` running after the frame has +> rendered (e.g., waiting for layout bounds to be drawn), migrating to +> `SideEffect` will execute *before* Layout and Draw. Always verify execution +> order dependencies when refactoring effects. + +#### B. Effect allocation and teardown costs + +Effects have substantially different overheads: + +- **`SideEffect`**: Cheapest effect; executes synchronously on every successful composition pass without lifecycle registration or coroutine allocation. +- **`DisposableEffect`** : Moderate overhead; registers callbacks and runs `onDispose` synchronously upon key change or exit from composition. +- **`LaunchedEffect`**: Highest overhead; requires allocating a coroutine launcher, launching a new coroutine job on the composition dispatcher, and canceling or tearing down the coroutine job when keys change or the composable exits. + +Because canceling a `LaunchedEffect` incurs coroutine job cancellation and +teardown overhead, avoid cycling `LaunchedEffect` keys frequently during +animations or fast scrolling. For synchronous keyed triggers without teardown, +always prefer keyed `SideEffect(keys)` over `LaunchedEffect(keys)`. + +## 3. Side effects API selection guide + +Choose the correct Side Effect API based on the execution context and lifecycle +requirements: + +| Use Case | Recommended API | Example | +|---|---|---| +| **Coroutine tied to Composable lifecycle** | `LaunchedEffect(keys)` | Triggering network calls or animations when an ID changes | +| **Synchronous cleanup on exit** | `DisposableEffect(keys)` | Registering and unregistering lifecycle listeners or observers | +| **Launch coroutine from UI callback** | `rememberCoroutineScope()` | Triggering a scroll animation on button click or swipe | +| **Convert Compose State into Flow** | `snapshotFlow { state }` | Debouncing search queries derived from text field state | +| **Synchronous keyed triggers** | `SideEffect` or `SideEffect(keys)` | Updating external imperatively managed views or non-Compose state | +| **Viewport visibility or impression tracking** | `Modifier.onVisibilityChanged` | Sending analytics or : logging impressions when an item enters the viewport \| | \ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/graphics-performance.md b/jetpack-compose/jetpack-compose-performance/references/graphics-performance.md new file mode 100644 index 0000000..04bac6f --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/graphics-performance.md @@ -0,0 +1,221 @@ +## 1. Custom drawing (`Canvas` versus `drawWithCache`) + +- **Avoid Nested Canvas Nodes** : Avoid using the `Canvas` composable solely to add decorative drawings to an existing layout. Instead, use `Modifier.drawBehind` or `Modifier.drawWithCache` on the existing Composable to avoid creating an extra node in the layout tree. +- **Cache Allocations with `drawWithCache`** : If your drawing logic allocates objects that depend on the size of the drawing area (like `Path`, `Brush`, `Shader`, or `Paint`), use **`Modifier.drawWithCache`** to cache allocated objects and avoid GC pressure during the draw phase. + +### A. Caching `Path` allocations + +- **Bad (Allocates a new Path on every single draw frame or animation tick)**: + + + ```kotlin + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, size.height) + } + drawPath(path, Color.Red) + } + ) + ``` + +
+ +- **Optimized (Caches the Path, only recreates it if the Box size changes)**: + + + ```kotlin + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, size.height) + } + onDrawBehind { + drawPath(path, Color.Red) + } + } + ) + ``` + +
+ +## 2. Scope fast-changing uniforms inside `drawWithCache` + +- **Remember the `RuntimeShader`** : Always `remember` the `RuntimeShader` (or lazily initialize it once at top-level / process scope) outside of `drawWithCache`. Although compiled shaders are cached internally by the runtime, parsing the AGSL shader source string incurs significant parsing overhead and should never be repeated inside `drawWithCache` size-change passes. +- **Remember the `ShaderBrush`** : `ShaderBrush` is a lightweight wrapper holding a reference to the shader. Remember it keyed on the shader: `remember(shader) { ShaderBrush(shader) }`. +- **Update layout- and size-dependent uniforms** (like viewport size or resolution using `size.width, size.height`) in the `drawWithCache` initialization block, as size changes only during layout passes. +- **Move dynamic, per-frame uniform updates** (such as updating time using `shader.setFloatUniform(...)`) inside the `onDrawBehind` or `onDrawWithContent` block without reallocating the shader or brush: + + +```kotlin +// RuntimeShader parsed once and cached in Composable scope +val shader = remember { RuntimeShader(SHADER_SRC) } +val brush = remember(shader) { ShaderBrush(shader) } + +Box( + modifier = modifier + .fillMaxSize() + .drawWithCache { + // Layout/size-dependent uniforms updated in cache block (on resize) + shader.setFloatUniform("u_resolution", size.width, size.height) + + onDrawBehind { + // Per-frame uniforms updated in draw block without reallocating + shader.setFloatUniform("u_time", timeState.value) + drawRect(brush) + } + } +) +``` + +
+ +## 3. Graphics `Path` performance and object reuse + +- **Prefer `Path.rewind()` over `Path.reset()` in Hot Draw Loops** : + - **`rewind()`** : Clears lines and curves but **retains internal buffers + and allocated capacity** in memory. Use `rewind()` when rebuilding a path on every frame with stable complexity (such as waveform monitors or custom charts) to avoid buffer reallocations. + - **`reset()`** : Clears the path and **releases internal buffers** . Use `reset()` only when the path will not be reused soon or when its complexity is shrinking sharply. + +## 4. Value classes, primitive collections, and packing + +Avoid allocating short-lived objects (such as `Pair`, `Pair`, `Point`, coordinate pairs, or dimensions) inside loops, animation +callbacks, measure blocks, or draw methods. + +- **Avoid Generic Tuples (`Pair`, `Triple`)** : Allocating `Pair(row, col)` or `Pair(x, y)` in hot loops creates continuous heap allocations and GC pressure (e.g. `HashSet>` allocates the `Pair` object, boxes both primitive `Int`s, and creates a `HashMap.Node` entry --- totaling 3+ heap allocations per element). +- **Standard Generic Sets Box Primitives** : `HashSet` or `HashSet` boxes every primitive to `java.lang.Long`/`java.lang.Integer` and allocates a `HashMap.Node`. For true zero-allocation collections, use **`androidx.collection.MutableLongSet`** or **`MutableIntSet`** from `androidx.collection:collection`. +- **Avoid Iterator Allocations in Loops** : `for (item in items)` allocates an `Iterator` object when `items` is a standard `List`. Use indexed loops (`for + (i in items.indices)`) or `items.fastForEach { ... }` from `androidx.compose.ui.util`. +- **Value Class Boxing Traps** : Kotlin `@JvmInline value class` and primitive types box into heap objects when used as generic type arguments (e.g. `Set`), when declared as nullable (`Point?`), or when cast to `Any`/interface types. +- **Use Built-in Compose Packing Utilities** : Prefer the built-in primitive packing functions in `androidx.compose.ui.util` (`packInts`, `unpackInt1`, `unpackInt2`, `packFloats`, `unpackFloat1`, `unpackFloat2`) or Compose value classes (`IntOffset`, `Offset`, `IntSize`, `Size`) instead of custom bitwise operators. + +### A. Primitive packing with `MutableLongSet` (grid or layout calculations) + +- **Bad (Allocates Pair, boxed Ints, iterator, and HashMap.Node per + element)**: + + + ```kotlin + val occupied = HashSet>() + for (item in items) { + occupied.add(Pair(item.row, item.col)) + } + ``` + +
+ +- **Optimized (0 heap allocations using MutableLongSet, packInts, and + fastForEach)**: + + + ```kotlin + // import androidx.collection.MutableLongSet + // import androidx.compose.ui.util.fastForEach + // import androidx.compose.ui.util.packInts + + val occupied = MutableLongSet() + items.fastForEach { item -> + occupied.add(packInts(item.row, item.col)) + } + ``` + +
+ +### B. Value class for coordinates + +- **Bad (Creates heap Point objects on every iteration)**: + + + ```kotlin + class Point(val x: Float, val y: Float) + var acc = Point(0f, 0f) + for (i in 0 until 1000) { + acc = Point(acc.x + i, acc.y - i) + } + ``` + +
+ +- **Optimized (0 heap allocations, packs 2 floats into a Long primitive value + class)**: + + + ```kotlin + // import androidx.compose.ui.util.packFloats + // import androidx.compose.ui.util.unpackFloat1 + // import androidx.compose.ui.util.unpackFloat2 + + @JvmInline + value class Point private constructor(val packedValue: Long) { + constructor(x: Float, y: Float) : this(packFloats(x, y)) + + val x: Float get() = unpackFloat1(packedValue) + val y: Float get() = unpackFloat2(packedValue) + } + + var acc = Point(0f, 0f) + for (i in 0 until 1000) { + acc = Point(acc.x + i, acc.y - i) + } + // Alternatively, use Compose's built-in Offset class, which implements + // this exact @JvmInline Long packing pattern under the hood. + ``` + +
+ +## 5. Image loading and decoding (`AsyncImage` versus `painterResource`) + +Avoid using `Image(painter = painterResource(resId))` to render raster photos or +large bitmap assets---especially inside scrolling lists (`LazyColumn`, `LazyRow`, +`LazyGrid`). Reserve `painterResource` strictly for vector drawables (`xml`) and +small UI icons. + +- **Synchronous Main-Thread Decoding** : `painterResource` decodes bitmaps synchronously on the main thread during composition (`Compose:recompose` -\> `ImageDecoder`), including during lazy list prefetch (`lazy:prefetch:compose`). Even a small compressed JPEG in the APK (e.g., 159 KB) can decompress into a large `2760x1840` bitmap, blocking the main thread and causing dropped frames. +- **No Target-Size Downsampling** : `painterResource` has no awareness of the layout size (such as `Modifier.size(160.dp)`) and decodes the image at full original resolution. +- **Prefer Async image loading like Coil's `AsyncImage` (or Asynchronous + Downsampling)** : + - **Offloads Decoding** : `AsyncImage` decodes images asynchronously on a background thread, keeping the main thread free during scroll and prefetch. + - **Automatic Downsampling (`ImageDecoder.setTargetSize`)** : `AsyncImage` measures the target layout bounds and configures `ImageDecoder.setTargetSize()` automatically so only the pixels actually displayed are decoded and uploaded to the GPU. + - **Ship Appropriately Sized Assets**: If bundling static thumbnails in the APK, ship pre-scaled thumbnail assets matching the display bucket rather than full-resolution camera photos. + +### A. Loading bitmap resources or photos in a fixed-size container + +- **Bad (Synchronously decodes full `2760x1840` bitmap on the main thread and + uploads the full texture to the GPU for a `160.dp` box)**: + + + ```kotlin + Image( + painter = painterResource(id = R.drawable.donut), + contentScale = ContentScale.Fit, + modifier = Modifier.size(160.dp), + contentDescription = stringResource(id = R.string.attached_image), + ) + ``` + +
+ +- **Optimized (Decodes off the main thread, automatically downsamples to the + `160.dp` target dimensions via `ImageDecoder.setTargetSize`, and caches the + result)**: + + + ```kotlin + // import coil3.compose.AsyncImage + + AsyncImage( + model = R.drawable.donut, + contentScale = ContentScale.Fit, + modifier = Modifier.size(160.dp), + contentDescription = stringResource(id = R.string.attached_image), + ) + ``` + +
\ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/layouts.md b/jetpack-compose/jetpack-compose-performance/references/layouts.md new file mode 100644 index 0000000..72a4272 --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/layouts.md @@ -0,0 +1,176 @@ +## 1. Layout coordinates, rects, and visibility tracking + +`Modifier.onGloballyPositioned` executes on every layout pass across the entire +Compose hierarchy whenever any node is placed or moved, making it a frequent +source of frame drops, unnecessary callbacks, and layout feedback loops. Use +targeted modern APIs instead: + +### 1. Viewport and dwell tracking: use `Modifier.onVisibilityChanged` + +Avoid calculating viewport intersections manually inside `onGloballyPositioned`. +Use **`Modifier.onVisibilityChanged`** (with `minFractionVisible` and optional +`minDurationMs` dwell thresholds) for: + +- **Impression Logging**: Ensures analytics fire only when an item actually enters the viewport and stays visible for a minimum duration (preventing premature logging on pre-fetched lazy items). +- **Deferring Lookahead Shared Transitions** : Attaches `Modifier.sharedBounds` or `Modifier.sharedElement` only when an item enters the viewport, eliminating Lookahead passes on offscreen items. + + +```kotlin +// Optimized: Viewport entry with dwell threshold +Box( + modifier = Modifier.onVisibilityChanged( + minFractionVisible = 0.5f, + minDurationMs = 500L, + ) { isVisible -> + if (isVisible) { + analytics.logImpression(itemId) + } + } +) +``` + +
+ +### 2. Local bounds and placement: prefer `onPlaced` or `onLayoutRectChanged` + +- **Dimensions Only (`IntSize`)** : If you only need width and height, use `Modifier.onSizeChanged`. It executes only when the node's dimensions change, ignoring position shifts. +- **Local Coordinates after Placement** : Use `Modifier.onPlaced` or `Modifier.onLayoutRectChanged` when you need local coordinates relative to the parent without subscribing to global window or screen coordinate updates across the entire tree. + +### 3. On-demand coordinate queries: avoid `MutableState` position tracking + +Avoid continuously listening to coordinate callbacks (`onGloballyPositioned` or +`onPlaced`) solely to cache layout coordinates into a `MutableState` for later +use when an event occurs (such as showing a tooltip, menu, or handling a +gesture). Storing coordinates in state causes redundant callback executions on +every layout or scroll pass and risks recomposition trashing or backwards +writes. + +#### A. In Custom Modifiers (`Modifier.Node`) + +`requireLayoutCoordinates()` is an API on `Modifier.Node` (specifically +`DelegatableNode`), designed to pull coordinates on demand: + +- **Query inside Event Handlers** : In custom modifier nodes (such as `PointerInputModifierNode` or `DrawModifierNode`), call `requireLayoutCoordinates()` directly inside gesture, touch, or draw callbacks at the moment the event occurs. +- **Eliminates continuous listeners** : You do not need to implement `LayoutAwareModifierNode` or listen to `onGloballyPositioned` continuously just to have coordinates available when an interaction happens. + + +```kotlin +// In a custom PointerInputModifierNode: +class InteractiveTooltipNode : Modifier.Node(), PointerInputModifierNode { + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize, + ) { + // Query coordinates on-demand at the exact moment of the interaction + if (isAttached) { + val coordinates = requireLayoutCoordinates() + val bounds = coordinates.boundsInRoot() + showTooltip(bounds) + } + } +} +``` + +
+ +#### B. In high-level composable code (versus `Modifier.clickable`) + +`requireLayoutCoordinates()` is strictly a `Modifier.Node` API---it is **not** +available in high-level composables, and `Modifier.clickable` does not provide +coordinates to its `onClick: () -> Unit` lambda: + +- **Standard clicks (`Modifier.clickable`)** : If your click handler does not require exact screen coordinates, use `Modifier.clickable` directly without attaching any coordinate listeners. +- **Position-aware gestures (`Modifier.pointerInput`)** : If you need the exact tap position in composable code (for example, to anchor a drop-down menu at the touch point), use `Modifier.pointerInput` with `detectTapGestures` to receive the tap `Offset` directly on the event, rather than storing `onGloballyPositioned` bounds in `MutableState`: + + +```kotlin +// Optimized: Captures touch offset on event without onGloballyPositioned +Box( + modifier = Modifier.pointerInput(Unit) { + detectTapGestures { offset -> + showMenuAt(offset) + } + } +) +``` + +
+ +### 4. Prohibit layout feedback loops (back-writing during Layout) + +Never write to `MutableState` inside `onPlaced`, `onSizeChanged`, or +`onGloballyPositioned` if reading that state invalidates the layout, size, or +placement of the node being measured. + +Writing to state in the Layout phase that is read in Composition creates a +**backwards write** , which triggers the **first-frame correctness problem**: the +first frame is rendered with invalid, default, or unsettled dimensions (such as +zero size or an incorrect offset), followed by a second frame recomposition that +causes visible visual popping, layout flashing, and dropped frames. If the +layout sizing continuously writes back a new value to state, it results in an +infinite recomposition loop. + +Similarly, within the Layout phase itself, state changes must flow forward: +updating state during measurement and reading it in placement is acceptable, but +writing to state in placement that is read during measurement causes a +continuous remeasure loop. + +If sizes or coordinates are needed for custom drawing or placement, read them +directly in the Draw or Layout phase using `Modifier.drawWithCache` or +`Modifier.layout` instead of passing them back to Composition. For more +information on phase flow and eliminating recomposition loops, see the +[Backwards Write documentation](https://developer.android.com/develop/ui/compose/performance/backwards-write). + +## 2. Phase deferral for layout offsets and constraints + +Defer reading animated or scroll-driven state values to the **Layout** phase by +wrapping reads in lambda-based layout modifiers. This bypasses the Composition +phase entirely on state changes: + +- **Bad (Recomposes on every offset change)**: + + + ```kotlin + val offset = scrollState.value + Box(modifier = Modifier.offset(x = offset.dp, y = 0.dp)) + ``` + +
+ +- **Optimized (Skips Composition, evaluates in Layout phase)**: + + + ```kotlin + Box(modifier = Modifier.offset { IntOffset(scrollState.value, 0) }) + ``` + +
+ +## 3. Minimizing layout tree depth and wrapper nodes + +Every layout node in Compose incurs measurement, placement, and snapshot +tracking costs. Minimize layout hierarchy depth: + +- **Avoid Decorative Wrapper Layouts** : Do not wrap a composable in an extra `Box` solely to apply a background, border, padding, or clickable modifier. Attach modifiers directly to the child composable. +- **Avoid Canvas Nodes for Decorative Backgrounds** : Avoid using the `Canvas` composable solely to add decorative drawings behind content. Use `Modifier.drawBehind` or `Modifier.drawWithCache` on the existing layout node to avoid adding an extra node to the layout tree. + +## 4. Subcomposition overhead (`SubcomposeLayout` and `BoxWithConstraints`) + +`SubcomposeLayout` (and composables built on top of it, such as +`BoxWithConstraints`) defers composition until the measurement pass, breaking +Compose's standard single-pass layout pipeline into multiple subcomposition +passes: + +- **Avoid `BoxWithConstraints` for Simple Responsiveness** : Do not use `BoxWithConstraints` when standard modifiers (`Modifier.fillMaxWidth()`, `Modifier.weight()`, or `Modifier.aspectRatio()`) or flow layouts can achieve the same visual layout. +- **Avoid `BoxWithConstraints` inside Lazy Items** : Using `BoxWithConstraints` inside `LazyColumn` or `LazyRow` items introduces subcomposition overhead for every recycled and pre-fetched item, increasing scroll jank. +- **Prefer `Modifier.layout` or `LayoutModifierNode`** : When custom measurement or placement rules depend on incoming constraints, prefer using `Modifier.layout { measurable, constraints -> ... }` or implementing `LayoutModifierNode` rather than spawning a `SubcomposeLayout` or `BoxWithConstraints`. + +## 5. Intrinsic measurements + +Intrinsic measurements (`IntrinsicSize.Min`, `IntrinsicSize.Max`) query children +for their preferred size before the final measurement pass, effectively running +a two-pass layout: + +- Use intrinsic measurements judiciously and only when siblings must match dimensions (for example, a `Divider` matching the height of adjacent text). +- Avoid chaining nested intrinsic measurements across deep hierarchies. \ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/lazy-layouts.md b/jetpack-compose/jetpack-compose-performance/references/lazy-layouts.md new file mode 100644 index 0000000..50e8462 --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/lazy-layouts.md @@ -0,0 +1,366 @@ +## 1. Lazy layout directives + +Implement strict item keying, content-type mapping, and index-lookup +optimizations to prevent unnecessary item recompositions, layout passes, and +dropped frames during scrolling. +> **Scope Guard (Virtualized Containers Only)** : The directives in this section +> apply **strictly** to lazy, virtualized layouts (`LazyColumn`, `LazyRow`, +> `LazyVerticalGrid`, `LazyHorizontalGrid`, `HorizontalPager`, `VerticalPager`). +> Standard non-virtualized containers (`Column`, `Row`, `Box`) iterating over +> collections using standard Kotlin `for` or `forEach` loops are **not** lazy +> layouts. Do **not** flag standard `Column`/`Row` loops for missing `key` or +> `contentType` parameters. + +### 1. Require keys and content types + +Always provide a stable `key` and a `contentType` for all items. + +- **`key`**: Enables Compose to maintain item state across reorderings and list updates without recreating item compositions from scratch. +- **`contentType`**: Enables Compose to reuse item compositions and node slots when recycling items of the same visual type, significantly reducing layout inflation overhead during fast scrolling. + + +```kotlin +LazyColumn { + items( + items = itemList, + key = { item -> item.id }, + contentType = { item -> item.type } + ) { item -> + ItemRow(item = item) + } +} +``` + +
+ +- **Ensure Keys are Bundle-Saveable** : Keys must be saveable in an Android `Bundle` because `LazyColumn` uses `rememberSaveable` internally to persist scroll states and item state across configuration changes and process death. + - **Do NOT** use custom data classes as keys unless they implement `Parcelable` or `Serializable`. + - **Do** use unique primitive types (like `Int` or `Long`). If needed, combine fields into a unique string: `key = { "${it.id}_${it.timestamp}" + }`. Prefer `Int` and `Long` over `String` for minimal memory and hashing overhead. +- **Avoid Duplicate and Unstable Keys** : Ensure keys are globally unique and deterministic. Never use `hashCode()` or list indexes as keys, as they change when items are reordered, inserted, or filtered. + +### 2. Avoid in-item collection searches and observable state queries + +Do not perform collection searches, linear index scans, or observable state +queries inside item layout blocks or outer list item scopes: + +- **Linear Index Scans (`indexOf`, `find`)** : Searching the list for an item's + index inside the layout block (for example, `list.indexOf(item)`) turns a + linear layout pass into an `O(N²)` operation and causes + `IndexOutOfBoundsException` during asynchronous mutations. Use + `itemsIndexed` instead. + + - **Bad (`O(N²)` linear scan per item)**: + + + ```kotlin + LazyColumn { + items(items = itemList, key = { it.id }) { item -> + // O(N) lookup inside each item! + val index = itemList.indexOf(item) + ItemRow(index = index, item = item) + } + } + ``` + +
+ + - **Optimized (Direct index passing with `itemsIndexed`)**: + + + ```kotlin + LazyColumn { + itemsIndexed( + items = itemList, + key = { _, item -> item.id }, + ) { index, item -> + ItemRow(index = index, item = item) + } + } + ``` + +
+ +- **Observable Selection \& State Queries** : Never use `mutableStateListOf` + (`SnapshotStateList`) for tracking selected IDs where `.contains()` and + `.remove()` perform `O(N)` linear scans; use an immutable `Set` in + `MutableState` (`mutableStateOf(emptySet())`) or + `mutableStateSetOf` (`SnapshotStateSet` in Compose 1.8+) for `O(1)` + lookups. Also, reading an observable collection state (like + `selectedIds.contains(item.id)`) inside the outer `items(...) { ... }` + block subscribes the entire list or section to changes in that collection. + Toggling a single item invalidates the parent scope and forces a full + recomposition of all visible items. Defer the state read to the leaf item + composable by passing a lambda provider (`isSelected: () -> Boolean`). + + - **Bad (`mutableStateListOf` `O(N)` lookups and reading state in outer + `items` scope triggers full list or grid recomposition on toggle)**: + + + ```kotlin + @Composable + fun TopicGrid(sections: List) { + val selectedTopicIds = remember { mutableStateListOf() } + LazyColumn { + items(sections, key = { it.id }) { section -> + Column { + Text(text = section.title) + section.topics.forEach { topic -> + // Avoid: O(N) List.contains() read in items() scope + // invalidates the whole section! + val isSelected = selectedTopicIds.contains(topic.id) + TopicChip( + topic = topic, + isSelected = isSelected, + onToggle = { + if (isSelected) { + selectedTopicIds.remove(topic.id) + } else { + selectedTopicIds.add(topic.id) + } + } + ) + } + } + } + } + } + ``` + +
+ + - **Optimized (Use `Set` (`mutableStateOf(emptySet())` or + `mutableStateSetOf()`) for `O(1)` lookup and pass lambda to leaf + composable to isolate recomposition to the clicked item)**: + + + ```kotlin + @Composable + fun TopicGrid(sections: List) { + var selectedTopicIds by remember { + mutableStateOf(emptySet()) + } + LazyColumn { + items( + items = sections, + key = { it.id }, + contentType = { "section" }, + ) { section -> + Column { + Text(text = section.title) + section.topics.forEach { topic -> + TopicChip( + topic = topic, + // O(1) Set read deferred to TopicChip's scope + isSelectedProvider = { + selectedTopicIds.contains(topic.id) + }, + onToggle = { + val selected = + selectedTopicIds.contains(topic.id) + selectedTopicIds = if (selected) { + selectedTopicIds - topic.id + } else { + selectedTopicIds + topic.id + } + } + ) + } + } + } + } + } + + @Composable + fun TopicChip( + topic: Topic, + isSelectedProvider: () -> Boolean, + onToggle: () -> Unit + ) { + // Read occurs inside leaf scope + val isSelected = isSelectedProvider() + val status = if (isSelected) "Selected" else "Unselected" + Box(modifier = Modifier.clickable { onToggle() }) { + Text(text = "${topic.title} ($status)") + } + } + ``` + +
+ +### 3. Avoid `derivedStateOf` for direct collection sizes and counts + +Do not wrap direct collection property accesses (such as `list.size` or +`list.isEmpty()`) in `derivedStateOf`. Accessing a property on an already +available list or snapshot collection is an `O(1)` read. Wrapping it in +`derivedStateOf` allocates a `DerivedState` wrapper object, snapshot record +tracking structures, and observer subscriptions that cost far more than reading +the integer property directly. + +### 4. Avoid lazy layouts for small or bounded collections + +**Tip:** For small static or bounded dynamic collections (e.g. 1 to 10 items +such as tags, buttons, form rows, or chips), prefer using a standard `Row` or +`Column` with a `for` or `forEach` loop instead of `LazyRow` or `LazyColumn`. +For small collections, composing all items eagerly reduces layout, prefetching, +and measurement overhead compared to lazy list virtualization machinery. +Benchmark this change before applying broad changes to code. + +- **Bad (Lazy virtualization overhead for tiny static list)**: + + + ```kotlin + LazyRow { + items(items = listOf("Work", "Personal", "Family")) { tag -> + FilterChip(tag = tag) + } + } + ``` + +
+ +- **Optimized (Standard Row with forEach)**: + + + ```kotlin + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("Work", "Personal", "Family").forEach { tag -> + FilterChip(tag = tag) + } + } + ``` + +
+ +### 5. Hoist shared `Flow` and state collections out of lazy items + +**Critical:** Never collect a shared `Flow`, `StateFlow`, or observable timer +directly inside list item composables using `collectAsState()` or +`collectAsStateWithLifecycle()`. + +When a shared flow is collected inside item composables, every visible item in +the `LazyColumn` or `LazyRow` creates an independent subscription to that same +flow. When the flow emits an update (such as an idle timer tick or periodic +sync), it invalidates and recomposes all visible items simultaneously, creating +massive CPU spikes and dropped frames during scrolling. + +Instead, **hoist the flow collection once** to the parent container above the +`LazyColumn`, derive the required state, and pass stable or primitive values +down to the individual item composables. + +- **Bad (Each visible item creates an independent subscription to the shared + timer)**: + + + ```kotlin + @Composable + fun FeedItem(item: Post) { + // Avoid: Multiplied subscriptions across all visible items! + val secondsSinceLastScroll by MainFeedIdleTracker + .secondsSinceLastScrollFlow + .collectAsState(0L) + val isDwellTriggered = secondsSinceLastScroll >= 5L + PostActions(isDwellTriggered = isDwellTriggered) + } + + @Composable + fun FeedList(posts: List) { + LazyColumn { + items(posts, key = { it.id }) { post -> + FeedItem(post) + } + } + } + ``` + +
+ +- **Optimized (Hoist subscription once above LazyColumn, pass stable value + down)**: + + + ```kotlin + @Composable + fun FeedList(posts: List) { + // Hoist once: Parent owns the single subscription + val secondsSinceLastScroll by MainFeedIdleTracker + .secondsSinceLastScrollFlow + .collectAsStateWithLifecycle(0L) + val isDwellTriggered = secondsSinceLastScroll >= 5L + + LazyColumn { + items(posts, key = { it.id }) { post -> + FeedItem(post = post, isDwellTriggered = isDwellTriggered) + } + } + } + + @Composable + fun FeedItem(post: Post, isDwellTriggered: Boolean) { + PostActions(isDwellTriggered = isDwellTriggered) + } + ``` + +
+ +### 6. Propagate `CompositionLocal`s down instead of reading in lazy items + +Avoid querying `CompositionLocal`s (such as `LocalContext.current` or +`LocalDensity.current`) directly inside individual lazy item composables. + +Querying a `CompositionLocal` inside every item adds repetitive map lookup +overhead during item composition and subscribes each individual item to the +CompositionLocal's updates. Instead, read the CompositionLocal once in the +parent screen or list container and pass the resolved values down as +parameters. + +- **Bad (Querying scopes and context per item)**: + + + ```kotlin + @Composable + fun SnackItem(snack: Snack) { + val sharedScope = LocalSharedTransitionScope.current + ?: error("No scope") + val context = LocalContext.current + // ... + } + ``` + +
+ +- **Optimized (Pass resolved dependencies down as parameters)**: + + + ```kotlin + @Composable + fun SnackList( + snacks: List, + sharedScope: SharedTransitionScope, + ) { + val context = LocalContext.current + LazyColumn { + items(snacks, key = { it.id }, contentType = { "snack" }) { snack -> + SnackItem( + snack = snack, + sharedScope = sharedScope, + context = context, + ) + } + } + } + ``` + +
+ +### 7. Defer shared transitions behind visibility indicators + +Attaching `Modifier.sharedBounds` or `Modifier.sharedElement` to every item in +a lazy layout adds significant modifier allocation, shared state tracking, and +snapshot record instantiation overhead---even for offscreen or pre-fetched items +that are not visible. + +In fast-scrolling or large lazy lists, gate shared transitions behind +visibility indicators (such as `Modifier.onVisibilityChanged` or viewport +visibility checks) so only rendered, visible items instantiate transition +scopes and coordinate tracking. \ No newline at end of file diff --git a/jetpack-compose/jetpack-compose-performance/references/state-and-stability.md b/jetpack-compose/jetpack-compose-performance/references/state-and-stability.md new file mode 100644 index 0000000..bc63fdb --- /dev/null +++ b/jetpack-compose/jetpack-compose-performance/references/state-and-stability.md @@ -0,0 +1,665 @@ +## 1. Phase deferral and lambdas (Composition versus Layout versus Draw) + +Defer reading animated, frequently changing, or scroll-driven state values until +the Layout or Draw phase by wrapping reads in lambdas. This bypasses the +expensive Composition phase entirely and avoids unnecessary recompositions. + +### A. Modifier offsets + +- **Bad (Recomposes on every pixel change)**: + + + ```kotlin + val offset = scrollState.value + Box(modifier = Modifier.offset(x = offset.dp, y = 0.dp)) + ``` + +
+ +- **Optimized (Skips Composition, goes straight to Layout)**: + + + ```kotlin + Box(modifier = Modifier.offset { IntOffset(scrollState.value, 0) }) + ``` + +
+ +### B. Alpha and rotation animations + +- **Bad (Recomposes on every animation frame)**: + + + ```kotlin + val alpha by animateFloatAsState(targetValue) + Box(modifier = Modifier.alpha(alpha)) + ``` + +
+ +- **Optimized (Skips Composition and Layout, goes straight to Draw)**: + + + ```kotlin + val alpha by animateFloatAsState(targetValue) + Box(modifier = Modifier.graphicsLayer { this.alpha = alpha }) + ``` + +
+ +### C. Custom composable parameters + +Passing a lambda provider (`valueProvider: () -> Float`) is beneficial only when +the value is read in Layout or Draw. If read in Composition, pass the raw value +directly to avoid lambda allocation overhead. + +- **Read in Composition (Pass value directly)**: + + + ```kotlin + @Composable + fun TitleText(text: String) { + Text(text = text) + } + ``` + +
+ +- **Read in Layout or Draw (Pass lambda provider)**: + + + ```kotlin + @Composable + fun FadingBox(alphaProvider: () -> Float) { + Box(Modifier.graphicsLayer { alpha = alphaProvider() }) + } + ``` + +
+ +## 2. Preventing backwards writes across frame phases + +A **backwards write** occurs whenever a Compose `State` is modified in a later +frame phase (or in a downstream composable scope within the same composition +pass) than where it was read. + +Compose executes frames in three strictly forward-flowing phases: +**Composition \> Layout \> Drawing**. Modifying state in a later phase (or after +reading it) forces Compose to schedule an earlier phase to re-run, creating an +unoptimized recomposition loop. + +### Consequences + +- **Extra Frames \& Dropped Frames**: Forces redundant composition passes across consecutive frames, wasting CPU/GPU resources and dropping frames. +- **First-Frame Correctness Issues**: Initial frames render with invalid, default, or unsettled data (such as zero size or an incorrect offset), causing visible layout popping or flashing when the second frame renders. +- **Infinite Recomposition Loops**: Occurs when layout sizing or draw logic continuously writes back to state that drives composition. + +### Core rules + +1. **Verify Read-Before-Write Order** : A backwards write **strictly requires a + state read to precede the state write** (`Read -> Write` in the same composition pass, or `Composition Read -> Layout or Draw Write` across frame phases). State writes belong in event callbacks (`onClick`), coroutines (`LaunchedEffect`), or side effects (`SideEffect`); layout callbacks (`onSizeChanged`, `onPlaced`) do not count as events. +2. **Do Not Confuse `Write -> Read` with Backwards Writes** : Assigning to a `MutableState` at the top of a composable **before** any read of that state occurs (or writing in Composition and reading later in the `Canvas` Draw phase) is **not** a backwards write, because no read observer has recorded the current composition scope prior to the write (and `Composition Write -> + Draw Read` flows forward). Instead, classify `var x by remember { + mutableStateOf(...) }; x = getValue()` as **Redundant State Wrapping** (unnecessary `MutableState` and slot table allocation that is also fragile if someone later adds a read preceding the write) and replace it with a plain `val` or read directly in the target phase. +3. **Never write to state in `onSizeChanged`, `onPlaced`, or `LayoutModifier` + if read in Composition** : Read sizes directly in Draw or Layout phase (`Modifier.drawWithCache`, `Modifier.layout`), hoist window-level sizing (`WindowWidthSizeClass`), or use `FlowRow`/`LazyVerticalGrid`. +4. **Never mutate state after reading it in Composition** : Use `rememberUpdatedState` when you need to capture a changing value in composition without triggering recomposition. +5. **Defer state reads to the latest possible phase**: Reading state in Layout or Draw skips Composition entirely when the state changes. + +### Common patterns: backwards writes versus event-driven updates + +#### A. Direct mutation in Composition (`Read -> Write` versus `Write -> Read`) + +- **Bad (`Read -> Write`: Mutating state after reading it triggers immediate + recomposition loop)**: + + + ```kotlin + @Composable + fun BadCounter() { + var count by remember { mutableIntStateOf(0) } + Text("Count: $count") // 1. State read in Composition + Button(onClick = {}) { + // 2. State write in Composition AFTER read (Backwards write!) + countability.kt + ``` + +
+ +- **Not a Backwards Write, but Redundant/Fragile (`Write -> Read` in + Composition)**: + + + ```kotlin + // NOT a backwards write because the write happens BEFORE any read in + // Composition (and before Canvas reads it in the Draw phase). + // However, wrapping a synchronous value in mutableStateOf is redundant + // and error-prone if a read is later added before the write. + @Composable + fun RedundantStateWrapper(viewModel: MyViewModel) { + var path by remember { mutableStateOf(Path()) } + // 1. Write occurs first (no prior read in scope) + path = viewModel.getPath() + Canvas(Modifier.fillMaxSize()) { + // 2. Read occurs later in Draw phase (Forward flow) + drawPath(path, Color.Red) + } + } + + // Optimized: Remove redundant mutableStateOf wrapper and read in Draw + @Composable + fun OptimizedWrapper(viewModel: MyViewModel) { + Canvas(Modifier.fillMaxSize()) { + val path = viewModel.getPath() + drawPath(path, Color.Red) + } + } + ``` + +
+ +#### B. Reading in Composition, writing in Layout (phase backwards write) + +- **Bad (Layout writes to state read in Composition, causing first-frame + popping)**: + + + ```kotlin + @Composable + fun BadLayout() { + var componentHeight by remember { mutableStateOf(0.dp) } + + // Composition reads state before Layout measures it + if (componentHeight > 100.dp) { + Banner() + } + + Box( + modifier = Modifier.onSizeChanged { size -> + // Backwards write: Layout -> Composition + componentHeight = size.height.dp + } + ) + } + ``` + +
+ +- **Optimized (Defer sizing to Layout phase or hoist to Window level)**: + + + ```kotlin + @Composable + fun OptimizedLayout(modifier: Modifier = Modifier) { + // Measurement and placement handled in Layout without recomposition + Box( + modifier = modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + ) + } + ``` + +
+ +For more information, see the [Backwards Write documentation](https://developer.android.com/develop/ui/compose/performance/backwards-write). + +## 3. State and snapshot optimizations + +### A. Optimize whole-collection updates with `neverEqualPolicy()` + +`mutableStateListOf` tracks every individual element mutation through the +snapshot system. When fine-grained element tracking is not needed, back your +collection with a standard `MutableList` wrapped in `mutableStateOf(list, +neverEqualPolicy())` and trigger recomposition using a load-bearing assignment +(`state.value = list`). + + +```kotlin +class ActiveQueueManager { + private val list = mutableListOf() + val items = mutableStateOf(list, neverEqualPolicy()) + + fun enqueue(item: Item) { + list.add(item) + items.value = list // Load-bearing assignment triggers recomposition + } +} +``` + +
+ +### B. Use `SnapshotStateList.toList()` for repeated queries and iterations + +Iterating or querying a `SnapshotStateList` (or `SnapshotStateMap`) repeatedly +inside a composable registers snapshot read observations on each accessed index +or element. + +When performing repeated queries (such as checking `.contains()` in a loop), +filtering, sorting, or multi-step processing on a snapshot collection, take an +atomic snapshot using `.toList()` (or `.toSet()` / `.toMap()`) once at the start +of the scope and perform operations on that read-only snapshot: + +- **Bad (Repeated contains queries inside loop register fine-grained + dependencies on every element)**: + + + ```kotlin + @Composable + fun ItemSelector( + items: List, + selectedIds: SnapshotStateList, + ) { + Column { + items.forEach { item -> + // Avoid: Repeatedly querying the snapshot list inside the loop + val isSelected = selectedIds.contains(item.id) + ItemRow(item = item, isSelected = isSelected) + } + } + } + ``` + +
+ +- **Optimized (Take a read-only snapshot Set once for `O(1)` queries)**: + + + ```kotlin + @Composable + fun ItemSelector( + items: List, + selectedIds: SnapshotStateList, + ) { + // Convert to a local Set once per recomposition for fast O(1) lookups + val selectedSet = selectedIds.toSet() + Column { + items.forEach { item -> + val isSelected = selectedSet.contains(item.id) + ItemRow(item = item, isSelected = isSelected) + } + } + } + ``` + +
+ +### C. Extract repeated state reads into local variables + +Reading Compose `State` properties (such as `transition.currentState`, +`scrollState.value`, or `state.value`) multiple times in a function registers +redundant snapshot lookups. Cache the state read into a local variable at the +top of the block. + +- **Bad (Repeated snapshot state reads)**: + + + ```kotlin + val label = when (transition.currentState) { + State.Idle -> "Idle" + State>.Running - "Running: ${transition.currentState}" + > State.Finished - "Finished: ${tre}" + }StateAndStability.kt + ``` + +
+ +- **Optimized (Single read cached in local variable)**: + + + ```kotlin + val currentState = transition.currentState + val label = when (currentState) { + State.Idle -> "Idle" + State>.Running - "Running: $currentState" + > State.Finished - "Fte" + }StateAndStability.kt + ``` + +
+ +### D. Use `computedStateOf` versus `derivedStateOf` (Compose 1.13+) + +When deriving state from frequently changing inputs (such as checking +`scrollState.value > 0` on every scroll tick), reading the raw state directly in +composition invalidates the composable on every frame. + +In Compose 1.13+, **`computedStateOf`** is introduced alongside +**`derivedStateOf`**: + +- **`computedStateOf`** : Computes its calculation on **every read** without caching the value. + - **First read** : Slightly faster than `derivedStateOf` because it avoids cache initialization and snapshot dependency bookkeeping. + - **Second read** : Slightly slower than `derivedStateOf` because it re-executes the calculation. + - **Read-after-write (invalidation)** : Significantly faster than `derivedStateOf` because it does not have the expensive cache invalidation path, cache write-back, and snapshot apply-listener dispatch. +- **`derivedStateOf`** : Caches the result of its calculation and returns the cached value as long as dependency states have not changed. + - Has a slower invalidation path when its dependency states change frequently, due to cache clearing, re-computation, cache write-back, and advancing snapshots or triggering apply listeners. + +#### When to use which + +1. **Prefer `computedStateOf` (Compose 1.13+) or `remember { derivedStateOf { + ... } }` (Compose \< 1.13)** for simple condition checks, boolean flags, or + lightweight derivations driven by fast-changing states (such as scroll + offsets or slider positions). Check `gradle/libs.versions.toml` or + `build.gradle(.kts)` for the project's Compose version before using + `computedStateOf`: + + + ```kotlin + @Composable + fun ScrollToTopButton(scrollState: ScrollState) { + // Compose 1.13+: Invalidates composition only when > 0 changes, + // with minimal invalidation overhead compared to derivedStateOf. + // On Compose < 1.13, use remember { derivedStateOf { ... } }. + val showButton by remember { computedStateOf { scrollState.value > 0 } } + + if (showButton) { + FloatingActionButton(onClick = { /* ... */ }) { + Icon(Icons.Default.ArrowUpward, "Scroll to Top") + ability.kt + ``` + +
+ +2. **Use `derivedStateOf`** when the calculation itself is computationally + heavy and is read multiple times across the composition pass, so that the + benefits of multi-read caching outweigh the invalidation cost. + + - *Read frequency nuance for trivial calculations* : `derivedStateOf` can also outperform `computedStateOf` for trivial calculations if read frequently enough per composition pass. Specifically, `derivedStateOf` starts to make up for the invalidation cost for trivial calculations when read about `10 / numberOfStatesReadPerCalculation` times per composition (assuming the calculation never invalidates between reads). +3. **Prefer plain Kotlin getters (`get()`)** for trivial property lookups or + inverted booleans where invalidation dampening is not needed: + + + ```kotlin + var isLocked by mutableStateOf(false) + val isReady: Boolean get() = !isLocked + ``` + +
+ +4. **Avoid `produceState` + `snapshotFlow`** for synchronous derivations: + Wrapping `snapshotFlow` in `produceState` adds unnecessary coroutine + allocation, context switching, and flow dispatch overhead compared to + `computedStateOf` or `derivedStateOf`. + + - **Bad (Unnecessary coroutine and flow dispatch)**: + + + ```kotlin + val isScrolledToTop by produceState(initialValue = true, scrollState) { + snapshotFlow { scrollState.firstVisibleItemIndex == 0 } + .collect { value = it } + } + ``` + +
+ + - **Optimized (Synchronous snapshot derivation)**: + + + ```kotlin + // Compose 1.13+: + val isScrolledToTop by remember { + computedStateOf { scrollState.firstVisibleItemIndex == 0 } + } + // Compose < 1.13: + val isScrolledToTop by remember { + derivedStateOf { scrollState.firstVisibleItemIndex == 0 } + } + ``` + +
+ +## 4. Strong skipping mode and stability + +Don't annotate everything with `@Stable` or `@Immutable` (or wrap collections +in `ImmutableList`) in an attempt to force every composable to skip. + +**Skipping a composable is not free**. Forcing stability can actually +degrade runtime performance instead of improving it. + +### A. How Compose handles skipping under the hood + +When a composable is made skippable, the Compose compiler generates slot table +storage and comparison logic for every parameter: + + // What you write + @Composable + fun ContactCell(contact: Contact) { + /* Your function body */ + } + + // What the Compose compiler generates (simplified) + @Composable + fun ContactCell( + contact: Contact, + $composer: Composer?, + $changed: Int + ) { + val $dirty = $changed or $composer.changed(contact) + + if ($dirty) { + /* Your function body */ + } else { + $composer.skipToGroupEnd() + } + } + +Every parameter passed to a composable is effectively stored in the slot table +as if it were `remember`ed. On every recomposition pass: + +1. Compose reads the previous parameter value from the slot table. +2. It executes an equality check between the old and new parameter values. +3. If any check fails, the composable executes its body; if all checks pass, it calls `$composer.skipToGroupEnd()`. + +This slot table caching and comparison logic incurs memory and CPU overhead on +every recomposition attempt. + +### B. Instance equality (`===`) versus structural equality (`.equals()`) + +Under **Strong Skipping Mode** (enabled by default in modern Compose): + +- **Unstable parameters** are compared using **instance equality** (`===` / `$composer.changedInstance(param)`). +- **Stable parameters** (primitives, `@Stable` / `@Immutable` annotated classes, or types with all stable properties) are compared using **structural equality** (`.equals()` / `$composer.changed(param)`). + +#### Why `@Stable`, `@Immutable`, and `ImmutableList` can hurt performance + +Unstable parameters are not prevented from skipping: if the same instance is +passed across recompositions (`param === oldParam`), Compose skips the +composable quickly with a fast `O(1)` pointer check. + +When you annotate a class with `@Stable` or `@Immutable` (or replace +`kotlin.collections.List` with `ImmutableList`), Compose switches from fast +`===` pointer comparison to deep `.equals()` structural comparison. + +- **Expensive Deep Comparisons** : In complex models or large collections (e.g. hundreds of items), running `equals()` on every recomposition can be far more expensive than executing the composable's body. Method traces often reveal that `Model.equals` consumes the vast majority of frame time. +- **Composition Counts != Benchmarks** : A composable that skips but spends 3ms evaluating a deep `.equals()` check is significantly slower than a composable that recomposes in 0.2ms. +- **Never Recommend Replacing `List` with `ImmutableList`** : Never recommend converting `kotlin.collections.List` properties into `kotlinx.collections.immutable.ImmutableList` (or Guava `ImmutableList` via `stability_config.txt`) on `@Stable` or `@Immutable` models. Standard `List` already skips via `O(1)` reference equality (`===`) under Strong Skipping Mode, whereas `ImmutableList` forces `O(N)` element-by-element `.equals()` traversal. + +#### Scope guard: when to remove existing `@Stable` or `@Immutable` + +When auditing or refactoring an existing codebase, apply a strict scope guard +before recommending changes to existing stability annotations: + +1. **Only Recommend Removing `@Stable` / `@Immutable` for Large Datasets in + Lazy Layouts** : Strongly recommend removing existing `@Stable` or `@Immutable` annotations from data models **only** when **both** conditions apply: + - The model is used within a **lazy layout** (`LazyColumn`, `LazyRow`, `LazyVerticalGrid`, `LazyHorizontalGrid`, `Pager`, `ScalingLazyColumn`) as the input collection container or item model, **AND** + - It holds a **large dataset or deep nested collection tree** (such as a full screen stream, hundreds of items/tags, or nested lists like `Post` containing `List` and `List`), where `O(N)` structural equality checks during scrolling or recomposition are expensive. +2. **Leave Existing Annotations Untouched Elsewhere** : For all other use cases where a codebase already has `@Stable`, `@Immutable`, or `ImmutableList`--- such as non-lazy screen/component models, standalone UI elements, or models with small/bounded collections (e.g. a card model holding 2--5 badges, thumbnails, or metadata rows) where dataset size is small or hard to estimate statically---**leave the existing annotations untouched**. + +### C. Parameter unwrapping and equality propagation pitfall (`ImmutableList`) + +The Compose compiler includes an optimization where propagating the exact same +object reference down the composable hierarchy allows Compose to reuse previous +equality results and skip redundant comparisons. + +When a parent `@Composable` passes the exact same parameter reference to a child +`@Composable`, the Compose compiler propagates its dirty bitmask so the child +can skip re-evaluating `.equals()`. + +However, when a parent `@Composable` receives a `data class` containing a large +`ImmutableList` and passes the unwrapped `ImmutableList` to a child +`@Composable`, **Compose runs `O(N)` structural equality twice**: + + +```kotlin +// Bad: Both HomeScreen (via data class .equals()) and HomeContent evaluate +// O(N) structural equality on the same ImmutableList in a single frame +data class HomeScreenModel( + val header: String, + val items: ImmutableList +) + +@Composable +private fun HomeScreen(model: HomeScreenModel) { + TopAppBar(title = { Text(model.header) }) + // Unwrapping model.items to pass to a child @Composable + HomeContent(model.items) +} + +// Because contentList is an ImmutableList, HomeContent must re-evaluate O(N) +// .equals() on the list itself, even though HomeScreen already compared model +@Composable +private fun HomeContent(contentList: ImmutableList) { + LazyColumn { + items(contentList) { /* ... */ } + } +} +``` + +
+ +In the preceding example, because `items` is an `ImmutableList` (which the +compiler treats as stable and compares with `.equals()`) inside a `data class`: + +1. `HomeScreen` evaluates `model.equals(oldModel)`, which traverses `model.items.equals(oldModel.items)` in `O(N)` time. +2. `HomeContent` receives `model.items` rather than `model`, so the compiler cannot propagate `model`'s `$dirty` flag and calls `contentList.equals(oldContentList)` a second time (`O(N)`). + +#### Scope guard: when parameter unwrapping is *not* an issue + +Do **not** flag parameter unwrapping unless **all** of the following conditions +are met: + +1. **Both caller and callee are skippable `@Composable` functions** : Plain Kotlin functions, node-building helpers, and `LazyListScope` / `LazyGridScope` DSL extensions (for example, `fun verticalGridUi(itemList = + content.itemList, ...)`) are not skippable composables and do not generate `$composer.changed(...)` equality checks in the slot table. +2. **The parent object is a `@Composable` parameter with structural + `.equals()`** : If the container object is read from state inside the function body (such as `val content = model.contentState.value`) rather than passed as a `@Composable` parameter, or is a regular `class` without a deep `data class` `.equals()` implementation, no parent equality check occurred in the first place. +3. **The unwrapped property itself triggers expensive `O(N)` `.equals()`** : The unwrapped property must be an `ImmutableList` (or a collection marked stable in `stability_config.txt`). **Never** flag unwrapping a standard `kotlin.collections.List` (`Set`, `Map`) or primitive/value-type properties (`Int`, `Dp`, `String`, `Boolean`, enums)---under Strong Skipping Mode, standard `List` is compared via `O(1)` reference equality (`===`), so unwrapping `content.itemList: List` has zero deep-comparison overhead. + +### D. Don't over-index on recomposition counts + +Recomposition counts are not a substitute for benchmarks. Once composition is +scheduled for a screen, skipping a single intermediate composable saves minimal +time while still incurring the overhead of remembering parameters and evaluating +equality checks. Rather than trying to eliminate every individual recomposition, +focus on **avoiding unnecessary compositions** altogether by deferring rapidly +changing state reads to the Layout or Draw phases (for example, using lambda +modifiers and `graphicsLayer`), and +**keep composables lightweight** so they execute quickly when recomposition +occurs. + +### E. Guidelines for designing fast composables + +1. **Prefer fewer, easy-to-compare arguments** : Pass primitives (`String`, `Int`, `Long`, `Dp`) or standard `List` references (which compare via fast `===`) rather than large composite objects whenever practical. +2. **Avoid double `O(N)` comparisons on `ImmutableList`** : Do not wrap large collections in `ImmutableList` inside `data class` models where both parent and child `@Composable` functions will run `O(N)` `.equals()` on the list; use standard `List` so Compose uses `O(1)` `===` reference equality. +3. **Merge related effects and state**: Minimize the number of remembered objects and dispatched effects to reduce slot table overhead. +4. **Be selective with `@Stable` / `@Immutable`** : Only add `@Stable` or `@Immutable` to small, flat UI models from non-Compose modules where instances are frequently recreated and `.equals()` is cheap `O(1)`. Never add `@Stable`, `@Immutable`, or `ImmutableList` to large collections or deep data graphs. When auditing existing code, only remove `@Stable` or `@Immutable` from models that feed lazy layouts (`LazyColumn`, `LazyRow`, `LazyGrid`) with large datasets or deep nested collections; leave existing annotations on non-lazy or small-collection models untouched. +5. **Always benchmark**: Use Macrobenchmark or Microbenchmark to measure real frame timing and CPU impact. Never rely solely on recomposition counts to judge performance. + +- **Optimized (Remove `@Immutable` on large lazy-layout models to rely on fast + `===` instance equality, and pass lightweight primitives)**: + + + ```kotlin + // Unstable model skips using === pointer check when instance is unchanged + data class FeedState( + val feedId: String, + val items: List + ) + + @Composable + fun FeedScreen(state: FeedState) { + // Pass only the primitive ID needed by the header + FeedHeader(feedId = state.feedId) + // Pass the state or items directly + FeedList(items = state.items) + } + ``` + +
+ +For more information, see the [Strong Skipping Mode documentation](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping). + +## 5. Using `remember` and calculation keys + +While `remember` is essential for preserving state and caching expensive +operations across recompositions, each call stores its result and keys in the +slot table and checks key equality on every recomposition. Use `remember` when +object identity must be preserved or when caching nontrivial work (such as +formatters, shaders, paths, or list transformations), rather than wrapping +trivial expressions or cheap allocations whose key comparisons cost more than +the allocation itself. + +### Require explicit calculation keys on `remember` + +`remember { ... }` with no keys executes only on initial composition and caches +the result for the lifetime of the Composable. If the Composable recomposes with +new parameter values, omitting keys returns the stale cached value. For standard +Kotlin calculations and object instantiations, always pass every external +variable or parameter referenced inside the `remember` block as a key. + +- **Exception for `derivedStateOf`** : When wrapping `derivedStateOf`, do + **not** pass the snapshot state properties read inside the calculation + lambda as `remember` keys: + + + ```kotlin + // Bad: passing snapshot state property as a key breaks derivedStateOf + val showButton by remember(scrollState.firstVisibleItemIndex) { + derivedStateOf { scrollState.firstVisibleItemIndex > 0 } + } + + // Optimized: derivedStateOf tracks firstVisibleItemIndex internally + val showButton by remember { + derivedStateOf { scrollState.firstVisibleItemIndex > 0 } + } + ``` + +
+ + `derivedStateOf` dynamically tracks snapshot state reads internally and + invalidates only when the derived result actually changes. Passing state + values as `remember` keys forces the `remember` block to recreate on every + single state mutation, completely destroying `derivedStateOf`'s ability to + skip recompositions. Only pass the `State` holder instance as a key if the + instance itself can change. +- **Bad (Omitting keys for standard calculation returns stale value upon + parameter change)**: + + + ```kotlin + @Composable + fun FormattedDateLabel(timestamp: Long, locale: Locale) { + val formattedDate = remember { + SimpleDateFormat("yyyy-MM-dd", locale).format(Date(timestamp)) + } + Text(text = formatability.kt + ``` + +
+ +- **Optimized (Pass referenced calculation parameters as keys)**: + + + ```kotlin + @Composable + fun FormattedDateLabel(timestamp: Long, locale: Locale) { + val formattedDate = remember(timestamp, locale) { + SimpleDateFormat("yyyy-MM-dd", locale).format(Date(timestamp)) + } + Text(text = formatability.kt + ``` + +
\ No newline at end of file diff --git a/navigation/navigation-3/SKILL.md b/navigation/navigation-3/SKILL.md index 5948db2..a7c77e7 100644 --- a/navigation/navigation-3/SKILL.md +++ b/navigation/navigation-3/SKILL.md @@ -8,7 +8,7 @@ description: Learn how to install and migrate to Jetpack Navigation 3, and how t license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-24' + last-updated: '2026-10-13' keywords: - recipe - Android diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md index aa6b456..d83409a 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md @@ -110,10 +110,9 @@ package com.example.nav3recipes.bottomsheet import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.rememberLifecycleOwner import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavMetadataKey import androidx.navigation3.runtime.get @@ -122,7 +121,6 @@ import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneStrategy import androidx.navigation3.scene.SceneStrategyScope -import com.example.nav3recipes.bottomsheet.BottomSheetSceneStrategy.Companion.bottomSheet /** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ @OptIn(ExperimentalMaterial3Api::class) @@ -137,17 +135,25 @@ internal data class BottomSheetScene( override val entries: List> = listOf(entry) + lateinit var sheetState: SheetState + override val content: @Composable (() -> Unit) = { - val lifecycleOwner = rememberLifecycleOwner() + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = false + ) ModalBottomSheet( onDismissRequest = onBack, properties = modalBottomSheetProperties, + sheetState = sheetState, ) { - CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { entry.Content() - } } } + + override suspend fun onRemove() { + // run hide animations when this bottom sheet is popped from the backStack + sheetState.hide() + } } /** diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md index aca3404..8fb2270 100644 --- a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md @@ -106,6 +106,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -183,6 +184,7 @@ class NavigationState( val decoratedEntries = backStacks.mapValues { (_, stack) -> val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() ) rememberDecoratedNavEntries( backStack = stack, diff --git a/navigation/navigation-event/SKILL.md b/navigation/navigation-event/SKILL.md index dda0b69..38ca3d9 100644 --- a/navigation/navigation-event/SKILL.md +++ b/navigation/navigation-event/SKILL.md @@ -7,7 +7,7 @@ description: Intercept back gestures and run Predictive Back animations using th license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-01' + last-updated: '2026-10-07' keywords: - Android - Navigation Event diff --git a/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md b/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md index 59d8ad4..f0b0847 100644 --- a/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md +++ b/navigation/navigation-event/references/android/guide/navigation/navigation-event/index.md @@ -13,7 +13,7 @@ such as those found in Jetpack Compose. The [`NavigationEventDispatcher`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventDispatcher) is the central class responsible for managing all registered navigation event consumers -([`NavigationEventHandler`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventHandler))) and orchestrating the flow of events. +([`NavigationEventHandler`](https://developer.android.com/reference/kotlin/androidx/navigationevent/NavigationEventHandler)) and orchestrating the flow of events. In a hierarchical setup, all dispatchers within the same chain share a single `NavigationEventProcessor`, which manages the global state and ensures a single, diff --git a/play/engage-sdk-integration/SKILL.md b/play/engage-sdk-integration/SKILL.md index a04a605..70dd857 100644 --- a/play/engage-sdk-integration/SKILL.md +++ b/play/engage-sdk-integration/SKILL.md @@ -6,7 +6,7 @@ description: Helps developers integrate, debug, and resolve Play Engage SDK impl license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-03' + last-updated: '2026-10-12' keywords: - android - engage diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_cpu.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_cpu.md new file mode 100644 index 0000000..4510348 --- /dev/null +++ b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_cpu.md @@ -0,0 +1,30 @@ +Expert hints for CPU scheduling, thread states, CPU contention, and +frequency scaling. +keywords: +- cpu +- scheduling +- latency +- runnable +- running +- thread state +- governor +- core + +## - frequency + +- When debugging a long slice: Examine its thread states to understand what the thread was doing (running, sleeping, blocked on I/O). +- When debugging a long slice for latency issues: Check if its duration is caused by one or more long-running child slices. Apply recursively. +- When a thread is woken up but there is a delay before it runs, check the "IRQ" track for the corresponding CPU to see if an interrupt is the cause. +- Check if kernel threads associated with hardware are running with real-time priority; if not, they can be preempted. +- When analysis of the primary application package does not reveal root cause, expand to all threads and processes. Search for other runnable threads on the same CPU or high-priority kernel threads. +- When investigating app startup, use an SQL query to aggregate reasons for uninterruptible sleep on the main thread. +- Check the "cpu_frequency" counter for CPU cores that ran the main process. Missing frequency transition events, lack of frequency scaling, or static low frequencies during heavy compute bursts indicate a governor bottleneck or stall (for example, a `su_gov` failure or governor lag); do not assume cores ran at maximum capacity. +- Query raw "ftrace" events for logs related to the governor thread (for example, "su_gov"). +- To find concurrency issues, search for critical threads (for example, 'RenderThread') in a blocked state (`thread_state.state = 'D'`) and join with scheduling data to find the waker. +- Compare time spent in userspace functions vs kernel (slices with `[k]` prefix). +- To detect a 'catch-up storm', look for threads with long gaps in thread_state/cpu_slice activity immediately followed by a high-density burst. +- Quantify scheduler contention by calculating scheduling latency (measure duration of preceding 'Runnable' state using `preceding_sched_slice_for_thread`). Search for maximums and high percentiles (p95/p99). +- If a task exhibits high scheduling latency, check if other CPUs were idle (running swapper or idle thread). +- Check the `cpu_id` for key threads; if consistently scheduled on slower cores, it signals a potential performance gain by allowing them on big cores. +- For a struggling thread, analyze 'Runnable' vs 'Running' state time. A large 'Runnable' time indicates CPU contention. +- If a slice's wall duration increases but the percentage of 'Running' time is unchanged, it strongly suggests a lower CPU frequency. Check `sched_switch` to focus on the correct cores. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_graphics.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_graphics.md new file mode 100644 index 0000000..ce7b26d --- /dev/null +++ b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_graphics.md @@ -0,0 +1,25 @@ +Expert hints for graphics performance, UI jank, GPU memory, and frame +rendering. +keywords: +- graphics +- ui jank +- gpu +- frame +- vsync +- render +- surfaceflinger +- texture + +## - bitmap + +- When investigating UI jank, check for long-running slices on the main thread; if a slice like "ConstraintLayout.onMeasure" is taking a long time (for example, \>8ms), it is a likely cause of the jank. +- When a `bitmap_write_to_parcel` slice takes milliseconds instead of microseconds, investigate its child slices to see how time is spent. Long durations often point to suboptimal kernel operations like memory mapping (`mmap`) or unnecessary data zeroing. +- For a high-level overview of graphics memory usage, track `gpu_mem_total` counter for a specific process (`upid`). +- To find the largest graphics allocations, query the `android_graphics_allocs` table and sort by `size_bytes` in descending order. Compare width and height against the device's display resolution. +- To detect the "double memory cost" of an image existing on both CPU and GPU, look for a large buffer in `android_graphics_allocs` and a simultaneous CPU memory allocation of a similar size (`rss_anon_bytes` or `heap_graph`). +- To find potentially costly intermediate render targets, look for large buffers in `android_graphics_allocs` where `usage_bits` lack a `COMPOSER_OVERLAY` flag. +- To check if an allocation is actually presented on screen, correlate `buffer_id` with SurfaceFlinger events. +- To see if high graphics memory is causing performance issues, check the frame duration in `actual_frame_timeline_slice`. Durations over vsync (for example, 16.6ms) correlating with `gpu_mem_total` spikes suggest memory pressure jank. +- Within the main thread of a janky graphics application, look for frequent or long-running `texture_upload` slices. +- Analyze the duration of `eglSwapBuffersWithDamageKHR` or similar buffer-swap slices in the graphics rendering thread. Consistently long durations suggest a large "damage area" being redrawn every frame. +- To identify UI jank, compare `actual_frame_timeline` against the `expected_frame_timeline` on the main process; a significant deviation indicates missed frames. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_io.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_io.md new file mode 100644 index 0000000..39f8b87 --- /dev/null +++ b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_io.md @@ -0,0 +1,26 @@ +Expert hints for I/O and storage bottlenecks, uninterruptible sleep, and +page cache. +keywords: +- i/o +- storage +- disk +- uninterruptible sleep +- d state +- page fault +- dm-verity +- file + +## - syscall + +- When debugging a long, uninterruptible sleep state related to I/O: Check for overlapping slices with "verity" in their name (dm-verity). +- When a thread is stuck in an uninterruptible sleep with no `blocked_function`, look for other threads that might be holding the memory lock (for example, "jit-thread-pool", memory mapping ops). +- When analyzing a long uninterruptible sleep, check the `blocked_function` in the thread state details (from `sched_blocked_reason` ftrace event). +- A lot of time spent in `do_page_fault` during app startup is a strong indicator of I/O contention. +- For file integrity mechanisms like DM-Verity, search for events like `dm_verity_fec_prefetch`. +- To find the specific kernel dependency of a stalled app thread, locate the thread in state 'D', then look for kworker or kernel threads that become runnable immediately after. +- For app stalls caused by I/O, analyze the scheduling latency of the relevant kworker threads handling the request. +- To find inefficient file I/O, query the syscall table for a high frequency of small, sequential `read()` or `pread()` syscalls on a single fd. +- If a thread spends significant time in 'Uninterruptible Sleep', check if `blocked_function` is `page_cache_readahead`. Correlate waking timestamps with `filemap_add_to_page_cache` ftrace events. +- Aggregate counts of `filemap_add_to_page_cache` grouping by `inode` to find the specific file causing I/O pressure. +- Inspect `nr_sector` in `block_rq_issue` ftrace events to understand file read-ahead size. +- If an I/O issue disappears on subsequent launches, it is a 'cold start' problem (populating page cache). \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_ipc.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_ipc.md new file mode 100644 index 0000000..34481bf --- /dev/null +++ b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_ipc.md @@ -0,0 +1,21 @@ +Expert hints for IPC, binder transactions, and cross-process dependencies. +keywords: +- ipc +- binder +- transaction +- cross-process +- bottleneck +- service + +## - system_server + +- Look for multiple outbound binder transactions from the same process (`system_server`) that carry similar data to different destinations in a short time frame. This "binder storm" indicates a lack of multiplexing. +- To trace data across processes, correlate slices using flow events by linking a slice's ID to `flow.source_slice_id` or `flow.dest_slice_id`. +- To detect binder spam, query the `binder_transaction` table and group by thread ID (`tid`), `service_name`, and `method_name` to find high numbers of identical calls. +- When high binder concurrency is found, identify the bottleneck server process by grouping transactions by `server_upid`. +- To analyze latency of a slow binder transaction, calculate time spent outside the server by subtracting `server_dur` from total `dur` in the `binder_transaction` table. +- When a thread is suspected of binder spam, correlate its `tid` with the `cpu_slice` table to check for high CPU consumption. +- To find code responsible for binder spam, get the `utid` of the problematic thread and use it to query `stack_profile_callsite`. +- To find callers of a problematic function, filter `stack_profile_callsite` for frames mapping to it, then trace upwards using `parent_id`. +- A long-running slice on one thread causally linked to a slice on another thread (for example, binder from `system_server` to SystemUI) indicates a scheduling dependency bottleneck. +- To find asynchronous operations that might cause UI jank, look for a binder transaction from a controlling process that returns quickly, followed by a long-running slice in the receiving process. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_memory.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_memory.md new file mode 100644 index 0000000..b6f0be6 --- /dev/null +++ b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_memory.md @@ -0,0 +1,29 @@ +Expert hints for memory pressure, Low Memory Killer (LMK), and allocations. +keywords: +- memory +- lmk +- lmkd +- oom +- rss +- swap +- kswapd +- thrashing +- pressure + +## - allocation + +- To investigate low memory kills, look for the `lmk_kill_occurred` ftrace event; a high count indicates severe memory pressure. +- When you see a burst of `lmk_kill_occurred` events, query for processes with high CPU `wall_duration` in the `sched_slice` table during the same time window. Runaway processes consuming CPU exacerbate memory pressure. +- To understand a process's memory impact, inspect its `anon_rss` (Anonymous Resident Set Size) from memory counters like `mem.info`. +- Check system-wide memory stats for a high or rapidly increasing `swap_used` value. +- To confirm memory thrashing, look for high CPU usage by the `kswapd` kernel thread. +- To find the direct trigger for LMKD, look for `memory_pressure` trace events from Pressure Stall Information (PSI). +- To detect 'lost' memory for processes using hardware accelerators (like a TPU), query the `counter` table for `RssFile` values. If it drops significantly while hardware is active, check for `kswapd0` scheduling slices. +- When analyzing memory shared with hardware, query `dma_heap_stat` and `dmabuf_total_size` counters (or `ion_total_size` for older devices) for a more accurate picture than RSS. +- Be aware that `RssFile` can over-report memory if the same physical page is mapped multiple times. +- When investigating OOMs, establish a baseline by querying process memory counters (for example, `mem.rss`) and compare the median and 95th percentile against its history. +- If bitmaps are a major memory consumer, check for outliers in bitmap count by querying `android.graphics.Bitmap` instances across traces. +- To find what is holding onto an object (like a bitmap), trace its retainer path back to a GC root by querying `heap_graph_reference`. Pay close attention to custom application classes. +- Query the `android_bitmaps` table to check the width and height properties of bitmaps. +- Check for the presence of software bitmaps, which consume memory on both the app heap and in graphics memory. +- When analyzing bitmaps, look for duplicates by checking for multiple `android.graphics.Bitmap` objects with identical properties. \ No newline at end of file diff --git a/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_power.md b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_power.md new file mode 100644 index 0000000..1ede7eb --- /dev/null +++ b/profilers/android-profiler/analysis/workflows/perfetto-trace-analysis/references/hints/hints_power.md @@ -0,0 +1,19 @@ +Expert hints for battery drain, power rails, network traffic, and wake locks. +keywords: +- power +- battery +- wakelock +- suspend +- network +- modem +- bluetooth + +## - drain + +- When investigating overall battery drain, start by querying the `power_rails` track and summing energy consumed (`power_ma * duration`) for each rail. +- To check if a device is sleeping correctly during screen-off periods, query the `suspend_state` track; a lack of time in "suspended" state indicates a wakefulness problem. +- To find the root cause of the device failing to suspend, query the `kernel_wakelock` track and aggregate total duration for each wake lock. +- If a top kernel wake lock name contains `bt_` or `bcm` (for example, `bt_host_wake`), cross-reference its timing with events in the `bluetooth_scan_results` track. +- If the modem rail in `power_rails` shows high consumption, query the `network_packets` table and aggregate traffic volume by UID to identify the app. +- When analyzing network traffic from a shared UID, use the `socket_tag` associated with network packets for granular attribution. +- Convert impact into a common energy unit like milliwatt-hours (mWh) to compare the severity of different issues. \ No newline at end of file diff --git a/system/glance-canonical-layouts/SKILL.md b/system/glance-canonical-layouts/SKILL.md new file mode 100644 index 0000000..d084271 --- /dev/null +++ b/system/glance-canonical-layouts/SKILL.md @@ -0,0 +1,245 @@ +--- +name: glance-canonical-layouts +description: Use this skill to create home screen widgets using Jetpack Glance Canonical + Layouts from the Android Platform Samples project, or when asked to implement standard + widget layout patterns (including Check List, Image Grid, Snap Scrolling, and Standard + Toolbar). +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-10-14' + keywords: + - android + - glance + - widgets + - appwidget + - canonical-layouts + - platform-samples + - action-list + - check-list + - image-grid + - image-text-list + - snap-scrolling + - full-bleed + - expressive-toolbar + - search-toolbar + - standard-toolbar +--- + +To implement a widget Canonical Layout that follows the recommended design +patterns: + +1. The project **MUST** use Jetpack Glance. +2. You **MUST** locate the requested layout from the list under **Canonical Layout Reference Files**. +3. Copy the layout reference files into your project's target app module (e.g., `src/main/java/` and `src/main/res/`) in one step to implement the exact design and reduce the number of tool calls. +4. You **MUST** set override val `sizeMode: SizeMode = SizeMode.Exact` in the widget provider class. +5. Validate that your project compiles, updating sample package declarations, import paths, and class names to match the target app's package name. +6. Clean your project's widget code, removing unused classes (for example, unused buttons). +7. You **MUST** register the widget receiver in `AndroidManifest.xml` inside `` after ``, ensuring `android:resource="@xml/..."` matches the filename of the copied Widget Info XML. Otherwise, the widget won't appear in the widget picker: + +
+ +```xml + + + + + + +``` + +
+ +## Dependencies + +Add Jetpack Glance dependencies to the app module's `build.gradle.kts` +(`dependencies` block): + +- `androidx.glance:glance-appwidget:1.2.0` or higher +- `androidx.glance:glance-material3:1.2.0` or higher +- `androidx.glance:glance-appwidget-preview:1.2.0` or higher +- `androidx.glance:glance-preview:1.2.0` or higher + +Use version `1.3.0-alpha02` or higher with `compileSdk = 37` and Android Gradle +Plugin (AGP) 9.1.0 or higher (for example, `agp = "9.1.0"` in +`gradle/libs.versions.toml`) when implementing Snap Scrolling. + +If copying sample data repositories that load bitmaps with Coil (such as +`FakeImageGridDataRepository.kt`, `FakeImageTextListDataRepository.kt`, or +`FakeTextWithImageRepository.kt`), also add an image loading library dependency +such as `io.coil-kt:coil` (e.g., `implementation("io.coil-kt:coil:2.7.0")`) if +one doesn't already exist. + +## Canonical layout reference files + +#### Action list layout (`action_list`) + +- **Widget Provider** : [ActionListAppWidget.kt](references/collections/ActionListAppWidget.kt) +- **Layout Composable** : [ActionListLayout.kt](references/collections/layout/ActionListLayout.kt) +- **Widget Info XML** : [sample_action_list_widget_info.xml](references/res/xml/sample_action_list_widget_info.xml) +- **Data Repository** : [FakeActionListDataRepository.kt](references/collections/data/FakeActionListDataRepository.kt) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_action_list_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_action_list_preview.png), [sample_home_icon.xml](references/res/drawable/sample_home_icon.xml), [sample_power_settings_icon.xml](references/res/drawable/sample_power_settings_icon.xml), [sample_bulb_icon.xml](references/res/drawable/sample_bulb_icon.xml), [sample_thermostat_icon.xml](references/res/drawable/sample_thermostat_icon.xml), [sample_ac_icon.xml](references/res/drawable/sample_ac_icon.xml), [sample_door_icon.xml](references/res/drawable/sample_door_icon.xml), [sample_arrow_right_icon.xml](references/res/drawable/sample_arrow_right_icon.xml), [sample_no_data_icon.xml](references/res/drawable/sample_no_data_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml) +- **Components** : [RoundedScrollingLazyVerticalGrid.kt](references/collections/layout/RoundedScrollingLazyVerticalGrid.kt), [ListItem.kt](references/collections/layout/ListItem.kt), [VerticalListItem.kt](references/collections/layout/VerticalListItem.kt), [RoundedScrollingLazyColumn.kt](references/collections/layout/RoundedScrollingLazyColumn.kt), [EmptyListContent.kt](references/collections/layout/EmptyListContent.kt), [NoDataContent.kt](references/collections/layout/NoDataContent.kt) +- **Utilities** : [CollectionsKtx.kt](references/CollectionsKtx.kt), [ActionUtils.kt](references/utils/ActionUtils.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Additional References** : [CanonicalLayoutActivity.kt](references/CanonicalLayoutActivity.kt), [ActionDemonstrationActivity.kt](references/ActionDemonstrationActivity.kt) +- **Guidance** : + - Handle list item actions with Glance action callbacks and ensure proper state updates. + +#### Check list layout (`check_list`) + +- **Widget Provider** : [CheckListAppWidget.kt](references/collections/CheckListAppWidget.kt) +- **Layout Composable** : [CheckListLayout.kt](references/collections/layout/CheckListLayout.kt) +- **Widget Info XML** : [sample_check_list_widget_info.xml](references/res/xml/sample_check_list_widget_info.xml) +- **Data Repository** : [FakeCheckListDataRepository.kt](references/collections/data/FakeCheckListDataRepository.kt) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_check_list_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_check_list_preview.png), [sample_pin_icon.xml](references/res/drawable/sample_pin_icon.xml), [sample_checked_circle_icon.xml](references/res/drawable/sample_checked_circle_icon.xml), [sample_circle_icon.xml](references/res/drawable/sample_circle_icon.xml), [sample_delete_icon.xml](references/res/drawable/sample_delete_icon.xml), [sample_edit_icon.xml](references/res/drawable/sample_edit_icon.xml), [sample_snooze_icon.xml](references/res/drawable/sample_snooze_icon.xml), [sample_no_data_icon.xml](references/res/drawable/sample_no_data_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml) +- **Components** : [ListItem.kt](references/collections/layout/ListItem.kt), [VerticalListItem.kt](references/collections/layout/VerticalListItem.kt), [RoundedScrollingLazyColumn.kt](references/collections/layout/RoundedScrollingLazyColumn.kt), [EmptyListContent.kt](references/collections/layout/EmptyListContent.kt), [NoDataContent.kt](references/collections/layout/NoDataContent.kt) +- **Utilities** : [CollectionsKtx.kt](references/CollectionsKtx.kt), [ActionUtils.kt](references/utils/ActionUtils.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Implement checkbox state toggling using action parameters or callbacks to update collection items. + - Use Glance Action callbacks (for example, `actionRunCallback`, `ActionCallback(actionParametersOf(...)` or `actionStartActivity(...)`) for widget onClick parameters to handle click events like tapping a checkbox. + +#### Expressive toolbar layout (`expressive_toolbar`) + +- **Widget Provider** : [ExpressiveToolbarAppWidget.kt](references/toolbars/ExpressiveToolbarAppWidget.kt) +- **Layout Composable** : [ExpressiveToolbarLayout.kt](references/toolbars/layout/ExpressiveToolbarLayout.kt) +- **Widget Info XML** : [sample_expressive_toolbar_widget_info.xml](references/res/xml/sample_expressive_toolbar_widget_info.xml) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_expressive_toolbar_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_expressive_toolbar_preview.png), [four_side_cookie_background.xml](references/res/drawable/four_side_cookie_background.xml), [sample_edit_icon.xml](references/res/drawable/sample_edit_icon.xml), [sample_share_icon.xml](references/res/drawable/sample_share_icon.xml), [sample_delete_icon.xml](references/res/drawable/sample_delete_icon.xml), [sample_info_icon.xml](references/res/drawable/sample_info_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml), [sample_file_upload_icon.xml](references/res/drawable/sample_file_upload_icon.xml), [sample_mic_icon.xml](references/res/drawable/sample_mic_icon.xml), [sample_camera_icon.xml](references/res/drawable/sample_camera_icon.xml) +- **Components** : [Buttons.kt](references/toolbars/layout/Buttons.kt), [LayoutComponents.kt](references/toolbars/layout/LayoutComponents.kt) +- **Utilities** : [ActionUtils.kt](references/utils/ActionUtils.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Avoid `ColorProvider` import errors: import `androidx.glance.unit.ColorProvider` and use `androidx.glance.color.ColorProvider(dayColor, nightColor)`. + - Use `GlanceModifier.background(Color)` directly for solid background colors rather than `ColorProvider`. + +#### Image grid layout (`image_grid`) + +- **Widget Provider** : [ImageGridAppWidget.kt](references/collections/ImageGridAppWidget.kt) +- **Layout Composable** : [ImageGridLayout.kt](references/collections/layout/ImageGridLayout.kt) +- **Widget Info XML** : [sample_image_grid_widget_info.xml](references/res/xml/sample_image_grid_widget_info.xml) +- **Data Repository** : [FakeImageGridDataRepository.kt](references/collections/data/FakeImageGridDataRepository.kt) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_image_grid_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_image_grid_preview.png), [sample_placeholder_image.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable/sample_placeholder_image.png), [sample_grid_icon.xml](references/res/drawable/sample_grid_icon.xml), [sample_refresh_icon.xml](references/res/drawable/sample_refresh_icon.xml), [sample_no_data_icon.xml](references/res/drawable/sample_no_data_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml) +- **Components** : [RoundedScrollingLazyVerticalGrid.kt](references/collections/layout/RoundedScrollingLazyVerticalGrid.kt), [EmptyListContent.kt](references/collections/layout/EmptyListContent.kt), [NoDataContent.kt](references/collections/layout/NoDataContent.kt), [VerticalListItem.kt](references/collections/layout/VerticalListItem.kt) +- **Utilities** : [CollectionsKtx.kt](references/CollectionsKtx.kt), [ImageUtils.kt](references/utils/ImageUtils.kt), [ImageAspectRatio.kt](references/utils/ImageAspectRatio.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Use solid colors (`GlanceModifier.background`) or local drawables for image items unless an image loading library is configured or requested. + +#### Image text list layout (`image_text_list`) + +- **Widget Provider** : [ImageTextListAppWidget.kt](references/collections/ImageTextListAppWidget.kt) +- **Layout Composable** : [ImageTextListLayout.kt](references/collections/layout/ImageTextListLayout.kt) +- **Widget Info XML** : [sample_image_text_list_widget_info.xml](references/res/xml/sample_image_text_list_widget_info.xml) +- **Data Repository** : [FakeImageTextListDataRepository.kt](references/collections/data/FakeImageTextListDataRepository.kt) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_image_text_list_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_image_text_list_preview.png), [sample_placeholder_image.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable/sample_placeholder_image.png), [sample_image_icon.xml](references/res/drawable/sample_image_icon.xml), [sample_heart_icon.xml](references/res/drawable/sample_heart_icon.xml), [sample_refresh_icon.xml](references/res/drawable/sample_refresh_icon.xml), [sample_no_data_icon.xml](references/res/drawable/sample_no_data_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml) +- **Components** : [ListItem.kt](references/collections/layout/ListItem.kt), [VerticalListItem.kt](references/collections/layout/VerticalListItem.kt), [RoundedScrollingLazyColumn.kt](references/collections/layout/RoundedScrollingLazyColumn.kt), [EmptyListContent.kt](references/collections/layout/EmptyListContent.kt), [NoDataContent.kt](references/collections/layout/NoDataContent.kt) +- **Utilities** : [CollectionsKtx.kt](references/CollectionsKtx.kt), [ImageUtils.kt](references/utils/ImageUtils.kt), [ImageAspectRatio.kt](references/utils/ImageAspectRatio.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Common Use Cases: News feeds, article lists, workout histories, message feeds, and vertical thumbnail lists. + - Balance list row height and text clipping when combining thumbnail images with primary and secondary text. + +#### Long text layout (`long_text`) + +- **Widget Provider** : [LongTextAppWidget.kt](references/text/LongTextAppWidget.kt) +- **Layout Composable** : [LongTextLayout.kt](references/text/layout/LongTextLayout.kt) +- **Widget Info XML** : [sample_long_text_widget_info.xml](references/res/xml/sample_long_text_widget_info.xml) +- **Data Repository** : [FakeLongTextRespository.kt](references/text/data/FakeLongTextRespository.kt) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_long_text_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_long_text_preview.png), [sample_text_icon.xml](references/res/drawable/sample_text_icon.xml), [sample_refresh_icon.xml](references/res/drawable/sample_refresh_icon.xml) +- **Utilities** : [FontUtils.kt](references/utils/FontUtils.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Import `androidx.compose.ui.unit.times` if multiplying Dp (for example, `2 * padding`). + - Follow sizing rules: ensure dimensions from dimens.xml are used for padding and typography scaling. + +#### Search toolbar layout (`search_toolbar`) + +- **Widget Provider** : [SearchToolBarAppWidget.kt](references/toolbars/SearchToolBarAppWidget.kt) +- **Layout Composable** : [SearchToolBarLayout.kt](references/toolbars/layout/SearchToolBarLayout.kt) +- **Widget Info XML** : [sample_search_toolbar_widget_info.xml](references/res/xml/sample_search_toolbar_widget_info.xml) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_search_toolbar_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_search_toolbar_preview.png), [sample_search_icon.xml](references/res/drawable/sample_search_icon.xml), [sample_refresh_icon.xml](references/res/drawable/sample_refresh_icon.xml), [sample_mic_icon.xml](references/res/drawable/sample_mic_icon.xml), [sample_camera_icon.xml](references/res/drawable/sample_camera_icon.xml), [sample_delete_icon.xml](references/res/drawable/sample_delete_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml), [sample_pin_icon.xml](references/res/drawable/sample_pin_icon.xml) +- **Components** : [Buttons.kt](references/toolbars/layout/Buttons.kt), [LayoutComponents.kt](references/toolbars/layout/LayoutComponents.kt) +- **Utilities** : [ActionUtils.kt](references/utils/ActionUtils.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Avoid `ColorProvider` import errors: import `androidx.glance.unit.ColorProvider` and use `androidx.glance.color.ColorProvider(dayColor, nightColor)`. + - Use `GlanceModifier.background(Color)` directly for solid background colors rather than `ColorProvider`. + +#### Full-bleed image layout and snap scrolling (`snap_scrolling`) + +- **Widget Provider** : [FullBleedImageAppWidget.kt](references/text/FullBleedImageAppWidget.kt) +- **Layout Composable** : [FullBleedImageLayout.kt](references/text/layout/FullBleedImageLayout.kt) +- **Widget Info XML** : [sample_full_bleed_image_widget_info.xml](references/res/xml/sample_full_bleed_image_widget_info.xml) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_full_bleed_image_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_full_bleed_image_preview.png), [sample_scrim_gradient.xml](references/res/drawable/sample_scrim_gradient.xml), [sample_placeholder_image.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable/sample_placeholder_image.png), [sample_app_logo.xml](references/res/drawable/sample_app_logo.xml), [sample_info_icon.xml](references/res/drawable/sample_info_icon.xml), [sample_no_data_icon.xml](references/res/drawable/sample_no_data_icon.xml) +- **Utilities** : [FontUtils.kt](references/utils/FontUtils.kt), [ImageAspectRatio.kt](references/utils/ImageAspectRatio.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Set `verticalScrollMode = + VerticalScrollMode.SnapScrollMatchHeight(size.height)` on `LazyColumn` for Snap Scrolling. + - Glance 1.3.0-alpha02 (or higher), Compile SDK 37 (Baklava) or higher, and Android Gradle Plugin (AGP) 9.1.0 or higher are required if implementing Snap Scrolling. + - Set override val sizeMode: SizeMode = SizeMode.Exact in the widget provider class. + +#### Standard toolbar layout (`standard_toolbar`) + +- **Widget Provider** : [ToolBarAppWidget.kt](references/toolbars/ToolBarAppWidget.kt) +- **Layout Composable** : [ToolBarLayout.kt](references/toolbars/layout/ToolBarLayout.kt) +- **Widget Info XML** : [sample_toolbar_widget_info.xml](references/res/xml/sample_toolbar_widget_info.xml) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_toolbar_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_toolbar_preview.png), [sample_app_logo.xml](references/res/drawable/sample_app_logo.xml), [sample_search_icon.xml](references/res/drawable/sample_search_icon.xml), [sample_add_icon.xml](references/res/drawable/sample_add_icon.xml), [sample_edit_icon.xml](references/res/drawable/sample_edit_icon.xml), [sample_share_icon.xml](references/res/drawable/sample_share_icon.xml), [sample_mic_icon.xml](references/res/drawable/sample_mic_icon.xml), [sample_camera_icon.xml](references/res/drawable/sample_camera_icon.xml), [sample_videocam_icon.xml](references/res/drawable/sample_videocam_icon.xml) +- **Components** : [Buttons.kt](references/toolbars/layout/Buttons.kt), [LayoutComponents.kt](references/toolbars/layout/LayoutComponents.kt) +- **Utilities** : [ActionUtils.kt](references/utils/ActionUtils.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Additional References** : [CanonicalLayoutActivity.kt](references/CanonicalLayoutActivity.kt) +- **Guidance** : + - See Troubleshooting to avoid `ColorProvider` import errors: use androidx.glance.color.ColorProvider(dayColor, nightColor). + - Use GlanceModifier.background(Color) directly for solid background colors rather than `ColorProvider`. + +#### Text with image layout (`text_with_image`) + +- **Widget Provider** : [TextWithImageAppWidget.kt](references/text/TextWithImageAppWidget.kt) +- **Layout Composable** : [TextWithImageLayout.kt](references/text/layout/TextWithImageLayout.kt) +- **Widget Info XML** : [sample_text_with_image_widget_info.xml](references/res/xml/sample_text_with_image_widget_info.xml) +- **Data Repository** : [FakeTextWithImageRepository.kt](references/text/data/FakeTextWithImageRepository.kt) +- **Dimensions** : [dimens.xml](references/res/values/dimens.xml) +- **Strings** : [strings.xml](references/res/values/strings.xml) +- **Drawables** : [sample_text_image_preview.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable-nodpi/sample_text_image_preview.png), [sample_text_icon.xml](references/res/drawable/sample_text_icon.xml), [sample_refresh_icon.xml](references/res/drawable/sample_refresh_icon.xml), [sample_placeholder_image.png](https://developer.android.com/static/agents/skills/system/glance-canonical-layouts/references/res/drawable/sample_placeholder_image.png), [sample_info_icon.xml](references/res/drawable/sample_info_icon.xml), [sample_no_data_icon.xml](references/res/drawable/sample_no_data_icon.xml) +- **Utilities** : [FontUtils.kt](references/utils/FontUtils.kt), [ImageAspectRatio.kt](references/utils/ImageAspectRatio.kt), [PreviewAnnotations.kt](references/utils/PreviewAnnotations.kt) +- **Theme** : [Color.kt](references/ui/theme/Color.kt), [Theme.kt](references/ui/theme/Theme.kt), [Type.kt](references/ui/theme/Type.kt) +- **Guidance** : + - Follow responsive layout sizing rules to accommodate text and image side-by-side or stacked based on available widget dimensions. + +## Troubleshooting + +1. **Background Colors** : Use `GlanceModifier.background(Color)` directly for solid background colors rather than `ColorProvider`, otherwise the background will be transparent. +2. **When using `ColorProvider` (outside of the background), avoid import errors** : + 1. Import type: `import androidx.glance.unit.ColorProvider` + 2. Use fully qualified creator: `androidx.glance.color.ColorProvider(dayColor, nightColor)` + +## Checklist + +- \[ \] Did you register the widget in the AndroidManifest? +- \[ \] Did you copy the corresponding **Widget Info XML** file and leave the given values including `minWidth`, `minHeight`, and `minResizeHeight` unchanged? +- \[ \] Did you use the given Glance Theme color values from the corresponding **Layout Composable** file? +- \[ \] Did you use dimension resource references for `android:minWidth` and `android:minHeight` in appwidget-provider XML definitions? +- \[ \] Did you override `val sizeMode: SizeMode = SizeMode.Exact` in the widget provider class? diff --git a/system/glance-canonical-layouts/references/ActionDemonstrationActivity.kt b/system/glance-canonical-layouts/references/ActionDemonstrationActivity.kt new file mode 100644 index 0000000..1998678 --- /dev/null +++ b/system/glance-canonical-layouts/references/ActionDemonstrationActivity.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.glance.action.ActionParameters + +internal val ActionSourceMessageKey = ActionParameters.Key("actionSourceMessageKey") + +/** + * Activity that is launched on clicks from different parts of sample widgets. Displays string + * describing source of the click. + */ +class ActionDemonstrationActivity : ComponentActivity() { + + override fun onResume() { + super.onResume() + setContent { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val source = intent.getStringExtra(ActionSourceMessageKey.name) ?: "Unknown" + Text("Launched from $source") + } + } + } +} diff --git a/system/glance-canonical-layouts/references/CanonicalLayoutActivity.kt b/system/glance-canonical-layouts/references/CanonicalLayoutActivity.kt new file mode 100644 index 0000000..8b599e2 --- /dev/null +++ b/system/glance-canonical-layouts/references/CanonicalLayoutActivity.kt @@ -0,0 +1,371 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.ActionListAppWidgetAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.collections.CheckListAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.collections.ImageGridAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.collections.ImageTextListAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.text.LongTextAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.text.TextWithImageAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.text.FullBleedImageAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.toolbars.ExpressiveToolbarAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.toolbars.SearchToolBarAppWidgetReceiver +import com.example.platform.ui.appwidgets.glance.layout.toolbars.ToolBarAppWidgetReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +import androidx.lifecycle.lifecycleScope + +class CanonicalLayoutActivity : ComponentActivity() { + + companion object { + fun start(context: Context) { + val intent = Intent(context, CanonicalLayoutActivity::class.java) + context.startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + + super.onCreate(savedInstanceState) + + // Publish Generated Widget Previews + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + lifecycleScope.launch { + try { + val context = this@CanonicalLayoutActivity + val receiver = FullBleedImageAppWidgetReceiver::class.java + val glanceAppWidgetManager = GlanceAppWidgetManager(context) + val appWidgetManager = context.getSystemService(AppWidgetManager::class.java) + + val providerInfo = appWidgetManager?.installedProviders?.firstOrNull { + it.provider.className == receiver.name + } + + if (providerInfo?.generatedPreviewCategories == 0) { + val result = glanceAppWidgetManager.setWidgetPreviews(FullBleedImageAppWidgetReceiver::class) + val status = when (result) { + GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_SUCCESS -> "Success" + GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_RATE_LIMITED -> "Rate-Limited" + else -> "Error ($result)" + } + Log.i("CanonicalLayoutActivity", "Published previews for ${receiver.simpleName}: $status") + } + } catch (e: Exception) { + Log.e("CanonicalLayoutActivity", "Failed to set widget previews", e) + } + } + } + + setContent { + MaterialTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Log.i("~~~", innerPadding.toString()) + Content( + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +private fun Content(modifier: Modifier) { + LazyColumn(modifier = modifier.fillMaxSize()) { + + item { + Header(modifier = Modifier.fillMaxWidth()) + } + + items(items = canonicalLayoutWidgets) { rowData: CanonicalLayoutRowData -> + CanonicalLayoutRow( + widget = rowData, + modifier = Modifier + .fillMaxWidth() + .padding(top = 54.dp, start = 8.dp, end = 8.dp), + ) + } + + item { + Spacer(modifier = Modifier.size(24.dp)) + } + } +} + +@Composable +private fun Header(modifier: Modifier) { + @Composable + fun Title(modifier: Modifier = Modifier) { + Text("Layouts", style = MaterialTheme.typography.displayMedium, modifier = modifier) + } + + @Composable + fun HeroImage(modifier: Modifier = Modifier) { + Image( + painterResource(id = R.drawable.cl_activity_row_hero_image), + contentDescription = null, + contentScale = ContentScale.FillHeight, + modifier = modifier, + ) + } + + @Composable + fun Description(modifier: Modifier = Modifier) { + val linkColor = MaterialTheme.colorScheme.primary + val url = "https://developer.android.com/design/ui/mobile/guides/widgets/layouts" + + val annotatedText = remember { + val part1 = + "Explore these widget layouts, showcasing common design patterns. Add them to your home screen for a hands-on experience or dive into " + val toLinkify = "detailed design guidelines." + + buildAnnotatedString { + append(part1) + pushStringAnnotation( + tag = "URL", + annotation = "url", + ) + withStyle( + style = SpanStyle( + color = linkColor, + ), + ) { + append(toLinkify) + } + pop() + } + } + + ClickableText( + text = annotatedText, + + style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center), + modifier = modifier, + onClick = { offset -> + annotatedText.getStringAnnotations( + tag = "URL", start = offset, end = offset, + ).firstOrNull()?.let { annotation -> + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + } + }, + ) + } + + Column( + modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Spacer(Modifier.size(24.dp)) + Title(Modifier.align(Alignment.CenterHorizontally)) + HeroImage() + Description(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp)) + } +} + +@Composable +private fun CanonicalLayoutRow(widget: CanonicalLayoutRowData, modifier: Modifier) { + @Composable + fun Title(modifier: Modifier = Modifier) { + Text( + text = stringResource(widget.rowTitle), + style = MaterialTheme.typography.titleMedium, + modifier = modifier, + ) + } + + @Composable + fun RowImage(modifier: Modifier = Modifier) { + Image( + painter = painterResource(id = widget.imageRes), + contentDescription = "Screenshot of ${widget.rowTitle}", + contentScale = ContentScale.FillWidth, + modifier = modifier.fillMaxWidth(), + ) + } + + @Composable + fun BodyText(modifier: Modifier = Modifier) { + Text( + text = stringResource(widget.rowDescription), + style = MaterialTheme.typography.bodyMedium, + modifier = modifier, + ) + } + + + @Composable + fun PinWidgetButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + Button(modifier = modifier, onClick = onClick) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Add, contentDescription = null) + Text("Add to home") + } + } + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Column(modifier, Arrangement.spacedBy(24.dp)) { + RowImage() + Title() + BodyText() + PinWidgetButton( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { requestPin(widget, context, scope) }, + ) + } +} + +private fun requestPin( + widget: CanonicalLayoutRowData, + context: Context, + coroutineScope: CoroutineScope, +) { + val glanceManager = GlanceAppWidgetManager(context) + val receiverClass = widget.receiver + val receiver = receiverClass.getDeclaredConstructor() + .newInstance() as GlanceAppWidgetReceiver + + coroutineScope.launch { + glanceManager.requestPinGlanceAppWidget(receiver::class.java) + } +} + + +private data class CanonicalLayoutRowData( + @StringRes val rowTitle: Int, + @StringRes val rowDescription: Int, + @DrawableRes val imageRes: Int, + val receiver: Class, +) + +private val canonicalLayoutWidgets = listOf( + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_checklist, + rowDescription = R.string.cl_description_checklist, + imageRes = R.drawable.cl_activity_row_checklist, + receiver = CheckListAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_long_text, + rowDescription = R.string.cl_description_long_text, + imageRes = R.drawable.cl_activity_row_long_text, + receiver = LongTextAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_action_list, + rowDescription = R.string.cl_description_action_list, + imageRes = R.drawable.cl_activity_row_action_list, + receiver = ActionListAppWidgetAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_image_text_list, + rowDescription = R.string.cl_description_image_text_list, + imageRes = R.drawable.cl_activity_row_image_text_list, + receiver = ImageTextListAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_text_and_image, + rowDescription = R.string.cl_description_text_and_image, + imageRes = R.drawable.cl_activity_row_text_image, + receiver = TextWithImageAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_full_bleed_image, + rowDescription = R.string.cl_description_full_bleed_image, + imageRes = R.drawable.cl_activity_row_full_bleed_image, + receiver = FullBleedImageAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_grid, + rowDescription = R.string.cl_description_grid, + imageRes = R.drawable.cl_activity_row_image_grid, + receiver = ImageGridAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_toolbar, + rowDescription = R.string.cl_description_toolbar, + imageRes = R.drawable.cl_activity_row_toolbar, + receiver = ToolBarAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_search_toolbar, + rowDescription = R.string.cl_description_search_toolbar, + imageRes = R.drawable.cl_activity_row_search_toolbar, + receiver = SearchToolBarAppWidgetReceiver::class.java, + ), + CanonicalLayoutRowData( + rowTitle = R.string.cl_title_expressive_toolbar, + rowDescription = R.string.cl_description_expressive_toolbar, + imageRes = R.drawable.cl_activity_row_expressive_toolbar, + receiver = ExpressiveToolbarAppWidgetReceiver::class.java, + ), +) diff --git a/system/glance-canonical-layouts/references/CollectionsKtx.kt b/system/glance-canonical-layouts/references/CollectionsKtx.kt new file mode 100644 index 0000000..d773ffb --- /dev/null +++ b/system/glance-canonical-layouts/references/CollectionsKtx.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout + +/** + * This function should be removed when API level is raised to 24. It exists to provide removeIf + * from java.util.Collection + */ +fun MutableList.removeIf(filter: (T) -> Boolean): Boolean { + + var removed = false + var toRemove = mutableListOf() + + forEach { item -> + if (filter(item)) { + removed = true + toRemove.add(item) + } + } + + removeAll(toRemove) + return removed + +} + +fun MutableMap.computeIfAbsent(key : K, calculation :(K)->V):V? { + var value = get(key) + + if (value == null) { + value = calculation(key) + put(key, value) + } + return value +} diff --git a/system/glance-canonical-layouts/references/collections/ActionListAppWidget.kt b/system/glance-canonical-layouts/references/collections/ActionListAppWidget.kt new file mode 100644 index 0000000..0552945 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/ActionListAppWidget.kt @@ -0,0 +1,86 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeActionListDataRepository +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeActionListDataRepository.Companion.getActionListDataRepo +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListItem +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayout +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** A sample [GlanceAppWidget] demonstrating the [ActionListLayout]. */ +class ActionListAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getActionListDataRepo(id) + + val initialItems = withContext(Dispatchers.Default) { + repo.load() + } + + provideContent { + val items by repo.items().collectAsState(initial = initialItems) + val checkedItems by repo.checkedItems().collectAsState(initial = emptyList()) + + GlanceTheme { + WidgetContent( + items = items, + checkedItems = checkedItems, + checkItemAction = { key -> repo.checkItem(key) } + ) + } + } + } + + @Composable + fun WidgetContent( + items: List, + checkedItems: List, + checkItemAction: (String) -> Unit, + ) { + val context = LocalContext.current + + ActionListLayout( + title = context.getString(R.string.sample_action_list_app_widget_name), + titleIconRes = R.drawable.sample_home_icon, + titleBarActionIconRes = R.drawable.sample_power_settings_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_action_list_settings_label + ), + titleBarAction = actionStartDemoActivity("Power settings title bar action"), + items = items, + checkedItems = checkedItems, + actionButtonClick = checkItemAction, + ) + } +} + +class ActionListAppWidgetAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = ActionListAppWidget() + + @SuppressLint("RestrictedApi") + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + FakeActionListDataRepository.cleanUp(AppWidgetId(appWidgetId = it)) + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/system/glance-canonical-layouts/references/collections/CheckListAppWidget.kt b/system/glance-canonical-layouts/references/collections/CheckListAppWidget.kt new file mode 100644 index 0000000..819a8c3 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/CheckListAppWidget.kt @@ -0,0 +1,95 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeCheckListDataRepository +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeCheckListDataRepository.Companion.getCheckListDataRepo +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListItem +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayout +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * A sample [GlanceAppWidget] demonstrating the [CheckListLayout]. + * + * Has ability to toggle between placeholder text and real-like text. + */ +class CheckListAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getCheckListDataRepo(id) + + val initialItems = withContext(Dispatchers.Default) { + repo.load() + } + + provideContent { + val items by repo.items().collectAsState(initial = initialItems) + val checkedItems by repo.checkedItems().collectAsState(initial = emptyList()) + + GlanceTheme { + WidgetContent( + items = items, + checkedItems = checkedItems, + checkItemAction = { key: String -> repo.checkItem(key) } + ) + } + } + } + + @Composable + fun WidgetContent( + items: List, + checkedItems: List, + checkItemAction: (String) -> Unit, + ) { + val context = LocalContext.current + + CheckListLayout( + title = context.getString(R.string.sample_check_list_app_widget_name), + titleIconRes = R.drawable.sample_pin_icon, + titleBarActionIconRes = R.drawable.sample_add_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_add_button_text + ), + titleBarAction = actionStartDemoActivity("Add icon in title bar"), + items = items, + checkedItems = checkedItems, + checkButtonContentDescription = context.getString( + R.string.sample_mark_done_button_content_description + ), + checkedIconRes = R.drawable.sample_checked_circle_icon, + unCheckedIconRes = R.drawable.sample_circle_icon, + onCheck = checkItemAction + ) + } +} + +class CheckListAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = CheckListAppWidget() + + @SuppressLint("RestrictedApi") + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + FakeCheckListDataRepository.cleanUp(AppWidgetId(appWidgetId = it)) + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/system/glance-canonical-layouts/references/collections/ImageGridAppWidget.kt b/system/glance-canonical-layouts/references/collections/ImageGridAppWidget.kt new file mode 100644 index 0000000..5385ab6 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/ImageGridAppWidget.kt @@ -0,0 +1,96 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.rememberCoroutineScope +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeImageGridDataRepository.Companion.cleanUp +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeImageGridDataRepository.Companion.getImageGridDataRepo +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridItemData +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A sample [GlanceAppWidget] demonstrating the [ImageGridLayout]. + * + * Can be configured to select different data sources. + */ +class ImageGridAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getImageGridDataRepo(id) + + val initialItems = withContext(Dispatchers.Default) { + repo.load(context) + } + + provideContent { + val items by repo.data().collectAsState(initial = initialItems) + val coroutineScope = rememberCoroutineScope() + + GlanceTheme { + key(LocalSize.current) { + WidgetContent( + items = items, + refreshAction = { + coroutineScope.launch { + withContext(Dispatchers.IO) { + repo.refresh(context) + } + } + } + ) + } + } + } + } +} + +@Composable +fun WidgetContent( + items: List, + refreshAction: () -> Unit, +) { + val context = LocalContext.current + + ImageGridLayout( + title = context.getString(R.string.sample_image_grid_app_widget_name), + titleIconRes = R.drawable.sample_grid_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = refreshAction, + items = items + ) +} + +class ImageGridAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = ImageGridAppWidget() + + @SuppressLint("RestrictedApi") + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + cleanUp(AppWidgetId(appWidgetId = it)) + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/system/glance-canonical-layouts/references/collections/ImageTextListAppWidget.kt b/system/glance-canonical-layouts/references/collections/ImageTextListAppWidget.kt new file mode 100644 index 0000000..278d7d0 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/ImageTextListAppWidget.kt @@ -0,0 +1,99 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.rememberCoroutineScope +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeImageTextListDataRepository +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeImageTextListDataRepository.Companion.getImageTextListDataRepo +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListItemData +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A sample [GlanceAppWidget] demonstrating the [ImageTextListLayout]. + * + * Has ability to toggle between placeholder text and real-like text. + */ +class ImageTextListAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getImageTextListDataRepo(id) + + val initialItems = withContext(Dispatchers.Default) { + repo.load(context) + } + + provideContent { + GlanceTheme { + val items by repo.data().collectAsState(initial = initialItems) + val coroutineScope = rememberCoroutineScope() + + key(LocalSize.current) { + WidgetContent( + items = items, + refreshAction = { + coroutineScope.launch { + withContext(Dispatchers.IO) { + repo.refresh(context) + } + } + } + ) + } + } + } + } + + @Composable + fun WidgetContent( + items: List, + refreshAction: () -> Unit, + ) { + val context = LocalContext.current + + ImageTextListLayout( + items = items, + title = context.getString( + R.string.sample_text_and_image_list_app_widget_name + ), + titleIconRes = R.drawable.sample_image_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = refreshAction, + ) + } +} + +class ImageTextListAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = ImageTextListAppWidget() + + @SuppressLint("RestrictedApi") + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + FakeImageTextListDataRepository.cleanUp(AppWidgetId(appWidgetId = it)) + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/system/glance-canonical-layouts/references/collections/data/FakeActionListDataRepository.kt b/system/glance-canonical-layouts/references/collections/data/FakeActionListDataRepository.kt new file mode 100644 index 0000000..df2bf18 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/data/FakeActionListDataRepository.kt @@ -0,0 +1,129 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.data + +import android.os.Build +import androidx.glance.GlanceId +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * A fake in-memory implementation of repository that produces list of [ActionListItem]. + */ +class FakeActionListDataRepository { + private val items = MutableStateFlow>(listOf()) + private val checkedItems = MutableStateFlow>(listOf()) + + /** + * Flow of [ActionListItem]s that can be listened to during a Glance session. + */ + fun items(): Flow> = items + + /** + * Flow of keys of [ActionListItem]s that are checked. This flow can be listened to during a + * Glance session. + */ + fun checkedItems(): Flow> = checkedItems + + fun checkItem(key: String) { + if (checkedItems.value.contains(key)) { + checkedItems.value = checkedItems.value.toMutableList().apply { remove(key) } + } else { + checkedItems.value = checkedItems.value.toMutableList().apply { add(key) } + } + } + + /** + * Loads the [ActionListItem]s from the selected data source. + */ + fun load(): List { + items.value = demoData + + checkedItems.value = listOf() + return items.value + } + + companion object { + private val repositories = mutableMapOf() + + val demoData = listOf( + ActionListItem( + key = "0", + title = "Living room light", + onSupportingText = "ON", + offSupportingText = "OFF", + stateIconRes = R.drawable.sample_bulb_icon, + onStateActionContentDescription = "", // Supporting text already covers this + offStartActionContentDescription = "", // Supporting text already covers this + ), + ActionListItem( + key = "1", + title = "Thermostat", + onSupportingText = "ON (74°F)", + offSupportingText = "OFF", + stateIconRes = R.drawable.sample_thermostat_icon, + onStateActionContentDescription = "", // Supporting text already covers this + offStartActionContentDescription = "", // Supporting text already covers this + trailingIconButtonRes = R.drawable.sample_arrow_right_icon, + trailingIconButtonContentDescription = "Edit temperature" + ), + ActionListItem( + key = "2", + title = "A/C", + onSupportingText = "ON", + offSupportingText = "OFF", + stateIconRes = R.drawable.sample_ac_icon, + onStateActionContentDescription = "", // Supporting text already covers this + offStartActionContentDescription = "" // Supporting text already covers this + ), + ActionListItem( + key = "3", + title = "Front door", + onSupportingText = "Open", + offSupportingText = "Closed", + stateIconRes = R.drawable.sample_door_icon, + onStateActionContentDescription = "", // Supporting text already covers this + offStartActionContentDescription = "" // Supporting text already covers this + ), + ActionListItem( + key = "4", + title = "Bedroom light", + onSupportingText = "ON", + offSupportingText = "OFF", + stateIconRes = R.drawable.sample_bulb_icon, + onStateActionContentDescription = "", // Supporting text already covers this + offStartActionContentDescription = "" // Supporting text already covers this + ), + ActionListItem( + key = "5", + title = "Hallway light", + onSupportingText = "ON", + offSupportingText = "OFF", + stateIconRes = R.drawable.sample_bulb_icon, + onStateActionContentDescription = "", // Supporting text already covers this + offStartActionContentDescription = "" // Supporting text already covers this + ), + ) + + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getActionListDataRepo(glanceId: GlanceId): FakeActionListDataRepository { + return synchronized(repositories) { + if (!repositories.contains(glanceId)) { + repositories[glanceId] = FakeActionListDataRepository() + } + return repositories[glanceId]?:FakeActionListDataRepository() + } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + } +} diff --git a/system/glance-canonical-layouts/references/collections/data/FakeCheckListDataRepository.kt b/system/glance-canonical-layouts/references/collections/data/FakeCheckListDataRepository.kt new file mode 100644 index 0000000..4d1c35e --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/data/FakeCheckListDataRepository.kt @@ -0,0 +1,125 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.data + +import androidx.glance.GlanceId +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListItem +import com.example.platform.ui.appwidgets.glance.layout.computeIfAbsent as computeIfAbsentExt +import com.example.platform.ui.appwidgets.glance.layout.removeIf as removeIfExt +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A fake in-memory implementation of a repository that produces a list of [CheckListItem]s. + */ +class FakeCheckListDataRepository { + private val items = MutableStateFlow>(listOf()) + private val checkedItems = MutableStateFlow>(listOf()) + + /** + * Flow of [CheckListItem]s that can be listened to during a Glance session. + */ + fun items(): Flow> = items + + /** + * Flow of keys of [CheckListItem]s that are checked. This flow can be listened to during a + * Glance session. + */ + fun checkedItems(): Flow> = checkedItems + + @OptIn(DelicateCoroutinesApi::class) + fun checkItem(key: String) { + GlobalScope.launch { + withContext(Dispatchers.IO) { + checkedItems.value = checkedItems.value.toMutableList().apply { add(key) } + + // Mimics backend processing that removes the item from backend database. + // Until it's removed from the backend, because it's in checkedItems, it appears as + // checked on the screen. Then, once the backend is updated, the items list won't contain it and + // it's removed from the UI. + delay(500) + + items.value = items.value.toMutableList().apply { + removeIfExt { item -> + item.key == key + } + } + checkedItems.value = checkedItems.value.toMutableList().apply { remove(key) } + } + } + } + + /** + * Loads the [CheckListItem]s from the selected data source. + */ + fun load(): List { + items.value = demoData + checkedItems.value = listOf() + + return items.value + } + + companion object { + private val repositories = mutableMapOf() + + val demoData = listOf( + CheckListItem( + key = "0", + title = "Pay electricity bill", + supportingText = "Due in 10 days", + ), + CheckListItem( + key = "1", + title = "Prepare for the meeting", + supportingText = "Due tomorrow" + ), + CheckListItem( + key = "2", + title = "Renew lease", + supportingText = "Due in 1 month", + ), + CheckListItem( + key = "3", + title = "Plan the trip", + supportingText = "Due tomorrow", + ), + CheckListItem( + key = "4", + title = "Call plumber", + supportingText = "Due today", + ), + CheckListItem( + key = "5", + title = "Dentist appointment", + supportingText = "Due in 1 week", + ), + CheckListItem( + key = "6", + title = "Eye appointment", + supportingText = "Due in 1 month", + ), + ) + + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getCheckListDataRepo(glanceId: GlanceId): FakeCheckListDataRepository { + return synchronized(repositories) { + repositories.computeIfAbsentExt(glanceId) { FakeCheckListDataRepository() }!! + } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + } +} diff --git a/system/glance-canonical-layouts/references/collections/data/FakeImageGridDataRepository.kt b/system/glance-canonical-layouts/references/collections/data/FakeImageGridDataRepository.kt new file mode 100644 index 0000000..1b53864 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/data/FakeImageGridDataRepository.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.collections.data + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import androidx.glance.GlanceId +import coil.ImageLoader +import coil.request.CachePolicy +import coil.request.ErrorResult +import coil.request.ImageRequest +import com.example.platform.ui.appwidgets.glance.layout.utils.AspectRatio +import com.example.platform.ui.appwidgets.glance.layout.utils.AspectRatio.Companion.asDouble +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridItemData +import com.example.platform.ui.appwidgets.glance.layout.utils.ImageUtils.getMaxPossibleImageSize +import com.example.platform.ui.appwidgets.glance.layout.utils.ImageUtils.getMaxWidgetMemoryAllowedSizeInBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import com.example.platform.ui.appwidgets.glance.layout.computeIfAbsent as computeIfAbsentExt +/** + * A fake in-memory implementation of repository that produces a list of + * [ImageGridItemData]. + * + * During the data mapping, loads resources as bitmaps, and scales them down to size within the + * limits allowed for widgets. + * + */ +class FakeImageGridDataRepository { + private val data = MutableStateFlow(listOf()) + private var items = demoItems.take(MAX_ITEMS_PER_WIDGET) + + /** + * Flow of list of [ImageGridItemData]s that can be listened to during a Glance session. + */ + fun data(): Flow> = data + + suspend fun refresh(context: Context) { + items = items.shuffled() + + this.load(context) + } + + /** + * Loads the [ImageGridItemData] items from the currently selected data source. + * + * User selected data source is set by configuration activity via [selectDataSource]. + */ + suspend fun load(context: Context): List { + data.value = processImagesAndBuildData( + context = context, + items = items + ) + + return data.value + } + + private suspend fun processImagesAndBuildData( + context: Context, + items: List, + ): List { + val maxAllowedBytes = context.getMaxWidgetMemoryAllowedSizeInBytes() + val maxAllowedBytesPerImage = maxAllowedBytes / items.size + val imageSizeLimit = getMaxPossibleImageSize( + aspectRatio = AspectRatio.Ratio16x9.asDouble(), + memoryLimitBytes = maxAllowedBytesPerImage, + maxImages = 1 + ) + + val width = IMAGE_SIZE.coerceAtMost(imageSizeLimit.width) + val height = width * 9 / 16 + + val mappedItems = coroutineScope { + items.map { item -> + async(Dispatchers.IO) { + var bitmap: Bitmap? = null + + val result = ImageLoader(context).execute( + ImageRequest.Builder(context) + .data(item.imageUrl) + .size(width, height) + .networkCachePolicy(CachePolicy.ENABLED) + .target { res: Drawable -> + bitmap = (res as BitmapDrawable).bitmap + }.build() + ) + + if (result is ErrorResult) { + Log.e(TAG, "Failed to load the image:", result.throwable) + } + + return@async ImageGridItemData( + key = item.key, + title = item.title, + supportingText = item.supportingText, + image = bitmap, + imageContentDescription = item.imageContentDescription + ) + } + }.awaitAll() + } + + return mappedItems + } + + private data class ImageGridItemBackendData( + val key: String, + val imageUrl: String, + val imageContentDescription: String?, + val title: String? = null, + val supportingText: String? = null, + ) + + companion object { + private val repositories = mutableMapOf() + + // Courtesy of https://unsplash.com/@iamliam + // 16:9 images + private val demoItems = listOf( + ImageGridItemBackendData( + key = "1", + imageUrl = "https://images.unsplash.com/photo-1531306760863-7fb02a41db12", + imageContentDescription = "Flowers at a wedding reception", + title = "Flowers at a wedding reception", + supportingText = "33,822 views" + ), + ImageGridItemBackendData( + key = "2", + imageUrl = "https://images.unsplash.com/photo-1566964423430-3e52903303a5", + imageContentDescription = "An up-close look at a Blushing Bride Protea flower.", + title = "An up-close look at a Blushing Bride Protea flower.", + supportingText = "31,072 views" + ), + ImageGridItemBackendData( + key = "3", + imageUrl = "https://images.unsplash.com/photo-1685540466252-8c21e7c37624", + imageContentDescription = "A single water droplet rests in a budding red pansy.", + title = "A single water droplet rests in a budding red pansy.", + supportingText = "193 views" + ), + ImageGridItemBackendData( + key = "4", + imageUrl = "https://images.unsplash.com/photo-1582817954171-c3533fffde89", + imageContentDescription = "Blossom, petal, flower", + title = "Blossom, petal, flower", + supportingText = "23,815 views", + ), + ImageGridItemBackendData( + key = "5", + imageUrl = "https://images.unsplash.com/photo-1565314912546-0d18918fdc8f", + imageContentDescription = "Green plant, sky and flowers", + title = "Green plant, sky and flowers", + supportingText = "99,467 views" + ), + ImageGridItemBackendData( + key = "6", + imageUrl = "https://images.unsplash.com/photo-1671525784444-392a8f8daa3f", + imageContentDescription = "A snow-shoer walking up Strelapass", + title = "A snow-shoer walking up Strelapass", + supportingText = "3,033 views", + ), + ImageGridItemBackendData( + key = "7", + imageUrl = "https://images.unsplash.com/photo-1671525737370-1d490286372e", + imageContentDescription = "Davos at sunrise, viewed from Schatzalp", + title = "Davos at sunrise, viewed from Schatzalp", + supportingText = "4,054 views", + ), + ImageGridItemBackendData( + key = "8", + imageUrl = "https://images.unsplash.com/photo-1629027272726-2eed15f90e8e", + imageContentDescription = "Nasturtium plants", + title = "Nasturtium plants", + supportingText = "975 views", + ) + ) + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getImageGridDataRepo(glanceId: GlanceId): FakeImageGridDataRepository { + return synchronized(repositories) { + repositories.computeIfAbsentExt(glanceId) { FakeImageGridDataRepository() }!! + } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + + // Capped at 8 to limit amount of memory consumed by bitmaps + const val MAX_ITEMS_PER_WIDGET = 8 + const val IMAGE_SIZE = 200 + const val TAG = "FIGDR" + } +} diff --git a/system/glance-canonical-layouts/references/collections/data/FakeImageTextListDataRepository.kt b/system/glance-canonical-layouts/references/collections/data/FakeImageTextListDataRepository.kt new file mode 100644 index 0000000..24e1f67 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/data/FakeImageTextListDataRepository.kt @@ -0,0 +1,205 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.data + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.glance.GlanceId +import coil.ImageLoader +import coil.request.CachePolicy +import coil.request.ErrorResult +import coil.request.ImageRequest +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.utils.AspectRatio +import com.example.platform.ui.appwidgets.glance.layout.utils.AspectRatio.Companion.asDouble +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListItemData +import com.example.platform.ui.appwidgets.glance.layout.computeIfAbsent as computeIfAbsentExt +import com.example.platform.ui.appwidgets.glance.layout.utils.ImageUtils +import com.example.platform.ui.appwidgets.glance.layout.utils.ImageUtils.getMaxWidgetMemoryAllowedSizeInBytes +import kotlin.random.Random +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking + +/** + * A fake in-memory implementation of repository that produces list of [ImageTextListItemData] + */ +class FakeImageTextListDataRepository { + private val data = MutableStateFlow(listOf()) + private var items = demoItems.take(MAX_ITEMS) + + /** + * Flow of [ImageTextListItemData]s that can be listened to during a Glance session. + */ + fun data(): Flow> = data + + /** + * Reloads items (which due to shuffling) helps mimic a refresh + */ + suspend fun refresh(context: Context) { + val showData = Random.nextInt(50) < 5 + + items = if (showData) { + demoItems.take(MAX_ITEMS).shuffled() + } else { + listOf() + } + this.load(context) + } + + /** + * Loads the list of [ImageTextListItemData]s. + */ + suspend fun load(context: Context): List { + data.value = if (items.isNotEmpty()) { + processImagesAndBuildData(context, items) + } else { + listOf() + } + + return data.value + } + + private suspend fun processImagesAndBuildData( + context: Context, + items: List, + ): List { + val maxAllowedBytes = context.getMaxWidgetMemoryAllowedSizeInBytes() + val maxAllowedBytesPerImage = maxAllowedBytes / items.size + val imageSizeLimit = ImageUtils.getMaxPossibleImageSize( + aspectRatio = AspectRatio.Ratio1x1.asDouble(), + memoryLimitBytes = maxAllowedBytesPerImage, + maxImages = 1 + ) + + val imageSize = + IMAGE_SIZE.coerceAtMost(imageSizeLimit.width.coerceAtMost(imageSizeLimit.height)) + + val mappedItems = runBlocking { + items.map { item -> + async(Dispatchers.IO) { + var bitmap: Bitmap? = null + + val result = ImageLoader(context).execute( + ImageRequest.Builder(context) + .data(item.supportingImageUrl) + .size(imageSize, imageSize) + .networkCachePolicy(CachePolicy.ENABLED) + .target { res: Drawable -> + bitmap = (res as BitmapDrawable).bitmap + }.build() + ) + + if (result is ErrorResult) { + Log.e(TAG, "Failed to load the image:", result.throwable) + } + + return@async ImageTextListItemData( + key = item.key, + title = item.title, + supportingText = item.supportingText, + supportingImageBitmap = bitmap, + // In this example we are using same icon, but could potentially be different. + trailingIconButton = R.drawable.sample_heart_icon, + trailingIconButtonContentDescription = "Like" + ) + } + }.awaitAll() + } + + return mappedItems + } + + private data class DemoDataItem( + val key: String, + val title: String, + val supportingText: String, + val supportingImageUrl: String, + @DrawableRes val trailingIconButton: Int? = null, + val trailingIconButtonContentDescription: String? = null, + ) + + companion object { + // Default size to load (that will be capped by the max allowed bytes in remote views) + private const val IMAGE_SIZE = 200 + private const val MAX_ITEMS = 10 + private const val TAG = "FITLDR" + + // Courtesy of https://unsplash.com/@iamliam + private val demoItems = listOf( + DemoDataItem( + key = "1", + title = "Flowers at a wedding reception", + supportingText = "33,822 views", + supportingImageUrl = "https://images.unsplash.com/photo-1531306760863-7fb02a41db12" + ), + DemoDataItem( + key = "2", + title = "An up-close look at a Blushing Bride Protea flower.", + supportingText = "31,072 views", + supportingImageUrl = "https://images.unsplash.com/photo-1566964423430-3e52903303a5", + ), + DemoDataItem( + key = "3", + supportingImageUrl = "https://images.unsplash.com/photo-1685540466252-8c21e7c37624", + title = "A single water droplet rests in a budding red pansy.", + supportingText = "193 views", + ), + DemoDataItem( + key = "4", + title = "Blossom, petal, flower", + supportingText = "23,815 views", + supportingImageUrl = "https://images.unsplash.com/photo-1582817954171-c3533fffde89", + ), + DemoDataItem( + key = "5", + title = "Orchids at New York Botanical Garden", + supportingText = "205,481 views", + supportingImageUrl = "https://images.unsplash.com/photo-1565357153781-98bf8686488a", + ), + DemoDataItem( + key = "6", + title = "Tabletop composition with flower", + supportingText = "85,060 views", + supportingImageUrl = "https://images.unsplash.com/photo-1591404789216-d03646c78f73", + ), + DemoDataItem( + key = "7", + title = "Wild bee on flower", + supportingText = "6,692 views", + supportingImageUrl = "https://images.unsplash.com/photo-1653927050791-5fc981435d12", + ), + DemoDataItem( + key = "8", + title = "Flowers on a vine", + supportingText = "31,862 views", + supportingImageUrl = "https://images.unsplash.com/photo-1531307119710-accdb402fe03", + ), + ) + + private val repositories = mutableMapOf() + + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getImageTextListDataRepo(glanceId: GlanceId): FakeImageTextListDataRepository { + return synchronized(repositories) { + repositories.computeIfAbsentExt(glanceId) { FakeImageTextListDataRepository() }!! + } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + } +} diff --git a/system/glance-canonical-layouts/references/collections/layout/ActionListLayout.kt b/system/glance-canonical-layouts/references/collections/layout/ActionListLayout.kt new file mode 100644 index 0000000..df6159e --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/ActionListLayout.kt @@ -0,0 +1,560 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.ExperimentalGlanceApi +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeActionListDataRepository.Companion.demoData +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.circularCornerRadius +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.filledItemCornerRadius +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.filledItemPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.gridCells +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.itemContentSpacing +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.stateIconBackgroundSize +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.stateIconSize +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.verticalSpacing +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutDimensions.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutSize.Companion.showTitleBar +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutSize.Large +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayoutSize.Small +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.LargeWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting list of two-state actions represented by a title (1-2 words), + * supporting text (~20 characters) and a leading state icon button to indicate the current state + * and an optional trailing icon button to perform additional operations. + * + * Tapping each item changes the state (e.g. turn ON / OFF). The additional actions can be used to + * perform tuned state changes e.g. editing the temperature The layout is suitable for use cases + * such as home control, Quick Settings, etc. + * + * The content is displayed in a [Scaffold] under an app-specific title bar. + * + * In larger sizes, the layout shows a multi-column grid with each item taking minimum size to + * fit the item content. The state icon is dropped in smaller sizes to allow space for additional + * action and the text. + * + * The layout is an implementation suggestion, you can customize it to your needs. For example, + * if you don't have a supporting text, you can drop the filled containers and use spacing to + * individually differentiate your content. You can use separate icons for on and off states if it + * suits your use case. And, if you need support for more states, you can use appropriate text and + * icons. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable + * resource, but you should use an appropriate icon source for your use case. + * @param titleBarActionIconRes resource ID of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For + * example, a search icon to launch search for finding specific + * items. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param items list of items to be included in the list; typically includes a short title, a + * supporting text and the action icon information. + * @param checkedItems list of keys of items that are in "ON" / checked state. + * @param actionButtonClick handler to toggle the state. + * + * @see [FilledActionListItem] for accepted inputs. + * @see [com.example.platform.ui.appwidgets.glance.layout.collections.CheckListAppWidget] + * + */ +@Composable +fun ActionListLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int, + titleBarActionIconContentDescription: String, + titleBarAction: Action, + items: List, + checkedItems: List, + actionButtonClick: (String) -> Unit, +) { + fun titleBar(): @Composable (() -> Unit) = { + TitleBar( + startIcon = ImageProvider(titleIconRes), + title = title.takeIf { ActionListLayoutSize.fromLocalSize() != Small } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction + ) + } + ) + } + + val scaffoldTopPadding = if (showTitleBar()) { + 0.dp + } else { + widgetPadding + } + + Scaffold( + backgroundColor = GlanceTheme.colors.widgetBackground, + modifier = GlanceModifier.padding(top = scaffoldTopPadding), + titleBar = if (showTitleBar()) { + titleBar() + } else { + null + }, + ) { + Content( + items = items, + checkedItems = checkedItems, + actionButtonOnClick = actionButtonClick, + ) + } +} + +@Composable +private fun Content( + items: List, + checkedItems: List, + actionButtonOnClick: (String) -> Unit, +) { + val actionListLayoutSize = ActionListLayoutSize.fromLocalSize() + + Box(modifier = GlanceModifier.padding(bottom = widgetPadding)) { + if (items.isEmpty()) { + EmptyListContent() + } else { + when (actionListLayoutSize) { + Large -> GridView( + items = items, + checkedItems = checkedItems, + actionButtonOnClick = actionButtonOnClick + ) + + else -> ListView( + items = items, + checkedItems = checkedItems, + actionButtonOnClick = actionButtonOnClick + ) + } + } + } +} + +@Composable +private fun ListView( + items: List, + checkedItems: List, + actionButtonOnClick: (String) -> Unit, +) { + RoundedScrollingLazyColumn( + modifier = GlanceModifier.fillMaxSize(), + items = items, + verticalItemsSpacing = verticalSpacing, + itemContentProvider = { item -> + FilledActionListItem( + item = item, + isChecked = checkedItems.contains(item.key), + actionButtonClick = actionButtonOnClick, + modifier = GlanceModifier.fillMaxSize() + ) + } + ) +} + +@Composable +private fun GridView( + items: List, + checkedItems: List, + actionButtonOnClick: (String) -> Unit, +) { + RoundedScrollingLazyVerticalGrid( + gridCells = gridCells, + items = items, + cellSpacing = itemContentSpacing, + itemContentProvider = { item -> + FilledActionListItem( + item = item, + isChecked = checkedItems.contains(item.key), + actionButtonClick = actionButtonOnClick, + modifier = GlanceModifier.fillMaxSize() + ) + }, + modifier = GlanceModifier.fillMaxSize(), + ) +} + +/** + * A filled list / grid item that displays a title, a supporting text, and a trailing state icon. + * + * Uses single line title (1-2 words), and 2-line supporting text (~ 50-55 characters) + */ +@OptIn(ExperimentalGlanceApi::class) +@Composable +private fun FilledActionListItem( + item: ActionListItem, + actionButtonClick: (String) -> Unit, + modifier: GlanceModifier = GlanceModifier, + isChecked: Boolean, +) { + @Composable + fun Title() { + Text( + text = item.title, + style = ActionListLayoutTextStyles.titleText(isChecked), + maxLines = 1, + // Container's content description already reads this text + modifier = GlanceModifier.semantics { contentDescription = "" } + ) + } + + @Composable + fun SupportingText() { + Text( + text = if (isChecked) { + item.onSupportingText + } else { + item.offSupportingText + }, + style = ActionListLayoutTextStyles.supportingText(isChecked), + maxLines = 2, + // Container's content description already reads this text + modifier = GlanceModifier.semantics { contentDescription = "" } + ) + } + + @Composable + fun StateIndicatorIcon() { + Box( + GlanceModifier + .size(stateIconBackgroundSize) + .cornerRadius(circularCornerRadius), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(item.stateIconRes), + modifier = modifier.size(stateIconSize), + contentDescription = null, // already covered in list item container's description + colorFilter = ColorFilter.tint( + if (isChecked) { + GlanceTheme.colors.onPrimary + } else { + GlanceTheme.colors.onSurfaceVariant + } + ) + ) + } + } + + /** + * Additional action that can be performed against the item. + * + * For example, edit the item. Onclick of the title toggles the state, however, sometimes, the + * may be additional controls associated with the action e.g. changing temperature. + */ + @Composable + fun AdditionalActionButton(@DrawableRes resId: Int, contentDescription: String?) { + CircleIconButton( + imageProvider = ImageProvider(resId), + contentDescription = contentDescription, + onClick = actionStartDemoActivity("$contentDescription ${item.key}"), + backgroundColor = null, + contentColor = if (isChecked) { + GlanceTheme.colors.onPrimary + } else { + GlanceTheme.colors.onSurface + } + ) + } + + /** + * Returns a combined content description that can be set on entire list item. + */ + fun combinedContentDescription(): String { + val contentDescriptionBuilder = StringBuilder() + contentDescriptionBuilder.append(item.title) + contentDescriptionBuilder.append(" ") + contentDescriptionBuilder.append( + if (isChecked) { + item.onSupportingText + } else { + item.offSupportingText + } + ) + contentDescriptionBuilder.append(" ") + contentDescriptionBuilder.append( + if (isChecked) { + item.onStateActionContentDescription + } else { + item.offStartActionContentDescription + } + ) + return contentDescriptionBuilder.toString() + } + + ListItem( + modifier = modifier + // We set a combined content description on list item since entire item is clickable. + .semantics { contentDescription = combinedContentDescription() } + .filledContainer(isChecked) + .clickable(key = "${LocalSize.current} ${item.key}") { actionButtonClick(item.key) }, + contentSpacing = itemContentSpacing, + leadingContent = takeComposableIf(ActionListLayoutSize.fromLocalSize() != Small) { + StateIndicatorIcon() + }, + headlineContent = { Title() }, + supportingContent = { SupportingText() }, + trailingContent = if (item.trailingIconButtonRes != null) { + { + AdditionalActionButton( + item.trailingIconButtonRes, + item.trailingIconButtonContentDescription + ) + } + } else null + ) +} + +/** Returns the provided [block] composable if [predicate] is true, else returns null */ +@Composable +private inline fun takeComposableIf( + predicate: Boolean, + crossinline block: @Composable () -> Unit, +): (@Composable () -> Unit)? { + return if (predicate) { + { block() } + } else null +} + +/** + * Converts an item into a filled container by applying the background color, padding and an + * appropriate corner radius. + */ +@Composable +private fun GlanceModifier.filledContainer(isChecked: Boolean): GlanceModifier { + return cornerRadius(filledItemCornerRadius) + .padding(filledItemPadding) + .background( + if (isChecked) { + GlanceTheme.colors.primary + } else { + GlanceTheme.colors.secondaryContainer + } + ) +} + +/** + * Holds data corresponding to each item in a + * [com.example.platform.ui.appwidgets.glance.layout.collections.layout.ActionListLayout]. + * + * @param key a unique identifier for a specific item + * @param title a short text (1-2 words) representing the item + * @param onSupportingText a compact text (~20 characters) that supports the [title] and provides + * textual indication of "ON" state of item; this allows keeping the title + * short and glanceable. + * @param offSupportingText a compact text (~20 characters) that supports the [title] and provides + * * textual indication of "OFF" state of item; this allows keeping the title + * * short and glanceable. + * @param stateIconRes a tintable icon that can represents the item and that can be presented on + * a colored background to indicate the current state (e.g. bulb ON / OFF). + * On click of this icon, the item's state will be toggled. + * @param onStateActionContentDescription text to describe what happens on click of the list item + * when item is in "ON" (checked) state; appended to the + * title and supporting text when used by the the + * accessibility services. + * @param offStartActionContentDescription text to describe what happens on click of the list item + * when item is in "OFF" (un-checked) state; appended to the + * title and supporting text when used by the the + * accessibility services. + */ +data class ActionListItem( + val key: String, + val title: String, + val onSupportingText: String, + val offSupportingText: String, + @DrawableRes val stateIconRes: Int, + val onStateActionContentDescription: String, + val offStartActionContentDescription: String, + @DrawableRes val trailingIconButtonRes: Int? = null, + val trailingIconButtonContentDescription: String? = null, +) + +/** + * Size of the widget per the reference breakpoints. Each size has its own display + * characteristics such as - showing content as list versus grid, font sizes etc. + * + * In this layout, only width breakpoints are used to scale the layout. + */ +private enum class ActionListLayoutSize(val maxWidth: Dp) { + // Single column list - compact view e.g. reduced fonts. + Small(maxWidth = 260.dp), + + // Single column list + Medium(maxWidth = 439.dp), + + // 2 Column Grid + Large(maxWidth = 644.dp); + + companion object { + /** + * Returns the corresponding [ActionListLayoutSize] to be considered for the current + * widget size. + */ + @Composable + fun fromLocalSize(): ActionListLayoutSize { + val width = LocalSize.current.width + + return if (width >= Medium.maxWidth) { + Large + } else if (width >= Small.maxWidth) { + Medium + } else { + Small + } + } + + @Composable + fun showTitleBar(): Boolean { + return LocalSize.current.height >= 180.dp + } + } +} + +private object ActionListLayoutTextStyles { + /** + * Style for the text displayed as title within each item. + */ + @Composable + fun titleText(checked: Boolean): TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = if (ActionListLayoutSize.fromLocalSize() == Small) { + 14.sp // M3 Title Small + } else { + 16.sp // M3 Title Medium + }, + color = if (checked) { + GlanceTheme.colors.onPrimary + } else { + GlanceTheme.colors.onSurface + } + ) + + /** + * Style for the text displayed as supporting text within each item. + */ + @Composable + fun supportingText(isChecked: Boolean): TextStyle = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, // M3 Label Medium + color = if (isChecked) { + GlanceTheme.colors.onPrimary + } else { + GlanceTheme.colors.onSurfaceVariant + } + ) +} + +private object ActionListLayoutDimensions { + /** Number of cells in the grid, when items are displayed as a grid. */ + const val gridCells = 2 + + /** Padding applied at bottom of the widget content */ + val widgetPadding = 12.dp + + /** Corner radius for each filled list item. */ + val filledItemCornerRadius = 16.dp + + /** Padding for filled list items. */ + val filledItemPadding = 12.dp + + /** Vertical spacing between items in the list.*/ + val verticalSpacing = 4.dp + + /** + * Spacing between individual sections in within the list item (for instance, horizontal spacing + * between state icon and text section. + */ + val itemContentSpacing = 4.dp + + /** Size of the background layer on the state icon. */ + val stateIconBackgroundSize = 48.dp + + /** Size of the state icon image. */ + val stateIconSize = 24.dp + + /** Corner radius to achieve circular shape. */ + val circularCornerRadius = 200.dp +} + +/** + * Preview sizes for the widget covering the breakpoints. + * + * This allows verifying updates across multiple breakpoints. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 259, heightDp = 200) +@Preview(widthDp = 438, heightDp = 200) +@Preview(widthDp = 644, heightDp = 200) +private annotation class ActionListBreakpointPreviews + +/** + * Previews for the action list layout. + * + * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition, + * the previews at standard sizes allows us to quickly verify updates across min / max and common + * widget sizes without needing to run the app or manually place the widget. + */ +@ActionListBreakpointPreviews +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun ActionListLayoutPreview() { + val context = LocalContext.current + + ActionListLayout( + title = context.getString(R.string.sample_action_list_app_widget_name), + titleIconRes = R.drawable.sample_home_icon, + titleBarActionIconRes = R.drawable.sample_power_settings_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_action_list_settings_label + ), + titleBarAction = actionStartDemoActivity("Power settings title bar action"), + items = demoData, + checkedItems = listOf("1", "3"), + actionButtonClick = {}, + ) +} diff --git a/system/glance-canonical-layouts/references/collections/layout/CheckListLayout.kt b/system/glance-canonical-layouts/references/collections/layout/CheckListLayout.kt new file mode 100644 index 0000000..f456d8f --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/CheckListLayout.kt @@ -0,0 +1,462 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeCheckListDataRepository.Companion.demoData +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutDimensions.checkListRowStartPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutDimensions.checkListRowEndPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutDimensions.scaffoldHorizontalPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutDimensions.verticalItemSpacing +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutDimensions.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutSize.Companion.isWiderThan +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutSize.Companion.showTitleBar +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayoutSize.Small +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.LargeWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting list of items in a check list. Content is displayed in a + * [Scaffold] below an app-specific title bar. + * + * The layout is a variation of [ActionListLayout], where on-check, item is removed from list once + * updated in the backend. The layout assumes checklist items follow a certain order, so, in + * larger sizes, instead of displaying items in a grid, the layout shows additional trailing + * actions per item. However, if grid works for your use case, you can switch over to it to + * support large sizes. + * + * In this sample, user sees the checked state until the item is removed from the list in the + * backing database. By showing the checked state, it is clear to the user that tapping did + * something. Specifically, for similar looking texts, it may not be immediately obvious to the + * user that item was removed; the intermediate checked state gives that clarity. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable + * resource, but you should use an appropriate icon source for your use case. + * @param titleBarActionIconRes resource ID of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For + * example, a search icon to launch search for finding specific + * items. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param items list of items to be included in the list; typically includes a short title and a + * supporting text. + * @param checkedItems list of keys of items that are in checked state. + * @param checkedIconRes tintable icon to be pressed to clear an item. + * @param unCheckedIconRes tintable icon to be pressed to check an item. + * @param checkButtonContentDescription description of the unchecked button to be used by the + * accessibility services; in checked state, button is + * not clickable. + * @param onCheck handler to perform the specific action on click of icon; in + * a to-do list this can be the handler removing the item from the list. + + * + * @see [CheckListItem] for accepted inputs. + * @see [com.example.platform.ui.appwidgets.glance.layout.collections.CheckListAppWidgetReceiver] + */ +@Composable +fun CheckListLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int, + titleBarActionIconContentDescription: String, + titleBarAction: Action, + items: List, + checkedItems: List, + @DrawableRes checkedIconRes: Int, + @DrawableRes unCheckedIconRes: Int, + checkButtonContentDescription: String, + onCheck: (String) -> Unit, +) { + val checkListLayoutSize = CheckListLayoutSize.fromLocalSize() + + fun titleBar(): @Composable (() -> Unit) = { + TitleBar( + startIcon = ImageProvider(titleIconRes), + // Based on your widget content, you may skip the title in smaller sizes. + title = title.takeIf { checkListLayoutSize != Small } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction + ) + } + ) + } + + val scaffoldTopPadding = if (showTitleBar()) { + 0.dp + } else { + widgetPadding + } + + Scaffold( + backgroundColor = GlanceTheme.colors.widgetBackground, + horizontalPadding = scaffoldHorizontalPadding, + modifier = GlanceModifier.padding( + top = scaffoldTopPadding, + bottom = widgetPadding + ), + titleBar = if (showTitleBar()) { + titleBar() + } else { + null + } + ) { + if (items.isEmpty()) { + EmptyListContent() + } else { + Content( + items = items, + checkedItems = checkedItems, + onCheck = onCheck, + checkedIconRes = checkedIconRes, + unCheckedIconRes = unCheckedIconRes, + checkButtonContentDescription = checkButtonContentDescription, + ) + } + } +} + +@Composable +private fun Content( + items: List, + checkedItems: List, + onCheck: (String) -> Unit, + @DrawableRes checkedIconRes: Int, + @DrawableRes unCheckedIconRes: Int, + checkButtonContentDescription: String, +) { + RoundedScrollingLazyColumn( + modifier = GlanceModifier.fillMaxSize(), + items = items, + verticalItemsSpacing = verticalItemSpacing, + itemContentProvider = { item -> + CheckListItem( + item = item, + isChecked = checkedItems.contains(item.key), + onCheck = onCheck, + checkedIconRes = checkedIconRes, + unCheckedIconRes = unCheckedIconRes, + checkButtonContentDescription = checkButtonContentDescription, + ) + } + ) +} + +/** + * A list item that displays a checkable item with a title, a supporting text, and trailing icon + * buttons. + * + * Uses single line title (1-2 words), and 2-line supporting text (~ 50-55 characters) + */ +@Composable +private fun CheckListItem( + item: CheckListItem, + @DrawableRes checkedIconRes: Int, + @DrawableRes unCheckedIconRes: Int, + checkButtonContentDescription: String, + onCheck: (String) -> Unit, + modifier: GlanceModifier = GlanceModifier, + isChecked: Boolean, +) { + val listItemEndPadding = if (item.hasTrailingIcons) { + 0.dp + } else { + checkListRowEndPadding + } + + @Composable + fun CheckButton() { + CircleIconButton( + imageProvider = if (isChecked) { + ImageProvider(checkedIconRes) + } else { + ImageProvider(unCheckedIconRes) + }, + backgroundColor = null, // to show transparent background + contentColor = GlanceTheme.colors.secondary, + contentDescription = checkButtonContentDescription, + enabled = !isChecked, + onClick = { onCheck(item.key) }, + key = "${LocalSize.current} ${item.key}" + ) + } + + @Composable + fun Title() { + Text( + text = item.title, + style = CheckListLayoutTextStyles.titleText, + maxLines = 2, + ) + } + + @Composable + fun SupportingText() { + Text( + text = item.supportingText, + style = CheckListLayoutTextStyles.supportingText, + maxLines = 2, + ) + } + + @Composable + fun TrailingActions() { + TrailingIconButtonSet( + leadingButtonRes = R.drawable.sample_edit_icon, + leadingButtonContentDescription = "Edit", + leadingButtonOnClick = actionStartDemoActivity(message = "Edit click on item: ${item.key}"), + middleButtonRes = R.drawable.sample_snooze_icon, + middleButtonContentDescription = "Snooze", + middleButtonOnClick = actionStartDemoActivity(message = "Snooze click on item: ${item.key}"), + trailingButtonRes = R.drawable.sample_delete_icon, + trailingButtonContentDescription = "Delete", + trailingButtonOnClick = actionStartDemoActivity(message = "Delete click on item: ${item.key}"), + ) + } + + // List item itself is not clickable, as it contains more trailing actions. + ListItem( + modifier = modifier.fillMaxWidth() + .padding(start = checkListRowStartPadding, end = listItemEndPadding), + contentSpacing = 0.dp, // Since check box's tap target covers the needed visual spacing + leadingContent = { CheckButton() }, + headlineContent = { Title() }, + supportingContent = { SupportingText() }, + trailingContent = takeComposableIf(item.hasTrailingIcons) + { TrailingActions() }, + ) +} + +@Composable +private fun TrailingIconButtonSet( + @DrawableRes leadingButtonRes: Int, + leadingButtonContentDescription: String, + leadingButtonOnClick: Action, + @DrawableRes middleButtonRes: Int, + middleButtonContentDescription: String, + middleButtonOnClick: Action, + @DrawableRes trailingButtonRes: Int, + trailingButtonContentDescription: String, + trailingButtonOnClick: Action, +) { + val checkListLayoutSize = CheckListLayoutSize.fromLocalSize() + + if (checkListLayoutSize.isWiderThan(CheckListLayoutSize.Medium)) { + CircleIconButton( + imageProvider = ImageProvider(leadingButtonRes), + backgroundColor = null, // to show transparent background + contentColor = GlanceTheme.colors.secondary, + contentDescription = leadingButtonContentDescription, + onClick = leadingButtonOnClick, + ) + if (checkListLayoutSize.isWiderThan(CheckListLayoutSize.Large)) { + CircleIconButton( + imageProvider = ImageProvider(middleButtonRes), + backgroundColor = null, // to show transparent background + contentColor = GlanceTheme.colors.secondary, + contentDescription = middleButtonContentDescription, + onClick = middleButtonOnClick, + ) + } + if (checkListLayoutSize.isWiderThan(CheckListLayoutSize.XLarge)) { + CircleIconButton( + imageProvider = ImageProvider(trailingButtonRes), + backgroundColor = null, // to show transparent background + contentColor = GlanceTheme.colors.secondary, + contentDescription = trailingButtonContentDescription, + onClick = trailingButtonOnClick, + ) + } + } +} + +/** Returns the provided [block] composable if [predicate] is true, else returns null */ +@Composable +private inline fun takeComposableIf( + predicate: Boolean, + crossinline block: @Composable () -> Unit, +): (@Composable () -> Unit)? { + return if (predicate) { + { block() } + } else null +} + +/** + * Holds data corresponding to each item in a + * [com.example.platform.ui.appwidgets.glance.layout.collections.layout.CheckListLayout]. + * + * @param key a unique identifier for a specific item + * @param title a short text (1-2 words) representing the item + * @param supportingText a compact text (~50-55 characters) supporting the [title]; this allows + * keeping the title short and glanceable, as well as helps support smaller + * widget sizes. + */ +data class CheckListItem( + val key: String, + val title: String, + val supportingText: String, + val hasTrailingIcons: Boolean = false, +) + +/** + * Size of the widget per the reference breakpoints. Each size has its own display + * characteristics such as - displaying containers on list items, font sizes, etc. + * + * In this layout, only width breakpoints are used to scale the layout. + */ +private enum class CheckListLayoutSize(val maxWidth: Dp) { + // Smaller fonts, no title in title-bar + Small(maxWidth = 260.dp), + + // larger fonts, title present, no trailing actions + Medium(maxWidth = 304.dp), + + // 1 trailing action + Large(maxWidth = 348.dp), + + // 2 trailing actions + XLarge(maxWidth = 396.dp), + + // 3 trailing actions + XXLarge(maxWidth = Dp.Infinity); + + companion object { + /** + * Returns the corresponding [CheckListLayoutSize] to be considered for the current widget size. + */ + @Composable + fun fromLocalSize(): CheckListLayoutSize { + val size = LocalSize.current + + CheckListLayoutSize.values().forEach { + if (size.width < it.maxWidth) { + return it + } + } + throw IllegalStateException("No mapped size ") + } + + fun CheckListLayoutSize.isWiderThan(checkListLayoutSize: CheckListLayoutSize): Boolean { + return this.maxWidth > checkListLayoutSize.maxWidth + } + + @Composable + fun showTitleBar(): Boolean { + return LocalSize.current.height >= 180.dp + } + } +} + +private object CheckListLayoutTextStyles { + /** + * Style for the text displayed as title within each item. + */ + val titleText: TextStyle + @Composable get() = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = if (CheckListLayoutSize.fromLocalSize() == Small) { + 14.sp // M3 Title Small + } else { + 16.sp // M3 Title Medium + }, + color = GlanceTheme.colors.onSurface + ) + + /** + * Style for the text displayed as supporting text within each item. + */ + val supportingText: TextStyle + @Composable get() = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, // M3 Label Medium + color = GlanceTheme.colors.secondary + ) +} + +private object CheckListLayoutDimensions { + val widgetPadding = 12.dp + + val verticalItemSpacing = 4.dp + + // Full width scrollable content + val scaffoldHorizontalPadding = 0.dp + // Match with the padding applied to the app icon in title bar; this allow us to vertically align + // the app icon with check icon button. + val checkListRowStartPadding = 2.dp + // Padding to be applied on right of each item if there isn't a icon button on right. + val checkListRowEndPadding = widgetPadding +} + +/** + * Preview sizes of layout at the configured width breakpoints. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 259, heightDp = 200) +@Preview(widthDp = 303, heightDp = 200) +@Preview(widthDp = 350, heightDp = 200) +private annotation class CheckListBreakpointPreviews + +/** + * Previews for the check list layout. + * + * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition, + * the previews at standard sizes allows us to quickly verify updates across min / max and common + * widget sizes without needing to run the app or manually place the widget. + */ +@CheckListBreakpointPreviews +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun CheckListLayoutPreview() { + val context = LocalContext.current + CheckListLayout( + title = context.getString(R.string.sample_check_list_app_widget_name), + titleIconRes = R.drawable.sample_pin_icon, + titleBarActionIconRes = R.drawable.sample_add_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_add_button_text, + ), + titleBarAction = actionStartDemoActivity("Add icon in title bar"), + items = demoData, + checkedItems = listOf("1"), + checkButtonContentDescription = context.getString( + R.string.sample_mark_done_button_content_description, + ), + checkedIconRes = R.drawable.sample_checked_circle_icon, + unCheckedIconRes = R.drawable.sample_circle_icon, + onCheck = {}, + ) +} diff --git a/system/glance-canonical-layouts/references/collections/layout/EmptyListContent.kt b/system/glance-canonical-layouts/references/collections/layout/EmptyListContent.kt new file mode 100644 index 0000000..8784ec1 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/EmptyListContent.kt @@ -0,0 +1,23 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.compose.runtime.Composable +import androidx.glance.LocalContext +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils + +/** + * Content to be displayed when there are no items in the list. To be displayed below the + * app-specific title bar in the [androidx.glance.appwidget.components.Scaffold] . + */ +@Composable +internal fun EmptyListContent() { + val context = LocalContext.current + + NoDataContent( + noDataText = context.getString(R.string.sample_no_data_text), + noDataIconRes = R.drawable.sample_no_data_icon, + actionButtonText = context.getString(R.string.sample_add_button_text), + actionButtonIcon = R.drawable.sample_add_icon, + actionButtonOnClick = ActionUtils.actionStartDemoActivity("on-click of add item button") + ) +} diff --git a/system/glance-canonical-layouts/references/collections/layout/ImageGridLayout.kt b/system/glance-canonical-layouts/references/collections/layout/ImageGridLayout.kt new file mode 100644 index 0000000..76e0e41 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/ImageGridLayout.kt @@ -0,0 +1,405 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.cornerRadius +import androidx.glance.layout.Box +import androidx.glance.layout.ContentScale +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.wrapContentHeight +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutDimensions.contentPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutDimensions.gridCells +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutDimensions.imageCornerRadius +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutDimensions.itemCornerRadius +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutDimensions.textStartMargin +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutDimensions.titleTextBreakpoint +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutSize.Large +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutSize.Medium +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridLayoutSize.Small +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils +import com.example.platform.ui.appwidgets.glance.layout.utils.LargeWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting a grid of images (with optional title and supporting text). The + * list is displayed in a [Scaffold] below an app-specific title bar. + * + * In this sample layout, image is the primary focus of the widget and title & supporting text act + * as secondary information. + * + * The layout scales by adjusting the number of grid cells, and the font size of the texts (if + * present). + * + * [com.example.platform.ui.appwidgets.glance.layout.collections.ImageGridAppWidget] loads the images as bitmaps, and + * scales them to fit into widget's limits. Number of items are capped. When using bitmaps, this + * approach is suitable for use cases that recommend / feature limited items. + * + * The layout serves as an implementation suggestion, but should be customized to fit your + * product's needs. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable resource, but you should + * use an appropriate icon source for your use case. + * @param titleBarActionIconRes resource id of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For example, a search icon. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param items list of items to be displayed in the grid; typically, image with optional title + * and supporting text. + * + * @see [ImageGridItemData] for accepted inputs. + * @see [com.example.platform.ui.appwidgets.glance.layout.collections.ImageGridAppWidget] + */ +@Composable +fun ImageGridLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int, + titleBarActionIconContentDescription: String, + titleBarAction: () -> Unit, + items: List, +) { + + @Composable + fun TitleBar() { + TitleBar( + startIcon = ImageProvider(titleIconRes), + title = title.takeIf { LocalSize.current.width >= titleTextBreakpoint } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction + ) + } + ) + } + + Scaffold( + titleBar = { TitleBar() }, + backgroundColor = GlanceTheme.colors.widgetBackground, + horizontalPadding = contentPadding, + modifier = GlanceModifier.padding(bottom = contentPadding) + ) { + if (items.isEmpty()) { + EmptyListContent() + } else { + Grid(items) + } + } +} + +@Composable +private fun Grid(items: List) { + RoundedScrollingLazyVerticalGrid( + modifier = GlanceModifier.fillMaxSize(), + gridCells = gridCells, + items = items, + itemContentProvider = { item -> + GridItem( + item = item, + modifier = GlanceModifier.fillMaxSize() + ) + }) +} + +@Composable +private fun GridItem( + item: ImageGridItemData, + modifier: GlanceModifier, +) { + @Composable + fun Image() { + val imageProvider = if (item.image != null) { + ImageProvider(item.image) + } else { + ImageProvider(R.drawable.sample_placeholder_image) + } + + Image( + provider = imageProvider, + contentDescription = item.imageContentDescription, + contentScale = ContentScale.Fit, + modifier = GlanceModifier + .cornerRadius(imageCornerRadius) + .fillMaxWidth() + .wrapContentHeight() + ) + } + + @Composable + fun Title(text: String) { + Text( + text = text, + maxLines = 1, + style = ImageGridLayoutTextStyles.titleText, + modifier = GlanceModifier.padding(start = textStartMargin) + ) + } + + @Composable + fun SupportingText(text: String) { + Text( + text = text, + maxLines = 1, + style = ImageGridLayoutTextStyles.supportingText, + modifier = GlanceModifier.padding(start = textStartMargin) + ) + } + + if (item.title != null) { + VerticalListItem( + modifier = modifier + .cornerRadius(itemCornerRadius) + .clickable(ActionUtils.actionStartDemoActivity("Item click ${item.title}")), + topContent = { Image() }, + titleContent = { Title(text = item.title) }, + supportingContent = takeComposableIf(item.supportingText != null) { + SupportingText(text = checkNotNull(item.supportingText)) + } + ) + } else { + Box(modifier) { + Image() + } + } +} + +/** Returns the provided [block] composable if [predicate] is true, else returns null */ +@Composable +private inline fun takeComposableIf( + predicate: Boolean, + crossinline block: @Composable () -> Unit, +): (@Composable () -> Unit)? { + return if (predicate) { + { block() } + } else null +} + +data class ImageGridItemData( + val key: String, + val image: Bitmap?, + val imageContentDescription: String?, + val title: String? = null, + val supportingText: String? = null, +) + +/** + * Size of the widget per the reference breakpoints. Each size has its own display + * characteristics such as - number of grid cells, font sizes, etc. + * + * In this layout, only width breakpoints are used to scale the layout. + */ +private enum class ImageGridLayoutSize(val maxWidth: Dp) { + // Smaller fonts, single column + Small(maxWidth = 320.dp), + + // Larger fonts, 2 column + Medium(maxWidth = 519.dp), + + // 3 column + Large(maxWidth = Dp.Infinity); + + companion object { + /** + * Returns the corresponding [ImageGridLayoutSize] to be considered for the current widget + * size. + */ + @Composable + fun fromLocalSize(): ImageGridLayoutSize { + val size = LocalSize.current + + ImageGridLayoutSize.values().forEach { + if (size.width < it.maxWidth) { + return it + } + } + throw IllegalStateException("No mapped size ") + } + } +} + +private object ImageGridLayoutTextStyles { + /** + * Style for the text displayed as title within each item. + */ + val titleText: TextStyle + @Composable get() = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = if (ImageGridLayoutSize.fromLocalSize() == Small) { + 14.sp // M3 Title Small + } else { + 16.sp // M3 Title Medium + }, + color = GlanceTheme.colors.onSurface + ) + + /** + * Style for the text displayed as supporting text within each item. + */ + val supportingText: TextStyle + @Composable get() = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, // M3 Label Medium + color = GlanceTheme.colors.secondary + ) +} + +private object ImageGridLayoutDimensions { + val contentPadding = 12.dp + + val titleTextBreakpoint = 200.dp + /** + * Amount of space before the text in each item + */ + val textStartMargin = 4.dp + + /** Corner radius for image in each item. */ + val imageCornerRadius = 16.dp + + /** Corner radius applied to each item **/ + val itemCornerRadius = 16.dp + + /** + * Number of columns the grid layout should use to display items in available space. + */ + val gridCells: Int + @Composable get() { + return when (ImageGridLayoutSize.fromLocalSize()) { + Medium -> 2 + Large -> 3 + else -> 1 + } + } +} + +/** + * Preview sizes for the widget covering the width based breakpoints of the image grid layout. + * + * This allows verifying updates across multiple breakpoints. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 319, heightDp = 200) +@Preview(widthDp = 321, heightDp = 200) +@Preview(widthDp = 518, heightDp = 200) +@Preview(widthDp = 520, heightDp = 200) +private annotation class ImageGridBreakpointPreviews + +/** + * Previews for the image grid layout with both title and supporting text below the image + * + * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition, + * the previews at standard sizes allows us to quickly verify updates across min / max and common + * widget sizes without needing to run the app or manually place the widget. + */ +@ImageGridBreakpointPreviews +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun ImageTextGridLayoutPreview() { + val context = LocalContext.current + + ImageGridLayout( + title = context.getString(R.string.sample_image_grid_app_widget_name), + titleIconRes = R.drawable.sample_grid_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = {}, + items = listOf( + ImageGridItemData( + key = "0", + image = null, // placeholder + imageContentDescription = "A single water droplet rests in a budding red pansy.", + title = "A single water droplet rests in a budding red pansy.", + supportingText = "193 views" + ), + ImageGridItemData( + key = "1", + image = null, // placeholder + imageContentDescription = "A single water droplet rests in a budding red pansy.", + title = "A single water droplet rests in a budding red pansy.", + supportingText = "193 views" + ), + ImageGridItemData( + key = "2", + image = null, // placeholder + imageContentDescription = "Green plant, sky and flowers", + title = "Green plant, sky and flowers", + supportingText = "99,467 views" + ), + ImageGridItemData( + key = "3", + image = null, // placeholder + imageContentDescription = "A snow-shoer walking up Strelapass", + title = "A snow-shoer walking up Strelapass", + supportingText = "3,033 views" + ) + ) + ) +} + +/** + * Previews for the image grid layout with only images + * + * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition, + * the previews at standard sizes allows us to quickly verify updates across min / max and common + * widget sizes without needing to run the app or manually place the widget. + */ +@ImageGridBreakpointPreviews +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun ImageOnlyGridLayoutPreview() { + val context = LocalContext.current + + ImageGridLayout( + title = context.getString(R.string.sample_image_grid_app_widget_name), + titleIconRes = R.drawable.sample_grid_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = {}, + items = (0..5).map { index -> + ImageGridItemData( + key = "$index", + image = null, // placeholder + imageContentDescription = null, + ) + } + ) +} diff --git a/system/glance-canonical-layouts/references/collections/layout/ImageTextListLayout.kt b/system/glance-canonical-layouts/references/collections/layout/ImageTextListLayout.kt new file mode 100644 index 0000000..ad5cf0d --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/ImageTextListLayout.kt @@ -0,0 +1,497 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.ContentScale +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.Dimensions.NUM_GRID_CELLS +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.Dimensions.fillItemItemPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.Dimensions.filledItemCornerRadius +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.Dimensions.imageCornerRadius +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.Dimensions.verticalSpacing +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.Dimensions.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayoutSize.Companion.shouldDisplayTrailingIconButton +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayoutSize.Companion.showTitleBar +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayoutSize.Large +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayoutSize.Medium +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayoutSize.Small +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.LargeWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting a list of text with an image, and an optional icon button. The + * list is displayed in a [Scaffold] below an app-specific title bar. + * + * The layout drops not-so-important details as the size of the widget goes smaller. For instance, + * in the smallest size it drops the image and the icon button to allow displaying more items at + * at glance. In an medium size, displays image, text and optionally icon button in a single + * column list. In the large size, shows items in 2 column grid. + * + * In this sample layout, text is the primary focus of the widget and image acts as a supporting + * content. So, we prefer displaying horizontal [ListItem]s in most displays. However, for a + * image-centric widget, you may prefer displaying a grid of + * [com.example.platform.ui.appwidgets.glance.layout.components.list.VerticalListItem]s featuring the image across all + * sizes. + * + * The layout serves as an implementation suggestion, but should be customized to fit your + * product's needs. As you customize the layout, prefer supporting narrower as well as larger + * widget sizes. + * + * Note: When using images as bitmap, you should limit the number of items displayed in widgets. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable resource, but you should + * use an appropriate icon source for your use case. + * @param titleBarActionIconRes resource id of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For example, a search icon. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param items list of items to be displayed in the list; typically includes a short title for + * item, a supporting text and an image. + * + * @see [ImageTextListItemData] for accepted inputs. + * @see [com.example.platform.ui.appwidgets.glance.layout.collections.ImageTextListAppWidget] + */ +@Composable +fun ImageTextListLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int, + titleBarActionIconContentDescription: String, + titleBarAction: (() -> Unit), + items: List, +) { + val imageTextListLayoutSize = ImageTextListLayoutSize.fromLocalSize() + + fun titleBar(): @Composable (() -> Unit) = { + TitleBar( + startIcon = ImageProvider(titleIconRes), + title = title.takeIf { imageTextListLayoutSize != Small } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction + ) + } + ) + } + + val scaffoldTopPadding = if (showTitleBar()) { + 0.dp + } else { + widgetPadding + } + + Scaffold( + backgroundColor = GlanceTheme.colors.widgetBackground, + modifier = GlanceModifier.padding( + top = scaffoldTopPadding, + bottom = widgetPadding + ), + titleBar = if (showTitleBar()) { + titleBar() + } else { + null + } + ) { + Content(items) + } +} + +@Composable +private fun Content(items: List) { + val displayTrailingIconIfPresent = shouldDisplayTrailingIconButton() + + if (items.isEmpty()) { + EmptyListContent() + } else { + when (ImageTextListLayoutSize.fromLocalSize()) { + Small -> { + ListView( + items = items, + displayImage = false, + displayTrailingIconIfPresent = displayTrailingIconIfPresent, + ) + } + + Medium -> { + ListView( + items = items, + displayImage = true, + displayTrailingIconIfPresent = displayTrailingIconIfPresent, + ) + } + + Large -> { + GridView( + items = items, + displayImage = true, + displayTrailingIconIfPresent = displayTrailingIconIfPresent + ) + } + } + } +} + +/** + * A vertical scrolling list displaying [FilledHorizontalListItem]s. Suitable for + * [ImageTextListLayoutSize.Small] and [ImageTextListLayoutSize.Large] sizes. + */ +@Composable +private fun ListView( + items: List, + displayImage: Boolean, + displayTrailingIconIfPresent: Boolean, +) { + RoundedScrollingLazyColumn( + modifier = GlanceModifier.fillMaxSize(), + items = items, + verticalItemsSpacing = verticalSpacing, + itemContentProvider = { item -> + FilledHorizontalListItem( + item = item, + displayImage = displayImage, + displayTrailingIcon = displayTrailingIconIfPresent, + onClick = actionStartDemoActivity("Item ${item.key} click"), + modifier = GlanceModifier.fillMaxSize(), + ) + }, + ) +} + +/** + * A grid of [FilledHorizontalListItem]s suitable for [ImageTextListLayoutSize.Large] sizes. + * + * Supporting the grid display allows large screen users view more information at once. + */ +@Composable +private fun GridView( + items: List, + displayImage: Boolean, + displayTrailingIconIfPresent: Boolean, +) { + RoundedScrollingLazyVerticalGrid( + gridCells = NUM_GRID_CELLS, + items = items, + cellSpacing = verticalSpacing, + itemContentProvider = { item -> + FilledHorizontalListItem( + item = item, + displayImage = displayImage, + displayTrailingIcon = displayTrailingIconIfPresent, + onClick = actionStartDemoActivity("Item ${item.key} click"), + modifier = GlanceModifier.fillMaxSize() + ) + }, + modifier = GlanceModifier.fillMaxSize() + ) +} + +/** + * Arranges the texts, the image and the icon button in a horizontal arrangement with a filled + * container. + */ +@Composable +private fun FilledHorizontalListItem( + item: ImageTextListItemData, + displayImage: Boolean, + displayTrailingIcon: Boolean, + onClick: Action, + modifier: GlanceModifier = GlanceModifier, +) { + @Composable + fun TitleText() { + Text( + text = item.title, + maxLines = 2, + style = TextStyles.titleText, + ) + } + + @Composable + fun SupportingText() { + Text( + text = item.supportingText, + maxLines = 2, + style = TextStyles.supportingText + ) + } + + @Composable + fun SupportingImage() { + // Bitmaps are ensured to be within remote views limit in the repository class. + val imageProvider = if (item.supportingImageBitmap != null) { + ImageProvider(item.supportingImageBitmap) + } else { + ImageProvider( + R.drawable.sample_placeholder_image + ) + } + + Image( + provider = imageProvider, + // contentDescription is null because in this sample, it serves merely as a visual; but if + // it gives additional info to user, you should set the appropriate content description. + contentDescription = null, + // Depending on your image content, you may want to select an appropriate ContentScale. + contentScale = ContentScale.Crop, + // Fixed size per UX spec + modifier = modifier.cornerRadius(imageCornerRadius).size(Dimensions.imageSize) + ) + } + + @Composable + fun IconButton() { + if (item.trailingIconButton != null) { + // Using CircleIconButton allows us to keep the touch target 48x48 + CircleIconButton( + imageProvider = ImageProvider(item.trailingIconButton), + backgroundColor = null, // to show transparent background. + contentDescription = item.trailingIconButtonContentDescription, + onClick = actionStartDemoActivity("Item ${item.key} icon button click") + ) + } + } + + ListItem( + modifier = modifier + .padding(fillItemItemPadding) + .cornerRadius(filledItemCornerRadius) + .background(GlanceTheme.colors.secondaryContainer), + headlineContent = { TitleText() }, + supportingContent = { SupportingText() }, + onClick = onClick, + leadingContent = if (displayImage) { + { SupportingImage() } + } else { + null + }, + trailingContent = if (displayTrailingIcon) { + { IconButton() } + } else null, + ) +} + +/** + * Holds data fields for a [com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageTextListLayout]. + * + * @param key a unique identifier for a specific item + * @param title a short text (1-3 words) representing the item + * @param supportingText a compact text (~50-55 characters) supporting the [title]; this allows + * keeping the title short and glanceable, as well as helps support smaller + * widget sizes. + * @param supportingImageBitmap an image to accompany the textual information displayed in [title] + * and the [supportingText]. + * @param trailingIconButton a tintable icon representing an action that can be performed in context + * of the item; e.g. bookmark icon, save icon, etc. + * @param trailingIconButtonContentDescription description of the [trailingIconButton] to be used by + * the accessibility services. + */ +data class ImageTextListItemData( + val key: String, + val title: String, + val supportingText: String, + val supportingImageBitmap: Bitmap? = null, + @DrawableRes val trailingIconButton: Int? = null, + val trailingIconButtonContentDescription: String? = null, +) + +/** + * Reference breakpoints for deciding on widget style to display e.g. list / grid etc. + * + * In this layout, only width breakpoints are used to scale the layout. + */ +private enum class ImageTextListLayoutSize(val maxWidth: Dp) { + // Single column vertical list without images or trailing button in this size. + Small(maxWidth = 260.dp), + + // Single column horizontal list with images and optional trailing button if exists. + Medium(maxWidth = 479.dp), + + // 2 Column Grid of horizontal list items. Images are always shown; trailing button is shown if + // it fits. + Large(maxWidth = 644.dp); + + companion object { + /** + * Returns the corresponding [ImageTextListLayoutSize] to be considered for the current + * widget size. + */ + @Composable + fun fromLocalSize(): ImageTextListLayoutSize { + val width = LocalSize.current.width + + return if (width >= Medium.maxWidth) { + Large + } else if (width >= Small.maxWidth) { + Medium + } else { + Small + } + } + + @Composable + fun showTitleBar(): Boolean { + return LocalSize.current.height >= 180.dp + } + + /** + * Returns if icon button should be displayed across medium and large sizes based on + * predefined breakpoints. + */ + @Composable + fun shouldDisplayTrailingIconButton(): Boolean { + val widgetWidth = LocalSize.current.width + return (widgetWidth in 340.dp..479.dp || widgetWidth > 620.dp) + } + } +} + +private object TextStyles { + /** + * Style for the text displayed as title within each item. + */ + val titleText: TextStyle + @Composable get() = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = if (ImageTextListLayoutSize.fromLocalSize() == Small) { + 14.sp // M3 Title Small + } else { + 16.sp // M3 Title Medium + }, + color = GlanceTheme.colors.onSurface + ) + + /** + * Style for the text displayed as supporting text within each item. + */ + val supportingText: TextStyle + @Composable get() = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, // M3 Label Medium + color = GlanceTheme.colors.secondary + ) +} + +private object Dimensions { + /** Number of cells in the grid, when items are displayed as a grid. */ + const val NUM_GRID_CELLS = 2 + + /** Padding around the the widget content */ + val widgetPadding = 12.dp + + /** Corner radius for each filled list item. */ + val filledItemCornerRadius = 16.dp + + /** Padding applied to each item in the list. */ + val fillItemItemPadding = 12.dp + + /** Vertical Space between each item in the list. */ + val verticalSpacing = 4.dp + + /** Size in which images should be displayed in the list. */ + val imageSize: Dp = 68.dp + + /** Corner radius for image in each item. */ + val imageCornerRadius = 12.dp +} + +/** + * Preview sizes for the widget covering the width based breakpoints of the image grid layout. + * + * This allows verifying updates across multiple breakpoints. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 259, heightDp = 200) +@Preview(widthDp = 261, heightDp = 200) +@Preview(widthDp = 480, heightDp = 200) +@Preview(widthDp = 644, heightDp = 200) +private annotation class ImageTextListBreakpointPreviews + +/** + * Previews for the image grid layout with both title and supporting text below the image + * + * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition, + * the previews at standard sizes allows us to quickly verify updates across min / max and common + * widget sizes without needing to run the app or manually place the widget. + */ +@ImageTextListBreakpointPreviews +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun ImageTextListLayoutPreview() { + val context = LocalContext.current + + ImageTextListLayout( + items = listOf( + ImageTextListItemData( + key = "0", + title = "Blossom, petal, flower", + supportingText = "23,815 views", + supportingImageBitmap = null // placeholder + ), + ImageTextListItemData( + key = "1", + title = "Orchids at New York Botanical Garden", + supportingText = "205,481 views", + supportingImageBitmap = null // placeholder + ), + ImageTextListItemData( + key = "2", + title = "Tabletop composition with flower", + supportingText = "85,060 views", + supportingImageBitmap = null // placeholder + ), + ImageTextListItemData( + key = "3", + title = "Wild bee on flower", + supportingText = "6,692 views ", + supportingImageBitmap = null // placeholder + ) + ), + title = context.getString( + R.string.sample_text_and_image_list_app_widget_name + ), + titleIconRes = R.drawable.sample_image_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = {}, + ) +} diff --git a/system/glance-canonical-layouts/references/collections/layout/ListItem.kt b/system/glance-canonical-layouts/references/collections/layout/ListItem.kt new file mode 100644 index 0000000..4018a7c --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/ListItem.kt @@ -0,0 +1,85 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics + +/** + * Component to build an item within a list. Lists are a continuous, vertical indexes of text or + * images. + * + * Apply padding to the item using the [androidx.glance.layout.padding] modifier. + * + * @param headlineContent the [Composable] headline content of the list item; typically a 1-line + * prominent text within the list item + * @param modifier [GlanceModifier] to be applied to the list item + * @param contentSpacing spacing between the leading, center and trailing sections; default 16.dp + * @param supportingContent 1-2 line text to be displayed below the headline text of the list item + * @param leadingContent the leading content of the list item such as an image, icon, or a selection + * control such as checkbox, switch, or a radio button + * @param trailingContent the trailing meta text, icon, or a selection control such as switch, + * checkbox, or a radio button + * @param onClick an option action to be performed on click of the list item. + * @param itemContentDescription an optional text used by accessibility services to describe what + * this list item represents. If not provided, the non-clickable + * content within the list item will be read out. + */ +@Composable +fun ListItem( + headlineContent: @Composable (() -> Unit), + modifier: GlanceModifier = GlanceModifier, + contentSpacing: Dp = 16.dp, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + onClick: Action? = null, + itemContentDescription: String? = null, +) { + val listItemModifier = if (itemContentDescription != null) { + modifier.semantics { contentDescription = itemContentDescription } + } else { + modifier + } + + Row( + modifier = listItemModifier.maybeClickable(onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + // Leading + leadingContent?.let { + it() + Spacer(modifier = GlanceModifier.width(contentSpacing)) + } + // Center + Column( + modifier = GlanceModifier.defaultWeight(), + verticalAlignment = Alignment.CenterVertically + ) { + headlineContent() + supportingContent?.let { it() } + } + // Trailing + trailingContent?.let { + Spacer(modifier = GlanceModifier.width(contentSpacing)) + it() + } + } +} + +private fun GlanceModifier.maybeClickable(action: Action?): GlanceModifier { + return if (action != null) { + this.clickable(action) + } else { + this + } +} diff --git a/system/glance-canonical-layouts/references/collections/layout/NoDataContent.kt b/system/glance-canonical-layouts/references/collections/layout/NoDataContent.kt new file mode 100644 index 0000000..fb43835 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/NoDataContent.kt @@ -0,0 +1,77 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.components.FilledButton +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle + +/** + * Component for a view that can be shown when the app has no data to present. + * + * The content should be displayed in a [androidx.glance.appwidget.components.Scaffold] below an app-specific + * title bar. + * + * @param noDataText text indicating that there is no data available to display. + * @param noDataIconRes a tintable icon indicating there is no data available to display; usually + * a crossed-out icon representing the data that is empty; e.g. a crossed out + * message icon if there were no messages. + * @param actionButtonText text for the button that performs specific operation when there is no + * data; e.g. sign-in button, add button, etc. + * @param actionButtonIcon a leading icon to be displayed within the action button. + * @param actionButtonOnClick action to be performed on click of the action button. + */ +@Composable +fun NoDataContent( + noDataIconRes: Int, + noDataText: String, + actionButtonText: String, + actionButtonIcon: Int, + actionButtonOnClick: Action, +) { + @Composable + fun showIcon() = LocalSize.current.height >= 180.dp + + Column( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.fillMaxSize() + ) { + if (showIcon()) { + Image( + provider = ImageProvider(noDataIconRes), + colorFilter = ColorFilter.tint(GlanceTheme.colors.secondary), + contentDescription = null, // only decorative + ) + Spacer(modifier = GlanceModifier.height(8.dp)) + } + Text( + text = noDataText, + style = TextStyle( + fontWeight = FontWeight.Medium, + color = GlanceTheme.colors.onSurface, + fontSize = 16.sp // M3 - title/medium + ) + ) + Spacer(modifier = GlanceModifier.height(8.dp)) + FilledButton( + text = actionButtonText, + icon = ImageProvider(actionButtonIcon), + onClick = actionButtonOnClick + ) + } +} diff --git a/system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyColumn.kt b/system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyColumn.kt new file mode 100644 index 0000000..35a9734 --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyColumn.kt @@ -0,0 +1,79 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.LazyListScope +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.lazy.itemsIndexed +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height + +/** + * A variant of [LazyColumn] that clips its scrolling content to a rounded rectangle. + * + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param content a block which describes the content. Inside this block you can use methods like + * [LazyListScope.item] to add a single item or [LazyListScope.items] to add a list of items. If the + * item has more than one top-level child, they will be automatically wrapped in a Box. + * @see LazyColumn + */ +@Composable +fun RoundedScrollingLazyColumn( + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: LazyListScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .cornerRadius(16.dp) // to present a rounded scrolling experience + ) { + LazyColumn( + horizontalAlignment = horizontalAlignment, + content = content + ) + } +} + +/** + * * A variant of [LazyColumn] that clips its scrolling content to a rounded rectangle and spaces + * out each item in the list with a default 8.dp spacing + * + * @param items the list of data items to be displayed in the list + * @param itemContentProvider a lambda function that provides item content without any spacing + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param verticalItemsSpacing vertical spacing between items + * @see LazyColumn + */ +@Composable +fun RoundedScrollingLazyColumn( + items: List, + itemContentProvider: @Composable (item: T) -> Unit, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalItemsSpacing: Dp = 4.dp +) { + val lastIndex = items.size - 1 + + RoundedScrollingLazyColumn(modifier, horizontalAlignment) { + itemsIndexed(items) { index, item -> + Column(modifier = GlanceModifier.fillMaxWidth()) { + itemContentProvider(item) + if (index != lastIndex) { + Spacer(modifier = GlanceModifier.height(verticalItemsSpacing)) + } + } + } + } +} diff --git a/system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyVerticalGrid.kt b/system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyVerticalGrid.kt new file mode 100644 index 0000000..b4bc7db --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/RoundedScrollingLazyVerticalGrid.kt @@ -0,0 +1,127 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.GridCells +import androidx.glance.appwidget.lazy.LazyVerticalGrid +import androidx.glance.appwidget.lazy.LazyVerticalGridScope +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.lazy.itemsIndexed +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import kotlin.math.ceil + +/** + * A variant of [LazyVerticalGrid] that clips its scrolling content to a rounded rectangle. + * + * @param gridCells the number of columns in the grid. + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param content a block which describes the content. Inside this block you can use methods like + * [LazyVerticalGridScope.item] to add a single item or [LazyVerticalGridScope.items] to add a list + * of items. If the item has more than one top-level child, they will be automatically wrapped in a + * Box. + * @see LazyVerticalGrid + */ +@Composable +fun RoundedScrollingLazyVerticalGrid( + gridCells: GridCells, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: LazyVerticalGridScope.() -> Unit, +) { + Box( + modifier = GlanceModifier + .cornerRadius(16.dp) // to present a rounded scrolling experience + .then(modifier) + ) { + LazyVerticalGrid( + gridCells = gridCells, + horizontalAlignment = horizontalAlignment, + content = content + ) + } +} + +/** + * A variant of [LazyVerticalGrid] that clips its scrolling content to a rounded rectangle and + * spaces out each item in the grid with a default 8.dp spacing. + * + * @param gridCells number of columns in the grid + * @param items the list of data items to be displayed in the list + * @param itemContentProvider a lambda function that provides item content without any spacing + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param cellSpacing horizontal and vertical spacing between cells + * @see LazyVerticalGrid + */ +@Composable +fun RoundedScrollingLazyVerticalGrid( + gridCells: Int, + items: List, + itemContentProvider: @Composable (item: T) -> Unit, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + cellSpacing: Dp = 12.dp, +) { + val numRows = ceil(items.size.toDouble() / gridCells).toInt() + + // Cell spacing is achieved by allocating equal amount of padding to each cell. Cells on edge + // apply it completely to inner sides, while cells not on edge apply it evenly on sides. + val perCellHorizontalPadding = (cellSpacing * (gridCells - 1)) / gridCells + val perCellVerticalPadding = (cellSpacing * (numRows - 1)) / numRows + + RoundedScrollingLazyVerticalGrid( + gridCells = GridCells.Fixed(gridCells), + horizontalAlignment = horizontalAlignment, + modifier = modifier + ) { + + itemsIndexed(items) { index, item -> + val row = index / gridCells + val column = index % gridCells + + val cellTopPadding = when (row) { + 0 -> 0.dp + numRows - 1 -> perCellVerticalPadding + else -> perCellVerticalPadding / 2 + } + + val cellBottomPadding = when (row) { + 0 -> perCellVerticalPadding + numRows - 1 -> 0.dp + else -> perCellVerticalPadding / 2 + } + + val cellStartPadding = when (column) { + 0 -> 0.dp + gridCells - 1 -> perCellHorizontalPadding + else -> perCellHorizontalPadding / 2 + } + + val cellEndPadding = when (column) { + 0 -> perCellHorizontalPadding + gridCells - 1 -> 0.dp + else -> perCellHorizontalPadding / 2 + } + + Box( + modifier = modifier + .fillMaxSize() + .padding( + start = cellStartPadding, + end = cellEndPadding, + top = cellTopPadding, + bottom = cellBottomPadding + ) + ) { + itemContentProvider(item) + } + } + } +} diff --git a/system/glance-canonical-layouts/references/collections/layout/VerticalListItem.kt b/system/glance-canonical-layouts/references/collections/layout/VerticalListItem.kt new file mode 100644 index 0000000..124908d --- /dev/null +++ b/system/glance-canonical-layouts/references/collections/layout/VerticalListItem.kt @@ -0,0 +1,89 @@ +package com.example.platform.ui.appwidgets.glance.layout.collections.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.height +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics + +/** + * Component to build an item with content arranged vertically. Lists are a continuous, vertical + * indexes of text or images. + * + * Apply padding to the item using the [androidx.glance.layout.padding] modifier. + * + * @param titleContent the [Composable] title content of the list item; typically a 1-line + * prominent text within the list item; displayed below the top content + * (if present). + * @param modifier [GlanceModifier] to be applied to the list item + * @param topContent the content of the list item such as an image displayed on the top; even if + * title content may be the primary information, displaying the content such as + * images in the top allows to easy visual differentiation among the list items. + * @param supportingContent 1-2 line text to be displayed below the headline text of the list item + * @param trailingBottomContent the trailing meta text, icon, or a selection control such as switch, + * checkbox, or a radio button displayed along side the bottom section + * with the headline and the supporting content. + * @param onClick an option action to be performed on click of the list item. + * @param itemContentDescription an optional text used by accessibility services to describe what + * this list item represents. If not provided, the non-clickable + * content within the list item will be read out. + */ +@Composable +fun VerticalListItem( + titleContent: @Composable (() -> Unit), + modifier: GlanceModifier = GlanceModifier, + topContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + trailingBottomContent: @Composable (() -> Unit)? = null, + onClick: Action? = null, + itemContentDescription: String? = null, +) { + val listItemModifier = if (itemContentDescription != null) { + modifier.semantics { contentDescription = itemContentDescription } + } else { + modifier + } + + Column( + modifier = listItemModifier.maybeClickable(onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + // Top + topContent?.let { + it() + // Smaller spacing to make the vertical spacing between individual list items more prominent. + Spacer(modifier = GlanceModifier.height(4.dp)) + } + // Bottom + Row { + Column( + modifier = GlanceModifier.defaultWeight(), + verticalAlignment = Alignment.CenterVertically + ) { + titleContent() + supportingContent?.let { it() } + } + // Trailing bottom + trailingBottomContent?.let { + Spacer(modifier = GlanceModifier.width(8.dp)) + it() + } + } + } +} + +private fun GlanceModifier.maybeClickable(action: Action?): GlanceModifier { + return if (action != null) { + this.clickable(action) + } else { + this + } +} diff --git a/system/glance-canonical-layouts/references/res/drawable/four_side_cookie_background.xml b/system/glance-canonical-layouts/references/res/drawable/four_side_cookie_background.xml new file mode 100644 index 0000000..cb1c9a5 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/four_side_cookie_background.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_ac_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_ac_icon.xml new file mode 100644 index 0000000..8008277 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_ac_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_add_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_add_icon.xml new file mode 100644 index 0000000..89633bb --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_add_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_app_logo.xml b/system/glance-canonical-layouts/references/res/drawable/sample_app_logo.xml new file mode 100644 index 0000000..790c783 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_app_logo.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_arrow_right_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_arrow_right_icon.xml new file mode 100644 index 0000000..5d47b4d --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_arrow_right_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_bulb_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_bulb_icon.xml new file mode 100644 index 0000000..60ee5b2 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_bulb_icon.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_camera_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_camera_icon.xml new file mode 100644 index 0000000..f4c1370 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_camera_icon.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_checked_circle_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_checked_circle_icon.xml new file mode 100644 index 0000000..b83d1bc --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_checked_circle_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_circle_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_circle_icon.xml new file mode 100644 index 0000000..f3733bf --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_circle_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_delete_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_delete_icon.xml new file mode 100644 index 0000000..ef36649 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_delete_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_door_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_door_icon.xml new file mode 100644 index 0000000..6c34bc5 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_door_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_edit_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_edit_icon.xml new file mode 100644 index 0000000..183682b --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_edit_icon.xml @@ -0,0 +1,6 @@ + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_file_upload_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_file_upload_icon.xml new file mode 100644 index 0000000..fea534a --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_file_upload_icon.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_grid_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_grid_icon.xml new file mode 100644 index 0000000..2345b7a --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_grid_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_heart_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_heart_icon.xml new file mode 100644 index 0000000..dea7a6b --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_heart_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_home_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_home_icon.xml new file mode 100644 index 0000000..1e36068 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_home_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_image_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_image_icon.xml new file mode 100644 index 0000000..13b78a7 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_image_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_info_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_info_icon.xml new file mode 100644 index 0000000..9bd4157 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_info_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_mic_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_mic_icon.xml new file mode 100644 index 0000000..7bb2cf5 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_mic_icon.xml @@ -0,0 +1,25 @@ + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_no_data_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_no_data_icon.xml new file mode 100644 index 0000000..a4b80d9 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_no_data_icon.xml @@ -0,0 +1,6 @@ + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_pin_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_pin_icon.xml new file mode 100644 index 0000000..d460a6f --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_pin_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_power_settings_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_power_settings_icon.xml new file mode 100644 index 0000000..cdee457 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_power_settings_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_refresh_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_refresh_icon.xml new file mode 100644 index 0000000..7923899 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_refresh_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_scrim_gradient.xml b/system/glance-canonical-layouts/references/res/drawable/sample_scrim_gradient.xml new file mode 100644 index 0000000..5c79e09 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_scrim_gradient.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_search_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_search_icon.xml new file mode 100644 index 0000000..a8317a1 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_search_icon.xml @@ -0,0 +1,25 @@ + + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_share_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_share_icon.xml new file mode 100644 index 0000000..87cea78 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_share_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_snooze_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_snooze_icon.xml new file mode 100644 index 0000000..836f4c3 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_snooze_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_text_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_text_icon.xml new file mode 100644 index 0000000..b7dfdf8 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_text_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_thermostat_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_thermostat_icon.xml new file mode 100644 index 0000000..7051106 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_thermostat_icon.xml @@ -0,0 +1,3 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/drawable/sample_videocam_icon.xml b/system/glance-canonical-layouts/references/res/drawable/sample_videocam_icon.xml new file mode 100644 index 0000000..8ea3b0d --- /dev/null +++ b/system/glance-canonical-layouts/references/res/drawable/sample_videocam_icon.xml @@ -0,0 +1,25 @@ + + + + diff --git a/system/glance-canonical-layouts/references/res/values/dimens.xml b/system/glance-canonical-layouts/references/res/values/dimens.xml new file mode 100644 index 0000000..c75be76 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/values/dimens.xml @@ -0,0 +1,146 @@ + + + + 0dp + 48dp + + + + + + + + 2 + 2 + 120dp + 115dp + 56dp + 56dp + 624dp + 200dp + + + 4 + 1 + 256dp + 48dp + 48dp + 48dp + 624dp + 422dp + + + 2 + 2 + 120dp + 115dp + 48dp + 48dp + 624dp + 422dp + + + 2 + 2 + 120dp + 115dp + 120dp + 115dp + 624dp + 276dp + + + 4 + 2 + 256dp + 115dp + 120dp + 115dp + 624dp + 422dp + + + 4 + 2 + 256dp + 115dp + 120dp + 115dp + 624dp + 422dp + + + 2 + 2 + 120dp + 115dp + 120dp + 115dp + 624dp + 422dp + + + 4 + 2 + 256dp + 115dp + 120dp + 115dp + 624dp + 422dp + + + 4 + 3 + 256dp + 185dp + 120dp + 115dp + 624dp + 422dp + + + 2 + 2 + 120dp + 48dp + 120dp + 48dp + + + diff --git a/system/glance-canonical-layouts/references/res/values/strings.xml b/system/glance-canonical-layouts/references/res/values/strings.xml new file mode 100644 index 0000000..b01e0d0 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/values/strings.xml @@ -0,0 +1,77 @@ + + + AppWidget + Pin an App Widget: + Pinning is not supported in the default launcher + Sample to demonstrate how to get the app\'s appwidgets info and request the user to pin them in the launcher. + Tap \"Add to home\" button on any of the items in the canonical widget layouts demo page: + Or tap on any of the items below to request the launcher to pin them: + ☆ Canonical widget layouts + Explore and pin the recommended, intentionally designed widget samples + Glance Weather + Widget for weather forecast + Mostly cloudy + + LayoutSamples + Settings + Refresh + No data + + + Long Text + Text & Image + Text and Image + Image Text List + Checklist + Action List + Image Grid + Expressive Toolbar + Toolbar + Search Toolbar + + + Add + Learn more + See more + Search + + + sample_mark_done + Create visually impactful, scrollable image galleries using image-only grids. Rows and columns automatically adapt to various screen sizes for optimal presentation. + Grid + Text and Image + Include an image for added visual impact. For more information, see Breakpoints to learn how to adapt this layout for different screen sizes. + Easily scannable text and image lists are perfect for showcasing multiple content types, such as news headlines, playlists with album art, or messages. + Image Text List + Provide intuitive control grouping with action lists, where visual on/off states offer immediate feedback on item statuses. + Action List + Long Text + Ideal for titles, status updates, short descriptions, or any scenario where a single line of text effectively conveys the message. + Checklist + The checklist layout is perfect for displaying tasks, providing clear tap targets for users to easily mark items as done. + Toolbar + Increase user engagement and streamline key workflows by providing instant access to your app\'s most important features with a toolbar widget. + Expressive Toolbar + Increase user engagement and streamline key workflows by providing instant access to your app\'s most important features with an expressive shaped toolbar widget. + Search Toolbar + Ideal for apps where search is paramount, this layout provides a dedicated search entry point while allowing for additional shortcuts based on available widget space. + + Full Bleed Snap Scrolling + Full Bleed Snap Scrolling + A full bleed gallery widget demonstrating snap scrolling. Scroll vertically between featured items with an overlaid title and caption. + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_action_list_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_action_list_widget_info.xml new file mode 100644 index 0000000..c6bb660 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_action_list_widget_info.xml @@ -0,0 +1,33 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_check_list_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_check_list_widget_info.xml new file mode 100644 index 0000000..169d1e7 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_check_list_widget_info.xml @@ -0,0 +1,34 @@ + + + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_expressive_toolbar_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_expressive_toolbar_widget_info.xml new file mode 100644 index 0000000..2b683aa --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_expressive_toolbar_widget_info.xml @@ -0,0 +1,28 @@ + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_full_bleed_image_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_full_bleed_image_widget_info.xml new file mode 100644 index 0000000..6f07699 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_full_bleed_image_widget_info.xml @@ -0,0 +1,26 @@ + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_image_grid_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_image_grid_widget_info.xml new file mode 100644 index 0000000..fb416ea --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_image_grid_widget_info.xml @@ -0,0 +1,33 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_image_text_list_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_image_text_list_widget_info.xml new file mode 100644 index 0000000..84c70f2 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_image_text_list_widget_info.xml @@ -0,0 +1,33 @@ + + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_long_text_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_long_text_widget_info.xml new file mode 100644 index 0000000..4d032ec --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_long_text_widget_info.xml @@ -0,0 +1,28 @@ + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_search_toolbar_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_search_toolbar_widget_info.xml new file mode 100644 index 0000000..153d53c --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_search_toolbar_widget_info.xml @@ -0,0 +1,28 @@ + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_text_with_image_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_text_with_image_widget_info.xml new file mode 100644 index 0000000..e415da8 --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_text_with_image_widget_info.xml @@ -0,0 +1,28 @@ + + diff --git a/system/glance-canonical-layouts/references/res/xml/sample_toolbar_widget_info.xml b/system/glance-canonical-layouts/references/res/xml/sample_toolbar_widget_info.xml new file mode 100644 index 0000000..8b6af4a --- /dev/null +++ b/system/glance-canonical-layouts/references/res/xml/sample_toolbar_widget_info.xml @@ -0,0 +1,28 @@ + + diff --git a/system/glance-canonical-layouts/references/text/FullBleedImageAppWidget.kt b/system/glance-canonical-layouts/references/text/FullBleedImageAppWidget.kt new file mode 100644 index 0000000..8f26e61 --- /dev/null +++ b/system/glance-canonical-layouts/references/text/FullBleedImageAppWidget.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the details; and limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.text + +import android.content.Context +import android.util.Log +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeImageGridDataRepository +import com.example.platform.ui.appwidgets.glance.layout.collections.data.FakeImageGridDataRepository.Companion.getImageGridDataRepo +import com.example.platform.ui.appwidgets.glance.layout.text.layout.FullBleedImageLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Glance widget showcasing Full Bleed Snap Scrolling, powered by [FakeImageGridDataRepository]. + */ +class FullBleedImageAppWidget : GlanceAppWidget() { + override val sizeMode: SizeMode = SizeMode.Exact + + override val previewSizeMode = SizeMode.Responsive( + setOf( + DpSize(109.dp, 115.dp) + ) + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getImageGridDataRepo(id) + + val initialData = withContext(Dispatchers.IO) { + repo.load(context) + } + + provideContent { + val data by repo.data().collectAsState(initial = initialData) + + GlanceTheme { + FullBleedImageLayout( + data = data + ) + } + } + } + + override suspend fun providePreview(context: Context, widgetCategory: Int) { + val repo = FakeImageGridDataRepository() + + val initialData = withContext(Dispatchers.IO) { + repo.load(context) + } + + provideContent { + GlanceTheme { + FullBleedImageLayout( + data = initialData.take(1) + ) + } + } + } +} + +/** + * Receiver for the Full Bleed Snap Scrolling widget. + */ +class FullBleedImageAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = FullBleedImageAppWidget() + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + val glanceAppWidgetManager = GlanceAppWidgetManager(context) + appWidgetIds.forEach { id -> + try { + val glanceId = glanceAppWidgetManager.getGlanceIdBy(id) + FakeImageGridDataRepository.cleanUp(glanceId) + } catch (e: IllegalArgumentException) { + Log.w("FullBleedImageReceiver", "Skipping cleanup for invalid AppWidget ID: $id", e) + } + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/system/glance-canonical-layouts/references/text/LongTextAppWidget.kt b/system/glance-canonical-layouts/references/text/LongTextAppWidget.kt new file mode 100644 index 0000000..2057d07 --- /dev/null +++ b/system/glance-canonical-layouts/references/text/LongTextAppWidget.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.text + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.text.data.FakeLongTextRepository +import com.example.platform.ui.appwidgets.glance.layout.text.layout.LongTextLayout +import com.example.platform.ui.appwidgets.glance.layout.text.layout.LongTextLayoutData +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LongTextAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = FakeLongTextRepository.getRepo(id) + + val initialData = withContext(Dispatchers.Default) { + repo.load() + } + + provideContent { + val data by repo.data().collectAsState(initial = initialData) + + GlanceTheme { + LongTextAppWidgetContent( + data = data, + refreshDataAction = { repo.refresh() }) + } + } + } + + @Composable + fun LongTextAppWidgetContent( + data: LongTextLayoutData, + refreshDataAction: () -> Unit, + ) { + val context = LocalContext.current + + LongTextLayout( + title = context.getString(R.string.sample_long_text_app_widget_name), + titleIconRes = R.drawable.sample_text_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = refreshDataAction, + data = data, + action = actionStartDemoActivity(data.key), + ) + } +} + +class LongTextAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = LongTextAppWidget() +} diff --git a/system/glance-canonical-layouts/references/text/TextWithImageAppWidget.kt b/system/glance-canonical-layouts/references/text/TextWithImageAppWidget.kt new file mode 100644 index 0000000..3a0091f --- /dev/null +++ b/system/glance-canonical-layouts/references/text/TextWithImageAppWidget.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.text + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.rememberCoroutineScope +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.text.data.FakeTextWithImageRepository +import com.example.platform.ui.appwidgets.glance.layout.text.data.FakeTextWithImageRepository.Companion.getRepo +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageData +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class TextWithImageAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getRepo(id) + + val initialData = withContext(Dispatchers.Default) { + repo.load(context) + } + + provideContent { + val data by repo.data().collectAsState(initial = initialData) + val coroutineScope = rememberCoroutineScope() + + GlanceTheme { + key(LocalSize.current) { + WidgetContent( + data = data, + refreshDataAction = { + coroutineScope.launch { + withContext(Dispatchers.Default) { + repo.refresh(context) + } + } + }) + } + } + } + } + + @Composable + fun WidgetContent(data: TextWithImageData?, refreshDataAction: () -> Unit) { + val context = LocalContext.current + + TextWithImageLayout( + title = context.getString(R.string.sample_text_and_image_app_widget_name), + titleIconRes = R.drawable.sample_text_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = refreshDataAction, + data = data + ) + } +} + +class TextWithImageAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TextWithImageAppWidget() + + @SuppressLint("RestrictedApi") + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + FakeTextWithImageRepository.cleanUp(AppWidgetId(appWidgetId = it)) + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/system/glance-canonical-layouts/references/text/data/FakeLongTextRespository.kt b/system/glance-canonical-layouts/references/text/data/FakeLongTextRespository.kt new file mode 100644 index 0000000..05185af --- /dev/null +++ b/system/glance-canonical-layouts/references/text/data/FakeLongTextRespository.kt @@ -0,0 +1,90 @@ +package com.example.platform.ui.appwidgets.glance.layout.text.data + +import androidx.glance.GlanceId +import com.example.platform.ui.appwidgets.glance.layout.text.layout.LongTextLayoutData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import com.example.platform.ui.appwidgets.glance.layout.computeIfAbsent as computeIfAbsentExt +/** + * An fake in-memory repository to provide data for displaying different demo samples in + * [com.example.platform.ui.appwidgets.glance.layout.text.layout.LongTextLayout] + */ +class FakeLongTextRepository { + private var itemIndex: Int = 0 + private var itemsCount: Int = 0 + private val data = MutableStateFlow(demoItems[0]) + + fun data(): Flow = data + + /** + * Mimics refresh by returning a different data item from the demo data list. + * + * This allows us to try the layout with various texts pertaining to a specific kind of data. + */ + fun refresh() { + itemIndex = (itemIndex + 1) % itemsCount + + this.load() + } + + /** Loads the data and updates the flow */ + fun load(): LongTextLayoutData { + itemsCount = demoItems.size + data.value = demoItems[itemIndex] + + return data.value + } + + companion object { + private val repositories = mutableMapOf() + + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getRepo(glanceId: GlanceId): FakeLongTextRepository { + return synchronized(repositories) { + repositories.computeIfAbsentExt(glanceId) { + FakeLongTextRepository() + }!! + } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + + // Lorem ipsum text generated with https://loremipsum.io/ + val demoItems = listOf( + LongTextLayoutData( + key = "item 0", + text = "This is allows for a longer text string. Specifically because the focus in this, layout is on the primary text.", + caption = "Caption", + ), + LongTextLayoutData( + key = "item 1", + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + caption = "Ut mollis", + ), + LongTextLayoutData( + key = "item 2", + text = "Cursus mattis molestie a iaculis at erat pellentesque adipiscing commodo elit at imperdiet dui accumsan sit amet.", + caption = "Ipsum faucibus", + ), + LongTextLayoutData( + key = "item 3", + text = "Tellus orci ac auctor augue mauris augue neque gravida in fermentum et sollicitudin ac orci", + caption = "Amet cursus" + ), + LongTextLayoutData( + key = "item 4", + text = "Dolor sit amet consectetur adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam.", + caption = "Amet cursus" + ), + ) + } +} diff --git a/system/glance-canonical-layouts/references/text/data/FakeTextWithImageRepository.kt b/system/glance-canonical-layouts/references/text/data/FakeTextWithImageRepository.kt new file mode 100644 index 0000000..5b78b4a --- /dev/null +++ b/system/glance-canonical-layouts/references/text/data/FakeTextWithImageRepository.kt @@ -0,0 +1,160 @@ +package com.example.platform.ui.appwidgets.glance.layout.text.data + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import androidx.glance.GlanceId +import coil.ImageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import com.example.platform.ui.appwidgets.glance.layout.utils.AspectRatio +import com.example.platform.ui.appwidgets.glance.layout.utils.AspectRatio.Companion.asDouble +import com.example.platform.ui.appwidgets.glance.layout.text.layout.ImageData +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextData +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageData +import com.example.platform.ui.appwidgets.glance.layout.utils.ImageUtils +import com.example.platform.ui.appwidgets.glance.layout.utils.ImageUtils.getMaxWidgetMemoryAllowedSizeInBytes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import com.example.platform.ui.appwidgets.glance.layout.computeIfAbsent as computeIfAbsentExt +/** + * An fake in-memory repository to provide data for displaying different demo samples in + * [com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayout] + */ +class FakeTextWithImageRepository { + private val data = MutableStateFlow(null) + private var itemIndex = 0 + private var items = demoItems + + fun data(): Flow = data + + suspend fun refresh(context: Context) { + val itemCount = items.size + itemIndex = (itemIndex + 1) % itemCount + + this.load(context) + } + + suspend fun load(context: Context): TextWithImageData? { + val item = items[itemIndex] + val bitmap = fetchImage(context, item.url) + val mappedImageData = ImageData( + bitmap = bitmap, + contentDescription = item.imageContentDescription + ) + + data.value = TextWithImageData( + textData = TextData( + key = "$itemIndex", + primary = item.primary, + secondary = item.secondary, + caption = item.caption + ), + imageData = mappedImageData + ) + return data.value + } + + private suspend fun fetchImage(context: Context, url: String): Bitmap? { + val maxAllowedBytes = context.getMaxWidgetMemoryAllowedSizeInBytes() + val imageSizeLimit = ImageUtils.getMaxPossibleImageSize( + aspectRatio = AspectRatio.Ratio16x9.asDouble(), + memoryLimitBytes = maxAllowedBytes, + maxImages = 1 + ) + val maxWidth = imageSizeLimit.width + val maxHeight = imageSizeLimit.height + + var bitmap: Bitmap? = null + + val result = ImageLoader(context).execute( + ImageRequest.Builder(context) + .data(url) + .size(maxWidth, maxHeight) + .target { res: Drawable -> + bitmap = (res as BitmapDrawable).bitmap + }.build() + ) + + if (result is ErrorResult) { + Log.e(TAG, "Failed to load the image:", result.throwable) + } + + return bitmap + } + + companion object { + private const val TAG = "FTWIR" + + private val repositories = mutableMapOf() + + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getRepo(glanceId: GlanceId): FakeTextWithImageRepository { + return synchronized(repositories) { + repositories.computeIfAbsentExt(glanceId) { + FakeTextWithImageRepository() + }!! + } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + + data class DemoData( + val url: String, + val imageContentDescription: String? = null, + val primary: String, + val secondary: String, + val caption: String, + ) + + // Courtesy of https://unsplash.com/@iamliam + val demoItems = listOf( + DemoData( + primary = "Davos at sunrise, viewed from Schatzalp", + secondary = "Golden light washes over the alpine village of Davos, a breathtaking view unfolding from Schatzalp's vantage point.", + caption = "33,822 views", + url = "https://images.unsplash.com/photo-1671525737370-1d490286372e", + imageContentDescription = "Davos at sunrise, viewed from Schatzalp", + ), + DemoData( + primary = "Flowers at a wedding reception", + secondary = "I took this photo at a wedding reception. The flowers were in a vase next to a window", + caption = "33,822 views", + url = "https://images.unsplash.com/photo-1531306760863-7fb02a41db12", + imageContentDescription = "Flowers at a wedding reception", + ), + DemoData( + primary = "Blushing Bride", + secondary = "An up-close look at a Blushing Bride Protea flower, native to South Africa", + caption = "31,072 views", + url = "https://images.unsplash.com/photo-1566964423430-3e52903303a5", + imageContentDescription = "Blushing Bride flower", + ), + DemoData( + primary = "Winter in Switzerland", + secondary = "A snow-shoer walking up Strelapass on snow lined with deep trails from skiiers", + caption = "15 min", + url = "https://images.unsplash.com/photo-1671525784444-392a8f8daa3f", + imageContentDescription = "A snow-shoer walking up Strelapass on snow lined with deep trails from skiiers", + ), + DemoData( + primary = "A single water droplet rests in a budding red pansy.", + secondary = "Secrets held within a drop: A microcosm of beauty in a world of red.", + caption = "193 views", + url = "https://images.unsplash.com/photo-1685540466252-8c21e7c37624", + imageContentDescription = "A single water droplet rests in a budding red pansy.", + ) + ) + } +} diff --git a/system/glance-canonical-layouts/references/text/layout/FullBleedImageLayout.kt b/system/glance-canonical-layouts/references/text/layout/FullBleedImageLayout.kt new file mode 100644 index 0000000..99ffb39 --- /dev/null +++ b/system/glance-canonical-layouts/references/text/layout/FullBleedImageLayout.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.text.layout + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.VerticalScrollMode +import androidx.glance.appwidget.lazy.items +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.color.ColorProvider +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.ImageGridItemData +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.NoDataContent +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A full bleed snap scrolling gallery canonical layout using [ImageGridItemData]. + * + * Each item displays an edge-to-edge background photo with overlaid + * title and caption details that auto-scale to fit the current widget size. + */ +@Composable +fun FullBleedImageLayout( + data: List? = null, +) { + val size = LocalSize.current + val isSmall = size.height <= 110.dp + val appName = LocalContext.current.getString(R.string.sample_full_bleed_image_app_widget_name) + + val scrollMode = if (Build.VERSION.SDK_INT >= 37) { + VerticalScrollMode.SnapScrollMatchHeight(size.height) + } else { + VerticalScrollMode.Normal + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .cornerRadius(16.dp) + ) { + if (data.isNullOrEmpty()) { + val context = LocalContext.current + NoDataContent( + noDataText = context.getString(R.string.sample_no_data_text), + noDataIconRes = R.drawable.sample_no_data_icon, + actionButtonText = context.getString(R.string.sample_learn_more_button_text), + actionButtonIcon = R.drawable.sample_info_icon, + actionButtonOnClick = actionStartDemoActivity("on-click of info button in no data view") + ) + } else { + SnapScrollingGallery( + data = data, + isSmall = isSmall, + appName = appName, + size = size, + scrollMode = scrollMode + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES_FULL.BAKLAVA_1) +@Composable +private fun SnapScrollingGallery( + data: List?, + isSmall: Boolean, + appName: String, + size: DpSize, + scrollMode: VerticalScrollMode, +) { + val galleryData = data ?: return + if (galleryData.isEmpty()) return + + if (galleryData.size == 1) { + // If there's only 1 item (like in the widget preview), render with fillMaxSize to + // bypass LazyColumn measurement issues where the generated widget preview item doesn't + // fill the widget bounds. + GalleryItemCard( + item = galleryData[0], + isSmall = isSmall, + appName = appName, + modifier = GlanceModifier.fillMaxSize() + ) + } else { + LazyColumn( + modifier = GlanceModifier.fillMaxSize(), + verticalScrollMode = scrollMode + ) { + items(galleryData.take(5), itemId = { it.key.hashCode().toLong() }) { item -> + GalleryItemCard( + item = item, + isSmall = isSmall, + appName = appName, + modifier = GlanceModifier.size(size.width, size.height) + ) + } + } + } +} + +@Composable +private fun GalleryItemCard( + item: ImageGridItemData, + isSmall: Boolean, + appName: String, + modifier: GlanceModifier = GlanceModifier, +) { + val itemTitle = item.title ?: "" + + Box( + modifier = modifier, + contentAlignment = Alignment.BottomStart + ) { + val imageProvider = item.image?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.sample_placeholder_image) + + Image( + provider = imageProvider, + contentDescription = item.imageContentDescription ?: itemTitle, + contentScale = ContentScale.Crop, + modifier = GlanceModifier.fillMaxSize() + ) + + val titleFontSize = if (isSmall) { + WidgetTextDimensions.primaryTextFontSizeAndMaxLines(itemTitle).first + } else { + WidgetTextDimensions.maxPrimaryTextFontSize + } + + Column( + modifier = GlanceModifier + .fillMaxWidth() + // Implementing a partial gradient scrim by applying a background modifier directly + // to a text Column results in the gradient stretching to fill the entire widget. + .background(ImageProvider(R.drawable.sample_scrim_gradient)) + .padding(WidgetTextDimensions.widgetPadding), + verticalAlignment = Alignment.Bottom, + ) { + // App Logo Icon styled as a fixed size monochrome asset above the Title as the Caption + Image( + provider = ImageProvider(R.drawable.sample_app_logo), + contentDescription = appName, + contentScale = ContentScale.Fit, + modifier = GlanceModifier.size(24.dp) + ) + + if (itemTitle.isNotEmpty()) { + Text( + text = itemTitle, + style = TextStyle( + color = ColorProvider(Color.White, Color.White), + fontWeight = FontWeight.Bold, + fontSize = titleFontSize + ), + modifier = GlanceModifier.fillMaxWidth().padding(top = 4.dp) + ) + } + } + } +} + +@RequiresApi(Build.VERSION_CODES_FULL.BAKLAVA_1) +@SmallWidgetPreview +@MediumWidgetPreview +@Composable +private fun FullBleedImageLayoutPreview() { + val previewItems = listOf( + ImageGridItemData( + key = "0", + title = "Yosemite Valley under clear blue skies", + image = null, + imageContentDescription = null + ), + ImageGridItemData( + key = "1", + title = "Mystical forest lake reflection at sunrise", + image = null, + imageContentDescription = null + ) + ) + + FullBleedImageLayout( + data = previewItems + ) +} diff --git a/system/glance-canonical-layouts/references/text/layout/LongTextLayout.kt b/system/glance-canonical-layouts/references/text/layout/LongTextLayout.kt new file mode 100644 index 0000000..e119867 --- /dev/null +++ b/system/glance-canonical-layouts/references/text/layout/LongTextLayout.kt @@ -0,0 +1,347 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.text.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.text.layout.WidgetTextDimensions.captionFontSizeAndMaxLines +import com.example.platform.ui.appwidgets.glance.layout.text.layout.WidgetTextDimensions.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.text.layout.WidgetTextDimensions.primaryTextFontSizeAndMaxLines +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.FontUtils.calculateFontSizeAndMaxLines +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting text only content. + * + * A longer text with a short caption are displayed in a [Scaffold] under an app-specific title bar. + * + * This serves as an implementation suggestion, but should be customized to fit your product's + * needs. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable resource, but you should + * use an appropriate icon source for your use case. + * @param titleBarActionIconRes resource id of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For example, a search icon. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param data the text and caption to be displayed in the widget. + * @param action action to be performed on click of the main content. + */ +@Composable +fun LongTextLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int? = null, + titleBarActionIconContentDescription: String? = null, + titleBarAction: (() -> Unit)? = null, + data: LongTextLayoutData, + action: Action? = null, +) { + val showTitleBar = LongTextLayoutSize.fromLocalSize() != LongTextLayoutSize.XSmall + val scaffoldTopPadding = if (showTitleBar) { + 0.dp + } else { + widgetPadding + } + + Scaffold( + backgroundColor = GlanceTheme.colors.widgetBackground, + horizontalPadding = widgetPadding, + modifier = GlanceModifier + .maybeClickable(action) + .padding( + bottom = widgetPadding, + top = scaffoldTopPadding + ), + titleBar = { + if (showTitleBar) { + TitleBarContent( + titleIconRes, + title, + titleBarAction, + titleBarActionIconRes, + titleBarActionIconContentDescription + ) + } + }, + ) { + TextStack( + data = data, + verticalAlignment = if (showTitleBar) { + Alignment.Bottom + } else Alignment.CenterVertically + ) + } +} + +@Composable +private fun TitleBarContent( + titleIconRes: Int, + title: String, + titleBarAction: (() -> Unit)?, + titleBarActionIconRes: Int?, + titleBarActionIconContentDescription: String?, +) { + TitleBar( + startIcon = ImageProvider(titleIconRes), + title = title.takeIf { showTitle() } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + if (titleBarAction != null && titleBarActionIconRes != null) { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction + ) + } + } + ) +} + +@Composable +private fun TextStack( + data: LongTextLayoutData, + verticalAlignment: Alignment.Vertical, +) { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = verticalAlignment + ) { + val primaryTextFontSizeAndMaxLines = primaryTextFontSizeAndMaxLines(data.text) + val captionFontSizeAndMaxLines = + captionFontSizeAndMaxLines(primaryTextFontSizeAndMaxLines.first) + // Caption + Text( + text = data.caption, + style = TextStyle( + fontSize = captionFontSizeAndMaxLines.first, + color = GlanceTheme.colors.secondary + ), + maxLines = captionFontSizeAndMaxLines.second + ) + // Primary text + Text( + text = data.text, + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = primaryTextFontSizeAndMaxLines.first + ), + maxLines = primaryTextFontSizeAndMaxLines.second + ) + } +} + +private enum class LongTextLayoutSize { + XSmall, + Normal; + + companion object { + @Composable + fun fromLocalSize(): LongTextLayoutSize { + return if (LocalSize.current.height <= 180.dp) { + XSmall + } else { + Normal + } + } + } +} + +@Composable +private fun showTitle(): Boolean { + return LocalSize.current.width >= 260.dp +} + +private fun GlanceModifier.maybeClickable(action: Action?): GlanceModifier { + return if (action != null) { + this.clickable(action) + } else { + this + } +} + +/** + * Information to be displayed in a [com.example.platform.ui.appwidgets.glance.layout.text.layout.LongTextLayout]. + * + * @param key a unique identifier for the data being displayed as primary content e.g. article ID, + * in an "article of the day" widget. This may not be necessary for your use case; in this sample, + * we use this key to differentiate between placeholder and real data when demonstrating clicks. + * @param text a required text representing primary information being conveyed to the user via the + * widget; suitable for text of about 65 characters. + * @param caption shorter text accompanying the primary [text] - that can fit in one line; e.g. + * author's name in an "article of the day" widget. + */ +data class LongTextLayoutData( + val key: String, + val text: String, + val caption: String, +) + +internal object WidgetTextDimensions { + val widgetPadding = 16.dp + private val titleBarHeight: Dp + @Composable get() = if (LongTextLayoutSize.fromLocalSize() == LongTextLayoutSize.XSmall) { + 0.dp + } else { + 56.dp + } + + /** Height and width in dp available to main content (excluding title bar, padding, spacing). */ + val contentSize: DpSize + @Composable get() { + val size = LocalSize.current + + return DpSize( + width = size.width - (2 * widgetPadding), + height = size.height - widgetPadding - titleBarHeight + ) + } + + // Upper and lower bounds for the caption. + internal val minCaptionFontSize = 12.sp // low - GM3 Label Medium + internal val maxCaptionFontSize = 14.sp // high - GM3 Label Large + + // Upper bound for primary text. + internal val maxPrimaryTextFontSize = 28.sp // GM3 Headline Medium + + // For a font size 16 of primary text, we want caption to be of size 14. + internal const val captionToPrimaryTextRatio = 0.875f + + @Composable + fun primaryTextFontSizeAndMaxLines(text: String): Pair { + val size = LocalSize.current + // Primary text and caption share 70:30 height within the area available for texts. + val availableHeightForPrimaryText = Dp(0.70f * contentSize.height.value) + // In this layout, texts take up entire horizontal space except the paddings on the sides. + val availableWidthForPrimaryText = size.width - (widgetPadding * 2) + + return calculateFontSizeAndMaxLines( + context = LocalContext.current, + text = text, + availableWidth = availableWidthForPrimaryText, + availableHeight = availableHeightForPrimaryText, + minFontSize = (minCaptionFontSize.value / captionToPrimaryTextRatio).sp, + maxFontSize = maxPrimaryTextFontSize + ) + } + + fun captionFontSizeAndMaxLines(primaryFontSize: TextUnit): Pair { + val estimatedFontSize = primaryFontSize.value * captionToPrimaryTextRatio + val captionMaxLines = 1 // Caption is always 1 line. + return estimatedFontSize.coerceAtMost(maxCaptionFontSize.value).sp to captionMaxLines + } +} + +/** + * Previews of the long text layout with shorter caption and longer main text + * + * Previewing them at standard & min-max sizes allows us to adjust font sizes if needed. Use the + * Preview annotation to view the widget at specific width / height. + */ +@SmallWidgetPreview +@MediumWidgetPreview +@Composable +private fun ShortCaptionSuperLongTextPreview() { + LongTextLayoutPreview( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Cursus mattis molestie a iaculis at erat pellentesque adipiscing commodo elit at imperdiet dui accumsan sit amet.", + caption = "Ut mollis", + ) +} + + +/** + * Previews of the long text layout with longer caption and shorter main text + * + * Previewing them at standard & min-max sizes allows us to adjust font sizes if needed. Use the + * Preview annotation to view the widget at specific width / height. + */ +@SmallWidgetPreview +@MediumWidgetPreview +@Composable +private fun LongCaptionShortTextPreview() { + LongTextLayoutPreview( + text = "Comparatively small text", + caption = "Ipsum faucibus ut mollis amet cursus", + ) +} + +/** + * Previews of the long text layout with medium sized caption and main text + * + * Previewing them at standard & min-max sizes allows us to adjust font sizes if needed. Use the + * Preview annotation to view the widget at specific width / height. + */ +@SmallWidgetPreview +@MediumWidgetPreview +@Composable +private fun MediumTextPreview() { + LongTextLayoutPreview( + text = "This allows for a longer text string. Specifically because the focus in this, layout is on the primary text.", + caption = "Ut mollis amet cursus", + ) +} + +@Composable +private fun LongTextLayoutPreview(text: String, caption: String) { + val context = LocalContext.current + + LongTextLayout( + title = context.getString(R.string.sample_long_text_app_widget_name), + titleIconRes = R.drawable.sample_text_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = {}, + data = LongTextLayoutData( + key = "1", + text = text, + caption = caption, + ), + action = actionStartDemoActivity("1"), + ) +} diff --git a/system/glance-canonical-layouts/references/text/layout/TextWithImageLayout.kt b/system/glance-canonical-layouts/references/text/layout/TextWithImageLayout.kt new file mode 100644 index 0000000..4816280 --- /dev/null +++ b/system/glance-canonical-layouts/references/text/layout/TextWithImageLayout.kt @@ -0,0 +1,520 @@ +package com.example.platform.ui.appwidgets.glance.layout.text.layout + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.cornerRadius +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.collections.layout.NoDataContent +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutDimensions.contentSize +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutDimensions.contentSpacing +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutDimensions.pictureRadius +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutDimensions.verticalTextsSpacing +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutDimensions.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutSize.HorizontalLarge +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutSize.HorizontalSmall +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutSize.VerticalLarge +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutSize.VerticalSmall +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutTextStyles.primaryTextFontValues +import com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayoutTextStyles.secondaryTextFontValues +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.FontUtils +import com.example.platform.ui.appwidgets.glance.layout.utils.LargeWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting a long text (~65 characters), its one line caption, a supporting + * image and a secondary text in a [Scaffold] with an app-specific title bar. + * + * In compact spaces, the image, title bar, secondary text may not be shown. + * + * The layout serves as an implementation suggestion, but should be customized to fit your product's + * needs. For instance, a ratings element may be additionally displayed. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable resource, but you should + * use an appropriate icon source for your use case. + * @param titleBarActionIconRes optional resource id of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For example, a search icon to find other + * articles in an "article of the day" widget. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param data the primary text, caption, secondary text and image to be displayed. + * + * @see [TextWithImageData] for accepted inputs. + * @see [com.example.platform.ui.appwidgets.glance.layout.text.TextWithImageAppWidget] + * + */ +@Composable +fun TextWithImageLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int? = null, + titleBarActionIconContentDescription: String? = null, + titleBarAction: (() -> Unit)? = null, + data: TextWithImageData? = null, +) { + fun titleBar(): @Composable (() -> Unit) = { + TitleBar( + startIcon = ImageProvider(titleIconRes), + title = title.takeIf { LocalSize.current.width >= 230.dp } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + if (titleBarAction != null && titleBarActionIconRes != null) { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction + ) + } + } + ) + } + + val action = actionStartDemoActivity("TextWithImage: ${data?.textData?.key}") + val layoutSize = TextWithImageLayoutSize.fromLocalSize() + + val titleBar = if (layoutSize.showTitleBar()) { + titleBar() + } else { + null + } + val scaffoldTopPadding = if (layoutSize.showTitleBar()) { + 0.dp + } else { + widgetPadding + } + + Scaffold( + titleBar = titleBar, + horizontalPadding = widgetPadding, + backgroundColor = GlanceTheme.colors.widgetBackground, + modifier = GlanceModifier + .maybeClickable(action) + .padding( + bottom = widgetPadding, + top = scaffoldTopPadding + ), + ) { + when (data) { + null -> NoDataContent() + else -> { + when (layoutSize) { + VerticalSmall -> VerticalContent( + data = data, + showImage = true, + showSecondaryText = false + ) + + VerticalLarge -> VerticalContent( + data = data, + showImage = true, + showSecondaryText = true + ) + + HorizontalSmall -> HorizontalContent( + data = data, + showImage = false, + showSecondaryText = false + ) + + HorizontalLarge -> HorizontalContent( + data = data, + showImage = true, + showSecondaryText = true + ) + } + } + } + } +} + +@Composable +private fun NoDataContent() { + val context = LocalContext.current + + NoDataContent( + noDataText = context.getString(R.string.sample_no_data_text), + noDataIconRes = R.drawable.sample_no_data_icon, + actionButtonText = context.getString( + R.string.sample_learn_more_button_text + ), + actionButtonIcon = R.drawable.sample_info_icon, + actionButtonOnClick = actionStartDemoActivity("on-click of info button in no data view") + ) +} + +@Composable +private fun HorizontalContent( + data: TextWithImageData, + showImage: Boolean, + showSecondaryText: Boolean, +) { + val contentWidth = contentSize.width - contentSpacing + val contentHeight = contentSize.height - (2 * verticalTextsSpacing) + Row( + verticalAlignment = Alignment.Vertical.Bottom, + horizontalAlignment = Alignment.Horizontal.Start, + modifier = GlanceModifier.fillMaxSize() + ) { + TextStack( + data = data.textData, + showSecondaryText = showSecondaryText, + modifier = GlanceModifier.fillMaxHeight().defaultWeight(), + availableSize = DpSize( + // Use 40% of available width for text area if showing image on side. + width = (0.4 * contentWidth).takeIf { showImage } ?: contentWidth, + // Use 80% of vertical space for text area. + height = (0.80 * contentHeight).takeIf { showImage } ?: contentHeight + ) + ) + if (showImage) { + Spacer(modifier = GlanceModifier.width(contentSpacing)) + Image( + data = data.imageData, + modifier = GlanceModifier.fillMaxHeight().width(contentWidth / 2) + ) + } + } +} + +@Composable +private fun VerticalContent( + data: TextWithImageData, + showImage: Boolean, + showSecondaryText: Boolean, +) { + Column( + verticalAlignment = Alignment.Vertical.Bottom, + modifier = GlanceModifier.fillMaxHeight() + ) { + val contentWidth = contentSize.width + val contentHeight = contentSize.height - (2 * verticalTextsSpacing) + + if (showImage) { + Image( + data = data.imageData, + modifier = GlanceModifier.fillMaxWidth().defaultWeight() + ) + Spacer(modifier = GlanceModifier.height(contentSpacing)) + } + TextStack( + data = data.textData, + showSecondaryText = showSecondaryText, + modifier = GlanceModifier.fillMaxWidth(), + availableSize = DpSize( + // Use 80% of available width to leave some offset for word break differences. + width = 0.8 * contentWidth, + // Use 40% vertical space for text area if showing image + height = (0.4 * contentHeight).takeIf { showImage } ?: contentHeight + ) + ) + } +} + +@Composable +private fun TextStack( + data: TextData, + modifier: GlanceModifier, + availableSize: DpSize, + showSecondaryText: Boolean, +) { + val (primaryTextFontSize, primaryTextMaxLines) = primaryTextFontValues( + text = data.primary, + availableSize = availableSize, + showSecondaryText = showSecondaryText + ) + + Column( + verticalAlignment = Alignment.Bottom, + modifier = modifier + ) { + Text( + text = data.caption, + maxLines = 1, + style = TextWithImageLayoutTextStyles.caption, + modifier = GlanceModifier.fillMaxWidth() + ) + Spacer(modifier = GlanceModifier.height(verticalTextsSpacing)) + Text( + text = data.primary, + maxLines = primaryTextMaxLines, + style = TextWithImageLayoutTextStyles.primary.copy(fontSize = primaryTextFontSize), + ) + if (showSecondaryText) { + val (secondaryTextFontSize, secondaryTextMaxLines) = secondaryTextFontValues( + text = data.secondary, + availableSize = availableSize + ) + + Spacer(modifier = GlanceModifier.height(verticalTextsSpacing)) + Text( + text = data.secondary, + maxLines = secondaryTextMaxLines, + style = TextWithImageLayoutTextStyles.secondary.copy(fontSize = secondaryTextFontSize), + ) + } + } +} + +@Composable +private fun Image( + data: ImageData, + modifier: GlanceModifier, +) { + val imageProvider = if (data.bitmap != null) { + ImageProvider(data.bitmap) + } else { + ImageProvider(R.drawable.sample_placeholder_image) + } + + Image( + provider = imageProvider, + contentDescription = data.contentDescription, + contentScale = ContentScale.Crop, + modifier = modifier.cornerRadius(pictureRadius), + ) +} + +private fun GlanceModifier.maybeClickable(action: Action?): GlanceModifier { + return if (action != null) { + this.clickable(action) + } else { + this + } +} + +/** + * Data object holding information displayed in + * [com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayout]. + */ +data class TextWithImageData( + val textData: TextData, + val imageData: ImageData, +) + +/** + * Data object holding texts displayed in + * [com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayout]. + * + * @param key a unique identifier for the data being displayed as primary content e.g. article ID, + * in an "article of the day" widget. This may not be necessary for your use case; in this sample, + * we use this key to differentiate between placeholder and real data when demonstrating clicks. + * @param primary text representing primary information being conveyed to the user via the + * widget + * @param secondary text supporting the primary text; for example, in an "article of the day" + * widget, this could be a brief description of the article. + * @param caption shorter text (couple of words) that can fit in one line; e.g. author's name in an + * "article of the day" widget. + */ +data class TextData( + val key: String, + val primary: String, + val secondary: String, + val caption: String, +) + +/** + * Data object holding raw information about the image accompanying the [TextData] in + * [com.example.platform.ui.appwidgets.glance.layout.text.layout.TextWithImageLayout]. + * + * @param resId an image accompanying the [TextData] content; for example, a preview image of + * an article in an "article of the day" widget. + * @param contentDescription an optional description about the [resId] that can be + * used by the accessibility services + */ +data class ImageData( + val bitmap: Bitmap? = null, + val contentDescription: String? = null, +) + +private enum class TextWithImageLayoutSize { + // No title bar or secondary text + HorizontalSmall, + + // No title bar or secondary text + VerticalSmall, + + // Text with Image on the side + HorizontalLarge, + + // Image with text below it + VerticalLarge; + + companion object { + @Composable + fun fromLocalSize(): TextWithImageLayoutSize { + val size = LocalSize.current + val isTall = size.height >= size.width + + return if (isTall && size.height <= 300.dp) { + VerticalSmall + } else if (isTall) { + VerticalLarge + } else if (size.width <= 165.dp) { + HorizontalSmall + } else { + HorizontalLarge + } + } + } + + @Composable + fun showTitleBar() = LocalSize.current.height >= 180.dp +} + + +private object TextWithImageLayoutTextStyles { + val primary: TextStyle + @Composable get() = TextStyle( + fontWeight = FontWeight.Medium, + color = GlanceTheme.colors.onSurface + ) + + val secondary: TextStyle + @Composable get() = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant + ) + + val caption: TextStyle + @Composable get() = + TextStyle(color = GlanceTheme.colors.secondary) + + @Composable + fun primaryTextFontValues( + text: String, + availableSize: DpSize, + showSecondaryText: Boolean, + ): Pair { + val availableHeight = if (showSecondaryText) { + // Within the text area, 30% space is used by primary text. + 0.30 * availableSize.height + } else { + 0.60 * availableSize.height + } + + return FontUtils.calculateFontSizeAndMaxLines( + context = LocalContext.current, + text = text, + availableWidth = availableSize.width, + availableHeight = availableHeight, + maxFontSize = 24.sp, + minFontSize = 16.sp + ) + } + + @Composable + fun secondaryTextFontValues(text: String, availableSize: DpSize): Pair { + return FontUtils.calculateFontSizeAndMaxLines( + context = LocalContext.current, + text = text, + availableWidth = availableSize.width, + // Within the text area, 25% space is used by secondary text. + availableHeight = 0.25 * availableSize.height, + maxFontSize = 24.sp, + minFontSize = 12.sp + ) + } +} + +private object TextWithImageLayoutDimensions { + /** Padding that visually appears between the widget outline and anything inside. */ + val widgetPadding = 16.dp + + /** Corner radius to be applied to an image. */ + val pictureRadius = 16.dp + + /** + * Space between larger building blocks within the layout. e.g space between text stack and + * the image. + */ + val contentSpacing = 12.dp + + /** + * Vertical spacing between text items + */ + val verticalTextsSpacing = 4.dp + + /** Height of the title bar. */ + private val titleBarHeight: Dp + @Composable get() = if (TextWithImageLayoutSize.fromLocalSize().showTitleBar()) { + 0.dp + } else { + 56.dp + } + + /** Height and width in dp available to main content (excluding title bar, padding, spacing). */ + val contentSize: DpSize + @Composable get() { + val size = LocalSize.current + + return DpSize( + width = size.width - (2 * widgetPadding), + height = size.height - titleBarHeight - widgetPadding + ) + } +} + +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun TextWithImagePreview() { + val context = LocalContext.current + + TextWithImageLayout( + title = context.getString(R.string.sample_text_and_image_app_widget_name), + titleIconRes = R.drawable.sample_text_icon, + titleBarActionIconRes = R.drawable.sample_refresh_icon, + titleBarActionIconContentDescription = context.getString( + R.string.sample_refresh_icon_button_label + ), + titleBarAction = {}, + data = TextWithImageData( + textData = TextData( + key = "1", + primary = "This allows for a longer text string.", + secondary = "Specifically because the focus in this, layout is on the primary text.", + caption = "Ut mollis amet cursus", + ), + imageData = ImageData() + ) + ) +} diff --git a/system/glance-canonical-layouts/references/toolbars/ExpressiveToolbarAppWidget.kt b/system/glance-canonical-layouts/references/toolbars/ExpressiveToolbarAppWidget.kt new file mode 100644 index 0000000..9ffc5dc --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/ExpressiveToolbarAppWidget.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.toolbars + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ExpressiveToolBarButton +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ExpressiveToolbarLayout +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity + +/** + * A sample [GlanceAppWidget] that demonstrates the [ExpressiveToolbarLayout] that uses a background + * of 4-sided cookie shape. + * + * Uses an example of a notes app with quick actions for + * - adding a note (primary), + * - starting a new note with microphone, + * - starting a new note with camera, + * - deeplink to sharing notes, + * - starting a new note by uploading a file. + */ +class ExpressiveToolbarAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + WidgetContent() + } + } + } + + @Composable + fun WidgetContent() { + ExpressiveToolbarLayout( + centerButton = ExpressiveToolBarButton( + iconRes = R.drawable.sample_add_icon, + contentDescription = "Add notes", + onClick = actionStartDemoActivity("add notes button") + ), + cornerButtons = listOf( + ExpressiveToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "mic", + onClick = actionStartDemoActivity("mic button") + ), + ExpressiveToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ), + ExpressiveToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ExpressiveToolBarButton( + iconRes = R.drawable.sample_file_upload_icon, + contentDescription = "file upload", + onClick = actionStartDemoActivity("file upload button") + ), + ) + ) + } +} + +class ExpressiveToolbarAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = ExpressiveToolbarAppWidget() +} diff --git a/system/glance-canonical-layouts/references/toolbars/SearchToolBarAppWidget.kt b/system/glance-canonical-layouts/references/toolbars/SearchToolBarAppWidget.kt new file mode 100644 index 0000000..f0b49e9 --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/SearchToolBarAppWidget.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.toolbars + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarButton +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayout +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity + +/** + * A widget to demonstrate the [SearchToolBarLayout]. + */ +class SearchToolBarAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + WidgetContent() + } + } + } + + @Composable + fun WidgetContent() { + SearchToolBarLayout( + searchButton = SearchToolBarButton( + iconRes = R.drawable.sample_search_icon, + contentDescription = "Search notes", + text = "Search", + onClick = actionStartDemoActivity("search notes button") + ), + trailingButtons = listOf( + SearchToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "audio", + onClick = actionStartDemoActivity("audio button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_videocam_icon, + contentDescription = "video note", + onClick = actionStartDemoActivity("video note button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ) + ) + } +} + +/** + * Receiver registered in the manifest for the [SearchToolBarAppWidget]. + */ +class SearchToolBarAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = SearchToolBarAppWidget() +} diff --git a/system/glance-canonical-layouts/references/toolbars/ToolBarAppWidget.kt b/system/glance-canonical-layouts/references/toolbars/ToolBarAppWidget.kt new file mode 100644 index 0000000..191a72c --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/ToolBarAppWidget.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.toolbars + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarButton +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayout +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity + +/** + * A widget to demonstrate the [ToolBarLayout]. + */ +class ToolBarAppWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + WidgetContent() + } + } + } + + @Composable + fun WidgetContent() { + ToolBarLayout( + appName = "App name", + appIconRes = R.drawable.sample_app_logo, + headerButton = ToolBarButton( + iconRes = R.drawable.sample_add_icon, + contentDescription = "add", + onClick = actionStartDemoActivity("add button") + ), + buttons = listOf( + ToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "mic", + onClick = actionStartDemoActivity("mic button") + ), + ToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ToolBarButton( + iconRes = R.drawable.sample_videocam_icon, + contentDescription = "video", + onClick = actionStartDemoActivity("video button") + ), + ToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ) + ) + ) + } +} + +/** + * Receiver registered in the manifest for the [ToolBarAppWidget]. + */ +class ToolBarAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = ToolBarAppWidget() +} diff --git a/system/glance-canonical-layouts/references/toolbars/layout/Buttons.kt b/system/glance-canonical-layouts/references/toolbars/layout/Buttons.kt new file mode 100644 index 0000000..c49bbe0 --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/layout/Buttons.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.unit.ColorProvider + +/** + * A rectangular button displaying the provided icon on a background of + * provided corner radius and colors. + * + * @param imageProvider icon to be displayed at center of the button + * @param onClick [Action] to be performed on click of button + * @param roundedCornerShape type of rounding to be applied to the button + * @param contentDescription description about the button that can be used by the accessibility + * services + * @param iconSize size of the icon displayed at center of the button + * @param modifier the modifier to be applied to this button. + * @param backgroundColor background color for the button + * @param contentColor color of the icon displayed at center of the button + */ +@Composable +fun RectangularIconButton( + imageProvider: ImageProvider, + onClick: Action, + roundedCornerShape: RoundedCornerShape, + contentDescription: String, + iconSize: Dp, + modifier: GlanceModifier, + backgroundColor: ColorProvider = GlanceTheme.colors.primary, + contentColor: ColorProvider = GlanceTheme.colors.onPrimary, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background(backgroundColor) + .cornerRadius(roundedCornerShape.cornerRadius) + .semantics { this.contentDescription = contentDescription } + .clickable(onClick) + ) { + Image( + provider = imageProvider, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = GlanceModifier.size(iconSize) + ) + } +} + +/** + * A fixed height pill-shaped button meant to be displayed in a title bar. + * + * @param iconImageProvider icon to be displayed in the button + * @param iconSize size of the icon displayed at center of the button + * @param backgroundColor background color for the button + * @param contentColor color of the icon displayed in the button + * @param contentDescription description about the button that can be used by the accessibility + * services + * @param onClick [Action] to be performed on click of button + * @param modifier the modifier to be applied to this button. + */ +@Composable +fun PillShapedButton( + iconImageProvider: ImageProvider, + iconSize: Dp, + backgroundColor: ColorProvider, + contentColor: ColorProvider, + contentDescription: String, + onClick: Action, + modifier: GlanceModifier, +) { + Box( // A clickable transparent outer container + contentAlignment = Alignment.Center, + modifier = modifier + .semantics { this.contentDescription = contentDescription } + .height(48.dp) + .clickable(onClick), + ) { + Box( // A filled background with smaller height + contentAlignment = Alignment.Center, + modifier = GlanceModifier + .width(52.dp) + .height(32.dp) + .background(backgroundColor) + .cornerRadius(RoundedCornerShape.FULL.cornerRadius) + ) { // The icon. + Image( + provider = iconImageProvider, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = GlanceModifier.size(iconSize) + ) + } + } +} + +/** + * Defines the roundness of a shape inline with the tokens used in M3 + * https://m3.material.io/styles/shape/shape-scale-tokens + */ +enum class RoundedCornerShape(val cornerRadius: Dp) { + FULL(100.dp), + MEDIUM(16.dp), +} diff --git a/system/glance-canonical-layouts/references/toolbars/layout/ExpressiveToolbarLayout.kt b/system/glance-canonical-layouts/references/toolbars/layout/ExpressiveToolbarLayout.kt new file mode 100644 index 0000000..c43c1bd --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/layout/ExpressiveToolbarLayout.kt @@ -0,0 +1,411 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import android.os.Build +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.unit.ColorProvider +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ExpressiveToolBarLayoutDimens.minCenterButtonTapTarget +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ExpressiveToolBarLayoutDimens.minCornerButtonTapTarget +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ExpressiveToolBarLayoutDimens.scaledButtonBackground +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ExpressiveToolBarLayoutDimens.scaledIconSize +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity + +/** + * Layout focused on presenting 5 most frequently used actions in your app. + * + * Uses a 4-sided cookie cutout as the background shape of the widget - the shape fits at-least two + * vertical or two horizontal edges to meet the quality criteria; see + * https://developer.android.com/design/ui/mobile/guides/widgets/widget_quality_guide + * + * Lays out the buttons using a Box layout by placing a center button with sufficient tap target + * first; followed by 2x2 grid of corner buttons centered in each cell of the grid. The way Box + * layout works, center button is placed behind the corner buttons. To avoid the overlap, at small + * sizes, we show only center button. + * + * This serves as an implementation suggestion, but should be customized to fit your product's + * needs. For instance, similar approach can be used to present the widget in other material shapes + * such as a circle shape or a clover shape. At 4x1 size, you may decide to show a rectangular + * toolbar instead of single center button shown in this layout. + * + * @param centerButton a button shown at the center of the shape; usually used for most frequently + * used action among the 5 + * @param cornerButtons list of other 4 buttons displayed at the corners of the shape; arranged in + * order (left-to-right, row-by-row) in a 2x2 grid + * @see ExpressiveToolbarLayoutSize for supported breakpoints + */ +@Composable +fun ExpressiveToolbarLayout( + centerButton: ExpressiveToolBarButton, + // 4 buttons passed as list for convenience that you can inline in your implementation. + cornerButtons: List, +) { + checkCornerButtonsListSize(cornerButtons) + + val widgetSize = LocalSize.current + // Since we use a non-rectangular background, we aren't able to fill entire widget space, however, + // we try to at least fill the space either horizontally or vertically. + val cookieBackgroundSize = widgetSize.height.coerceAtMost(widgetSize.width) + + val backgroundModifier = GlanceModifier + .size(cookieBackgroundSize) + .appWidgetBackground() + .fourSidedCookieBackground() + + when (ExpressiveToolbarLayoutSize.fromLocalSize()) { + ExpressiveToolbarLayoutSize.SMALL -> CenterButtonOnlyLayout( + centerButton = centerButton, + modifier = backgroundModifier + ) + + ExpressiveToolbarLayoutSize.MEDIUM -> AllButtonsScaledLayout( + centerButton = centerButton, + cornerButtons = cornerButtons, + cookieBackgroundSize = cookieBackgroundSize, + modifier = backgroundModifier + ) + } +} + +@Composable +private fun GlanceModifier.fourSidedCookieBackground(): GlanceModifier { + return this.background( + imageProvider = ImageProvider(R.drawable.four_side_cookie_background), + colorFilter = ColorFilter.tint(GlanceTheme.colors.widgetBackground) + ) +} + +/** + * Lays out just the center button + */ +@Composable +private fun CenterButtonOnlyLayout( + centerButton: ExpressiveToolBarButton, + modifier: GlanceModifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + CenterButton( + button = centerButton, + buttonBackgroundSize = 48.dp, + clickableSize = 48.dp, + iconSize = 24.dp, + filled = false, + shape = RoundedCornerShape.MEDIUM + ) + } +} + +/** + * Lays out corner and center buttons by scaling them based on background size and reference design. + */ +@Composable +private fun AllButtonsScaledLayout( + centerButton: ExpressiveToolBarButton, + cornerButtons: List, + cookieBackgroundSize: Dp, + modifier: GlanceModifier, +) { + val buttonBackgroundSize = scaledButtonBackground(cookieBackgroundSize) + val iconSize = scaledIconSize(cookieBackgroundSize) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + // Centered at the bottom layer (with larger tap target). + CenterButton( + button = centerButton, + buttonBackgroundSize = buttonBackgroundSize, + clickableSize = minCenterButtonTapTarget, + iconSize = iconSize, + // Older android versions didn't support rounded corners, we use unfilled buttons in those + // versions. + filled = Build.VERSION.SDK_INT > Build.VERSION_CODES.S, + shape = RoundedCornerShape.MEDIUM + ) + // 4 corner buttons on top layer + CornerButtonsGrid( + cornerButtons = cornerButtons, + buttonBackgroundSize = buttonBackgroundSize, + iconSize = iconSize, + clickableSize = buttonBackgroundSize.coerceAtLeast(minCornerButtonTapTarget), + modifier = GlanceModifier.fillMaxSize() + ) + } +} + +/** + * Data class representing buttons displayed in an expressive toolbar widget. + * @param iconRes Resource id of the icon button + * @param contentDescription description about the button that can be used by the accessibility + * services + * @param onClick action to perform on click of the button + * @param text optional text that will be displayed if space suffices. + */ +data class ExpressiveToolBarButton( + @DrawableRes val iconRes: Int, + val contentDescription: String, + val onClick: Action, + val text: String? = null, +) + +@Composable +private fun CornerButtonsGrid( + cornerButtons: List, + buttonBackgroundSize: Dp, + clickableSize: Dp, + iconSize: Dp, + modifier: GlanceModifier, +) { + checkCornerButtonsListSize(cornerButtons) + + TwoRowGrid( + spacing = 0.dp, + modifier = modifier, + items = cornerButtons.map { + { + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier.fillMaxSize() + ) { + CornerButton( + toolBarButton = it, + buttonBackgroundSize = buttonBackgroundSize, + iconSize = iconSize, + clickableSize = clickableSize + ) + } + } + }, + ) +} + +@Composable +private fun CornerButton( + toolBarButton: ExpressiveToolBarButton, + buttonBackgroundSize: Dp, + clickableSize: Dp, + iconSize: Dp, +) { + IconButton( + imageProvider = ImageProvider(toolBarButton.iconRes), + contentDescription = toolBarButton.contentDescription, + iconSize = iconSize, + backgroundSize = buttonBackgroundSize, + backgroundColor = androidx.glance.color.ColorProvider(Color.Transparent, Color.Transparent), + contentColor = GlanceTheme.colors.primary, + roundedCornerShape = RoundedCornerShape.FULL, + onClick = toolBarButton.onClick, + modifier = GlanceModifier.size(clickableSize) + ) +} + +@Composable +private fun CenterButton( + button: ExpressiveToolBarButton, + clickableSize: Dp, + buttonBackgroundSize: Dp, + iconSize: Dp, + shape: RoundedCornerShape, + filled: Boolean, +) { + val backgroundColor = if (filled) { + GlanceTheme.colors.tertiary + } else { + androidx.glance.color.ColorProvider(Color.Transparent, Color.Transparent) + } + + val contentColor = if (filled) { + GlanceTheme.colors.onTertiary + } else { + GlanceTheme.colors.primary + } + + IconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + iconSize = iconSize, + backgroundSize = buttonBackgroundSize, + backgroundColor = backgroundColor, + contentColor = contentColor, + roundedCornerShape = shape, + onClick = button.onClick, + modifier = GlanceModifier.size(clickableSize) + ) +} + +/** + * Buttons that have separate clickable area and filled background container. + */ +@Composable +private fun IconButton( + imageProvider: ImageProvider, + contentDescription: String, + backgroundSize: Dp, + iconSize: Dp, + roundedCornerShape: RoundedCornerShape, + onClick: Action, + modifier: GlanceModifier, + backgroundColor: ColorProvider, + contentColor: ColorProvider, +) { + Box( // clickable area + contentAlignment = Alignment.Center, + modifier = modifier + .cornerRadius(roundedCornerShape.cornerRadius) + .semantics { this.contentDescription = contentDescription } + .clickable(onClick) + ) { + Box( // colored background + contentAlignment = Alignment.Center, + modifier = GlanceModifier + .size(backgroundSize) + .background(backgroundColor) + .cornerRadius(roundedCornerShape.cornerRadius) + ) { + Image( + provider = imageProvider, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = GlanceModifier.size(iconSize) + ) + } + } +} + +private enum class ExpressiveToolbarLayoutSize { + // Size at which only center button is shown + SMALL, + + // Size at which center button has filled background and corner buttons are shown as well. + MEDIUM; + + companion object { + @Composable + fun fromLocalSize(): ExpressiveToolbarLayoutSize { + val size = LocalSize.current + val boxSize = size.width.coerceAtMost(size.height) + + return when { + // Size below which buttons would get too close to be able to keep their tap targets + // accessible. We would like to drop widget at 2x2 size. In most 5 column grids, 2x2 size + // has ~140-141dp width. So, we try to show all buttons at that breakpoint and show only + // center button at smaller sizes. + boxSize < 140.dp -> SMALL + + // At these sizes, all buttons can be shown and filled background for center button. + else -> MEDIUM + } + } + } +} + +// Various dimensions coming from the UX design (with a 4-sided cookie cut shape) +private object ExpressiveToolBarLayoutDimens { + val minCornerButtonTapTarget = 48.dp + val minCenterButtonTapTarget = 60.dp + + // Reference UX design uses an icon of 24.dp when widget size is 182.dp + // So, we scale the icon by 24/182 = 13% + fun scaledIconSize(backgroundSize: Dp) = + Dp((13 * backgroundSize.value) / 100) + + // Reference UX design uses background of 48.dp when widget size is 182.dp + // So, we scale the icon by 48/182 = 26% + // This isn't size of tap target, so it can be smaller. + fun scaledButtonBackground(backgroundSize: Dp) = + Dp((26 * backgroundSize.value) / 100) +} + +private fun checkCornerButtonsListSize(cornerButtons: List) { + if (cornerButtons.size != 4) { + Log.w("ExpressiveToolbarLayout", "Expected 4 corner buttons, but passed ${cornerButtons.size}") + } +} + +@OptIn(ExperimentalGlancePreviewApi::class) +@Composable +@Preview(widthDp = 56, heightDp = 56) // Min resize size +@Preview(widthDp = 120, heightDp = 115) // Min drop size +@Preview(widthDp = 140, heightDp = 140) // all buttons; center button transparent bg +@Preview(widthDp = 624, heightDp = 200) // Max size +fun ExpressiveToolbarLayoutPreview() { + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier + .fillMaxSize() + .background(Color.Black) + ) { + ExpressiveToolbarLayout( + centerButton = ExpressiveToolBarButton( + iconRes = R.drawable.sample_add_icon, + contentDescription = "Add notes", + onClick = actionStartDemoActivity("add notes button") + ), + cornerButtons = listOf( + ExpressiveToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "mic", + onClick = actionStartDemoActivity("mic button") + ), + ExpressiveToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ), + ExpressiveToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ExpressiveToolBarButton( + iconRes = R.drawable.sample_file_upload_icon, + contentDescription = "file upload", + onClick = actionStartDemoActivity("file upload button") + ), + ) + ) + } +} diff --git a/system/glance-canonical-layouts/references/toolbars/layout/LayoutComponents.kt b/system/glance-canonical-layouts/references/toolbars/layout/LayoutComponents.kt new file mode 100644 index 0000000..2200b3a --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/layout/LayoutComponents.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.glance.GlanceModifier +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width + +/** + * Arranges the provided [items] vertically spaced by given [spacing]. + */ +@Composable +fun SpacedColumn( + items: List<@Composable () -> Unit>, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxHeight(), +) { + val padding = spacing / 2 // split spacing between siblings + + Row(modifier = modifier) { + Column( + modifier = GlanceModifier + .fillMaxHeight() + .defaultWeight() + ) { + items.forEachIndexed { index, item -> + val paddingModifier = when (index) { + // Only bottom padding + 0 -> GlanceModifier.padding(bottom = padding) + + // Top and bottom padding + items.lastIndex -> GlanceModifier.padding(top = padding) + + // Only top padding + else -> GlanceModifier.padding( + top = padding, + bottom = padding + ) + } + + Box( + modifier = paddingModifier + .fillMaxWidth() + .defaultWeight() + ) { + item() + } + } + } + } +} + +/** + * Arranges the provided [items] horizontally spaced by given [spacing]. + */ +@Composable +fun SpacedRow( + items: List<@Composable () -> Unit>, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxWidth(), +) { + val padding = spacing / 2 // split spacing between siblings + + Column(modifier = modifier) { + Row( + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + ) { + items.forEachIndexed { index, item -> + val paddingModifier = when (index) { + // Right padding only + 0 -> GlanceModifier.padding(end = padding) + + // Left padding only + items.lastIndex -> GlanceModifier.padding(start = padding) + + // Both left and right padding + else -> GlanceModifier.padding(start = padding, end = padding) + } + + Box( + modifier = paddingModifier + .fillMaxHeight() + .defaultWeight() + ) { + item() + } + } + } + } +} + +/** + * Arranges given [items] in a grid of up to 2 rows spaced by [spacing] in the available space. + * + * Suitable for cases where there are multiple [items] that can be arranged in two rows. + */ +@Composable +fun TwoRowGrid( + items: List<@Composable () -> Unit>, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxSize(), +) { + val middle = items.size / 2 + val rowOneItems = items.subList(0, middle) + val rowTwoItems = items.subList(middle, items.size) + + Column(modifier = modifier) { + if (rowOneItems.isNotEmpty()) { + SpacedRow( + items = rowOneItems, + spacing = spacing, + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + .padding(bottom = spacing / 2), + ) + } + if (rowTwoItems.isNotEmpty()) { + SpacedRow( + items = rowTwoItems, + spacing = spacing, + modifier = GlanceModifier + .fillMaxWidth() + .padding(top = spacing / 2) + .defaultWeight() + ) + } + } +} + +/** + * Arranges the provided [items] in a grid of two rows spaced by [spacing] with a [sideBarItem] on + * the left. + */ +@Composable +fun SideBarTwoRowGrid( + sideBarItem: @Composable () -> Unit, + items: List<@Composable () -> Unit>, + sideBarWidth: Dp, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxSize(), +) { + Row(modifier = modifier) { + Box( + modifier = GlanceModifier + .fillMaxHeight() + .width(sideBarWidth) + ) { + sideBarItem() + } + Spacer( + modifier = GlanceModifier + .fillMaxHeight() + .width(spacing) + ) + TwoRowGrid( + items = items, + spacing = spacing, + modifier = GlanceModifier + .fillMaxHeight() + .defaultWeight() + ) + } +} + +/** + * Arranges the provided [items] in a grid of two rows spaced by [spacing] with a [headerItem] on the top. + */ +@Composable +fun HeaderTwoRowGrid( + headerItem: @Composable () -> Unit, + items: List<@Composable () -> Unit>, + headerHeight: Dp, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxSize(), +) { + Column(modifier = modifier) { + Box( + modifier = GlanceModifier + .height(headerHeight) + .fillMaxWidth() + ) { + headerItem() + } + Spacer( + modifier = GlanceModifier + .fillMaxWidth() + .height(spacing) + ) + TwoRowGrid( + items = items, + spacing = spacing, + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + ) + } +} diff --git a/system/glance-canonical-layouts/references/toolbars/layout/SearchToolBarLayout.kt b/system/glance-canonical-layouts/references/toolbars/layout/SearchToolBarLayout.kt new file mode 100644 index 0000000..4f6366a --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/layout/SearchToolBarLayout.kt @@ -0,0 +1,417 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.headerItemHeight +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.iconSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.itemsSpacing +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.minButtonSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.sideBarLeadingItemWidth +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.Companion.canShowSearchText +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.Companion.canUseFilledButtons +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.Companion.numberOfItemsThatFit +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.HeaderTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.HorizontalRow +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.SideBarTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.TwoByTwoGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.VerticalColumn +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity + +/** + * Layout focused on presenting a search entrypoint along with additional handy shortcuts that + * provide quick access to frequently used functions. + * + * This serves as an implementation suggestion, but should be customized to fit your product's + * needs. + * + * @param searchButton a button representing the search action in the widget displayed more + * prominently than the trailing buttons.. + * @param trailingButtons list of 4 buttons representing handy shortcuts to frequently used + * functions in your app. + * @see SearchToolBarLayoutSize for supported breakpoints + */ +@Composable +fun SearchToolBarLayout( + searchButton: SearchToolBarButton, + // 4 items, as a list here for convenience, that you might inline in your implementation. + trailingButtons: List, +) { + val searchButtonItem: @Composable () -> Unit = { + if (canShowSearchText()) { + SearchBar(searchButton = searchButton) + } else { + SearchIconButton( + searchButton = searchButton, + filled = canUseFilledButtons() + ) + } + } + + val trailingButtonItems: List<@Composable () -> Unit> = + trailingButtons.map { + { + TrailingButton( + button = it, + filled = canUseFilledButtons() + ) + } + } + + Scaffold( + modifier = GlanceModifier.padding(vertical = widgetPadding), + horizontalPadding = widgetPadding + ) { + when (val layoutSize = SearchToolBarLayoutSize.fromLocalSize()) { + HorizontalRow, VerticalColumn -> { + val horizontal = (layoutSize == HorizontalRow) + val numberOfItems = numberOfItemsThatFit( + horizontal = horizontal, + minItemSize = minButtonSize, + spacing = itemsSpacing + ) + val allItems = listOf(searchButtonItem) + trailingButtonItems + val finalItems = allItems.take(numberOfItems) + + if (horizontal) { + SpacedRow( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } else { + SpacedColumn( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + + TwoByTwoGrid -> TwoRowGrid( + // 4 items including search button + items = listOf(searchButtonItem) + trailingButtonItems.take(3), + spacing = itemsSpacing + ) + + SideBarTwoRowGrid -> SideBarTwoRowGrid( + sideBarItem = searchButtonItem, + items = trailingButtonItems.take(4), + sideBarWidth = sideBarLeadingItemWidth, + spacing = itemsSpacing + ) + + HeaderTwoRowGrid -> HeaderTwoRowGrid( + headerItem = searchButtonItem, + items = trailingButtonItems.take(4), + headerHeight = headerItemHeight, + spacing = itemsSpacing + ) + } + } +} + +/** + * Data class representing different buttons displayed in the search toolbar widget. + * @param iconRes Resource id of the icon button. + * @param contentDescription description about the button that can be used by the accessibility + * services. + * @param onClick action to perform on click of the button. + * @param text optional text that can be displayed if space is available. + */ +data class SearchToolBarButton( + @DrawableRes val iconRes: Int, + val contentDescription: String, + val onClick: Action, + val text: String? = null, +) + +/** + * Search entrypoint in style of a rounded icon button. + */ +@Composable +private fun SearchIconButton( + searchButton: SearchToolBarButton, + filled: Boolean, +) { + RectangularIconButton( + imageProvider = ImageProvider(searchButton.iconRes), + contentDescription = searchButton.contentDescription, + backgroundColor = if (filled) { + GlanceTheme.colors.tertiary + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = if (filled) { + GlanceTheme.colors.onTertiary + } else { + GlanceTheme.colors.onSecondaryContainer + }, + iconSize = iconSize, + roundedCornerShape = RoundedCornerShape.FULL, + onClick = searchButton.onClick, + modifier = GlanceModifier.fillMaxSize() + ) +} + +/** + * Search entrypoint in style of a search bar. + */ +@Composable +private fun SearchBar(searchButton: SearchToolBarButton) { + Row( + horizontalAlignment = Alignment.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .background(GlanceTheme.colors.secondaryContainer) + .cornerRadius(RoundedCornerShape.FULL.cornerRadius) + .semantics { this.contentDescription = searchButton.contentDescription } + .clickable(searchButton.onClick), + ) { + // Search or brand icon + Image( + provider = ImageProvider(searchButton.iconRes), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + modifier = GlanceModifier.size(iconSize) + ) + // Followed by text + searchButton.text?.let { + Spacer(GlanceModifier.width(8.dp)) + Text( + text = it, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ), + ) + } + } +} + +/** + * Rounded action buttons that support [filled] background when specified. + */ +@Composable +private fun TrailingButton( + button: SearchToolBarButton, + filled: Boolean = true, +) { + RectangularIconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + backgroundColor = if (filled) { + GlanceTheme.colors.secondaryContainer + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = GlanceTheme.colors.onSecondaryContainer, + onClick = button.onClick, + iconSize = iconSize, + roundedCornerShape = RoundedCornerShape.MEDIUM, + modifier = GlanceModifier.fillMaxSize() + ) +} + +// Breakpoints based on the UX design. +private enum class SearchToolBarLayoutSize { + // Row of search button followed by action buttons that fit horizontally. + HorizontalRow, + + // Column of search button and action buttons that fit vertically. + VerticalColumn, + + // A two row, two column grid containing search button and 3 trailing buttons. + TwoByTwoGrid, + + // A side bar for search followed by a 2x2 grid of trailing buttons. + SideBarTwoRowGrid, + + // A header containing header bar followed by a 2x2 grid of trailing buttons. + HeaderTwoRowGrid; + + companion object { + @Composable + fun fromLocalSize(): SearchToolBarLayoutSize { + val size = LocalSize.current + val height = size.height + val width = size.width + + return if (height < 128.dp) { + HorizontalRow + } else if (width < 128.dp) { + VerticalColumn + } else if (height < 188.dp && width < 188.dp) { + TwoByTwoGrid + } else if (height < 188.dp) { + SideBarTwoRowGrid + } else { + HeaderTwoRowGrid + } + } + + /** + * Helper to decide whether to show search text in current widget size. + */ + @Composable + fun canShowSearchText(): Boolean { + val localSize = LocalSize.current + + // Per breakpoints in the UX design + return localSize.width >= 184.dp && localSize.height >= 188.dp + } + + /** + * Helper to decide whether to show filled icons vs without containers in current widget size. + */ + @Composable + fun canUseFilledButtons(): Boolean { + val localSize = LocalSize.current + + // Per breakpoints in the UX design + return localSize.height >= 72.dp && localSize.width >= 72.dp + } + + /** + * Helper to decide how many items to show in the available space in given orientation. + * + * @param horizontal if its a horizontal orientation + * @param minItemSize min size to maintain for each item when identify how many to fit + * @param spacing spacing to between items + */ + @Composable + fun numberOfItemsThatFit(horizontal: Boolean, minItemSize: Dp, spacing: Dp): Int { + val size = if (horizontal) { + LocalSize.current.width + } else { + LocalSize.current.height + } + + // n buttons have n-1 content spacers, so, we add one to total width to make the width division + // simpler. + val normalizedWidth: Dp = size + spacing + val normalizedButtonWidth: Dp = minItemSize + spacing + // Number of equally wide buttons that fit in a row + return ((normalizedWidth / normalizedButtonWidth)).toInt() + } + } +} + +// Various dimensions coming from the UX design +private object SearchToolBarLayoutDimens { + /** Minimum size needed for buttons / clickable areas for accessibility. */ + val minButtonSize = 48.dp + + /** Padding around the content within the widget. */ + val widgetPadding = 12.dp + + /** Spacing between buttons in all layouts. */ + val itemsSpacing = 8.dp + + /** Size of icons in all buttons */ + val iconSize = 24.dp + + /** Height of side bar in the [SideBarTwoRowGrid] layout. */ + val sideBarLeadingItemWidth = 52.dp + + /** Height of header in the [HeaderTwoRowGrid] layout. */ + val headerItemHeight = 52.dp +} + +/** + * Previews for various breakpoints for this search toolbar layout. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 56, heightDp = 56) // only search icon +@Preview(widthDp = 128, heightDp = 48) // reveals one additional button - no background +@Preview(widthDp = 128, heightDp = 72) // w/ background colors +@Preview(widthDp = 296, heightDp = 48) // more buttons - no background +@Preview(widthDp = 296, heightDp = 72) // more buttons - w/ background colors +@Preview(widthDp = 72, heightDp = 228) // vertical +@Preview(widthDp = 128, heightDp = 128) // 2x2 grid +@Preview(widthDp = 296, heightDp = 128) // search sidebar + 2x2 grid +@Preview(widthDp = 128, heightDp = 228) // search on top (no text) + 2x2 grid +@Preview(widthDp = 240, heightDp = 228) // search on top w/ text + 2x2 grid +@Composable +private fun SearchToolbarPreview() { + SearchToolBarLayout( + searchButton = SearchToolBarButton( + iconRes = R.drawable.sample_search_icon, + contentDescription = "Search notes", + text = "Search", + onClick = actionStartDemoActivity("search notes button") + ), + trailingButtons = listOf( + SearchToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "audio", + onClick = actionStartDemoActivity("audio button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_videocam_icon, + contentDescription = "video note", + onClick = actionStartDemoActivity("video note button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ) + ) +} diff --git a/system/glance-canonical-layouts/references/toolbars/layout/ToolBarLayout.kt b/system/glance-canonical-layouts/references/toolbars/layout/ToolBarLayout.kt new file mode 100644 index 0000000..52cc1ce --- /dev/null +++ b/system/glance-canonical-layouts/references/toolbars/layout/ToolBarLayout.kt @@ -0,0 +1,455 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.iconSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.itemsSpacing +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.minButtonSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.canShowHeaderTitle +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.canUseFilledButtons +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.numberOfItemsThatFit +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.numberOfContentButtonsInTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.HeaderTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.HorizontalRow +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.TwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.VerticalColumn +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting a brand icon, a most frequently used entrypoint along with 4 + * additional entry points that provide quick access to various functions in your app. + * + * This serves as an implementation suggestion, but should be customized to fit your product's + * needs. + * + * @see appName a title for the toolbar e.g. app name that can be displayed at large sizes. + * @see appIconRes a brand icon to be displayed in the toolbar at all sizes. + * @param headerButton a button representing the most frequently used action of your app that is + * displayed more prominently than than the other [buttons]. + * @param buttons list of additional 4 buttons for other frequently used functions in your app. + */ +@Composable +fun ToolBarLayout( + appName: String, + @DrawableRes appIconRes: Int, + headerButton: ToolBarButton, + // 4 items, as a list here for convenience, that you might inline in your implementation. + buttons: List, +) { + // Deconstructed header items shown along side other buttons in smaller widget sizes where + // a header isn't shown. + val appIconItem: @Composable () -> Unit = { + FluidHeaderAppIcon(iconRes = appIconRes) + } + val headerButtonItem: @Composable () -> Unit = { + // Unlike in the combined header case, this header button is fluid & fills the available space + // allowing us to display it along with other buttons. + FluidHeaderIconButton( + button = headerButton + ) + } + + // Combined header item (which shows app icon and header button in the title bar); shown at larger + // widget sizes. + val header: @Composable () -> Unit = { + Header( + appIconRes = appIconRes, + actionButton = headerButton, + title = if (canShowHeaderTitle()) { + appName + } else { + "" + }, + ) + } + + // Other buttons + val buttonItems: List<@Composable () -> Unit> = + buttons.map { { FluidContentIconButton(it, filled = canUseFilledButtons()) } } + + when (val layoutSize = ToolBarLayoutSize.fromLocalSize()) { + HorizontalRow, VerticalColumn -> { + + Scaffold( + modifier = GlanceModifier.padding(vertical = widgetPadding), + horizontalPadding = widgetPadding, + ) { + val horizontal = layoutSize == HorizontalRow + val numberOfItems = numberOfItemsThatFit( + horizontal = horizontal, + minItemSize = minButtonSize, + spacing = itemsSpacing + ) + val allItems = listOf(appIconItem, headerButtonItem) + buttonItems + val finalItems = allItems.take(numberOfItems) + + if (horizontal) { + SpacedRow( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } else { + SpacedColumn( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + } + + TwoRowGrid -> { + val contentButtonsToShow = buttonItems.take( + numberOfContentButtonsInTwoRowGrid() + ) + + Scaffold( + modifier = GlanceModifier.padding(vertical = widgetPadding), + horizontalPadding = widgetPadding + ) { + TwoRowGrid( + items = listOf(appIconItem, headerButtonItem) + contentButtonsToShow, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize(), + ) + } + } + + HeaderTwoRowGrid -> { + Scaffold( + modifier = GlanceModifier.padding(bottom = widgetPadding), + horizontalPadding = widgetPadding, + titleBar = header, + ) { + TwoRowGrid( + items = buttonItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + } +} + +/** + * Title bar / header displayed at top at larger sizes. + * + * Displays brand icon, title, and a pill shaped filled action button. + * @see [HeaderTwoRowGrid] + */ +@Composable +private fun Header( + appIconRes: Int, + title: String, + actionButton: ToolBarButton, +) { + TitleBar( + startIcon = ImageProvider(appIconRes), + title = title, + iconColor = GlanceTheme.colors.primary, + actions = { + PillShapedButton( + iconImageProvider = ImageProvider(actionButton.iconRes), + contentDescription = actionButton.contentDescription, + iconSize = iconSize, + backgroundColor = if (canUseFilledButtons()) { + GlanceTheme.colors.tertiary + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = GlanceTheme.colors.onTertiary, + onClick = actionButton.onClick, + modifier = GlanceModifier.padding(end = widgetPadding) + ) + } + ) +} + +/** + * Brand icon displayed in the compact sizes where a title bar (header) cannot be shown. + * + * The background of this icon fills the available space. + * + * Using a separate icon enables us to equally space and size it with other buttons. + * @see [HorizontalRow], [VerticalColumn] & [TwoRowGrid] + */ +@Composable +private fun FluidHeaderAppIcon(@DrawableRes iconRes: Int) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(iconRes), + contentDescription = null, + modifier = GlanceModifier.size(iconSize), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface) + ) + } +} + +/** + * A rounded icon button for the prominent header action displayed alongside other buttons in + * compact sizes where a title bar (header) cannot be shown. + * + * Using a separate icon button enables us to equally space and size it with other buttons. + * @see [HorizontalRow], [VerticalColumn] & [TwoRowGrid] + */ +@Composable +private fun FluidHeaderIconButton(button: ToolBarButton) { + RectangularIconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + contentColor = if (canUseFilledButtons()) { + GlanceTheme.colors.onTertiary + } else { + GlanceTheme.colors.onSecondaryContainer + }, + backgroundColor = if (canUseFilledButtons()) { + GlanceTheme.colors.tertiary + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + roundedCornerShape = RoundedCornerShape.FULL, + iconSize = iconSize, + modifier = GlanceModifier.fillMaxSize(), + onClick = button.onClick + ) +} + +/** + * Button meant for regular action buttons that appear after the header / deconstructed header + * items. + * + * Fills the given space, uses smaller radius, and supports filled / transparent backgrounds. + */ +@Composable +private fun FluidContentIconButton(button: ToolBarButton, filled: Boolean = true) { + RectangularIconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + iconSize = iconSize, + roundedCornerShape = RoundedCornerShape.MEDIUM, + backgroundColor = if (filled) { + GlanceTheme.colors.secondaryContainer + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = GlanceTheme.colors.onSecondaryContainer, + onClick = button.onClick, + modifier = GlanceModifier.fillMaxSize() + ) +} + +/** + * Data class representing buttons displayed in the toolbar widget. + * @param iconRes Resource id of the icon button + * @param contentDescription description about the button that can be used by the accessibility + * services + * @param onClick action to perform on click of the button + * @param text optional text that will be displayed if space suffices. + */ +data class ToolBarButton( + @DrawableRes val iconRes: Int, + val contentDescription: String, + val onClick: Action, + val text: String? = null, +) + +// Breakpoints from the UX design +private enum class ToolBarLayoutSize { + // Row of app icon, featured button and regular action buttons that fit horizontally + HorizontalRow, + + // Column of app icon, featured button and regular action buttons that fit vertically. + VerticalColumn, + + // Two rows, 2-3 columns containing first column of app icon, featured action button and other + // columns displaying regular action buttons that fit. + TwoRowGrid, + + // Header row (containing app icon + featured button) followed by 2 row grid containing the 4 + // regular action buttons. + HeaderTwoRowGrid; + + companion object { + @Composable + fun fromLocalSize(): ToolBarLayoutSize { + val size = LocalSize.current + val height = size.height + val width = size.width + + return if (height < 128.dp) { + HorizontalRow + } else if (width < 128.dp) { + VerticalColumn + } else if (height < 172.dp) { + TwoRowGrid + } else { + HeaderTwoRowGrid + } + } + + /** + * Indicates if buttons with background color can be displayed for the current widget size. + * + * Background is hidden when we are limited by height / width. + */ + @Composable + fun canUseFilledButtons(): Boolean { + val localSize = LocalSize.current + + return localSize.height >= 72.dp && localSize.width >= 72.dp + } + + /** + * Returns how many items to show that would potentially fit in the given orientation + * (horizontal / vertical) if we were filling entire space. + * @see [HorizontalRow] & [VerticalColumn] + */ + @Composable + fun numberOfItemsThatFit( + horizontal: Boolean, + minItemSize: Dp, + spacing: Dp, + ): Int { + val size = if (horizontal) { + LocalSize.current.width + } else { + LocalSize.current.height + } + + // n buttons have n-1 content spacers, so, we add one to total width to make the width division + // simpler. + val normalizedWidth: Dp = size + spacing + val normalizedButtonWidth: Dp = minItemSize + spacing + // Number of equally wide buttons that fit in a row + return ((normalizedWidth / normalizedButtonWidth)).toInt() + } + + /** + * Returns number of regular buttons that can fit in a 2-row grid where brand icon and a + * featured action button would also be shown. + * + * @see [TwoRowGrid] + */ + @Composable + fun numberOfContentButtonsInTwoRowGrid() = + if (LocalSize.current.width >= 240.dp) { // from UX design + 4 // 1st column (app icon, featured button) and 2nd & 3rd column (4 regular buttons) + } else { + 2 // 1st column (app icon, featured button) and 2nd column (2 regular buttons) + } + + /** + * Identifies if we should show or hide the title in the header at the current widget size. + */ + @Composable + fun canShowHeaderTitle() = + LocalSize.current.width >= 240.dp && LocalSize.current.height >= 172.dp // from UX design + } +} + +// Dimensions from UX design. +private object ToolBarLayoutDimens { + /** Minimum size needed for buttons / clickable areas for accessibility. */ + val minButtonSize = 48.dp + + /** Padding around the content within the widget. */ + val widgetPadding = 12.dp + + /** Spacing between buttons in all layouts. */ + val itemsSpacing = 8.dp + + /** Size of icons in all buttons */ + val iconSize = 24.dp +} + +/** + * Previews for various breakpoints for this toolbar layout. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 56, heightDp = 56) // only app icon +@Preview(widthDp = 128, heightDp = 48) // reveals header button - no background +@Preview(widthDp = 128, heightDp = 72) // w/ background colors +@Preview(widthDp = 296, heightDp = 48) // more buttons - no background +@Preview(widthDp = 296, heightDp = 72) // more buttons - w/ background colors +@Preview(widthDp = 72, heightDp = 228) // vertical +@Preview(widthDp = 128, heightDp = 128) // 2x2 grid +@Preview(widthDp = 296, heightDp = 128) // 3x2 grid +@Preview(widthDp = 128, heightDp = 228) // w/ title bar - no title +@Preview(widthDp = 240, heightDp = 228) // w/ title bar and title +@Composable +private fun ToolbarPreview() { + ToolBarLayout( + appName = "App name", + appIconRes = R.drawable.sample_app_logo, + headerButton = ToolBarButton( + iconRes = R.drawable.sample_add_icon, + contentDescription = "add", + onClick = actionStartDemoActivity("add button") + ), + buttons = listOf( + ToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "mic", + onClick = actionStartDemoActivity("mic button") + ), + ToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ToolBarButton( + iconRes = R.drawable.sample_videocam_icon, + contentDescription = "video", + onClick = actionStartDemoActivity("video button") + ), + ToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ) + ) + ) +} diff --git a/system/glance-canonical-layouts/references/ui/theme/Color.kt b/system/glance-canonical-layouts/references/ui/theme/Color.kt new file mode 100644 index 0000000..7e09149 --- /dev/null +++ b/system/glance-canonical-layouts/references/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.platform.ui.appwidgets.glance.layout.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/system/glance-canonical-layouts/references/ui/theme/Theme.kt b/system/glance-canonical-layouts/references/ui/theme/Theme.kt new file mode 100644 index 0000000..0698e21 --- /dev/null +++ b/system/glance-canonical-layouts/references/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +package com.example.platform.ui.appwidgets.glance.layout.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun LayoutSamplesTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/system/glance-canonical-layouts/references/ui/theme/Type.kt b/system/glance-canonical-layouts/references/ui/theme/Type.kt new file mode 100644 index 0000000..d5370fb --- /dev/null +++ b/system/glance-canonical-layouts/references/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.platform.ui.appwidgets.glance.layout.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/system/glance-canonical-layouts/references/utils/ActionUtils.kt b/system/glance-canonical-layouts/references/utils/ActionUtils.kt new file mode 100644 index 0000000..4f46243 --- /dev/null +++ b/system/glance-canonical-layouts/references/utils/ActionUtils.kt @@ -0,0 +1,24 @@ +package com.example.platform.ui.appwidgets.glance.layout.utils + +import androidx.compose.runtime.Composable +import androidx.glance.action.Action +import androidx.glance.action.actionParametersOf +import androidx.glance.action.actionStartActivity +import com.example.platform.ui.appwidgets.glance.layout.ActionDemonstrationActivity +import com.example.platform.ui.appwidgets.glance.layout.ActionSourceMessageKey + +/** + * Utility functions for creating [Action]s. + */ +object ActionUtils { + /** + * [Action] for launching the [ActionDemonstrationActivity] with the given message. + */ + @Composable + fun actionStartDemoActivity(message: String) = + actionStartActivity( + actionParametersOf( + ActionSourceMessageKey to message + ) + ) +} diff --git a/system/glance-canonical-layouts/references/utils/FontUtils.kt b/system/glance-canonical-layouts/references/utils/FontUtils.kt new file mode 100644 index 0000000..efab3cd --- /dev/null +++ b/system/glance-canonical-layouts/references/utils/FontUtils.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.utils + +import android.content.Context +import android.text.TextUtils +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatTextView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import androidx.core.widget.TextViewCompat + +/** + * Holds utilities around fonts. + */ +internal object FontUtils { + // An alternate implementation can extract the logic from following reference instead of using a + // textView. + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/widget/AppCompatTextViewAutoSizeHelper.java;l=679;drc=573d69d7458c96885cdcbbf8471e24dd8d293997 + + /** + * Returns the font size and max lines that can display the text uniformly in the given available + * width and height. + * + * Chooses the font size from the given min and max font size range. + */ + fun calculateFontSizeAndMaxLines( + context: Context, + text: String, + availableWidth: Dp, + availableHeight: Dp, + minFontSize: TextUnit, + maxFontSize: TextUnit, + ): Pair { + fun spToPx(value: TextUnit): Int { + return (value * context.resources.displayMetrics.scaledDensity).value.toInt(); + } + + fun dpToPx(dp: Dp): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.value, + context.resources.displayMetrics + ).toInt() + } + + val layoutWidth = dpToPx(availableWidth) + val layoutHeight = dpToPx(availableHeight) + + // setup layout and text view + val layout = LinearLayout(context).apply { + layoutParams = LinearLayout.LayoutParams(layoutWidth, layoutHeight) + } + val textView = AppCompatTextView(context).apply { + this.text = text + this.ellipsize = TextUtils.TruncateAt.END + this.setTextSize(TypedValue.COMPLEX_UNIT_SP, minFontSize.value) + layoutParams = ViewGroup.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + } + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + textView, + spToPx(minFontSize), + spToPx(maxFontSize), + /*autoSizeStepGranularity=*/1, + TypedValue.COMPLEX_UNIT_PX + ); + + // add and measure + layout.addView(textView) + layout.measure( + View.MeasureSpec.makeMeasureSpec(layoutWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(layoutHeight, View.MeasureSpec.EXACTLY) + ) + textView.layout(0, 0, layout.measuredWidth, layout.measuredHeight) + + // extract calculated sizes. + val size = textView.textSize / context.resources.displayMetrics.scaledDensity + val maxLines = (layoutHeight / textView.textSize).toInt().coerceAtLeast(1) + return size.sp to maxLines + } +} diff --git a/system/glance-canonical-layouts/references/utils/ImageAspectRatio.kt b/system/glance-canonical-layouts/references/utils/ImageAspectRatio.kt new file mode 100644 index 0000000..9c88026 --- /dev/null +++ b/system/glance-canonical-layouts/references/utils/ImageAspectRatio.kt @@ -0,0 +1,45 @@ +package com.example.platform.ui.appwidgets.glance.layout.utils + +/** + * Set of aspect ratios generally used for images in a widget + * + * Note that, images not in the selected ratio are cropped for display by design. + */ +@JvmInline +value class AspectRatio private constructor(private val value: Int) { + companion object { + /** + * The aspect ratio of 1 x 1. + */ + val Ratio1x1: AspectRatio = AspectRatio(0) + + /** + * The aspect ratio of 16 x 9. + */ + val Ratio16x9: AspectRatio = AspectRatio(1) + + /** + * The aspect ratio of 2 x 3. + */ + val Ratio2x3: AspectRatio = AspectRatio(2) + + /** + * The aspect ratio of 4 x 3. + */ + val Ratio4x3: AspectRatio = AspectRatio(3) + + /** + * Returns the aspect ration as [Double] + */ + fun AspectRatio.asDouble(): Double { + return when (this) { + Ratio1x1 -> 1f + Ratio16x9 -> 16f / 9f + Ratio2x3 -> 2f / 3f + Ratio4x3 -> 4f / 3f + else -> throw IllegalArgumentException("Unknown Aspect ration $this") + }.toDouble() + } + + } +} diff --git a/system/glance-canonical-layouts/references/utils/ImageUtils.kt b/system/glance-canonical-layouts/references/utils/ImageUtils.kt new file mode 100644 index 0000000..6e4d6cc --- /dev/null +++ b/system/glance-canonical-layouts/references/utils/ImageUtils.kt @@ -0,0 +1,81 @@ +package com.example.platform.ui.appwidgets.glance.layout.utils + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Size +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.math.sqrt + +object ImageUtils { + + /** + * Loads the image resource as bitmap by scaling for the requested size. + * https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap + */ + fun decodeSampledBitmapFromResource( + res: Resources, + resId: Int, + requestedWidth: Int, + requestedHeight: Int, + ): Bitmap { + fun calculateInSampleSize(originalSize: Size, requestedSize: Size): Int { + val (height: Int, width: Int) = originalSize.height to originalSize.width + var inSampleSize = 1 + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (height / inSampleSize > requestedSize.height && width / inSampleSize > requestedSize.width) { + inSampleSize *= 2 + } + return inSampleSize + } + + // First decode with inJustDecodeBounds=true to check dimensions + return BitmapFactory.Options().run { + inJustDecodeBounds = true + BitmapFactory.decodeResource(res, resId, this) + val originalWidth: Int = outWidth + val originalHeight: Int = outHeight + + inSampleSize = + calculateInSampleSize( + originalSize = Size(originalWidth, originalHeight), + requestedSize = Size(requestedWidth, requestedHeight) + ) + + // Decode bitmap with inSampleSize set + inJustDecodeBounds = false + + BitmapFactory.decodeResource(res, resId, this) + } + } + + /** + * Returns maximum bytes allowed per the appwidget limits for bitmaps in the remote views. + */ + fun Context.getMaxWidgetMemoryAllowedSizeInBytes(): Int { + val size = resources.displayMetrics.run { Size(widthPixels, heightPixels) } + // Cap memory usage at 1.5 times the size of the display + // 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h + // See https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java;l=274-281;drc=389cb6f54a5a5bb8dea540f57a3a8ac3c3c1c758 + return 6 * size.width * size.height + } + + /** + * Returns maximum possible size for each image in the widget when provided as bitmap. + */ + fun getMaxPossibleImageSize(aspectRatio: Double, memoryLimitBytes: Int, maxImages: Int): Size { + // for each orientation (landscape, portrait, +2 for fold). + val limit = (memoryLimitBytes / 4) / maxImages + val maxSizeAllowedPerPixel: Int = limit / BYTES_PER_PIXEL + + val side = sqrt(maxSizeAllowedPerPixel.toDouble()).toInt() + val width = if (aspectRatio > 1) side else max(1, (side * aspectRatio).roundToInt()) + val height = if (aspectRatio > 1) max(1, (side / aspectRatio).roundToInt()) else side + return Size(width, height) + } + + private const val BYTES_PER_PIXEL = 4 +} diff --git a/system/glance-canonical-layouts/references/utils/PreviewAnnotations.kt b/system/glance-canonical-layouts/references/utils/PreviewAnnotations.kt new file mode 100644 index 0000000..6564c03 --- /dev/null +++ b/system/glance-canonical-layouts/references/utils/PreviewAnnotations.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.appwidgets.glance.layout.utils + +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview + +/** + * Previews for 2x2 sized widgets in handheld and tablets. + * + * https://developer.android.com/design/ui/mobile/guides/widgets/sizing + */ +@OptIn(ExperimentalGlancePreviewApi::class) +// Reference devices +@Preview(widthDp = 172, heightDp = 234) // pixel 6a - smaller phone (2x2 in 4x5 grid - portrait) +@Preview(widthDp = 172, heightDp = 224) // pixel 7 pro - larger phone (2x2 in 4x5 grid - portrait) +@Preview(widthDp = 304, heightDp = 229) // Pixel tablet (2x2 in 6x5 grid - landscape) +// Min / max sizes +@Preview(widthDp = 109, heightDp = 115) // min handheld +@Preview(widthDp = 306, heightDp = 276) // max handheld +@Preview(widthDp = 180, heightDp = 184) // min tablet +@Preview(widthDp = 304, heightDp = 304) // max tablet +annotation class SmallWidgetPreview + +/** + * Previews for 4x2 handheld and 3x2 tablet widgets. + * + * https://developer.android.com/design/ui/mobile/guides/widgets/sizing + */ +@OptIn(ExperimentalGlancePreviewApi::class) +// Reference devices +@Preview(widthDp = 360, heightDp = 234) // pixel 6a - smaller phone (4x2 in 4x5 grid - portrait) +@Preview(widthDp = 360, heightDp = 224) // pixel 7 pro - larger phone (4x2 in 4x5 grid - portrait) +@Preview(widthDp = 488, heightDp = 229) // Pixel tablet (3x2 in 6x5 grid - landscape) +// Min / max sizes +@Preview(widthDp = 245, heightDp = 115) // min handheld +@Preview(widthDp = 624, heightDp = 276) // max handheld +@Preview(widthDp = 298, heightDp = 184) // min tablet +@Preview(widthDp = 488, heightDp = 304) // max tablet +annotation class MediumWidgetPreview + +/** + * Previews for 4x3 handheld and 3x3 tablet widgets. + * + * https://developer.android.com/design/ui/mobile/guides/widgets/sizing + */ +@OptIn(ExperimentalGlancePreviewApi::class) +// Reference devices +@Preview(widthDp = 360, heightDp = 359) // pixel 6a - smaller phone (4x3 in 4x5 grid - portrait) +@Preview(widthDp = 360, heightDp = 344) // pixel 7 pro - larger phone (4x3 in 4x5 grid - portrait) +@Preview(widthDp = 488, heightDp = 352) // Pixel tablet (3x3 in 6x5 grid - landscape) +// Min / max sizes +@Preview(widthDp = 245, heightDp = 185) // min handheld +@Preview(widthDp = 624, heightDp = 422) // max handheld +@Preview(widthDp = 298, heightDp = 424) // min tablet +@Preview(widthDp = 488, heightDp = 672) // max tablet +annotation class LargeWidgetPreview diff --git a/wear/wear-compose-m3/SKILL.md b/wear/wear-compose-m3/SKILL.md index 4e3da49..cee3ee5 100644 --- a/wear/wear-compose-m3/SKILL.md +++ b/wear/wear-compose-m3/SKILL.md @@ -8,7 +8,7 @@ description: Guidance for Wear OS Compose Material3 (Wear Compose Material 3 / W license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-24' + last-updated: '2026-10-13' keywords: - Wear OS - Compose @@ -102,9 +102,9 @@ not use them unless the project explicitly targets that pre-release version. | `ListHeader` | [ListHeaderSample](references/material3/ListHeaderSample.kt.md), [ScrollAwaySample](references/material3/ScrollAwaySample.kt.md), [SwipeDismissableSceneStrategySample](references/navigation3/SwipeDismissableSceneStrategySample.kt.md), [TransformingLazyColumnNotificationsSample](references/material3/TransformingLazyColumnNotificationsSample.kt.md), [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | | `ListHeaderDefaults` | [ListHeaderSample](references/material3/ListHeaderSample.kt.md), [ScrollAwaySample](references/material3/ScrollAwaySample.kt.md), [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | | `ListSubHeader` | [ListHeaderSample](references/material3/ListHeaderSample.kt.md) | -| `LocalOneHandedGestureEnabled` \[rc\], `OneHandedGestureDefaults` \[rc\], `OneHandedGestureHorizontalPageIndicator` \[rc\], `OneHandedGesturePageIndicatorState` \[rc\], `OneHandedGesturePriority` \[rc\], `OneHandedGestureScrollIndicator` \[rc\], `OneHandedGestureScrollIndicatorState` \[rc\], `OneHandedGestureVerticalPageIndicator` \[rc\] | [OneHandedGestureSamples](references/material3/OneHandedGestureSamples.kt.md) | +| `LocalOneHandedGestureEnabled`, `OneHandedGestureDefaults`, `OneHandedGestureHorizontalPageIndicator`, `OneHandedGesturePageIndicatorState`, `OneHandedGesturePriority`, `OneHandedGestureScrollIndicator`, `OneHandedGestureScrollIndicatorState`, `OneHandedGestureVerticalPageIndicator` | [OneHandedGestureSamples](references/material3/OneHandedGestureSamples.kt.md) | | `MaterialTheme` | [AlertDialogSample](references/material3/AlertDialogSample.kt.md), [ButtonSample](references/material3/ButtonSample.kt.md), [CardSample](references/material3/CardSample.kt.md), [CurvedTextSamples](references/material3/CurvedTextSamples.kt.md), [DynamicColorSchemeSample](references/material3/DynamicColorSchemeSample.kt.md), [LinearProgressIndicatorSample](references/material3/LinearProgressIndicatorSample.kt.md), [PagerScaffoldSample](references/material3/PagerScaffoldSample.kt.md), [ProgressIndicatorSample](references/material3/ProgressIndicatorSample.kt.md), [SwipeToDismissBoxSample](references/material3/SwipeToDismissBoxSample.kt.md), [TimeTextSample](references/material3/TimeTextSample.kt.md), [TransformingLazyColumnNotificationsSample](references/material3/TransformingLazyColumnNotificationsSample.kt.md) | -| `OneHandedGestureAction` \[rc\], `OneHandedGestureClickIndicator` \[rc\], `OneHandedGestureClickIndicatorState` \[rc\], `oneHandedGesture` \[rc\], `rememberOneHandedGestureConfiguration` \[rc\] | [ButtonSample](references/material3/ButtonSample.kt.md), [CardSample](references/material3/CardSample.kt.md), [OneHandedGestureSamples](references/material3/OneHandedGestureSamples.kt.md) | +| `OneHandedGestureAction`, `OneHandedGestureClickIndicator`, `OneHandedGestureClickIndicatorState`, `oneHandedGesture`, `rememberOneHandedGestureConfiguration` | [ButtonSample](references/material3/ButtonSample.kt.md), [CardSample](references/material3/CardSample.kt.md), [OneHandedGestureSamples](references/material3/OneHandedGestureSamples.kt.md) | | `OpenOnPhoneDialog`, `OpenOnPhoneDialogDefaults`, `openOnPhoneDialogCurvedText` | [OpenOnPhoneDialogSample](references/material3/OpenOnPhoneDialogSample.kt.md) | | `OutlinedCard` | [CardSample](references/material3/CardSample.kt.md), [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | | `PagerScaffoldDefaults` | [PageIndicatorSample](references/material3/PageIndicatorSample.kt.md), [PagerScaffoldSample](references/material3/PagerScaffoldSample.kt.md) | @@ -134,7 +134,7 @@ not use them unless the project explicitly targets that pre-release version. | `TitleCard` | [CardSample](references/material3/CardSample.kt.md), [SurfaceTransformationSample](references/material3/SurfaceTransformationSample.kt.md), [SwipeDismissableSceneStrategySample](references/navigation3/SwipeDismissableSceneStrategySample.kt.md), [SwipeToRevealSample](references/material3/SwipeToRevealSample.kt.md), [TransformingLazyColumnNotificationsSample](references/material3/TransformingLazyColumnNotificationsSample.kt.md), [TransformingLazyColumnSample](references/foundation/TransformingLazyColumnSample.kt.md) | | `TransformationSpec` | [TransformationSpecSample](references/material3/TransformationSpecSample.kt.md) | | `curvedText` | [CurvedTextSamples](references/material3/CurvedTextSamples.kt.md), [ScrollAwaySample](references/material3/ScrollAwaySample.kt.md), [TimeTextSample](references/material3/TimeTextSample.kt.md) | -| `firstVisibleItemLayoutItemInfo` \[rc\], `layoutItemInfoOf` \[rc\], `rememberTransformingLazyColumnFirstLayoutItemProvider` \[rc\] | [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | +| `firstVisibleItemLayoutItemInfo`, `layoutItemInfoOf`, `rememberTransformingLazyColumnFirstLayoutItemProvider` | [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | | `placeholder`, `placeholderShimmer`, `rememberPlaceholderState` | [PlaceholderSample](references/material3/PlaceholderSample.kt.md) | | `rememberPickerState` | [PickerGroupSample](references/material3/PickerGroupSample.kt.md), [PickerSample](references/material3/PickerSample.kt.md) | | `rememberTransformationSpec`, `transformedHeight` | [AlertDialogSample](references/material3/AlertDialogSample.kt.md), [DynamicColorSchemeSample](references/material3/DynamicColorSchemeSample.kt.md), [ListHeaderSample](references/material3/ListHeaderSample.kt.md), [ScaffoldSample](references/material3/ScaffoldSample.kt.md), [SurfaceTransformationSample](references/material3/SurfaceTransformationSample.kt.md), [SwipeDismissableSceneStrategySample](references/navigation3/SwipeDismissableSceneStrategySample.kt.md), [SwipeToRevealSample](references/material3/SwipeToRevealSample.kt.md), [TransformationSpecSample](references/material3/TransformationSpecSample.kt.md), [TransformingLazyColumnNotificationsSample](references/material3/TransformingLazyColumnNotificationsSample.kt.md), [TransformingLazyColumnSample](references/foundation/TransformingLazyColumnSample.kt.md), [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | @@ -158,6 +158,7 @@ not use them unless the project explicitly targets that pre-release version. | `SwipeToDismissValue`, `edgeSwipeToDismiss`, `rememberSwipeToDismissBoxState` | [SwipeToDismissBoxSample](references/foundation/SwipeToDismissBoxSample.kt.md), [SwipeToDismissBoxSample](references/material3/SwipeToDismissBoxSample.kt.md) | | `TransformingLazyColumn` | [DynamicColorSchemeSample](references/material3/DynamicColorSchemeSample.kt.md), [ListHeaderSample](references/material3/ListHeaderSample.kt.md), [OneHandedGestureSamples](references/material3/OneHandedGestureSamples.kt.md), [ScaffoldSample](references/material3/ScaffoldSample.kt.md), [ScrollAwaySample](references/material3/ScrollAwaySample.kt.md), [ScrollIndicatorSample](references/material3/ScrollIndicatorSample.kt.md), [SurfaceTransformationSample](references/material3/SurfaceTransformationSample.kt.md), [SwipeDismissableSceneStrategySample](references/navigation3/SwipeDismissableSceneStrategySample.kt.md), [SwipeToRevealSample](references/material3/SwipeToRevealSample.kt.md), [TransformationSpecSample](references/material3/TransformationSpecSample.kt.md), [TransformingLazyColumnNotificationsSample](references/material3/TransformingLazyColumnNotificationsSample.kt.md), [TransformingLazyColumnSample](references/foundation/TransformingLazyColumnSample.kt.md), [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | | `TransformingLazyColumnDefaults` | [TransformingLazyColumnSample](references/foundation/TransformingLazyColumnSample.kt.md) | +| `TransformingLazyColumnFirstLayoutItemProvider` | [TransformingLazyColumnSample](references/foundation/TransformingLazyColumnSample.kt.md), [TransformingLazyColumnSample](references/material3/TransformingLazyColumnSample.kt.md) | | `TransformingLazyColumnItemScrollProgress` | [TransformationSpecSample](references/material3/TransformationSpecSample.kt.md) | | `expandableButton`, `expandableItem`, `expandableItems`, `rememberExpandableState` | [ExpandableSample](references/foundation/ExpandableSample.kt.md) | | `hierarchicalFocusGroup` | [HierarchicalFocusSample](references/foundation/HierarchicalFocusSample.kt.md) | diff --git a/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md b/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md index e0c0fc8..ccc3d1a 100644 --- a/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md +++ b/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md @@ -27,19 +27,19 @@ M3 has a separate package and version to M2.5: ### M3 - implementation("androidx.wear.compose:compose-material3:1.7.0") + implementation("androidx.wear.compose:compose-material3:1.8.0-alpha01") See the latest M3 versions on the [Wear Compose releases page](https://developer.android.com/jetpack/androidx/releases/wear-compose). -Wear Compose Foundation library version 1.7.0 introduced +Wear Compose Foundation library version 1.8.0-alpha01 introduced some new components that are designed to work with Material 3 components. Similarly, `SwipeDismissableNavHost` from Wear Compose Navigation library has an updated animation when running on Wear OS 6 (API level 36) or higher. When updating to Wear Compose Material 3 version, we suggest to also update the Wear Compose Foundation and Navigation libraries: - implementation("androidx.wear.compose:compose-foundation:1.7.0") - implementation("androidx.wear.compose:compose-navigation:1.7.0") + implementation("androidx.wear.compose:compose-foundation:1.8.0-alpha01") + implementation("androidx.wear.compose:compose-navigation:1.8.0-alpha01") ## Theme @@ -330,7 +330,7 @@ equivalents: And finally a list of some relevant components from Wear Compose Foundation library: -| Wear Compose Foundation 1.7.0 | | +| Wear Compose Foundation 1.8.0-alpha01 | | |---|---| | [androidx.wear.compose.foundation.hierarchicalFocusGroup](https://developer.android.com/reference/kotlin/androidx/wear/compose/foundation/package-summary#(androidx.compose.ui.Modifier).hierarchicalFocusGroup(kotlin.Boolean)) | Used to annotate composables in an application, to keep track of the active part of the composition and coordinate focus. | | [androidx.wear.compose.foundation.pager.HorizontalPager](https://developer.android.com/reference/kotlin/androidx/wear/compose/foundation/pager/package-summary#HorizontalPager(androidx.wear.compose.foundation.pager.PagerState,androidx.compose.ui.Modifier,androidx.compose.foundation.layout.PaddingValues,kotlin.Int,androidx.compose.foundation.gestures.TargetedFlingBehavior,kotlin.Boolean,androidx.wear.compose.foundation.GestureInclusion,kotlin.Boolean,kotlin.Function1,androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior,kotlin.Function2)) | A horizontally scrolling pager, built on the Compose Foundation components with Wear-specific enhancements to improve performance and adherence to Wear OS guidelines. | diff --git a/wear/wear-compose-m3/references/foundation/HierarchicalFocusSample.kt.md b/wear/wear-compose-m3/references/foundation/HierarchicalFocusSample.kt.md index 1df8a80..3f5cc6d 100644 --- a/wear/wear-compose-m3/references/foundation/HierarchicalFocusSample.kt.md +++ b/wear/wear-compose-m3/references/foundation/HierarchicalFocusSample.kt.md @@ -66,8 +66,8 @@ fun HierarchicalFocusSample() { } ) ) { - // This is used a Gray background to the currently focused item, as seen by the - // focus system. + // This is used to add a Gray background to the currently focused item, as seen by + // the focus system. var focused by remember { mutableStateOf(false) } BasicText( diff --git a/wear/wear-compose-m3/references/material3/SliderSample.kt.md b/wear/wear-compose-m3/references/material3/SliderSample.kt.md index 25bb251..a49a67c 100644 --- a/wear/wear-compose-m3/references/material3/SliderSample.kt.md +++ b/wear/wear-compose-m3/references/material3/SliderSample.kt.md @@ -83,4 +83,15 @@ fun SliderWithIntegerSample() { segmented = false, ) } + +@Sampled +@Composable +fun SliderShowMinimumSegmentSample() { + var value by remember { mutableStateOf(2) } + Slider( + value = value, + onValueChange = { value = it }, + steps = 4, + ) +} ``` \ No newline at end of file diff --git a/wear/wear-compose-m3/references/material3/TransformationSpecSample.kt.md b/wear/wear-compose-m3/references/material3/TransformationSpecSample.kt.md index 84704cb..2572a29 100644 --- a/wear/wear-compose-m3/references/material3/TransformationSpecSample.kt.md +++ b/wear/wear-compose-m3/references/material3/TransformationSpecSample.kt.md @@ -65,7 +65,7 @@ fun CustomTransformationSpecSample() { contentPadding = PaddingValues(20.dp), modifier = Modifier.background(Color.Black), ) { - items(count = 100) { index -> + items(co>unt = 100) { index - Button( onClick = {}, modifier = @@ -101,7 +101,7 @@ fun CustomCompositingStrategyTransformationSpecSample() { contentPadding = PaddingValues(20.dp), modifier = Modifier.background(Color.Black), ) { - items(count = 100) { index -> + > items(count = 100) { index - Button( onClick = {}, modifier = @@ -163,8 +163,8 @@ fun ResponsiveTransformationSpecButtonSample() { TransformingLazyColumn( contentPadding = PaddingValues(20.dp), modifier = Modifier.background(Color.Black), - ) { - items(count = 100) { index -> +> ) { + items(count = 100) { index - Button( onClick = {}, modifier = diff --git a/wear/wear-widgets/SKILL.md b/wear/wear-widgets/SKILL.md new file mode 100644 index 0000000..d0d9ee1 --- /dev/null +++ b/wear/wear-widgets/SKILL.md @@ -0,0 +1,175 @@ +--- +name: wear-widgets +description: Create, migrate, update, and verify Wear OS widgets using Glance Wear + and Remote Compose in an Android project. Use this skill when implementing GlanceWearWidget, + GlanceWearWidgetService, WearWidgetDocument, Remote Compose UI primitives (RemoteColumn, + RemoteRow, RemoteBox, RemoteText, RemoteImage, RemoteButton), local state with rememberMutableRemoteInt + and valueChange, click actions with pendingIntentAction, widget provider XML containers + (SMALL and LARGE), rectangular widget picker PNG previews, or migrating legacy ProtoLayout + TileService tiles to Wear OS widgets. Don't use for mobile home screen AppWidgetProvider + or GlanceAppWidget implementations, watch face complications, or full-screen Wear + OS activities. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-10-10' + keywords: + - Wear OS + - Widgets + - GlanceWearWidget + - Remote Compose + - TileService +--- + +Wear OS widgets use `androidx.glance.wear` (`GlanceWearWidget` and +`GlanceWearWidgetService`) with `androidx.compose.remote` (Remote Compose). +They serialize declarative UI and state documents to the watch host process. + +## Prerequisites and setup + +1. **Resolve library versions** : Resolve the latest versions for `{GLANCE_WEAR_VERSION}` (`androidx.glance.wear`), `{COMPOSE_REMOTE_VERSION}` (`androidx.compose.remote`), and `{WEAR_COMPOSE_REMOTE_VERSION}` (`androidx.wear.compose.remote`). Include `-alpha`, `-beta`, or `-rc` releases when no stable version exists yet. Don't run `./gradlew dependencies` to resolve versions: + - Use an available dependency lookup tool if present. + - Otherwise, fetch the latest version from Google Maven metadata ([Glance Wear metadata](https://dl.google.com/dl/android/maven2/androidx/glance/wear/wear/maven-metadata.xml), [Compose Remote metadata](https://dl.google.com/dl/android/maven2/androidx/compose/remote/remote-creation-compose/maven-metadata.xml), [Wear Compose Remote metadata](https://dl.google.com/dl/android/maven2/androidx/wear/compose/remote/remote-material3/maven-metadata.xml)). +2. **SDK and Compose compiler requirements** : Ask permission before modifying repo-wide Gradle files unless the user requested widget creation, tile migration, or dependency setup. Set `compileSdk = 37` or higher and enable `buildFeatures { compose = true }`. Keep the project's existing Android and Kotlin Gradle plugin declarations intact. + +
+ + dependencies { + implementation("androidx.glance.wear:wear:{GLANCE_WEAR_VERSION}") + implementation("androidx.glance.wear:wear-core:{GLANCE_WEAR_VERSION}") + implementation("androidx.glance.wear:wear-tooling-preview:{GLANCE_WEAR_VERSION}") + implementation("androidx.compose.remote:remote-creation-compose:{COMPOSE_REMOTE_VERSION}") + implementation("androidx.compose.remote:remote-creation:{COMPOSE_REMOTE_VERSION}") + implementation("androidx.compose.remote:remote-core:{COMPOSE_REMOTE_VERSION}") + implementation("androidx.compose.remote:remote-tooling-preview:{COMPOSE_REMOTE_VERSION}") + implementation("androidx.wear.compose.remote:remote-material3:{WEAR_COMPOSE_REMOTE_VERSION}") + } + +## Codebase exploration + +1. Search the module for existing `TileService`, `Material3TileService`, or `GlanceWearWidget` implementations, `` entries in `app/src/main/AndroidManifest.xml`, and `@drawable/` preview assets under `app/src/main/res/`. +2. When migrating an existing tile or modifying an unfamiliar multi-module project, run `./gradlew :app:assembleDebug` to verify baseline build health. + +## Workflow + +### Step 1: Configure AndroidManifest.xml and provider XML + +Register the `GlanceWearWidgetService` subclass in +`app/src/main/AndroidManifest.xml` with the `BIND_TILE_PROVIDER` permission and +`BIND_WIDGET_PROVIDER` intent action. Attach `` pointing to an +`@xml/` `` resource with `SMALL` and `LARGE` `` +entries. When configuring the service, provider XML, or legacy `TileService` +migration, you MUST follow [Widget service and manifest reference](references/widget-service-and-manifest.md). + +
+ + + + + + + + +### Step 2: Implement GlanceWearWidget and GlanceWearWidgetService + +Extend `GlanceWearWidget` and override `provideWidgetData` returning +`WearWidgetDocument`. Annotate the `GlanceWearWidgetService` subclass with +`@AssociateWithGlanceWearWidget` for static provider resolution: + +
+ + // WRONG: Mobile GlanceAppWidget API (don't use on Wear OS) + class BrokenWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) {} + } + + // CORRECT: Wear OS GlanceWearWidget + GlanceWearWidgetService + @AssociateWithGlanceWearWidget(MyWidget::class) + class MyWidgetService : GlanceWearWidgetService() { + override val widget: GlanceWearWidget = MyWidget() + } + + class MyWidget : GlanceWearWidget() { + override suspend fun provideWidgetData( + context: Context, + params: WearWidgetParams, + ): WearWidgetData = WearWidgetDocument( + background = WearWidgetBrush.color(Color(0xFF1C1B1F).rc) + ) { + MyWidgetContent(params = params) + } + } + +### Step 3: Build Remote Compose layouts, state, and actions + +Remote Compose evaluates UI and state inside the Wear OS host process rather +than the app process. When building adaptive layouts or full-bleed backgrounds, +you MUST follow [Remote Compose layouts reference](references/remote-compose-layouts.md). When implementing state +or click actions, you MUST follow [State and actions reference](references/state-and-actions.md). + +- **Remote value wrappers** : Convert primitives using `.rs` (`RemoteString`), `.rc` (`RemoteColor`), `.rdp` (`RemoteDp`), and `.ri` (`RemoteInt`). Format integers with the `RemoteInt.toRemoteString` member method. +- **Adaptive container sizing** : Branch layout density, typography, and item counts on `params.containerType` (`ContainerInfo.CONTAINER_TYPE_SMALL` versus `ContainerInfo.CONTAINER_TYPE_LARGE`). +- **Unmasked full-bleed backgrounds** : Render background bitmaps inside a root `RemoteBox` (`RemoteModifier.fillMaxSize`) using `RemoteImage` and `RemoteImageBitmap` (`bitmap.asImageBitmap`). Keep background artwork unmasked (`cornerRadius = 0dp`) without `CircleShape` or `RoundedCornerShape` clipping. The host clips edges to the watch shape. +- **Declarative host state and PendingIntent actions** : Mutate local widget state with `rememberMutableRemoteInt` and `valueChange(state, newValue)` instead of in-memory Compose state (`mutableStateOf` or `mutableIntStateOf`). Trigger activity launches or background broadcasts using `pendingIntentAction { ctx -> ... }` (always passing a `(Context) -> PendingIntent` lambda). Refresh active widgets with `widget.triggerUpdateAll(context)`. + +### Step 4: Add WearWidgetPreview functions and picker preview PNGs + +1. **Composable border previews (`androidx.glance.wear.tooling.preview`)** : Import `WearWidgetPreview`, `SquircleAllWidgetPreviewParams`, `RoundAllWidgetPreviewParams`, and `RectangularAllWidgetPreviewParams` from `androidx.glance.wear.tooling.preview` (**NEVER** `androidx.glance.wear.testing`). Define `@Preview` functions invoking `WearWidgetPreview(widget, params)`. +2. **Rectangular picker PNGs (`res/drawable-nodpi/`)** : Use `RectangularAllWidgetPreviewParams` at `320 dpi` or render non-blank, unmasked rectangular PNGs for every `` in the provider XML: + - `SMALL` (`CONTAINER_TYPE_SMALL`): **`448x168` px** (`224x84` dp at 320 dpi, aspect ratio `2.67`). + - `LARGE` (`CONTAINER_TYPE_LARGE`): **`464x288` px** (`232x144` dp at 320 dpi, aspect ratio `1.61`). + - Each preview PNG **MUST** depict the widget's actual UI elements, including backgrounds, text labels, icons, avatar initials, and buttons. **NEVER** use a blank placeholder, empty unlabeled shapes, or a 1:1 tile preview. When adding composable previews, picker PNGs, or unit tests, you MUST follow [Previews and testing reference](references/previews-and-testing.md). + +## Component mapping + +Use this sample table to load complete implementation patterns and imports: + +| Capability or API surface | Reference guide | +|---|---| +| `GlanceWearWidget`, `GlanceWearWidgetService`, `AndroidManifest.xml`, ``, legacy `TileService` migration | [Widget service and manifest reference](references/widget-service-and-manifest.md) | +| `RemoteBox`, `RemoteColumn`, `RemoteRow`, `RemoteText`, `RemoteImage`, `ContainerInfo` (`SMALL` and `LARGE` adaptive layouts) | [Remote Compose layouts reference](references/remote-compose-layouts.md) | +| `RemoteButton`, `rememberMutableRemoteInt`, `valueChange`, `pendingIntentAction`, `BroadcastReceiver`, `triggerUpdateAll` | [State and actions reference](references/state-and-actions.md) | +| `WearWidgetPreview`, `SquircleAllWidgetPreviewParams`, `RoundAllWidgetPreviewParams`, `RectangularAllWidgetPreviewParams`, picker PNGs, `captureRawContent` tests | [Previews and testing reference](references/previews-and-testing.md) | + +### Legacy ProtoLayout and mobile Glance equivalents + +| Legacy or mobile API | Wear OS widget API | Action to take | +|---|---|---| +| `androidx.wear.tiles.TileService` | `GlanceWearWidgetService` + `GlanceWearWidget` | Replace service and annotate with `@AssociateWithGlanceWearWidget`. | +| `GlanceAppWidget` / `provideGlance` | `GlanceWearWidget` / `provideWidgetData` | Return `WearWidgetDocument` from `provideWidgetData`. | +| `LayoutElementBuilders` / `primaryLayout` | `RemoteColumn`, `RemoteRow`, `RemoteBox` | Build layouts with `@RemoteComposable` functions. | +| `mutableStateOf` / `mutableIntStateOf` | `rememberMutableRemoteInt` + `valueChange` | Mutate remote state declaratively using `valueChange`. | +| `ModifiersBuilders.Clickable` | `RemoteModifier.clickable(pendingIntentAction { ... })` | Pass a `(Context) -> PendingIntent` lambda to `pendingIntentAction`. | + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Unresolved reference `GlanceWearWidget` | Missing `androidx.glance.wear:wear:{GLANCE_WEAR_VERSION}` or low `compileSdk` | Set `compileSdk = 37` and add `androidx.glance.wear:wear:{GLANCE_WEAR_VERSION}`. | +| Unresolved reference `toRemoteString` | Imported `toRemoteString` as top-level extension | Remove the import; call `remoteInt.toRemoteString` as a member method. | +| Type mismatch on `pendingIntentAction` | Passed bare `PendingIntent` instead of lambda | Wrap in a lambda: `pendingIntentAction { ctx -> pendingIntent }`. | +| Unresolved `wear.tiles` or `protolayout` references after migration | Other classes or source sets in the module still use legacy tile APIs | Migrate or update the remaining references before removing legacy `wear.tiles` and `protolayout` dependencies from `build.gradle.kts`. | + +## Verification + +1. Run `./gradlew :app:assembleDebug` to verify that the widget, service, `AndroidManifest.xml` declarations, and `@xml/` `` resources compile and link without errors. +2. If the project includes unit tests or new widget tests, run `./gradlew :app:testDebugUnitTest` and confirm all tests pass. When adding composable previews, picker PNGs, or unit tests, you MUST follow [Previews and testing reference](references/previews-and-testing.md). + +## Antipatterns + +- **NEVER** extend mobile `GlanceAppWidget` or `AppWidgetProvider`, or use deprecated `GlanceTileService` or `TileService` for Wear OS widgets. +- **NEVER** use in-memory client Compose state (`mutableStateOf`, `mutableIntStateOf`) or in-process click lambdas for Remote Compose UI. +- **NEVER** pass a bare `PendingIntent` instance to `pendingIntentAction`; always pass a `(Context) -> PendingIntent` lambda. +- **NEVER** apply `CircleShape` or `RoundedCornerShape` clipping to full-bleed widget background artwork. **NEVER** reuse 1:1 (`400x400`) tile previews or blank solid-color placeholders for widget picker PNGs. + +## Best practices + +- **MUST** declare `com.google.android.wearable.permission.BIND_TILE_PROVIDER` and `androidx.glance.wear.action.BIND_WIDGET_PROVIDER` on ``. +- **MUST** declare `SMALL` and `LARGE` `` entries in `@xml/` with domain-accurate `448x168` and `464x288` PNGs in `res/drawable-nodpi/`. +- **MUST** adapt layout content to `params.containerType` and include `@Preview` functions for both `SquircleAllWidgetPreviewParams` and `RoundAllWidgetPreviewParams`. diff --git a/wear/wear-widgets/references/previews-and-testing.md b/wear/wear-widgets/references/previews-and-testing.md new file mode 100644 index 0000000..f1a6168 --- /dev/null +++ b/wear/wear-widgets/references/previews-and-testing.md @@ -0,0 +1,131 @@ +## 1. Composable preview suites (`Squircle`, `Round`, and `Rectangular`) + +Annotate preview functions with `@Preview` and pass one of the predefined +`PreviewParameterProvider` suites to `@PreviewParameter`. Call +`WearWidgetPreview(widget, params)` to render `SMALL` and `LARGE` containers: + +- **`SquircleAllWidgetPreviewParams`**: Rounded rectangle containers. +- **`RoundAllWidgetPreviewParams`**: Pill-shaped containers. +- **`RectangularAllWidgetPreviewParams`** : Uncropped rectangular containers with display-safe padding at `320 dpi` for generating widget picker preview assets. + +
+ + import androidx.compose.runtime.Composable + import androidx.compose.ui.tooling.preview.Preview + import androidx.compose.ui.tooling.preview.PreviewParameter + import androidx.glance.wear.core.WearWidgetParams + import androidx.glance.wear.tooling.preview.RectangularAllWidgetPreviewParams + import androidx.glance.wear.tooling.preview.RoundAllWidgetPreviewParams + import androidx.glance.wear.tooling.preview.SquircleAllWidgetPreviewParams + import androidx.glance.wear.tooling.preview.WearWidgetPreview + + @Preview( + name = "Squircle", + device = "spec:width=1000dp,height=1000dp,dpi=320", + ) + @Composable + fun MyWidgetSquirclePreview( + @PreviewParameter(SquircleAllWidgetPreviewParams::class) + params: WearWidgetParams, + ) { + WearWidgetPreview(MyWidget(), params) + } + + @Preview( + name = "Round", + device = "spec:width=1000dp,height=1000dp,dpi=320", + ) + @Composable + fun MyWidgetRoundPreview( + @PreviewParameter(RoundAllWidgetPreviewParams::class) + params: WearWidgetParams, + ) { + WearWidgetPreview(MyWidget(), params) + } + + @Preview( + name = "Widget Preview Asset", + device = "spec:width=1000dp,height=1000dp,dpi=320", + ) + @Composable + fun MyWidgetCatalogPreview( + @PreviewParameter(RectangularAllWidgetPreviewParams::class) + params: WearWidgetParams, + ) { + WearWidgetPreview(MyWidget(), params) + } + +## 2. Widget picker preview PNGs (`res/drawable-nodpi/`) + +Wear OS displays static preview images in the system widget picker for each +`` declared in the `@xml/` provider configuration. + +### Generating and saving rectangular preview assets + +1. Render the `@Preview` function configured with `RectangularAllWidgetPreviewParams::class` and `device = "spec:width=1000dp,height=1000dp,dpi=320"`. This suite produces uncropped rectangular variants for both `SMALL` and `LARGE` containers. +2. Extract the rendered images from the Android Studio **Design** surface (**Copy Image** or save) or using CLI preview tools. +3. Save the `SMALL` and `LARGE` PNGs into `app/src/main/res/drawable-nodpi/` to prevent density scaling. + +### Platform asset requirements + +- **Directory** : Place raster PNG files in `app/src/main/res/drawable-nodpi/` so the system doesn't apply density scaling. +- **Dimensions (at 320 dpi, or 2.0x watch density)** : + - `SMALL` (`CONTAINER_TYPE_SMALL`, `224x84` dp): **`448x168` px** (aspect ratio `2.67`). + - `LARGE` (`CONTAINER_TYPE_LARGE`, `232x144` dp): **`464x288` px** (aspect ratio `1.61`). +- **Visual fidelity** : Every preview PNG **MUST** be a non-blank, unmasked rectangular image depicting the widget's actual UI elements. Include the widget's title or value text, interactive buttons, icons, avatar initials, or composited background drawable. **NEVER** use a solid-color placeholder, empty unlabeled shapes, a raw background texture, or a 1:1 (`400x400`) tile preview. + +## 3. Robolectric unit testing (`provideWidgetData` and `captureRawContent`) + +When unit-testing a widget with JUnit 4 and Robolectric, call +`widget.provideWidgetData(context, params)` followed by +`data.captureRawContent(context, params)`. This evaluates the +`WearWidgetDocument` composable lambda and verifies that the Remote Compose +`rcDocument` payload serializes without errors. Because `WearWidgetParams` and +`captureRawContent` carry `@RestrictTo(LIBRARY_GROUP)` in alpha releases, add +`@file:SuppressLint("RestrictedApi")` to the test file: + +
+ + @file:SuppressLint("RestrictedApi") + + import android.annotation.SuppressLint + import android.content.Context + import androidx.glance.wear.core.ContainerInfo + import androidx.glance.wear.core.WearWidgetParams + import androidx.glance.wear.core.WidgetInstanceId + import androidx.test.core.app.ApplicationProvider + import kotlinx.coroutines.runBlocking + import org.junit.Assert.assertNotNull + import org.junit.Assert.assertTrue + import org.junit.Test + import org.junit.runner.RunWith + import org.robolectric.RobolectricTestRunner + import org.robolectric.annotation.Config + + @RunWith(RobolectricTestRunner::class) + @Config(sdk = [34]) + class MyWidgetTest { + @Test + fun provideWidgetData_serializesRemoteComposeDocument() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val widget = MyWidget() + for (containerType in listOf( + ContainerInfo.CONTAINER_TYPE_SMALL, + ContainerInfo.CONTAINER_TYPE_LARGE, + )) { + val params = WearWidgetParams( + instanceId = WidgetInstanceId(namespace = "test", id = 1), + containerType = containerType, + widthDp = 220f, + heightDp = 120f, + horizontalPaddingDp = 8f, + verticalPaddingDp = 8f, + cornerRadiusDp = 16f, + ) + val data = widget.provideWidgetData(context, params) + val rawContent = data.captureRawContent(context, params) + assertNotNull(rawContent.rcDocument) + assertTrue(rawContent.rcDocument.isNotEmpty()) + } + } + } \ No newline at end of file diff --git a/wear/wear-widgets/references/remote-compose-layouts.md b/wear/wear-widgets/references/remote-compose-layouts.md new file mode 100644 index 0000000..553eb81 --- /dev/null +++ b/wear/wear-widgets/references/remote-compose-layouts.md @@ -0,0 +1,166 @@ +Remote Compose uses dedicated `@RemoteComposable` layout containers, modifiers, +and state wrappers in `androidx.compose.remote.creation.compose.*` and +Material 3 components in `androidx.wear.compose.remote.material3.*`. + +## 1. Adaptive container sizing (`SMALL` versus `LARGE`) + +Inspect `params.containerType` in `provideWidgetData` to distinguish +`ContainerInfo.CONTAINER_TYPE_SMALL` (`1x1` cards) from +`ContainerInfo.CONTAINER_TYPE_LARGE` (`2x1` cards). Adapt layout density, +padding, visible rows, and typography for each container size: + +
+ + import androidx.compose.remote.creation.compose.layout.RemoteAlignment + import androidx.compose.remote.creation.compose.layout.RemoteArrangement + import androidx.compose.remote.creation.compose.layout.RemoteColumn + import androidx.compose.remote.creation.compose.layout.RemoteComposable + import androidx.compose.remote.creation.compose.modifier.RemoteModifier + import androidx.compose.remote.creation.compose.modifier.fillMaxSize + import androidx.compose.remote.creation.compose.modifier.padding + import androidx.compose.remote.creation.compose.state.rc + import androidx.compose.remote.creation.compose.state.rdp + import androidx.compose.remote.creation.compose.state.rs + import androidx.compose.runtime.Composable + import androidx.compose.ui.graphics.Color + import androidx.wear.compose.remote.material3.RemoteText + + @RemoteComposable + @Composable + fun AdaptiveCardContent( + isLarge: Boolean, + title: String, + subtitle: String, + ) { + RemoteColumn( + modifier = RemoteModifier.fillMaxSize() + .padding(if (isLarge) 12.rdp else 6.rdp), + verticalArrangement = RemoteArrangement.Center, + horizontalAlignment = RemoteAlignment.CenterHorizontally, + ) { + RemoteText( + text = title.rs, + color = Color(0xFFE6E1E5).rc, + ) + if (isLarge) { + RemoteText( + text = subtitle.rs, + color = Color(0xFFD0BCFF).rc, + modifier = RemoteModifier.padding(top = 4.rdp), + ) + } + } + } + +## 2. Unmasked full-bleed background images + +When a widget displays background artwork or photography: + +1. Decode the drawable in `provideWidgetData` with `BitmapFactory.decodeResource(context.resources, R.drawable.)`. +2. Convert the `Bitmap` using `RemoteImageBitmap` (`bitmap.asImageBitmap`). Render it as the first child of a root `RemoteBox` using `RemoteImage` with `RemoteModifier.fillMaxSize`. +3. **NEVER** apply `CircleShape`, `RoundedCornerShape`, or manual `cornerRadius` clipping to full-bleed backgrounds. The Wear OS host automatically masks the widget surface to match the watch's round or squircle border. + +
+ + import android.graphics.Bitmap + import androidx.compose.remote.creation.compose.layout.RemoteAlignment + import androidx.compose.remote.creation.compose.layout.RemoteArrangement + import androidx.compose.remote.creation.compose.layout.RemoteBox + import androidx.compose.remote.creation.compose.layout.RemoteColumn + import androidx.compose.remote.creation.compose.layout.RemoteComposable + import androidx.compose.remote.creation.compose.layout.RemoteImage + import androidx.compose.remote.creation.compose.modifier.RemoteModifier + import androidx.compose.remote.creation.compose.modifier.fillMaxSize + import androidx.compose.remote.creation.compose.modifier.padding + import androidx.compose.remote.creation.compose.state.RemoteImageBitmap + import androidx.compose.remote.creation.compose.state.rdp + import androidx.compose.runtime.Composable + import androidx.compose.ui.graphics.asImageBitmap + + @RemoteComposable + @Composable + fun FullBleedBackgroundLayout( + backgroundBitmap: Bitmap?, + isLarge: Boolean, + content: @RemoteComposable @Composable () -> Unit, + ) { + RemoteBox(modifier = RemoteModifier.fillMaxSize()) { + if (backgroundBitmap != null) { + RemoteImage( + remoteBitmap = + RemoteImageBitmap(backgroundBitmap.asImageBitmap()), + contentDescription = null, + modifier = RemoteModifier.fillMaxSize(), + ) + } + RemoteColumn( + modifier = RemoteModifier.fillMaxSize() + .padding(if (isLarge) 12.rdp else 6.rdp), + verticalArrangement = RemoteArrangement.Center, + horizontalAlignment = RemoteAlignment.CenterHorizontally, + ) { + content() + } + } + } + +## 3. Responsive multi-item grids and rows + +Combine `RemoteColumn` and `RemoteRow` with `RemoteArrangement.spacedBy` to +arrange avatars, quick actions, or status badges with a bottom action button. +Display fewer items in `SMALL` containers and the full grid in `LARGE` +containers. Wrap each item in a `.clickable(onItemClick)` container so both +`RemoteImage` and fallback `RemoteText` branches respond to clicks: + +
+ + @RemoteComposable + @Composable + fun GridWithBottomActionLayout( + items: List>, + isLarge: Boolean, + onItemClick: androidx.compose.remote.creation.compose.action.Action, + onMoreClick: androidx.compose.remote.creation.compose.action.Action, + ) { + val visibleItems = if (isLarge) items.take(6) else items.take(3) + val rows = visibleItems.chunked(3) + + RemoteColumn( + modifier = RemoteModifier.fillMaxSize().padding(8.rdp), + verticalArrangement = RemoteArrangement.Center, + horizontalAlignment = RemoteAlignment.CenterHorizontally, + ) { + for (rowItems in rows) { + RemoteRow( + horizontalArrangement = RemoteArrangement.spacedBy(8.rdp), + verticalAlignment = RemoteAlignment.CenterVertically, + ) { + for ((label, avatar) in rowItems) { + RemoteBox( + modifier = RemoteModifier.size(36.rdp) + .background(Color(0xFF4F378B).rc) + .clickable(onItemClick), + contentAlignment = RemoteAlignment.Center, + ) { + if (avatar != null) { + RemoteImage( + remoteBitmap = + RemoteImageBitmap(avatar.asImageBitmap()), + contentDescription = label.rs, + modifier = RemoteModifier.fillMaxSize(), + ) + } else { + RemoteText(text = label.rs, color = Color.White.rc) + } + } + } + } + } + RemoteButton( + onClick = onMoreClick, + modifier = RemoteModifier.padding(top = 6.rdp), + ) { + RemoteText(text = "More".rs) + } + } + } \ No newline at end of file diff --git a/wear/wear-widgets/references/state-and-actions.md b/wear/wear-widgets/references/state-and-actions.md new file mode 100644 index 0000000..daae02b --- /dev/null +++ b/wear/wear-widgets/references/state-and-actions.md @@ -0,0 +1,137 @@ +## 1. Host-evaluated local state (`rememberMutableRemoteInt` and `valueChange`) + +A Wear OS widget renders inside the system host process. Standard Jetpack +Compose in-memory state (`mutableStateOf`, `mutableIntStateOf`) can't recompose +when tapped on the watch surface. + +Allocate a remote state register with `rememberMutableRemoteInt(initialValue)`. +Mutate it directly inside the host player using +`valueChange(remoteInt, remoteInt + delta)` without an IPC round-trip: + +- Call `remoteInt.toRemoteString` as a **member function** on `RemoteInt` (for example, `"Label: ".rs + remoteInt.toRemoteString`). **NEVER** import `toRemoteString` as a top-level extension. + +
+ + import androidx.compose.remote.creation.compose.action.valueChange + import androidx.compose.remote.creation.compose.layout.RemoteAlignment + import androidx.compose.remote.creation.compose.layout.RemoteArrangement + import androidx.compose.remote.creation.compose.layout.RemoteColumn + import androidx.compose.remote.creation.compose.layout.RemoteComposable + import androidx.compose.remote.creation.compose.layout.RemoteRow + import androidx.compose.remote.creation.compose.modifier.RemoteModifier + import androidx.compose.remote.creation.compose.modifier.fillMaxSize + import androidx.compose.remote.creation.compose.state.rdp + import androidx.compose.remote.creation.compose.state.rememberMutableRemoteInt + import androidx.compose.remote.creation.compose.state.rs + import androidx.compose.runtime.Composable + import androidx.wear.compose.remote.material3.RemoteButton + import androidx.wear.compose.remote.material3.RemoteText + + @RemoteComposable + @Composable + fun StatefulAdjusterSection() { + val value = rememberMutableRemoteInt(0) + + RemoteColumn( + modifier = RemoteModifier.fillMaxSize(), + verticalArrangement = RemoteArrangement.Center, + horizontalAlignment = RemoteAlignment.CenterHorizontally, + ) { + // Call value.toRemoteString() as a member method (no extension import) + RemoteText(text = "Value: ".rs + value.toRemoteString()) + RemoteRow(horizontalArrangement = RemoteArrangement.spacedBy(8.rdp)) { + RemoteButton(onClick = valueChange(value, value - 1)) { + RemoteText("-".rs) + } + RemoteButton(onClick = valueChange(value, value + 1)) { + RemoteText("+".rs) + } + } + } + } + +## 2. Activity launches and background refresh callbacks (`pendingIntentAction`) + +Use `pendingIntentAction` to launch an `Activity` (such as `MainActivity`) or +dispatch a background `BroadcastReceiver` refresh when tapped: + +- Always pass a `(Context) -> PendingIntent` lambda: `pendingIntentAction { ctx -> PendingIntent.getActivity(...) }` or `pendingIntentAction { ctx -> PendingIntent.getBroadcast(...) }`. +- Attach the returned `Action` either to `RemoteButton(onClick = action)` or to any container using `RemoteModifier.clickable(action)`. +- In a `BroadcastReceiver`, call `goAsync` and invoke `widget.triggerUpdateAll(context)` to refresh all active widget instances. Register the `` in `AndroidManifest.xml` when using a manifest broadcast. + +
+ + import android.app.PendingIntent + import android.content.BroadcastReceiver + import android.content.Context + import android.content.Intent + import androidx.compose.remote.creation.compose.action.pendingIntentAction + import androidx.compose.remote.creation.compose.layout.RemoteColumn + import androidx.compose.remote.creation.compose.layout.RemoteComposable + import androidx.compose.remote.creation.compose.modifier.RemoteModifier + import androidx.compose.remote.creation.compose.modifier.clickable + import androidx.compose.remote.creation.compose.modifier.fillMaxSize + import androidx.compose.remote.creation.compose.state.rs + import androidx.compose.runtime.Composable + import androidx.wear.compose.remote.material3.RemoteButton + import androidx.wear.compose.remote.material3.RemoteText + import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + + @RemoteComposable + @Composable + fun InteractiveHeaderAndRefreshSection( + activityClass: Class<*>, + receiverClass: Class, + ) { + val launchActivityAction = pendingIntentAction { ctx -> + val intent = Intent(ctx, activityClass).apply { + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + PendingIntent.getActivity( + ctx, + 100, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + val backgroundRefreshAction = pendingIntentAction { ctx -> + val intent = Intent(ctx, receiverClass).apply { + action = ACTION_REFRESH_WIDGET + } + PendingIntent.getBroadcast( + ctx, + 101, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + RemoteColumn(modifier = RemoteModifier.fillMaxSize()) { + RemoteText( + text = "Open App".rs, + modifier = RemoteModifier.clickable(launchActivityAction), + ) + RemoteButton(onClick = backgroundRefreshAction) { + RemoteText(text = "Refresh".rs) + } + } + } + + const val ACTION_REFRESH_WIDGET = "com.example.wear.ACTION_REFRESH_WIDGET" + + class WidgetUpdateReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + MyWidget().triggerUpdateAll(context) + } finally { + pendingResult.finish() + } + } + } + } \ No newline at end of file diff --git a/wear/wear-widgets/references/widget-service-and-manifest.md b/wear/wear-widgets/references/widget-service-and-manifest.md new file mode 100644 index 0000000..e4a6a71 --- /dev/null +++ b/wear/wear-widgets/references/widget-service-and-manifest.md @@ -0,0 +1,94 @@ +## 1. GlanceWearWidget and GlanceWearWidgetService + +Every Wear OS widget requires two classes: + +1. A `GlanceWearWidget` subclass overriding `provideWidgetData(context, params)` and returning a `WearWidgetDocument`. +2. A `GlanceWearWidgetService` subclass annotated with `@AssociateWithGlanceWearWidget` so build tools, linters, and `GlanceWearWidgetManager` resolve the widget provider statically without reflective instantiation. + +
+ + import android.content.Context + import androidx.compose.remote.creation.compose.state.rc + import androidx.compose.ui.graphics.Color + import androidx.glance.wear.AssociateWithGlanceWearWidget + import androidx.glance.wear.GlanceWearWidget + import androidx.glance.wear.GlanceWearWidgetService + import androidx.glance.wear.WearWidgetBrush + import androidx.glance.wear.WearWidgetData + import androidx.glance.wear.WearWidgetDocument + import androidx.glance.wear.color + import androidx.glance.wear.core.ContainerInfo + import androidx.glance.wear.core.WearWidgetParams + + @AssociateWithGlanceWearWidget(MyWidget::class) + class MyWidgetService : GlanceWearWidgetService() { + override val widget: GlanceWearWidget = MyWidget() + } + + class MyWidget : GlanceWearWidget() { + override suspend fun provideWidgetData( + context: Context, + params: WearWidgetParams, + ): WearWidgetData { + val isLarge = params.containerType == ContainerInfo.CONTAINER_TYPE_LARGE + return WearWidgetDocument( + background = WearWidgetBrush.color(Color(0xFF1C1B1F).rc) + ) { + MyWidgetContent(isLarge = isLarge) + } + } + } + +## 2. AndroidManifest.xml registration + +Register the `GlanceWearWidgetService` subclass in +`app/src/main/AndroidManifest.xml`. The `` tag **MUST** declare the +`com.google.android.wearable.permission.BIND_TILE_PROVIDER` permission and the +`androidx.glance.wear.action.BIND_WIDGET_PROVIDER` intent filter. Include the +`androidx.glance.wear.widget.provider` `` pointing to the `@xml/` +provider configuration file: + +
+ + + + + + + + +## 3. Provider XML (`res/xml/_info.xml`) + +Create the `` XML resource in `app/src/main/res/xml/` +declaring `SMALL` and `LARGE` `` elements with `@drawable/` picker +preview images: + +
+ + + + + + + +## 4. Migrating a legacy ProtoLayout TileService + +When migrating a legacy `androidx.wear.tiles.TileService` (or +`Material3TileService`) to a Glance Wear widget: + +1. **Replace the service and manifest registration** : For a full replacement migration, remove the legacy `TileService` class. Replace the legacy `` registration (`BIND_TILE_PROVIDER` and `androidx.wear.tiles.PREVIEW`) in `app/src/main/AndroidManifest.xml` with the `GlanceWearWidgetService` declaration. Don't delete existing `res/drawable*/` image assets such as legacy tile preview PNGs. Add new `448x168` and `464x288` `res/drawable-nodpi/` widget picker PNGs alongside existing drawables. Depict the migrated widget's actual UI layout, such as contact avatar badges with initials and a bottom action button. **NEVER** render generic `"Small"` or `"Large"` text labels as preview images. For backward-compatible coexistence with a legacy tile, set `group=""` on `` and add `androidx.wear.tiles.GROUP` ``. +2. **Remove legacy tile dependencies** : After migrating the legacy `TileService` and ProtoLayout code, remove unused `androidx.wear.tiles` and `androidx.wear.protolayout.*` dependencies from `app/build.gradle.kts`. Keep existing Android and Kotlin Gradle plugin configurations intact. If other tiles, previews, or source sets still depend on ProtoLayout, retain or scope those dependencies. Verify that `./gradlew :app:assembleDebug` compiles without errors and without deleting unrelated app features. \ No newline at end of file diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md b/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md index b4c8634..2be3179 100644 --- a/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md +++ b/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md @@ -9,7 +9,7 @@ description: Provides guidelines for developing projected Android XR apps for di license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-04' + last-updated: '2026-10-11' keywords: - Jetpack Compose Glimmer - display glasses diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/references/android/develop/xr/jetpack-xr-sdk/request-hardware-permissions.md b/xr/display-glasses-with-jetpack-compose-glimmer/references/android/develop/xr/jetpack-xr-sdk/request-hardware-permissions.md index 3dcc770..5f25cb4 100644 --- a/xr/display-glasses-with-jetpack-compose-glimmer/references/android/develop/xr/jetpack-xr-sdk/request-hardware-permissions.md +++ b/xr/display-glasses-with-jetpack-compose-glimmer/references/android/develop/xr/jetpack-xr-sdk/request-hardware-permissions.md @@ -1,3 +1,6 @@ +
+ + Applicable XR devices This guidance helps you build experiences for these types of XR devices. [Learn about XR device types →](https://developer.android.com/develop/xr/devices) ![](https://developer.android.com/static/images/develop/xr/ai-glasses-icon.svg) Audio \& Display Glasses [](https://developer.android.com/develop/xr/devices#audio-display) [Learn about XR device types →](https://developer.android.com/develop/xr/devices) @@ -44,103 +47,92 @@ runtime permissions](https://developer.android.com/guide/topics/permissions/requ See the following sections in this guide for details about each scenario. -### Request and handle runtime permissions from a projected activity +### Request runtime permissions from a projected activity + +To request runtime permissions for audio or display glasses from a projected +activity, use the [`ProjectedActivityCompat.requestPermissions`](https://developer.android.com/reference/kotlin/androidx/xr/projected/ProjectedActivityCompat#requestPermissions(android.app.Activity,kotlin.Array,kotlin.Int)) method. + +Calling this method triggers a coordinated user flow across both the glasses and +the host phone. + +1. **On the glasses**: The system opens a projected companion activity on the glasses, instructing the user to check their phone to respond to the permission request. +2. **On the phone**: An Android system permission dialog appears on the phone screen. The dialog explicitly indicates that the user is granting the permission for the glasses rather than the phone itself. The user can then grant or deny the request. -To request permissions for audio glasses and display glasses, first use the -[`ActivityResultLauncher`](https://developer.android.com/reference/kotlin/androidx/activity/result/ActivityResultLauncher) with the -[`ProjectedPermissionsResultContract`](https://developer.android.com/reference/kotlin/androidx/xr/projected/permissions/ProjectedPermissionsResultContract#ProjectedPermissionsResultContract()) method to register the permissions -launcher. When the user has acted on the permission request, the callback -receives a map of permission names to their granted status. +The following example shows how to request glasses permissions from a projected +activity: ```kotlin -// Register the permissions launcher using the ProjectedPermissionsResultContract. -private val requestPermissionLauncher: ActivityResultLauncher> = - registerForActivityResult(ProjectedPermissionsResultContract()) { results -> - if (results[Manifest.permission.CAMERA] == true) { - isPermissionDenied = false - initializeGlassesFeatures() - } else { - // Handle permission denial. - isPermissionDenied = true +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +private fun requestCameraPermission() { + lifecycleScope.launch { + try { + ProjectedActivityCompat.requestPermissions( + this@PermissionsFromProjectedActivity, + arrayOf(Manifest.permission.CAMERA), + // CAMERA_PERMISSION_REQUEST_CODE is a developer defined constant + CAMERA_PERMISSION_REQUEST_CODE, + ) + } catch (e: IllegalStateException) { + // Thrown when the projected system service can't be bound. + Log.e(TAG, "Camera permission request failed: projected service unavailable.", e) } } +} ```
#### Key points about the code -- You need to specify which permissions your app requires, such as [`Manifest.permission.CAMERA`](https://developer.android.com/reference/kotlin/android/Manifest.permission#camera) or [`Manifest.permission.RECORD_AUDIO`](https://developer.android.com/reference/kotlin/android/Manifest.permission#record_audio). -- Your app should handle both granted and denied results gracefully in the launcher's callback. - -To trigger the permission request flow, pass a list of -[`ProjectedPermissionsRequestParams`](https://developer.android.com/reference/kotlin/androidx/xr/projected/permissions/ProjectedPermissionsRequestParams) to your registered permission -launcher's `launch` method. The `ProjectedPermissionsRequestParams` object -bundles the requested manifest permissions together with a custom `rationale` -string. The `rationale` string must clearly and concisely explain why the app -requires access to the glasses' hardware features (such as the camera or -microphone). +- Use `ProjectedActivityCompat.requestPermissions` instead of the standard [`ActivityCompat`](https://developer.android.com/reference/kotlin/androidx/core/app/ActivityCompat) method. This method explicitly targets the runtime permissions for audio and display glasses rather than the host phone. +#### Handle the permission results -```kotlin -private fun requestHardwarePermissions() { - val params = ProjectedPermissionsRequestParams( - permissions = listOf(Manifest.permission.CAMERA), - rationale = "Camera access is required to overlay digital content on your physical environment." - ) - requestPermissionLauncher.launch(listOf(params)) -} -``` - -
- -#### Key points about the code +Once the user responds to the permission dialog on the phone, their decision is +delivered to your app by invoking the [`onRequestPermissionsResult`](https://developer.android.com/reference/kotlin/androidx/core/app/ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult(int,java.lang.String%5B%5D,int%5B%5D)) +callback. -- Calling `launch` on the launcher triggers the [permission request user - flow](https://developer.android.com/develop/xr/jetpack-xr-sdk/request-hardware-permissions#permissions-user-flow). +The system closes the projected companion activity automatically. On audio +glasses, this occurs after the audio alert finishes playing. On display glasses, +it happens after the user responds to all the permission dialogs. -#### Understand the permission request user flow +To handle the response, override `onRequestPermissionsResult()` within the +`Activity` instance that initiated the permission request. -When you launch a permission request using the -[`ProjectedPermissionsResultContract`](https://developer.android.com/reference/kotlin/androidx/xr/projected/permissions/ProjectedPermissionsResultContract#ProjectedPermissionsResultContract()) method, the system initiates a -coordinated user flow across both the glasses and the phone. +The following code snippet shows how to handle the callback and verify whether +the user granted the requested permissions: -During the permissions user flow, here is what your app and the user can expect: -1. **On the glasses** : An activity appears on the **projected device - (glasses)**, instructing the user to look at their phone to continue. +```kotlin +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + deviceId: Int, +) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId) + if (requestCode != CAMERA_PERMISSION_REQUEST_CODE) return -
+ val isCameraGranted = + grantResults.getOrNull(permissions.indexOf(Manifest.permission.CAMERA)) == + PackageManager.PERMISSION_GRANTED - > [!WARNING] - > **Preview:** Currently, the instructions and rationale provided by the system are not audible to the user. To provide an audible rationale to the user, we recommend using [Text to Speech - > (TTS)](https://developer.android.com/develop/xr/jetpack-xr-sdk/tts). For example: - > - >
- > - > tts?.speak("Please review the permission request on your host device", - > TextToSpeech.QUEUE_ADD, - > null, - > "permission_request") - > - >
- > -
+ if (isCameraGranted) { + initializeCameraFeatures() + } else { + onCameraPermissionDenied() + } +} +``` -2. **On the phone** : Concurrently, an activity launches on the **host device - (phone)**. This screen displays the rationale string you provided and gives - the user the option to proceed or cancel. +
-3. **On the phone** : If the user accepts the rationale, a modified Android - system permission dialog appears on the phone telling the user that they are - granting the permission **for the glasses** (not the phone), and the user - can formally grant or deny the permission. +##### Key points about the code -4. **Receiving the result** : After the user makes their final choice, the - activities on both the phone and glasses are dismissed. Your - [`ActivityResultLauncher`](https://developer.android.com/reference/kotlin/androidx/activity/result/ActivityResultLauncher) callback is then invoked with a map - containing the granted status for each requested permission. +- Use the `deviceId` overload with the [`onRequestPermissionsResult`](https://developer.android.com/reference/kotlin/androidx/core/app/ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult(int,java.lang.String%5B%5D,int%5B%5D)) callback to ensure the permission status is correctly mapped to the specific context, such as the audio or display glasses rather than the host phone. +- Your app should handle both granted and denied results gracefully. ### Request runtime permissions in a phone activity @@ -148,11 +140,7 @@ If your app is running in a phone activity but requires permissions for audio or display glasses---for example, to let a user switch a video stream to the glasses' camera for a first-person point of view---request the permissions using the device-specific [`Activity#requestPermissions(permissions, requestCode, -deviceId)`](https://developer.android.com/reference/kotlin/android/app/Activity#requestpermissions_1) method. - -To target audio and display glasses, obtain the appropriate device ID by calling -[`getDeviceId`](https://developer.android.com/reference/kotlin/android/content/Context#getdeviceid) on a projected device context. Pass this ID when requesting -permissions from your phone activity, as shown in the following example: +deviceId)`](https://developer.android.com/reference/kotlin/android/app/Activity#requestpermissions_1) method: ```kotlin @@ -167,12 +155,17 @@ requestPermissions(
+#### Key points about the code + +- To target audio and display glasses, obtain the appropriate device ID from a projected context that you've created using [`createProjectedDeviceContext`](https://developer.android.com/reference/kotlin/androidx/xr/projected/ProjectedContext#createProjectedDeviceContext(android.content.Context)). Pass this ID with your call to the `requestPermissions` method. + #### Handle the permission results Once the user responds to the permission dialog on the phone, their decision is -delivered to your app by invoking the onRequestPermissionsResult callback. +delivered to your app by invoking the [`onRequestPermissionsResult`](https://developer.android.com/reference/kotlin/androidx/core/app/ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult(int,java.lang.String%5B%5D,int%5B%5D)) +callback. -To handle the response, override [`onRequestPermissionsResult`](https://developer.android.com/reference/kotlin/androidx/core/app/ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult(int,java.lang.String%5B%5D,int%5B%5D)) within the +To handle the response, override `onRequestPermissionsResult` within the `Activity` instance that initiated the permission request. The following code snippet shows how to handle the callback and verify whether @@ -180,27 +173,24 @@ the user granted the requested permissions: ```kotlin -private companion object { - // REQUEST_CODE_GLASSES_CAMERA is a developer-defined constant. - const val REQUEST_CODE_GLASSES_CAMERA = 1001 -} - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) override fun onRequestPermissionsResult( requestCode: Int, - permissions: Array, + permissions: Array, grantResults: IntArray, - deviceId: Int + deviceId: Int, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId) + if (requestCode != CAMERA_PERMISSION_REQUEST_CODE) return - // Handle the result of the permission request - if (requestCode == REQUEST_CODE_GLASSES_CAMERA && deviceId == projectedDeviceId) { - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // Proceed with glasses camera features - } else { - // Handle glasses permission denied - } + val isCameraGranted = + grantResults.getOrNull(permissions.indexOf(Manifest.permission.CAMERA)) == + PackageManager.PERMISSION_GRANTED + + if (isCameraGranted) { + initializeCameraFeatures() + } else { + onCameraPermissionDenied() } } ```