1 /*
<lambda>null2 * Copyright 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16 package androidx.glance.appwidget
17
18 import android.os.Build
19 import android.util.Log
20 import androidx.compose.ui.unit.dp
21 import androidx.glance.BackgroundModifier
22 import androidx.glance.Emittable
23 import androidx.glance.EmittableButton
24 import androidx.glance.EmittableImage
25 import androidx.glance.EmittableLazyItemWithChildren
26 import androidx.glance.EmittableWithChildren
27 import androidx.glance.GlanceModifier
28 import androidx.glance.ImageProvider
29 import androidx.glance.action.ActionModifier
30 import androidx.glance.action.LambdaAction
31 import androidx.glance.action.NoRippleOverride
32 import androidx.glance.addChild
33 import androidx.glance.addChildIfNotNull
34 import androidx.glance.appwidget.action.CompoundButtonAction
35 import androidx.glance.extractModifier
36 import androidx.glance.findModifier
37 import androidx.glance.layout.Alignment
38 import androidx.glance.layout.ContentScale
39 import androidx.glance.layout.EmittableBox
40 import androidx.glance.layout.HeightModifier
41 import androidx.glance.layout.PaddingModifier
42 import androidx.glance.layout.WidthModifier
43 import androidx.glance.layout.fillMaxHeight
44 import androidx.glance.layout.fillMaxSize
45 import androidx.glance.layout.fillMaxWidth
46 import androidx.glance.layout.padding
47 import androidx.glance.removeModifiersOfType
48 import androidx.glance.text.EmittableText
49 import androidx.glance.toEmittableText
50 import androidx.glance.unit.Dimension
51
52 internal fun normalizeCompositionTree(
53 root: RemoteViewsRoot,
54 isPreviewComposition: Boolean = false
55 ) {
56 coerceToOneChild(root)
57 root.normalizeSizes()
58 root.transformTree { view ->
59 if (isPreviewComposition) {
60 view.removeActionModifiers()
61 }
62 if (view is EmittableLazyItemWithChildren) normalizeLazyListItem(view)
63 view.transformBackgroundImageAndActionRipple()
64 }
65 }
66
67 /** Remove any action modifiers within the tree. */
Emittablenull68 private fun Emittable.removeActionModifiers() {
69 if (this !is EmittableSizeBox && this.modifier.any { it is ActionModifier }) {
70 this.modifier = this.modifier.removeModifiersOfType<ActionModifier>()
71 }
72 }
73
74 /**
75 * Ensure that [container] has only one direct child.
76 *
77 * If [container] has multiple children, wrap them in an [EmittableBox] and make that the only child
78 * of container. If [container] contains only children of type [EmittableSizeBox], then we will make
79 * sure each of the [EmittableSizeBox]es has one child by wrapping their children in an
80 * [EmittableBox].
81 */
coerceToOneChildnull82 private fun coerceToOneChild(container: EmittableWithChildren) {
83 if (container.children.isNotEmpty() && container.children.all { it is EmittableSizeBox }) {
84 for (item in container.children) {
85 item as EmittableSizeBox
86 if (item.children.size == 1) continue
87 val box = EmittableBox()
88 box.children += item.children
89 item.children.clear()
90 item.children += box
91 }
92 return
93 } else if (container.children.size == 1) {
94 return
95 }
96 val box = EmittableBox()
97 box.children += container.children
98 container.children.clear()
99 container.children += box
100 }
101
102 /**
103 * Resolve mixing wrapToContent and fillMaxSize on containers.
104 *
105 * Make sure that if a node with wrapToContent has a child with fillMaxSize, then it becomes
106 * fillMaxSize. Otherwise, the behavior depends on the version of Android.
107 */
EmittableWithChildrennull108 private fun EmittableWithChildren.normalizeSizes() {
109 children.forEach { child ->
110 if (child is EmittableWithChildren) {
111 child.normalizeSizes()
112 }
113 }
114 if (
115 (modifier.findModifier<HeightModifier>()?.height ?: Dimension.Wrap) is Dimension.Wrap &&
116 children.any { child ->
117 child.modifier.findModifier<HeightModifier>()?.height is Dimension.Fill
118 }
119 ) {
120 modifier = modifier.fillMaxHeight()
121 }
122 if (
123 (modifier.findModifier<WidthModifier>()?.width ?: Dimension.Wrap) is Dimension.Wrap &&
124 children.any { child ->
125 child.modifier.findModifier<WidthModifier>()?.width is Dimension.Fill
126 }
127 ) {
128 modifier = modifier.fillMaxWidth()
129 }
130 }
131
132 /** Transform each node in the tree. */
transformTreenull133 private fun EmittableWithChildren.transformTree(block: (Emittable) -> Emittable) {
134 children.forEachIndexed { index, child ->
135 val newChild = block(child)
136 children[index] = newChild
137 if (newChild is EmittableWithChildren) newChild.transformTree(block)
138 }
139 }
140
141 /**
142 * Walks through the Emittable tree and updates the key for all LambdaActions.
143 *
144 * This function updates the key such that the final key is equal to the original key plus a string
145 * indicating its index among its siblings. This is because sibling Composables will often have the
146 * same key due to how [androidx.compose.runtime.currentCompositeKeyHash] works. Adding the index
147 * makes sure that all of these keys are unique.
148 *
149 * Note that, because we run the same composition multiple times for different sizes in certain
150 * modes (see [ForEachSize]), action keys in one SizeBox should mirror the action keys in other
151 * SizeBoxes, so that if an action is triggered on the widget being displayed in one size, the state
152 * will be updated for the composition in all sizes. This is why there can be multiple LambdaActions
153 * for each key, even after de-duping.
154 */
updateLambdaActionKeysnull155 internal fun EmittableWithChildren.updateLambdaActionKeys(): Map<String, List<LambdaAction>> =
156 children.foldIndexed(mutableMapOf<String, MutableList<LambdaAction>>()) { index, actions, child
157 ->
158 val (action: LambdaAction?, modifiers: GlanceModifier) =
159 child.modifier.extractLambdaAction()
160 if (
161 action != null && child !is EmittableSizeBox && child !is EmittableLazyItemWithChildren
162 ) {
163 val newKey = action.key + "+$index"
164 val newAction = LambdaAction(newKey, action.block)
165 actions.getOrPut(newKey) { mutableListOf() }.add(newAction)
166 child.modifier = modifiers.then(ActionModifier(newAction))
167 }
168 if (child is EmittableWithChildren) {
169 child.updateLambdaActionKeys().forEach { (key, childActions) ->
170 actions.getOrPut(key) { mutableListOf() }.addAll(childActions)
171 }
172 }
173 actions
174 }
175
GlanceModifiernull176 private fun GlanceModifier.extractLambdaAction(): Pair<LambdaAction?, GlanceModifier> =
177 extractModifier<ActionModifier>().let { (actionModifier, modifiers) ->
178 val action = actionModifier?.action
179 when {
180 action is LambdaAction -> action to modifiers
181 action is CompoundButtonAction && action.innerAction is LambdaAction ->
182 action.innerAction to modifiers
183 else -> null to modifiers
184 }
185 }
186
normalizeLazyListItemnull187 private fun normalizeLazyListItem(view: EmittableLazyItemWithChildren) {
188 val box = EmittableBox()
189 box.children += view.children
190 box.contentAlignment = view.alignment
191 box.modifier = view.modifier
192 view.children.clear()
193 view.children += box
194 view.alignment = Alignment.CenterStart
195 }
196
197 /**
198 * If this [Emittable] has a background image or a ripple, transform the emittable so that it is
199 * wrapped in an [EmittableBox], with the background and ripple added as [ImageView]s in the
200 * background and foreground.
201 *
202 * If this is an [EmittableButton], we additonally set a clip outline on the wrapper box, and
203 * convert the target emittable to an [EmittableText]
204 */
Emittablenull205 private fun Emittable.transformBackgroundImageAndActionRipple(): Emittable {
206 // EmittableLazyItemWithChildren and EmittableSizeBox are wrappers for their immediate
207 // only child, and do not get translated to their own element. We will transform their child
208 // instead.
209 if (this is EmittableLazyItemWithChildren || this is EmittableSizeBox) return this
210
211 var target = this
212 val isButton = target is EmittableButton
213
214 // Button ignores background modifiers.
215 if (isButton && Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
216 // Buttons cannot have a background image modifier. Remove BackgroundModifier.Image from
217 // the button if it exists.
218 val (maybeBgImageModifier, modifiersMinusBgImage) =
219 target.modifier.extractModifier<BackgroundModifier.Image>()
220 if (maybeBgImageModifier != null) {
221 Log.w(
222 GlanceAppWidgetTag,
223 "Glance Buttons should not have a background image modifier. " +
224 "Consider an image with a clickable modifier."
225 )
226 target.modifier = modifiersMinusBgImage
227 }
228
229 // Buttons ignore background color modifier. Remove it.
230 val (maybeBgColorModifier, modifiersMinusBgColor) =
231 target.modifier.extractModifier<BackgroundModifier.Image>()
232 if (maybeBgColorModifier != null) {
233 Log.w(
234 GlanceAppWidgetTag,
235 "Glance Buttons should not have a background color modifier. " +
236 "Consider a tinted image with a clickable modifier"
237 )
238 target.modifier = modifiersMinusBgColor
239 }
240 }
241
242 val shouldWrapTargetInABox =
243 target.modifier.any {
244 // Background images (i.e. BitMap or drawable resources) are emulated by placing the
245 // image
246 // before the target in the wrapper box. This allows us to support content scale as well
247 // as
248 // can help support additional processing on background images. Note: Button's don't
249 // support
250 // bg image modifier.
251 (it is BackgroundModifier.Image) ||
252 // R- buttons are implemented using box, images and text.
253 (isButton && Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) ||
254 // Ripples are implemented by placing a drawable after the target in the wrapper
255 // box.
256 (it is ActionModifier && !hasBuiltinRipple())
257 }
258 if (!shouldWrapTargetInABox) return target
259
260 // Hoisted modifiers are subtracted from the target one by one and added to the box and the
261 // remaining modifiers are applied to the target.
262 val boxModifiers = mutableListOf<GlanceModifier?>()
263 val targetModifiers = mutableListOf<GlanceModifier?>()
264 var backgroundImage: EmittableImage? = null
265 var rippleImage: EmittableImage? = null
266
267 val (bgModifier, targetModifiersMinusBg) = target.modifier.extractModifier<BackgroundModifier>()
268
269 if (bgModifier != null) {
270 if (isButton) {
271 // Emulate rounded corners (fixed radius) using a drawable and apply background colors
272 // to it. Note: Currently, button doesn't support bg image modifier, but only button
273 // colors.
274 backgroundImage =
275 EmittableImage().apply {
276 modifier = GlanceModifier.fillMaxSize()
277 provider = ImageProvider(R.drawable.glance_button_outline)
278 // Without setting alpha, if this drawable's base was transparent, solid color
279 // won't
280 // be applied as the default blending mode uses alpha from base. And if this
281 // drawable's base was white/none, applying transparent tint will lead to black
282 // color. This shouldn't be issue for icon type drawables, but in this case we
283 // are
284 // emulating colored outline. So, we apply tint as well as alpha.
285 (bgModifier as? BackgroundModifier.Color)?.colorProvider?.let {
286 colorFilterParams = TintAndAlphaColorFilterParams(it)
287 }
288 contentScale = ContentScale.FillBounds
289 }
290 } else {
291 // bgModifier.imageProvider is converted to an actual image but bgModifier.colorProvider
292 // is applied back to the target. Note: We could have hoisted the bg color to box
293 // instead of adding it back to the target, but for buttons, we also add an outline
294 // background to the box.
295 when (bgModifier) {
296 is BackgroundModifier.Image -> {
297 backgroundImage =
298 EmittableImage().apply {
299 modifier = GlanceModifier.fillMaxSize()
300 provider = bgModifier.imageProvider
301 contentScale = bgModifier.contentScale
302 colorFilterParams = bgModifier.colorFilter?.colorFilterParams
303 alpha = bgModifier.alpha
304 }
305 }
306 is BackgroundModifier.Color -> {
307 targetModifiers += bgModifier
308 }
309 }
310 }
311 }
312
313 // Action modifiers are hoisted on the wrapping box and a ripple image is added to the
314 // foreground if the target doesn't have it built-in.
315 targetModifiersMinusBg.warnIfMultipleClickableActions()
316 val (actionModifier, targetModifiersMinusAction) =
317 targetModifiersMinusBg.extractModifier<ActionModifier>()
318 boxModifiers += actionModifier
319 if (actionModifier != null && !hasBuiltinRipple()) {
320 val maybeRippleOverride = actionModifier.rippleOverride
321 val rippleImageProvider =
322 if (maybeRippleOverride != NoRippleOverride) {
323 ImageProvider(maybeRippleOverride)
324 } else if (isButton) {
325 ImageProvider(R.drawable.glance_button_ripple)
326 } else {
327 ImageProvider(R.drawable.glance_ripple)
328 }
329 rippleImage =
330 EmittableImage().apply {
331 modifier = GlanceModifier.fillMaxSize()
332 provider = rippleImageProvider
333 }
334 }
335
336 // Hoist the size and corner radius modifiers to the wrapping Box, then set the target element
337 // to fill the given space.
338 val (sizeAndCornerModifiers, targetModifiersMinusSizeAndCornerRadius) =
339 targetModifiersMinusAction.extractSizeAndCornerRadiusModifiers()
340 boxModifiers += sizeAndCornerModifiers
341 targetModifiers += targetModifiersMinusSizeAndCornerRadius.fillMaxSize()
342
343 if (target is EmittableButton) {
344 boxModifiers += GlanceModifier.enabled(target.enabled)
345 target = target.toEmittableText()
346 if (target.modifier.findModifier<PaddingModifier>() == null) {
347 targetModifiers += GlanceModifier.padding(horizontal = 16.dp, vertical = 8.dp)
348 }
349 }
350
351 return EmittableBox().apply {
352 modifier = boxModifiers.collect()
353 target.modifier = targetModifiers.collect()
354
355 if (isButton) contentAlignment = Alignment.Center
356
357 addChildIfNotNull(backgroundImage)
358 addChild(target)
359 addChildIfNotNull(rippleImage)
360 }
361 }
362
hasBuiltinRipplenull363 private fun Emittable.hasBuiltinRipple() =
364 this is EmittableSwitch ||
365 this is EmittableRadioButton ||
366 this is EmittableCheckBox ||
367 // S+ versions use a native button with fixed rounded corners and matching ripple set in
368 // layout xml. In R- versions, buttons are implemented using a background drawable with
369 // rounded corners and an EmittableText in R- versions.
370 (this is EmittableButton && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
371
372 private data class ExtractedSizeAndCornerModifiers(
373 val sizeAndCornerModifiers: GlanceModifier = GlanceModifier,
374 val nonSizeOrCornerModifiers: GlanceModifier = GlanceModifier,
375 )
376
377 /**
378 * Split the [GlanceModifier] into one that contains the [WidthModifier]s, [HeightModifier]s,
379 * [CornerRadiusModifier]s, [AppWidgetBackgroundModifier] and one that contains the rest.
380 *
381 * The [AppWidgetBackgroundModifier] is relevant to corner radius.
382 */
383 private fun GlanceModifier.extractSizeAndCornerRadiusModifiers() =
384 if (
385 any {
386 it is WidthModifier ||
387 it is HeightModifier ||
388 it is CornerRadiusModifier ||
389 it is AppWidgetBackgroundModifier
390 }
391 ) {
accnull392 foldIn(ExtractedSizeAndCornerModifiers()) { acc, modifier ->
393 if (
394 modifier is WidthModifier ||
395 modifier is HeightModifier ||
396 modifier is CornerRadiusModifier ||
397 modifier is AppWidgetBackgroundModifier
398 ) {
399 acc.copy(sizeAndCornerModifiers = acc.sizeAndCornerModifiers.then(modifier))
400 } else {
401 acc.copy(nonSizeOrCornerModifiers = acc.nonSizeOrCornerModifiers.then(modifier))
402 }
403 }
404 } else {
405 ExtractedSizeAndCornerModifiers(nonSizeOrCornerModifiers = this)
406 }
407
GlanceModifiernull408 private fun GlanceModifier.warnIfMultipleClickableActions() {
409 val actionCount =
410 foldIn(0) { count, modifier -> if (modifier is ActionModifier) count + 1 else count }
411 if (actionCount > 1) {
412 Log.w(
413 GlanceAppWidgetTag,
414 "More than one clickable defined on the same GlanceModifier, " +
415 "only the last one will be used."
416 )
417 }
418 }
419
MutableListnull420 private fun MutableList<GlanceModifier?>.collect(): GlanceModifier =
421 fold(GlanceModifier) { acc: GlanceModifier, mod: GlanceModifier? ->
422 mod?.let { acc.then(mod) } ?: acc
423 }
424