• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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 
17 package com.android.systemui.keyboard.shortcut.ui.composable
18 
19 import androidx.compose.foundation.BorderStroke
20 import androidx.compose.foundation.IndicationNodeFactory
21 import androidx.compose.foundation.LocalIndication
22 import androidx.compose.foundation.background
23 import androidx.compose.foundation.border
24 import androidx.compose.foundation.clickable
25 import androidx.compose.foundation.interaction.FocusInteraction
26 import androidx.compose.foundation.interaction.HoverInteraction
27 import androidx.compose.foundation.interaction.InteractionSource
28 import androidx.compose.foundation.interaction.MutableInteractionSource
29 import androidx.compose.foundation.interaction.PressInteraction
30 import androidx.compose.foundation.interaction.collectIsFocusedAsState
31 import androidx.compose.foundation.layout.Arrangement
32 import androidx.compose.foundation.layout.Box
33 import androidx.compose.foundation.layout.Row
34 import androidx.compose.foundation.layout.Spacer
35 import androidx.compose.foundation.layout.padding
36 import androidx.compose.foundation.layout.size
37 import androidx.compose.foundation.layout.width
38 import androidx.compose.foundation.layout.wrapContentSize
39 import androidx.compose.foundation.selection.selectable
40 import androidx.compose.foundation.shape.RoundedCornerShape
41 import androidx.compose.material3.ColorScheme
42 import androidx.compose.material3.Icon
43 import androidx.compose.material3.LocalAbsoluteTonalElevation
44 import androidx.compose.material3.LocalContentColor
45 import androidx.compose.material3.LocalTonalElevationEnabled
46 import androidx.compose.material3.MaterialTheme
47 import androidx.compose.material3.Text
48 import androidx.compose.material3.contentColorFor
49 import androidx.compose.material3.minimumInteractiveComponentSize
50 import androidx.compose.material3.surfaceColorAtElevation
51 import androidx.compose.runtime.Composable
52 import androidx.compose.runtime.CompositionLocalProvider
53 import androidx.compose.runtime.NonRestartableComposable
54 import androidx.compose.runtime.Stable
55 import androidx.compose.runtime.mutableStateOf
56 import androidx.compose.runtime.remember
57 import androidx.compose.ui.Alignment
58 import androidx.compose.ui.Modifier
59 import androidx.compose.ui.geometry.CornerRadius
60 import androidx.compose.ui.geometry.Offset
61 import androidx.compose.ui.geometry.Rect
62 import androidx.compose.ui.geometry.Size
63 import androidx.compose.ui.graphics.Color
64 import androidx.compose.ui.graphics.RectangleShape
65 import androidx.compose.ui.graphics.Shape
66 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
67 import androidx.compose.ui.graphics.drawscope.Stroke
68 import androidx.compose.ui.graphics.graphicsLayer
69 import androidx.compose.ui.node.DelegatableNode
70 import androidx.compose.ui.node.DrawModifierNode
71 import androidx.compose.ui.platform.LocalDensity
72 import androidx.compose.ui.semantics.Role
73 import androidx.compose.ui.semantics.role
74 import androidx.compose.ui.semantics.semantics
75 import androidx.compose.ui.text.style.TextOverflow
76 import androidx.compose.ui.unit.Dp
77 import androidx.compose.ui.unit.dp
78 import androidx.compose.ui.unit.sp
79 import androidx.compose.ui.zIndex
80 import com.android.app.tracing.coroutines.launchTraced as launch
81 import com.android.compose.modifiers.thenIf
82 import com.android.systemui.keyboard.shortcut.ui.model.IconSource
83 
84 /**
85  * A selectable surface with no default focus/hover indications.
86  *
87  * This composable is similar to [androidx.compose.material3.Surface], but removes default
88  * focus/hover states to enable custom implementations.
89  */
90 @Composable
91 @NonRestartableComposable
92 fun SelectableShortcutSurface(
93     selected: Boolean,
94     onClick: () -> Unit,
95     modifier: Modifier = Modifier,
96     enabled: Boolean = true,
97     shape: Shape = RectangleShape,
98     color: Color = MaterialTheme.colorScheme.surface,
99     contentColor: Color = contentColorFor(color),
100     tonalElevation: Dp = 0.dp,
101     shadowElevation: Dp = 0.dp,
102     border: BorderStroke? = null,
103     interactionSource: MutableInteractionSource? = null,
104     interactionsConfig: InteractionsConfig = InteractionsConfig(),
105     content: @Composable () -> Unit,
106 ) {
107     @Suppress("NAME_SHADOWING")
108     val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
109     val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
110     CompositionLocalProvider(
111         LocalContentColor provides contentColor,
112         LocalAbsoluteTonalElevation provides absoluteElevation,
113     ) {
114         val isFocused = interactionSource.collectIsFocusedAsState()
115         Box(
116             modifier =
117                 modifier
118                     .minimumInteractiveComponentSize()
119                     .surface(
120                         shape = shape,
121                         backgroundColor =
122                             surfaceColorAtElevation(color = color, elevation = absoluteElevation),
123                         border = border,
124                         shadowElevation = with(LocalDensity.current) { shadowElevation.toPx() },
125                     )
126                     .selectable(
127                         selected = selected,
128                         interactionSource = interactionSource,
129                         indication = ShortcutHelperIndication(interactionsConfig),
130                         enabled = enabled,
131                         onClick = onClick,
132                     )
133                     .thenIf(isFocused.value) { Modifier.zIndex(1f) },
134             propagateMinConstraints = true,
135         ) {
136             content()
137         }
138     }
139 }
140 
141 /**
142  * A clickable surface with no default focus/hover indications.
143  *
144  * This composable is similar to [androidx.compose.material3.Surface], but removes default
145  * focus/hover states to enable custom implementations.
146  */
147 @Composable
148 @NonRestartableComposable
ClickableShortcutSurfacenull149 fun ClickableShortcutSurface(
150     onClick: () -> Unit,
151     modifier: Modifier = Modifier,
152     enabled: Boolean = true,
153     shape: Shape = RectangleShape,
154     color: Color = MaterialTheme.colorScheme.surface,
155     contentColor: Color = contentColorFor(color),
156     tonalElevation: Dp = 0.dp,
157     shadowElevation: Dp = 0.dp,
158     border: BorderStroke? = null,
159     interactionSource: MutableInteractionSource? = null,
160     interactionsConfig: InteractionsConfig = InteractionsConfig(),
161     content: @Composable () -> Unit,
162 ) {
163     @Suppress("NAME_SHADOWING")
164     val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
165     val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
166     CompositionLocalProvider(
167         LocalContentColor provides contentColor,
168         LocalAbsoluteTonalElevation provides absoluteElevation,
169     ) {
170         Box(
171             modifier =
172                 modifier
173                     .minimumInteractiveComponentSize()
174                     .surface(
175                         shape = shape,
176                         backgroundColor =
177                             surfaceColorAtElevation(color = color, elevation = absoluteElevation),
178                         border = border,
179                         shadowElevation = with(LocalDensity.current) { shadowElevation.toPx() },
180                     )
181                     .clickable(
182                         interactionSource = interactionSource,
183                         indication = ShortcutHelperIndication(interactionsConfig),
184                         enabled = enabled,
185                         onClick = onClick,
186                     ),
187             propagateMinConstraints = true,
188         ) {
189             content()
190         }
191     }
192 }
193 
194 /**
195  * A composable that provides a button with a customizable icon and text, designed to be re-used
196  * across shortcut helper/customizer. Supports defaults hover/focus/pressed states used across
197  * shortcut helper.
198  *
199  * This button utilizes [ClickableShortcutSurface] to provide a clickable surface with hover and
200  * pressed states, and a focus outline.
201  *
202  * The content of the button can be an icon (from [IconSource]) and/or text.
203  *
204  * @param modifier The modifier to be applied to the button.
205  * @param onClick The callback function that will be invoked when the button is clicked.
206  * @param shape The shape of the button. Defaults to a rounded corner shape used across shortcut
207  *   helper.
208  * @param color The background color of the button.
209  * @param width The width of the button.
210  * @param height The height of the button. Defaults to 40.dp as often used in shortcut helper
211  * @param iconSource The source of the icon to be displayed. Defaults to an empty [IconSource].
212  * @param text The text to be displayed. Defaults to null.
213  * @param contentColor The color of the icon and text.
214  * @param contentPaddingHorizontal The horizontal padding of the content. Defaults to 16.dp.
215  * @param contentPaddingVertical The vertical padding of the content. Defaults to 10.dp.
216  */
217 @Composable
ShortcutHelperButtonnull218 fun ShortcutHelperButton(
219     onClick: () -> Unit,
220     contentColor: Color,
221     color: Color,
222     modifier: Modifier = Modifier,
223     shape: Shape = RoundedCornerShape(360.dp),
224     iconSource: IconSource = IconSource(),
225     text: String? = null,
226     contentPaddingHorizontal: Dp = 16.dp,
227     contentPaddingVertical: Dp = 10.dp,
228     enabled: Boolean = true,
229     border: BorderStroke? = null,
230     contentDescription: String? = null,
231 ) {
232     ClickableShortcutSurface(
233         onClick = onClick,
234         shape = shape,
235         color = color.getDimmedColorIfDisabled(enabled),
236         border = border,
237         modifier = modifier.semantics { role = Role.Button },
238         interactionsConfig =
239             InteractionsConfig(
240                 hoverOverlayColor = MaterialTheme.colorScheme.onSurface,
241                 hoverOverlayAlpha = 0.11f,
242                 pressedOverlayColor = MaterialTheme.colorScheme.onSurface,
243                 pressedOverlayAlpha = 0.15f,
244                 focusOutlineColor = MaterialTheme.colorScheme.secondary,
245                 focusOutlineStrokeWidth = 3.dp,
246                 focusOutlinePadding = 2.dp,
247                 surfaceCornerRadius = 28.dp,
248                 focusOutlineCornerRadius = 33.dp,
249             ),
250         enabled = enabled,
251     ) {
252         Row(
253             modifier =
254                 Modifier.padding(
255                     horizontal = contentPaddingHorizontal,
256                     vertical = contentPaddingVertical,
257                 ),
258             verticalAlignment = Alignment.CenterVertically,
259             horizontalArrangement = Arrangement.Center,
260         ) {
261             ShortcutHelperButtonContent(iconSource, contentColor, text, contentDescription)
262         }
263     }
264 }
265 
266 @Composable
ShortcutHelperButtonContentnull267 private fun ShortcutHelperButtonContent(
268     iconSource: IconSource,
269     contentColor: Color,
270     text: String?,
271     contentDescription: String?,
272 ) {
273     if (iconSource.imageVector != null) {
274         Icon(
275             tint = contentColor,
276             imageVector = iconSource.imageVector,
277             contentDescription = contentDescription,
278             modifier = Modifier.size(20.dp).wrapContentSize(Alignment.Center),
279         )
280     }
281 
282     if (iconSource.imageVector != null && text != null) Spacer(modifier = Modifier.width(8.dp))
283 
284     if (text != null) {
285         Text(
286             text,
287             color = contentColor,
288             fontSize = 14.sp,
289             style = MaterialTheme.typography.labelLarge,
290             modifier = Modifier.wrapContentSize(Alignment.Center),
291             overflow = TextOverflow.Ellipsis,
292         )
293     }
294 }
295 
getDimmedColorIfDisablednull296 private fun Color.getDimmedColorIfDisabled(enabled: Boolean): Color =
297     if (enabled) this else copy(alpha = 0.38f)
298 
299 @Composable
300 private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
301     return MaterialTheme.colorScheme.applyTonalElevation(color, elevation)
302 }
303 
304 @Composable
applyTonalElevationnull305 internal fun ColorScheme.applyTonalElevation(backgroundColor: Color, elevation: Dp): Color {
306     val tonalElevationEnabled = LocalTonalElevationEnabled.current
307     return if (backgroundColor == surface && tonalElevationEnabled) {
308         surfaceColorAtElevation(elevation)
309     } else {
310         backgroundColor
311     }
312 }
313 
314 /**
315  * Applies surface-related modifiers to a composable.
316  *
317  * This function adds background, border, and shadow effects to a composable. Also ensure the
318  * composable is clipped to the given shape.
319  *
320  * @param shape The shape to apply to the composable's background, border, and clipping.
321  * @param backgroundColor The background color to apply to the composable.
322  * @param border An optional border to draw around the composable.
323  * @param shadowElevation The size of the shadow below the surface. To prevent shadow creep, only
324  *   apply shadow elevation when absolutely necessary, such as when the surface requires visual
325  *   separation from a patterned background. Note that It will not affect z index of the Surface. If
326  *   you want to change the drawing order you can use `Modifier.zIndex`.
327  * @return The modified Modifier instance with surface-related modifiers applied.
328  */
329 @Stable
surfacenull330 private fun Modifier.surface(
331     shape: Shape,
332     backgroundColor: Color,
333     border: BorderStroke?,
334     shadowElevation: Float,
335 ): Modifier {
336     return this.thenIf(shadowElevation > 0f) {
337             Modifier.graphicsLayer(shadowElevation = shadowElevation, shape = shape, clip = false)
338         }
339         .thenIf(border != null) { Modifier.border(border!!, shape) }
340         .background(color = backgroundColor, shape = shape)
341 }
342 
343 private class ShortcutHelperInteractionsNode(
344     private val interactionSource: InteractionSource,
345     private val interactionsConfig: InteractionsConfig,
346 ) : Modifier.Node(), DrawModifierNode {
347 
348     var isFocused = mutableStateOf(false)
349     var isHovered = mutableStateOf(false)
350     var isPressed = mutableStateOf(false)
351 
onAttachnull352     override fun onAttach() {
353         coroutineScope.launch {
354             val hoverInteractions = mutableListOf<HoverInteraction.Enter>()
355             val focusInteractions = mutableListOf<FocusInteraction.Focus>()
356             val pressInteractions = mutableListOf<PressInteraction.Press>()
357 
358             interactionSource.interactions.collect { interaction ->
359                 when (interaction) {
360                     is FocusInteraction.Focus -> focusInteractions.add(interaction)
361                     is FocusInteraction.Unfocus -> focusInteractions.remove(interaction.focus)
362                     is HoverInteraction.Enter -> hoverInteractions.add(interaction)
363                     is HoverInteraction.Exit -> hoverInteractions.remove(interaction.enter)
364                     is PressInteraction.Press -> pressInteractions.add(interaction)
365                     is PressInteraction.Release -> pressInteractions.remove(interaction.press)
366                     is PressInteraction.Cancel -> pressInteractions.remove(interaction.press)
367                 }
368                 isHovered.value = hoverInteractions.isNotEmpty()
369                 isPressed.value = pressInteractions.isNotEmpty()
370                 isFocused.value = focusInteractions.isNotEmpty()
371             }
372         }
373     }
374 
drawnull375     override fun ContentDrawScope.draw() {
376 
377         fun getRectangleWithPadding(padding: Dp, size: Size): Rect {
378             return Rect(Offset.Zero, size).let {
379                 if (interactionsConfig.focusOutlinePadding > 0.dp) {
380                     it.inflate(padding.toPx())
381                 } else {
382                     it.deflate(padding.unaryMinus().toPx())
383                 }
384             }
385         }
386 
387         drawContent()
388         if (isHovered.value) {
389             val hoverRect = getRectangleWithPadding(interactionsConfig.pressedPadding, size)
390             drawRoundRect(
391                 color = interactionsConfig.hoverOverlayColor,
392                 alpha = interactionsConfig.hoverOverlayAlpha,
393                 cornerRadius = CornerRadius(interactionsConfig.surfaceCornerRadius.toPx()),
394                 topLeft = hoverRect.topLeft,
395                 size = hoverRect.size,
396             )
397         }
398         if (isPressed.value) {
399             val pressedRect = getRectangleWithPadding(interactionsConfig.pressedPadding, size)
400             drawRoundRect(
401                 color = interactionsConfig.pressedOverlayColor,
402                 alpha = interactionsConfig.pressedOverlayAlpha,
403                 cornerRadius = CornerRadius(interactionsConfig.surfaceCornerRadius.toPx()),
404                 topLeft = pressedRect.topLeft,
405                 size = pressedRect.size,
406             )
407         }
408         if (isFocused.value) {
409             val focusOutline = getRectangleWithPadding(interactionsConfig.focusOutlinePadding, size)
410             drawRoundRect(
411                 color = interactionsConfig.focusOutlineColor,
412                 style = Stroke(width = interactionsConfig.focusOutlineStrokeWidth.toPx()),
413                 topLeft = focusOutline.topLeft,
414                 size = focusOutline.size,
415                 cornerRadius = CornerRadius(interactionsConfig.focusOutlineCornerRadius.toPx()),
416             )
417         }
418     }
419 }
420 
421 data class ShortcutHelperIndication(private val interactionsConfig: InteractionsConfig) :
422     IndicationNodeFactory {
createnull423     override fun create(interactionSource: InteractionSource): DelegatableNode {
424         return ShortcutHelperInteractionsNode(interactionSource, interactionsConfig)
425     }
426 }
427 
428 data class InteractionsConfig(
429     val hoverOverlayColor: Color = Color.Transparent,
430     val hoverOverlayAlpha: Float = 0.0f,
431     val pressedOverlayColor: Color = Color.Transparent,
432     val pressedOverlayAlpha: Float = 0.0f,
433     val focusOutlineColor: Color = Color.Transparent,
434     val focusOutlineStrokeWidth: Dp = 0.dp,
435     val focusOutlinePadding: Dp = 0.dp,
436     val surfaceCornerRadius: Dp = 0.dp,
437     val focusOutlineCornerRadius: Dp = 0.dp,
438     val hoverPadding: Dp = 0.dp,
439     val pressedPadding: Dp = hoverPadding,
440 )
441 
442 @Composable
ProvideShortcutHelperIndicationnull443 fun ProvideShortcutHelperIndication(
444     interactionsConfig: InteractionsConfig,
445     content: @Composable () -> Unit,
446 ) {
447     CompositionLocalProvider(
448         LocalIndication provides ShortcutHelperIndication(interactionsConfig)
449     ) {
450         content()
451     }
452 }
453