| /* |
| * 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 androidx.tv.material3 |
| |
| import androidx.compose.animation.core.animateFloatAsState |
| import androidx.compose.foundation.focusable |
| import androidx.compose.foundation.indication |
| import androidx.compose.foundation.interaction.Interaction |
| import androidx.compose.foundation.interaction.MutableInteractionSource |
| import androidx.compose.foundation.interaction.PressInteraction |
| import androidx.compose.foundation.interaction.collectIsFocusedAsState |
| import androidx.compose.foundation.interaction.collectIsPressedAsState |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.BoxScope |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.CompositionLocalProvider |
| import androidx.compose.runtime.compositionLocalOf |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberCoroutineScope |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.composed |
| import androidx.compose.ui.draw.drawWithCache |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.Shape |
| import androidx.compose.ui.graphics.drawOutline |
| import androidx.compose.ui.graphics.graphicsLayer |
| import androidx.compose.ui.input.key.NativeKeyEvent |
| import androidx.compose.ui.input.key.onKeyEvent |
| import androidx.compose.ui.layout.layout |
| import androidx.compose.ui.platform.debugInspectorInfo |
| import androidx.compose.ui.semantics.disabled |
| import androidx.compose.ui.semantics.onClick |
| import androidx.compose.ui.semantics.semantics |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.dp |
| import androidx.tv.material3.tokens.Elevation |
| import kotlinx.coroutines.launch |
| |
| /** |
| * The [Surface] is a building block component that will be used for any focusable |
| * element on TV such as buttons, cards, navigation, etc. This clickable Surface is similar to |
| * Compose Material's Surface composable but will have more functionality that will make focus |
| * management easier. [Surface] will automatically apply the relevant modifier(s) based on |
| * the current interaction state. |
| * |
| * @param onClick callback to be called when the surface is clicked. Note: DPad Enter button won't |
| * work if this value is null |
| * @param modifier Modifier to be applied to the layout corresponding to the surface |
| * @param enabled Controls the enabled state of the surface. When `false`, this Surface will not be |
| * clickable or focusable. |
| * @param tonalElevation When [color] is [ColorScheme.surface], a higher the elevation will result |
| * in a darker color in light theme and lighter color in dark theme. |
| * @param shape Defines the surface's shape. |
| * @param color Color to be used on background of the Surface |
| * @param contentColor The preferred content color provided by this Surface to its children. |
| * @param scale Defines size of the Surface relative to its original size. |
| * @param border Defines a border around the Surface. |
| * @param glow Diffused shadow to be shown behind the Surface. |
| * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s |
| * for this Surface. You can create and pass in your own remembered [MutableInteractionSource] if |
| * you want to observe [Interaction]s and customize the appearance / behavior of this Surface in |
| * different [Interaction]s. |
| * @param content defines the [Composable] content inside the surface |
| */ |
| @ExperimentalTvMaterial3Api |
| @Composable |
| fun Surface( |
| onClick: () -> Unit, |
| modifier: Modifier = Modifier, |
| enabled: Boolean = true, |
| tonalElevation: Dp = 0.dp, |
| shape: ClickableSurfaceShape = ClickableSurfaceDefaults.shape(), |
| color: ClickableSurfaceColor = ClickableSurfaceDefaults.color(), |
| contentColor: ClickableSurfaceColor = ClickableSurfaceDefaults.contentColor(), |
| scale: ClickableSurfaceScale = ClickableSurfaceDefaults.scale(), |
| border: ClickableSurfaceBorder = ClickableSurfaceDefaults.border(), |
| glow: ClickableSurfaceGlow = ClickableSurfaceDefaults.glow(), |
| interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, |
| content: @Composable (BoxScope.() -> Unit) |
| ) { |
| val focused by interactionSource.collectIsFocusedAsState() |
| val pressed by interactionSource.collectIsPressedAsState() |
| SurfaceImpl( |
| modifier = modifier.tvClickable( |
| enabled = enabled, |
| onClick = onClick, |
| interactionSource = interactionSource, |
| ), |
| checked = false, |
| enabled = enabled, |
| tonalElevation = tonalElevation, |
| shape = ClickableSurfaceDefaults.shape( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| shape = shape |
| ), |
| color = ClickableSurfaceDefaults.color( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| color = color |
| ), |
| contentColor = ClickableSurfaceDefaults.color( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| color = contentColor |
| ), |
| scale = ClickableSurfaceDefaults.scale( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| scale = scale |
| ), |
| border = ClickableSurfaceDefaults.border( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| border = border |
| ), |
| glow = ClickableSurfaceDefaults.glow( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| glow = glow |
| ), |
| interactionSource = interactionSource, |
| content = content |
| ) |
| } |
| |
| /** |
| * The Surface is a building block component that will be used for any focusable |
| * element on TV such as buttons, cards, navigation, etc. |
| * |
| * This version of Surface is responsible for a toggling its checked state as well as everything |
| * else that a regular Surface does: |
| * |
| * This version of surface will react to the check toggles, calling |
| * [onCheckedChange] lambda, updating the [interactionSource] when [PressInteraction] occurs, and |
| * showing ripple indication in response to press events. If you don't need check |
| * handling, consider using a Surface function that doesn't require [onCheckedChange] param. |
| * |
| * To manually retrieve the content color inside a surface, use [LocalContentColor]. |
| * |
| * @param checked whether or not this Surface is toggled on or off |
| * @param onCheckedChange callback to be invoked when the toggleable Surface is clicked |
| * @param modifier Modifier to be applied to the layout corresponding to the surface |
| * @param enabled Controls the enabled state of the surface. When `false`, this Surface will not be |
| * clickable or focusable. |
| * @param tonalElevation When [color] is [ColorScheme.surface], a higher the elevation will result |
| * in a darker color in light theme and lighter color in dark theme. |
| * @param shape Defines the surface's shape. |
| * @param color Color to be used on background of the Surface |
| * @param contentColor The preferred content color provided by this Surface to its children. |
| * @param scale Defines size of the Surface relative to its original size. |
| * @param border Defines a border around the Surface. |
| * @param glow Diffused shadow to be shown behind the Surface. |
| * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s |
| * for this Surface. You can create and pass in your own remembered [MutableInteractionSource] if |
| * you want to observe [Interaction]s and customize the appearance / behavior of this Surface in |
| * different [Interaction]s. |
| * @param content defines the [Composable] content inside the surface |
| */ |
| @ExperimentalTvMaterial3Api |
| @Composable |
| fun Surface( |
| checked: Boolean, |
| onCheckedChange: (Boolean) -> Unit, |
| modifier: Modifier = Modifier, |
| enabled: Boolean = true, |
| tonalElevation: Dp = Elevation.Level0, |
| shape: ToggleableSurfaceShape = ToggleableSurfaceDefaults.shape(), |
| color: ToggleableSurfaceColor = ToggleableSurfaceDefaults.color(), |
| contentColor: ToggleableSurfaceColor = ToggleableSurfaceDefaults.contentColor(), |
| scale: ToggleableSurfaceScale = ToggleableSurfaceDefaults.scale(), |
| border: ToggleableSurfaceBorder = ToggleableSurfaceDefaults.border(), |
| glow: ToggleableSurfaceGlow = ToggleableSurfaceDefaults.glow(), |
| interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, |
| content: @Composable (BoxScope.() -> Unit) |
| ) { |
| val focused by interactionSource.collectIsFocusedAsState() |
| val pressed by interactionSource.collectIsPressedAsState() |
| |
| SurfaceImpl( |
| modifier = modifier.tvToggleable( |
| enabled = enabled, |
| checked = checked, |
| onCheckedChange = onCheckedChange, |
| interactionSource = interactionSource, |
| ), |
| checked = checked, |
| enabled = enabled, |
| tonalElevation = tonalElevation, |
| shape = ToggleableSurfaceDefaults.shape( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked, |
| shape = shape |
| ), |
| color = ToggleableSurfaceDefaults.color( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked, |
| color = color |
| ), |
| contentColor = ToggleableSurfaceDefaults.color( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked, |
| color = contentColor |
| ), |
| scale = ToggleableSurfaceDefaults.scale( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked, |
| scale = scale |
| ), |
| border = ToggleableSurfaceDefaults.border( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked, |
| border = border |
| ), |
| glow = ToggleableSurfaceDefaults.glow( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked, |
| glow = glow |
| ), |
| interactionSource = interactionSource, |
| content = content |
| ) |
| } |
| |
| @ExperimentalTvMaterial3Api |
| @Composable |
| private fun SurfaceImpl( |
| modifier: Modifier, |
| checked: Boolean, |
| enabled: Boolean, |
| shape: Shape, |
| color: Color, |
| contentColor: Color, |
| scale: Float, |
| border: Border, |
| glow: Glow, |
| tonalElevation: Dp, |
| interactionSource: MutableInteractionSource, |
| content: @Composable (BoxScope.() -> Unit) |
| ) { |
| val focused by interactionSource.collectIsFocusedAsState() |
| val pressed by interactionSource.collectIsPressedAsState() |
| |
| val surfaceAlpha = stateAlpha( |
| enabled = enabled, |
| focused = focused, |
| pressed = pressed, |
| selected = checked |
| ) |
| |
| val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation |
| |
| CompositionLocalProvider( |
| LocalContentColor provides contentColor, |
| LocalAbsoluteTonalElevation provides absoluteElevation |
| ) { |
| val zIndex by animateFloatAsState( |
| targetValue = if (focused) FocusedZIndex else NonFocusedZIndex |
| ) |
| |
| val backgroundColorByState = surfaceColorAtElevation( |
| color = color, |
| elevation = LocalAbsoluteTonalElevation.current |
| ) |
| |
| Box( |
| modifier = modifier |
| .indication( |
| interactionSource = interactionSource, |
| indication = remember(scale) { ScaleIndication(scale = scale) } |
| ) |
| .indication( |
| interactionSource = interactionSource, |
| indication = rememberGlowIndication( |
| color = surfaceColorAtElevation( |
| color = glow.elevationColor, |
| elevation = glow.elevation |
| ), |
| shape = shape, |
| glowBlurRadius = glow.elevation |
| ) |
| ) |
| // Increasing the zIndex of this Surface when it is in the focused state to |
| // avoid the glowIndication from being overlapped by subsequent items if |
| // this Surface is inside a list composable (like a Row/Column). |
| .layout { measurable, constraints -> |
| val placeable = measurable.measure(constraints) |
| layout(placeable.width, placeable.height) { |
| placeable.place(0, 0, zIndex = zIndex) |
| } |
| } |
| .then( |
| if (border != Border.None) { |
| Modifier.indication( |
| interactionSource = interactionSource, |
| indication = remember { BorderIndication(border = border) } |
| ) |
| } else Modifier |
| ) |
| .drawWithCache { |
| onDrawBehind { |
| drawOutline( |
| outline = shape.createOutline( |
| size = size, |
| layoutDirection = layoutDirection, |
| density = Density(density, fontScale) |
| ), |
| color = backgroundColorByState |
| ) |
| } |
| } |
| .graphicsLayer { |
| this.alpha = surfaceAlpha |
| this.shape = shape |
| this.clip = true |
| }, |
| propagateMinConstraints = true |
| ) { |
| Box( |
| modifier = Modifier.graphicsLayer { |
| this.alpha = if (!enabled) DisabledContentAlpha else EnabledContentAlpha |
| }, |
| content = content |
| ) |
| } |
| } |
| } |
| |
| /** |
| * This modifier handles click, press, and focus events for a TV composable. |
| * @param enabled decides whether [onClick] is executed |
| * @param onClick executes the provided lambda |
| * @param interactionSource used to emit [PressInteraction] events |
| */ |
| private fun Modifier.tvClickable( |
| enabled: Boolean, |
| onClick: (() -> Unit)?, |
| interactionSource: MutableInteractionSource |
| ) = this |
| .handleDPadEnter( |
| enabled = enabled, |
| interactionSource = interactionSource, |
| onClick = onClick |
| ) |
| .focusable(interactionSource = interactionSource) |
| .semantics(mergeDescendants = true) { |
| onClick { |
| onClick?.let { nnOnClick -> |
| nnOnClick() |
| return@onClick true |
| } |
| false |
| } |
| if (!enabled) { |
| disabled() |
| } |
| } |
| |
| /** |
| * This modifier handles click, press, and focus events for a TV composable. |
| * @param enabled decides whether [onCheckedChange] is executed |
| * @param checked differentiates whether the current item is checked or unchecked |
| * @param onCheckedChange executes the provided lambda while returning the inverse state of |
| * [checked] |
| */ |
| private fun Modifier.tvToggleable( |
| enabled: Boolean, |
| checked: Boolean, |
| onCheckedChange: (Boolean) -> Unit, |
| interactionSource: MutableInteractionSource, |
| ) = handleDPadEnter( |
| enabled = enabled, |
| interactionSource = interactionSource, |
| checked = checked, |
| onCheckedChanged = onCheckedChange |
| ) |
| .focusable(enabled = enabled, interactionSource = interactionSource) |
| .semantics(mergeDescendants = true) { |
| onClick { |
| onCheckedChange(!checked) |
| true |
| } |
| if (!enabled) { |
| disabled() |
| } |
| } |
| |
| /** |
| * This modifier is used to perform some actions when the user clicks the D-PAD enter button |
| * |
| * @param enabled if this is false, the D-PAD enter event is ignored |
| * @param interactionSource used to emit [PressInteraction] events |
| * @param onClick this lambda will be triggered on D-PAD enter event |
| * @param checked differentiates whether the current item is checked or unchecked |
| * @param onCheckedChanged executes the provided lambda while returning the inverse state of |
| * [checked] |
| */ |
| private fun Modifier.handleDPadEnter( |
| enabled: Boolean, |
| interactionSource: MutableInteractionSource, |
| onClick: (() -> Unit)? = null, |
| checked: Boolean = false, |
| onCheckedChanged: ((Boolean) -> Unit)? = null |
| ) = composed( |
| inspectorInfo = debugInspectorInfo { |
| name = "handleDPadEnter" |
| properties["enabled"] = enabled |
| properties["interactionSource"] = interactionSource |
| properties["onClick"] = onClick |
| properties["checked"] = checked |
| properties["onCheckedChanged"] = onCheckedChanged |
| } |
| ) { |
| val coroutineScope = rememberCoroutineScope() |
| val pressInteraction = remember { PressInteraction.Press(Offset.Zero) } |
| var isPressed by remember { mutableStateOf(false) } |
| this.then( |
| onKeyEvent { keyEvent -> |
| if (AcceptableKeys.any { keyEvent.nativeKeyEvent.keyCode == it } && enabled) { |
| when (keyEvent.nativeKeyEvent.action) { |
| NativeKeyEvent.ACTION_DOWN -> { |
| if (!isPressed) { |
| isPressed = true |
| coroutineScope.launch { |
| interactionSource.emit(pressInteraction) |
| } |
| } |
| } |
| |
| NativeKeyEvent.ACTION_UP -> { |
| if (isPressed) { |
| isPressed = false |
| coroutineScope.launch { |
| interactionSource.emit(PressInteraction.Release(pressInteraction)) |
| } |
| onClick?.invoke() |
| onCheckedChanged?.invoke(!checked) |
| } |
| } |
| } |
| return@onKeyEvent KeyEventPropagation.StopPropagation |
| } |
| KeyEventPropagation.ContinuePropagation |
| } |
| ) |
| } |
| |
| @Composable |
| @ExperimentalTvMaterial3Api |
| private fun surfaceColorAtElevation(color: Color, elevation: Dp): Color { |
| return if (color == MaterialTheme.colorScheme.surface) { |
| MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) |
| } else { |
| color |
| } |
| } |
| |
| /** |
| * Returns the alpha value for Surface's background based on its current indication state. The |
| * value ranges between 0f and 1f. |
| */ |
| private fun stateAlpha( |
| enabled: Boolean, |
| focused: Boolean, |
| pressed: Boolean, |
| selected: Boolean |
| ): Float { |
| return when { |
| !enabled && pressed -> DisabledPressedStateAlpha |
| !enabled && focused -> DisabledFocusedStateAlpha |
| !enabled && selected -> DisabledSelectedStateAlpha |
| enabled -> EnabledContentAlpha |
| else -> DisabledDefaultStateAlpha |
| } |
| } |
| |
| private const val DisabledPressedStateAlpha = 0.8f |
| private const val DisabledFocusedStateAlpha = 0.8f |
| private const val DisabledSelectedStateAlpha = 0.8f |
| private const val DisabledDefaultStateAlpha = 0.6f |
| |
| private const val FocusedZIndex = 0.5f |
| private const val NonFocusedZIndex = 0f |
| |
| private const val DisabledContentAlpha = 0.8f |
| internal const val EnabledContentAlpha = 1f |
| |
| /** |
| * CompositionLocal containing the current absolute elevation provided by Surface components. This |
| * absolute elevation is a sum of all the previous elevations. Absolute elevation is only used for |
| * calculating surface tonal colors, and is *not* used for drawing the shadow in a [SurfaceImpl]. |
| */ |
| val LocalAbsoluteTonalElevation = compositionLocalOf { 0.dp } |
| private val AcceptableKeys = listOf( |
| NativeKeyEvent.KEYCODE_DPAD_CENTER, |
| NativeKeyEvent.KEYCODE_ENTER, |
| NativeKeyEvent.KEYCODE_NUMPAD_ENTER |
| ) |