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