Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Shooting a “Glance” at Android App Widgets

Shooting a “Glance” at Android App Widgets

The Android community adopts Jetpack Compose more and more every day. But what about our app widgets?

In this talk, we will see how we can use “Glance” library to create app widgets. We will briefly talk about how to create “glanceable” UI using composables. We will also see how to use actions for user interactions and create widgets with different size modes. Moreover, we will touch upon the internal state of the Glance app widget and how to update this state outside of the widget process. Finally, we will discuss how to apply theming and use dynamic colors in the app widgets.

Fatih Giriş

June 25, 2022
Tweet

More Decks by Fatih Giriş

Other Decks in Technology

Transcript

  1. FATIH GIRIS ANDROID LEAD @DNB SHOOTING A “GLANCE” AT ANDROID

    APP WIDGETS @fatih_grs
  2. APP WIDGETS https://developer.android.com/guide/topics/appwidgets App widgets o ffi cial documentation

  3. APP WIDGETS https://developer.android.com/guide/topics/appwidgets App widget processing fl ow

  4. • What is Glance? • Actions • Size modes •

    State • Theming AGENDA
  5. • Library to create App Widgets & Wear Tiles •

    Uses compose runtime GLANCE
  6. Convert COMPOSABLES REMOTEVIEWS

  7. Convert GLANCEABLES REMOTEVIEWS

  8. Any Composable inside Glance is called Glanceable.

  9. • Library to create App Widgets & Wear Tiles •

    Uses compose runtime • Converts Composables to RemoteViews & Tiles • Android 12 widget improvements • Backward compatible (API 23) GLANCE
  10. SHOW ME THE CODE!

  11. GLANCE class MyAppWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget

    get() = MyAppWidget() }
  12. GLANCE class MyAppWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget

    get() = MyAppWidget() } AppWidgetReceiver
  13. GLANCE class MyAppWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget

    get() = MyAppWidget() } BroadcastReceiver
  14. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { // Write your App Widget composable } }
  15. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { Text( text = "Hello from a Glance widget!" ) } }
  16. GLANCE

  17. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { Text( text = "Hello from a Glance widget!" ) } }
  18. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { androidx.compose.material.Text( text = "Hello from a Glance widget!" ) } }
  19. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { androidx.glance.text.Text( text = "Hello from a Glance widget!" ) } }
  20. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { androidx.glance.text.Text( modifier = androidx.compose.ui.Modifier, text = "Hello from a Glance widget!" ) } }
  21. GLANCE class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { androidx.glance.text.Text( modifier = androidx.glance.GlanceModifier, text = "Hello from a Glance widget!" ) } }
  22. ACTIONS

  23. • Use actions in response to user interactions • 4

    types of actions - actionStartActivity - actionSendBroadcast - actionStartService - actionRunCallback ACTIONS
  24. ACTIONS class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { Text( modifier = GlanceModifier.clickable( actionStartActivity<MyActivity>() ), text = "Hello from a Glance widget!" ) } }
  25. ACTIONS class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { Text( modifier = GlanceModifier.clickable( actionStartActivity<MyActivity>() ), text = "Hello from a Glance widget!" ) } }
  26. ACTIONS

  27. ACTIONS class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { Text( modifier = GlanceModifier.clickable( actionStartActivity<MyActivity>() ), text = "Hello from a Glance widget!" ) } }
  28. ACTIONS class MyAppWidget : GlanceAppWidget() { @Composable override fun Content()

    { Text( modifier = GlanceModifier.clickable( actionRunCallback<MyCustomActionCallback>() ), text = "Hello from a Glance widget!" ) } }
  29. GLANCE WIDGETS ARE STATEFUL 🤩

  30. • Provides a composition local LocalState • Uses DataStore under

    the hood • Default state de fi nition supports: Boolean, Int, Long, Float, String, Set • Get the current state using currentState() STATE
  31. LET’S MAKE A CLICK COUNTER 🧮

  32. STATE

  33. STATE class MyAppWidget : GlanceAppWidget() { // The key for

    the data store private val COUNTER_KEY = intPreferencesKey("MY_COUNTER_KEY") // The key for the custom action parameter private val COUNTER_ACTION_PARAMETER_KEY = ActionParameters.Key<Int>( “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } }
  34. STATE class MyAppWidget : GlanceAppWidget() { // The key for

    the data store private val COUNTER_KEY = intPreferencesKey("MY_COUNTER_KEY") // The key for the custom action parameter private val COUNTER_ACTION_PARAMETER_KEY = ActionParameters.Key<Int>( “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf(
  35. class MyAppWidget : GlanceAppWidget() { // The key for the

    data store private val COUNTER_KEY = intPreferencesKey("MY_COUNTER_KEY") // The key for the custom action parameter private val COUNTER_ACTION_PARAMETER_KEY = ActionParameters.Key<Int>( “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() STATE
  36. // The key for the custom action parameter private val

    COUNTER_ACTION_PARAMETER_KEY = ActionParameters.Key<Int>( “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } } STATE
  37. // The key for the custom action parameter private val

    COUNTER_ACTION_PARAMETER_KEY = ActionParameters.Key<Int>( “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } } STATE
  38. private val COUNTER_ACTION_PARAMETER_KEY = ActionParameters.Key<Int>( “COUNTER_INCREMENT_ACTION” ) @Composable override fun

    Content() { // Get the saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } } STATE
  39. “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the

    saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } } STATE
  40. “COUNTER_INCREMENT_ACTION” ) @Composable override fun Content() { // Get the

    saved number of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } } STATE
  41. @Composable override fun Content() { // Get the saved number

    of clicks from the current state val numberOfClicks = currentState(key = COUNTER_KEY) ?: 0 Text( modifier = GlanceModifier.clickable( actionRunCallback<CounterIncrementActionCallback>( parameters = actionParametersOf( COUNTER_ACTION_PARAMETER_KEY to numberOfClicks + 1 ) ) ), text = numberOfClicks.toString() ) } } STATE
  42. STATE class CounterIncrementActionCallback : ActionCallback { override suspend fun onRun(

    context: Context, glanceId: GlanceId, parameters: ActionParameters ) { // Get the incremented value from the parameters since // we are sending it from the composable val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter } } // Need to update the widget as well in order // to invoke the content function again MyAppWidget().update(context, glanceId) } }
  43. STATE class CounterIncrementActionCallback : ActionCallback { override suspend fun onRun(

    context: Context, glanceId: GlanceId, parameters: ActionParameters ) { // Get the incremented value from the parameters since // we are sending it from the composable val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter
  44. class CounterIncrementActionCallback : ActionCallback { override suspend fun onRun( context:

    Context, glanceId: GlanceId, parameters: ActionParameters ) { // Get the incremented value from the parameters since // we are sending it from the composable val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter STATE
  45. class CounterIncrementActionCallback : ActionCallback { override suspend fun onRun( context:

    Context, glanceId: GlanceId, parameters: ActionParameters ) { // Get the incremented value from the parameters since // we are sending it from the composable val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter } } // Need to update the widget as well in order // to invoke the content function again STATE
  46. context: Context, glanceId: GlanceId, parameters: ActionParameters ) { // Get

    the incremented value from the parameters since // we are sending it from the composable val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter } } // Need to update the widget as well in order // to invoke the content function again MyAppWidget().update(context, glanceId) } } STATE
  47. // Get the incremented value from the parameters since //

    we are sending it from the composable val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter } } // Need to update the widget as well in order // to invoke the content function again MyAppWidget().update(context, glanceId) } } STATE
  48. val counter = parameters[MyAppWidget.COUNTER_ACTION_PARAMETER_KEY] ?: 0 // Save the counter

    into the data store updateAppWidgetState( context = context, definition = PreferencesGlanceStateDefinition, glanceId = glanceId ) { it.toMutablePreferences().apply { this[MyAppWidget.COUNTER_KEY] = counter } } // Need to update the widget as well in order // to invoke the content function again MyAppWidget().update(context, glanceId) } } STATE
  49. STATE

  50. WHAT ABOUT THE SIZING?

  51. • LocalSize to keep the size of a widget •

    3 di ff erent size modes - SizeMode.Single - SizeMode.Exact - SizeMode.Responsive SIZING
  52. SizeMode.Single • Content is called once with minimum size •

    LocalSize does not change
  53. SizeMode.Single class MyAppWidget : GlanceAppWidget() { override val sizeMode: SizeMode

    get() = SizeMode.Single @Composable override fun Content() { Box { Text( text = "I am a using SizeMode.Single" + "Content will be called only once" + "${LocalSize.current}" ) } } }
  54. SizeMode.Single class MyAppWidget : GlanceAppWidget() { override val sizeMode: SizeMode

    get() = SizeMode.Single @Composable override fun Content() { Box { Text( text = "I am a using SizeMode.Single" + "Content will be called only once" + "${LocalSize.current}" ) } } }
  55. SizeMode.Single

  56. SizeMode.Exact • Content is called every time size changes •

    LocalSize always has the latest size
  57. SizeMode.Exact class MyAppWidget : GlanceAppWidget() { override val sizeMode: SizeMode

    get() = SizeMode.Exact @Composable override fun Content() { Box { Text( text = "I am a using SizeMode.Single" + "Content will be called only once" + "${LocalSize.current}" ) } } }
  58. SizeMode.Exact class MyAppWidget : GlanceAppWidget() { override val sizeMode: SizeMode

    get() = SizeMode.Exact @Composable override fun Content() { Box { Text( text = "I am a using SizeMode.Single" + "Content will be called only once" + "${LocalSize.current}" ) } } }
  59. SizeMode.Exact

  60. NEED TO UPDATE THE WIDGET AFTER EVERY RESIZE?

  61. SizeMode.Responsive • Content is called for the de fi ned

    sizes • LocalSize has one of the de fi ned sizes
  62. SizeMode.Responsive class MyAppWidget : GlanceAppWidget() { companion object { //

    Responsive sizes val SMALL_BOX = DpSize(100.dp, 100.dp) val LARGE_BOX = DpSize(200.dp, 200.dp) } override val sizeMode: SizeMode get() = SizeMode.Responsive( setOf(SMALL_BOX, LARGE_BOX) ) @Composable override fun Content() { when (LocalSize.current) { SMALL_BOX -> SmallBox() LARGE_BOX -> LargeBox() } } }
  63. SizeMode.Responsive class MyAppWidget : GlanceAppWidget() { companion object { //

    Responsive sizes val SMALL_BOX = DpSize(100.dp, 100.dp) val LARGE_BOX = DpSize(200.dp, 200.dp) } override val sizeMode: SizeMode get() = SizeMode.Responsive( setOf(SMALL_BOX, LARGE_BOX) ) @Composable override fun Content() { when (LocalSize.current) { SMALL_BOX -> SmallBox() LARGE_BOX -> LargeBox() } } }
  64. SizeMode.Responsive class MyAppWidget : GlanceAppWidget() { companion object { //

    Responsive sizes val SMALL_BOX = DpSize(100.dp, 100.dp) val LARGE_BOX = DpSize(200.dp, 200.dp) } override val sizeMode: SizeMode get() = SizeMode.Responsive( setOf(SMALL_BOX, LARGE_BOX) ) @Composable override fun Content() { when (LocalSize.current) { SMALL_BOX -> SmallBox() LARGE_BOX -> LargeBox() } } }
  65. SizeMode.Responsive class MyAppWidget : GlanceAppWidget() { companion object { //

    Responsive sizes val SMALL_BOX = DpSize(100.dp, 100.dp) val LARGE_BOX = DpSize(200.dp, 200.dp) } override val sizeMode: SizeMode get() = SizeMode.Responsive( setOf(SMALL_BOX, LARGE_BOX) ) @Composable override fun Content() { when (LocalSize.current) { SMALL_BOX -> SmallBox() LARGE_BOX -> LargeBox() } } }
  66. SizeMode.Responsive

  67. THEMING 🌈

  68. • MaterialTheme THEMING

  69. MaterialTheme

  70. androidx.compose.material.MaterialTheme

  71. • Not possible to use MaterialTheme ❌ THEMING

  72. Starting from Android 12, we can use dynamic colors 🤩

  73. THEMING Box( modifier = GlanceModifier .background( com.google.android.material.R.color.m3_sys_color_dynamic_dark_primary ), contentAlignment =

    Alignment.Center ) { Text(text = "🙈") }
  74. THEMING Box( modifier = GlanceModifier .background( com.google.android.material.R.color.m3_sys_color_dynamic_dark_primary ), contentAlignment =

    Alignment.Center ) { Text(text = "🙈") }
  75. THEMING The widget background using the dark dynamic color from

    the system color pallet
  76. THEMING Box( modifier = GlanceModifier .background( com.google.android.material.R.color.m3_sys_color_dynamic_dark_primary ), contentAlignment =

    Alignment.Center ) { Text(text = "🙈") }
  77. What about day & night con fi guration?

  78. THEMING <resources> <color name=“dynamic_color_ref">@color/m3_sys_color_dynamic_light_primary</color> </resources> values/color.xml

  79. THEMING <resources> <color name=“dynamic_color_ref">@color/m3_sys_color_dynamic_light_primary</color> </resources> values-v31/color.xml

  80. THEMING <resources> <color name=“dynamic_color_ref”>@color/my_color</color> </resources> values/color.xml

  81. THEMING <resources> <color name=“dynamic_color_ref”>@color/m3_sys_color_dynamic_dark_primary</color> </resources> values-night-v31/color.xml

  82. THEMING Box( modifier = GlanceModifier .background(R.color.dynamic_color_ref), contentAlignment = Alignment.Center )

    { Text(text = "🙈") }
  83. THEMING Box( modifier = GlanceModifier .background(R.color.dynamic_color_ref), contentAlignment = Alignment.Center )

    { Text(text = "🙈") }
  84. THEMING Dynamic color reference in light mode Dynamic color reference

    in dark mode
  85. • Not possible to use MaterialTheme ❌ • Dynamic colors

    THEMING
  86. ColorProvider

  87. ColorProvider Docs: “Provider of colors for a glance composable's attributes.”

  88. ColorProvider FixedColorProvider ResourceColorProvider DayNightColorProvider

  89. ColorProvider FixedColorProvider ResourceColorProvider DayNightColorProvider internal 🤔

  90. ColorProvider function that provides day & night colors ColorProvider function

  91. THEMING Box( modifier = GlanceModifier .background( ColorProvider( day = Color.White,

    night = Color.Yellow ) ), contentAlignment = Alignment.Center ) { Text(text = "🙈") }
  92. THEMING Box( modifier = GlanceModifier .background( ColorProvider( day = Color.White,

    night = Color.Yellow ) ), contentAlignment = Alignment.Center ) { Text(text = "🙈") }
  93. THEMING White background color in light mode Yellow background color

    in dark mode
  94. • Not possible to use MaterialTheme ❌ • Dynamic colors

    • Use ColorProvider for day & night con fi guration THEMING
  95. • Glance converts Composables to RemoteViews (and Tiles) • Not

    possible to use the composables from the app • Actions: • actionStartActivity • actionStartService • actionSendBroadcast • actionRunCallback • Size modes: Single, Exact and Responsive TL;DR
  96. • currentState() provides the current state of the widget •

    Dynamic colors on Android 12 and above • ColorProvider()supports day & night colors • Remember that it’s still in alpha TL;DR
  97. THANKS!

  98. QUESTIONS @fatih_grs