749 lines
29 KiB
Markdown
749 lines
29 KiB
Markdown
When using surfaces to build a custom component, refer to the following source
|
|
code in `Surface.kt`:
|
|
|
|
|
|
```kotlin
|
|
/*
|
|
* Copyright 2025 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.xr.glimmer
|
|
|
|
import android.graphics.RuntimeShader
|
|
import android.os.Build
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.compose.animation.core.Animatable
|
|
import androidx.compose.animation.core.AnimationSpec
|
|
import androidx.compose.animation.core.AnimationVector1D
|
|
import androidx.compose.animation.core.LinearOutSlowInEasing
|
|
import androidx.compose.animation.core.spring
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.interaction.FocusInteraction
|
|
import androidx.compose.foundation.interaction.Interaction
|
|
import androidx.compose.foundation.interaction.InteractionSource
|
|
import androidx.compose.foundation.interaction.PressInteraction
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.Immutable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.geometry.Size
|
|
import androidx.compose.ui.graphics.Brush
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.Shader
|
|
import androidx.compose.ui.graphics.ShaderBrush
|
|
import androidx.compose.ui.graphics.Shape
|
|
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
|
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
|
import androidx.compose.ui.layout.layout
|
|
import androidx.compose.ui.node.DrawModifierNode
|
|
import androidx.compose.ui.node.ModifierNodeElement
|
|
import androidx.compose.ui.node.invalidateDraw
|
|
import androidx.compose.ui.node.requireGraphicsContext
|
|
import androidx.compose.ui.platform.InspectorInfo
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.lerp
|
|
import kotlinx.coroutines.CoroutineStart
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.isActive
|
|
import kotlinx.coroutines.launch
|
|
import org.intellij.lang.annotations.Language
|
|
|
|
/**
|
|
* A surface is a fundamental building block in Glimmer. A surface represents a distinct visual area
|
|
* or 'physical' boundary for components such as buttons and cards. A [surface] implements shared
|
|
* visual decoration for Jetpack Compose Glimmer components:
|
|
* 1) Clipping: a surface clips its children to the shape specified by [shape]
|
|
* 2) Border: a surface draws an inner [border] to emphasize the boundary of the component. When
|
|
* focused, a surface draws a wider border with a focused highlight on top to indicate the focus
|
|
* state.
|
|
* 3) Background: a surface has a background color of [color].
|
|
* 4) Depth effect: a surface can have different [DepthEffect] shadows for different states, as
|
|
* specified by [depthEffect].
|
|
* 5) Content color: a surface provides a [contentColor] for text and icons inside the surface. By
|
|
* default this is calculated from the provided background color.
|
|
* 6) Interaction states: when focused, a surface displays draws a wider border with a focused
|
|
* highlight on top. When pressed, a surface draws a pressed overlay. This happens for
|
|
* interactions emitted from [interactionSource].
|
|
*
|
|
* Use surface on its own for decorative elements that cannot be interacted with by a user:
|
|
*
|
|
* @sample androidx.xr.glimmer.samples.SurfaceSample
|
|
*
|
|
* In most cases surfaces should be interactive, to allow users to consistently move focus and
|
|
* navigate between components. You can use [androidx.compose.foundation.focusable] for focus-only
|
|
* surfaces, or [androidx.compose.foundation.clickable] and other modifiers for surfaces with
|
|
* actions. To ensure the surface correctly reflects the interaction states, provide the same
|
|
* [InteractionSource] to all modifiers.
|
|
*
|
|
* For example, to create a clickable surface:
|
|
*
|
|
* @sample androidx.xr.glimmer.samples.ClickableSurfaceSample
|
|
*
|
|
* Similarly, to create a focusable surface:
|
|
*
|
|
* @sample androidx.xr.glimmer.samples.FocusableSurfaceSample
|
|
* @param enabled controls the enabled state of this surface. When `false`, a disabled overlay
|
|
* visual will be drawn on top of the surface. Note that this only affects the visual decoration;
|
|
* it does not intercept input or block interaction states (such as focus or press) from the
|
|
* [interactionSource].
|
|
* @param shape the [Shape] used to clip this surface, and also used to draw the background and
|
|
* border
|
|
* @param color the background [Color] for this surface
|
|
* @param contentColor the [Color] for content inside this surface
|
|
* @param depthEffect the [SurfaceDepthEffect] for this surface, representing the [DepthEffect]
|
|
* shadows rendered in different states.
|
|
* @param border an optional inner border for this surface
|
|
* @param interactionSource the [InteractionSource] that emits [Interaction]s for this surface. For
|
|
* interactive surfaces, the [InteractionSource] instance provided to this surface must be shared
|
|
* with the modifier responsible for emitting [Interaction]s, such as
|
|
* [androidx.compose.foundation.focusable] or [androidx.compose.foundation.clickable].
|
|
*/
|
|
@Composable
|
|
public fun Modifier.surface(
|
|
enabled: Boolean = true,
|
|
shape: Shape = GlimmerTheme.shapes.medium,
|
|
color: Color = GlimmerTheme.colors.surface,
|
|
contentColor: Color = calculateContentColor(color),
|
|
depthEffect: SurfaceDepthEffect? = null,
|
|
border: BorderStroke? = SurfaceDefaults.border(),
|
|
interactionSource: InteractionSource? = null,
|
|
): Modifier {
|
|
return this.surfaceDepthEffect(depthEffect, shape, interactionSource)
|
|
.clip(shape)
|
|
.contentColorProvider(contentColor)
|
|
.then(
|
|
SurfaceNodeElement(
|
|
enabled = enabled,
|
|
shape = shape,
|
|
border = border,
|
|
interactionSource = interactionSource,
|
|
)
|
|
)
|
|
.background(color = color, shape = shape)
|
|
}
|
|
|
|
/**
|
|
* Represents the [DepthEffect] used by a [surface] in different states.
|
|
*
|
|
* Focused [surface]s with a [focusedDepthEffect] will have a higher zIndex set so they can draw
|
|
* their focused depth effect over siblings.
|
|
*
|
|
* @property [depthEffect] the [DepthEffect] used when the [surface] is in its default state (no
|
|
* other interactions are ongoing)
|
|
* @property [focusedDepthEffect] the [DepthEffect] used when the [surface] is focused
|
|
*/
|
|
@Immutable
|
|
public class SurfaceDepthEffect(
|
|
public val depthEffect: DepthEffect?,
|
|
public val focusedDepthEffect: DepthEffect?,
|
|
) {
|
|
override fun equals(other: Any?): Boolean {
|
|
if (this === other) return true
|
|
if (other !is SurfaceDepthEffect) return false
|
|
|
|
if (depthEffect != other.depthEffect) return false
|
|
if (focusedDepthEffect != other.focusedDepthEffect) return false
|
|
|
|
return true
|
|
}
|
|
|
|
override fun hashCode(): Int {
|
|
var result = depthEffect?.hashCode() ?: 0
|
|
result = 31 * result + (focusedDepthEffect?.hashCode() ?: 0)
|
|
return result
|
|
}
|
|
}
|
|
|
|
/** Default values used for [surface]. */
|
|
public object SurfaceDefaults {
|
|
/**
|
|
* Create the default [BorderStroke] used for a [surface]. Use the other overload in order to
|
|
* change the width or color.
|
|
*/
|
|
@Composable
|
|
public fun border(): BorderStroke {
|
|
return GlimmerTheme.LocalGlimmerTheme.current.defaultSurfaceBorder
|
|
}
|
|
|
|
/**
|
|
* Create the default [BorderStroke] used for a [surface], with optional overrides for [width]
|
|
* and [color].
|
|
*
|
|
* @param width width of the border in [Dp]. Use [Dp.Hairline] for one-pixel border.
|
|
* @param color color to paint the border with
|
|
*/
|
|
@Composable
|
|
public fun border(
|
|
width: Dp = DefaultSurfaceBorderWidth,
|
|
color: Color = GlimmerTheme.colors.outline,
|
|
): BorderStroke {
|
|
return BorderStroke(width, color)
|
|
}
|
|
|
|
/** Returns the default (cached) border for a [surface]. */
|
|
internal val GlimmerTheme.defaultSurfaceBorder: BorderStroke
|
|
get() {
|
|
return defaultSurfaceBorderCached
|
|
?: BorderStroke(DefaultSurfaceBorderWidth, colors.outline).also {
|
|
defaultSurfaceBorderCached = it
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Surface node responsible for drawing the border, focused border and highlight, and pressed
|
|
* overlay.
|
|
*/
|
|
private class SurfaceNodeElement(
|
|
private val enabled: Boolean,
|
|
private val shape: Shape,
|
|
private val border: BorderStroke?,
|
|
private val interactionSource: InteractionSource?,
|
|
) : ModifierNodeElement<SurfaceNode>() {
|
|
override fun create(): SurfaceNode =
|
|
SurfaceNode(
|
|
enabled = enabled,
|
|
shape = shape,
|
|
border = border,
|
|
interactionSource = interactionSource,
|
|
)
|
|
|
|
override fun update(node: SurfaceNode) =
|
|
node.update(
|
|
enabled = enabled,
|
|
shape = shape,
|
|
border = border,
|
|
interactionSource = interactionSource,
|
|
)
|
|
|
|
override fun equals(other: Any?): Boolean {
|
|
if (this === other) return true
|
|
if (other !is SurfaceNodeElement) return false
|
|
|
|
if (enabled != other.enabled) return false
|
|
if (shape != other.shape) return false
|
|
if (border != other.border) return false
|
|
if (interactionSource != other.interactionSource) return false
|
|
|
|
return true
|
|
}
|
|
|
|
override fun hashCode(): Int {
|
|
var result = enabled.hashCode()
|
|
result = 31 * result + shape.hashCode()
|
|
result = 31 * result + (border?.hashCode() ?: 0)
|
|
result = 31 * result + (interactionSource?.hashCode() ?: 0)
|
|
return result
|
|
}
|
|
|
|
override fun InspectorInfo.inspectableProperties() {
|
|
name = "surface"
|
|
properties["enabled"] = enabled
|
|
properties["shape"] = shape
|
|
properties["border"] = border
|
|
properties["interactionSource"] = interactionSource
|
|
}
|
|
}
|
|
|
|
private class SurfaceNode(
|
|
private var enabled: Boolean,
|
|
private var shape: Shape,
|
|
private var border: BorderStroke?,
|
|
private var interactionSource: InteractionSource?,
|
|
) : DrawModifierNode, Modifier.Node() {
|
|
|
|
override val shouldAutoInvalidate = false
|
|
|
|
// Cache borders and highlight for unfocused and focused states. This means we
|
|
// can avoid recreating these for a given surface, if the border and shape never
|
|
// change. Changing between unfocused and focus states only requires a draw
|
|
// invalidation, as the borders are already cached.
|
|
|
|
// Unfocused border
|
|
private val unfocusedBorderLogic: BorderLogic = BorderLogic()
|
|
private val unfocusedBorderWidth: () -> Dp = { border?.width ?: 0.dp }
|
|
// Focused border - this consists of two layers. A 'base' layer (which is the
|
|
// unfocused border with a different size) and the highlight we draw on top of
|
|
// this base layer. We need to increase the size of the underlying border to
|
|
// make sure that the highlight area fully matches the underlying border, to
|
|
// avoid inconsistent areas of coverage due to the transparency of the
|
|
// highlight.
|
|
private var focusedBorderLogic: BorderLogic? = null
|
|
private var focusedHighlightBorderLogic: BorderLogic? = null
|
|
private var focusedBorderWidth: (() -> Dp)? = null
|
|
|
|
// Highlight shader / brush
|
|
var shader: Shader? = null
|
|
var shaderBrush: Brush? = null
|
|
|
|
// Graphics Layer
|
|
private var unfocusedBorderLayer: GraphicsLayer? = null
|
|
private var unfocusedGraphicsLayerProvider: (() -> GraphicsLayer)? = null
|
|
|
|
private var focusedBorderLayer: GraphicsLayer? = null
|
|
private var focusedGraphicsLayerProvider: (() -> GraphicsLayer)? = null
|
|
|
|
private var focusedHighlightBorderLayer: GraphicsLayer? = null
|
|
private var focusedHighlightGraphicsLayerProvider: (() -> GraphicsLayer)? = null
|
|
|
|
private var interactionCollectionJob: Job? = null
|
|
|
|
// Enter / exit animation progress for the width and fade effect applied to the highlight
|
|
private var _focusedHighlightProgress: Animatable<Float, AnimationVector1D>? = null
|
|
private val focusedHighlightProgress
|
|
get() = _focusedHighlightProgress?.value ?: 0f
|
|
|
|
// Rotation progress applied to the highlight
|
|
private var _focusedHighlightRotationProgress: Animatable<Float, AnimationVector1D>? = null
|
|
private val focusedHighlightRotationProgress
|
|
get() = _focusedHighlightRotationProgress?.value ?: 0f
|
|
|
|
private var pressedOverlayAlpha: Animatable<Float, AnimationVector1D>? = null
|
|
// Job that runs for a minimum duration to make sure quick presses are still visible
|
|
private var minimumPressDuration: Job? = null
|
|
private var pressReleaseAnimation: Job? = null
|
|
|
|
fun update(
|
|
enabled: Boolean,
|
|
shape: Shape,
|
|
border: BorderStroke?,
|
|
interactionSource: InteractionSource?,
|
|
) {
|
|
if (this.enabled != enabled) {
|
|
this.enabled = enabled
|
|
invalidateDraw()
|
|
}
|
|
if (this.shape != shape) {
|
|
this.shape = shape
|
|
invalidateDraw()
|
|
}
|
|
if (this.border != border) {
|
|
this.border = border
|
|
invalidateDraw()
|
|
}
|
|
if (this.interactionSource != interactionSource) {
|
|
this.interactionSource = interactionSource
|
|
observeInteractions()
|
|
}
|
|
}
|
|
|
|
override fun onAttach() {
|
|
observeInteractions()
|
|
}
|
|
|
|
var isFocused = false
|
|
set(value) {
|
|
if (field != value) {
|
|
field = value
|
|
if (value) {
|
|
startFocusAnimation()
|
|
} else {
|
|
stopFocusAnimation()
|
|
}
|
|
// No need to invalidate the border cache - we build it ahead of time to account for
|
|
// focus changes. Just invalidate draw so we can switch to drawing the correct
|
|
// border.
|
|
invalidateDraw()
|
|
}
|
|
}
|
|
|
|
var isPressed = false
|
|
set(value) {
|
|
if (field != value) {
|
|
field = value
|
|
if (value) {
|
|
pressedOverlayAlpha = pressedOverlayAlpha ?: Animatable(0f)
|
|
pressReleaseAnimation?.cancel()
|
|
minimumPressDuration?.cancel()
|
|
|
|
minimumPressDuration =
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
delay(PressedOverlayMinimumDurationMillis)
|
|
}
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
pressedOverlayAlpha?.animateTo(
|
|
PressedOverlayAlpha,
|
|
PressedOverlayEnterAnimationSpec,
|
|
)
|
|
}
|
|
} else {
|
|
pressedOverlayAlpha?.let { progress ->
|
|
pressReleaseAnimation =
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
minimumPressDuration?.join()
|
|
progress.animateTo(0f, PressedOverlayExitAnimationSpec)
|
|
}
|
|
}
|
|
}
|
|
invalidateDraw()
|
|
}
|
|
}
|
|
|
|
private fun observeInteractions() {
|
|
interactionCollectionJob?.cancel()
|
|
interactionCollectionJob = null
|
|
isFocused = false
|
|
isPressed = false
|
|
interactionSource?.let { source ->
|
|
interactionCollectionJob =
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
var focusCount = 0
|
|
var pressCount = 0
|
|
source.interactions.collect { interaction ->
|
|
when (interaction) {
|
|
is FocusInteraction.Focus -> focusCount++
|
|
is FocusInteraction.Unfocus -> focusCount--
|
|
is PressInteraction.Press -> pressCount++
|
|
is PressInteraction.Release -> pressCount--
|
|
is PressInteraction.Cancel -> pressCount--
|
|
}
|
|
isFocused = focusCount > 0
|
|
isPressed = pressCount > 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startFocusAnimation() {
|
|
_focusedHighlightProgress = _focusedHighlightProgress ?: Animatable(0f)
|
|
_focusedHighlightRotationProgress = _focusedHighlightRotationProgress ?: Animatable(0f)
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
_focusedHighlightProgress?.snapTo(0f)
|
|
_focusedHighlightProgress?.animateTo(
|
|
targetValue = 1f,
|
|
animationSpec = FocusedEnterAnimationSpec,
|
|
)
|
|
}
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
_focusedHighlightRotationProgress?.snapTo(0f)
|
|
_focusedHighlightRotationProgress?.animateTo(
|
|
targetValue = 1f,
|
|
animationSpec = FocusedHighlightRotationAnimationSpec,
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun stopFocusAnimation() {
|
|
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
|
_focusedHighlightProgress?.animateTo(
|
|
targetValue = 0f,
|
|
animationSpec = FocusedExitAnimationSpec,
|
|
)
|
|
if (isActive) {
|
|
_focusedHighlightRotationProgress?.snapTo(0f)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun ContentDrawScope.draw() {
|
|
val outline = shape.createOutline(size, layoutDirection, this)
|
|
drawContent()
|
|
val pressedOverlayColor = PressedOverlayColor.copy(alpha = pressedOverlayAlpha?.value ?: 0f)
|
|
drawRect(color = pressedOverlayColor)
|
|
if (border != null) {
|
|
val progress = focusedHighlightProgress
|
|
val gContext = requireGraphicsContext()
|
|
if (progress > 0f) {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
val rotationProgressRadians =
|
|
(FocusedHighlightRotationEndAngleRadians -
|
|
FocusedHighlightRotationStartAngleRadians) *
|
|
focusedHighlightRotationProgress
|
|
val rotationRadians =
|
|
FocusedHighlightRotationStartAngleRadians + rotationProgressRadians
|
|
shader =
|
|
HighlightShaderHelper.configureShader(
|
|
shader = shader,
|
|
size = size,
|
|
rotationRadians = rotationRadians.toFloat(),
|
|
progress = progress,
|
|
)
|
|
shaderBrush = shaderBrush ?: ShaderBrush(shader!!)
|
|
}
|
|
focusedBorderLogic = focusedBorderLogic ?: BorderLogic()
|
|
focusedHighlightBorderLogic = focusedHighlightBorderLogic ?: BorderLogic()
|
|
focusedBorderWidth =
|
|
focusedBorderWidth
|
|
?: {
|
|
val b = border
|
|
if (b != null) {
|
|
lerp(
|
|
b.width,
|
|
FocusedSurfaceBorderWidth,
|
|
// Capture class property instead of function-local progress to
|
|
// make sure this will read the animation state when the lambda
|
|
// is invoked and not capture a stale variable
|
|
focusedHighlightProgress,
|
|
)
|
|
} else {
|
|
0.dp
|
|
}
|
|
}
|
|
focusedBorderLogic!!.drawBorder(
|
|
this,
|
|
focusedBorderWidth!!,
|
|
border!!.brush,
|
|
focusedGraphicsLayerProvider
|
|
?: {
|
|
focusedBorderLayer
|
|
?: gContext.createGraphicsLayer().also {
|
|
focusedBorderLayer = it
|
|
}
|
|
}
|
|
.also { focusedGraphicsLayerProvider = it },
|
|
outline,
|
|
)
|
|
|
|
shaderBrush?.let {
|
|
focusedHighlightBorderLogic!!.drawBorder(
|
|
this,
|
|
focusedBorderWidth!!,
|
|
it,
|
|
focusedHighlightGraphicsLayerProvider
|
|
?: {
|
|
focusedHighlightBorderLayer
|
|
?: gContext.createGraphicsLayer().also {
|
|
focusedHighlightBorderLayer = it
|
|
}
|
|
}
|
|
.also { focusedHighlightGraphicsLayerProvider = it },
|
|
outline,
|
|
)
|
|
}
|
|
} else {
|
|
unfocusedBorderLogic.drawBorder(
|
|
this,
|
|
unfocusedBorderWidth,
|
|
border!!.brush,
|
|
unfocusedGraphicsLayerProvider
|
|
?: {
|
|
unfocusedBorderLayer
|
|
?: gContext.createGraphicsLayer().also {
|
|
unfocusedBorderLayer = it
|
|
}
|
|
}
|
|
.also { unfocusedGraphicsLayerProvider = it },
|
|
outline,
|
|
)
|
|
}
|
|
}
|
|
if (!enabled) {
|
|
drawRect(DisabledOverlayColor)
|
|
}
|
|
}
|
|
|
|
override fun onDetach() {
|
|
_focusedHighlightProgress = null
|
|
_focusedHighlightRotationProgress = null
|
|
pressedOverlayAlpha = null
|
|
|
|
unfocusedGraphicsLayerProvider = null
|
|
val gContext = requireGraphicsContext()
|
|
unfocusedBorderLayer?.let {
|
|
gContext.releaseGraphicsLayer(it)
|
|
unfocusedBorderLayer = null
|
|
}
|
|
|
|
focusedGraphicsLayerProvider = null
|
|
focusedBorderLayer?.let {
|
|
gContext.releaseGraphicsLayer(it)
|
|
focusedBorderLayer = null
|
|
}
|
|
|
|
focusedHighlightGraphicsLayerProvider = null
|
|
focusedHighlightBorderLayer?.let {
|
|
gContext.releaseGraphicsLayer(it)
|
|
focusedHighlightBorderLayer = null
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders and animates a [surface]'s [depthEffect] for a given [shape], by observing
|
|
* [interactionSource].
|
|
*/
|
|
@Composable
|
|
private fun Modifier.surfaceDepthEffect(
|
|
depthEffect: SurfaceDepthEffect?,
|
|
shape: Shape,
|
|
interactionSource: InteractionSource?,
|
|
): Modifier {
|
|
if (depthEffect == null) return this
|
|
val focusedProgress = remember { Animatable(0f) }
|
|
// If focused and there is focused depth effect, we need to draw the surface on top of
|
|
// other siblings to make sure the depth effect occludes siblings.
|
|
val zIndex by remember {
|
|
// Derived to avoid invalidating layout each frame of the animation
|
|
derivedStateOf {
|
|
if (depthEffect.focusedDepthEffect != null && focusedProgress.value >= 0.5f) 1f else 0f
|
|
}
|
|
}
|
|
if (interactionSource != null) {
|
|
LaunchedEffect(interactionSource) {
|
|
interactionSource.interactions.collect { interaction ->
|
|
when (interaction) {
|
|
is FocusInteraction.Focus ->
|
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
|
focusedProgress.animateTo(1f, FocusedEnterAnimationSpec)
|
|
}
|
|
|
|
is FocusInteraction.Unfocus ->
|
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
|
focusedProgress.animateTo(0f, FocusedExitAnimationSpec)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return layout { measurable, constraints ->
|
|
val placeable = measurable.measure(constraints)
|
|
layout(placeable.width, placeable.height) { placeable.place(0, 0, zIndex = zIndex) }
|
|
}
|
|
.depthEffect(
|
|
from = depthEffect.depthEffect,
|
|
to = depthEffect.focusedDepthEffect,
|
|
shape = shape,
|
|
progress = { focusedProgress.value },
|
|
)
|
|
}
|
|
|
|
/** Default border width for a [surface]. */
|
|
private val DefaultSurfaceBorderWidth = 2.dp
|
|
|
|
/** Focused border width for a [surface]. */
|
|
private val FocusedSurfaceBorderWidth = 5.dp
|
|
|
|
/** Enter animation for focus highlight and depth effect */
|
|
private val FocusedEnterAnimationSpec: AnimationSpec<Float> =
|
|
spring(dampingRatio = 1f, stiffness = 600f)
|
|
|
|
/** Exit animation for focus highlight and depth effect */
|
|
private val FocusedExitAnimationSpec: AnimationSpec<Float> =
|
|
spring(dampingRatio = 1f, stiffness = 100f)
|
|
|
|
private val FocusedHighlightRotationAnimationSpec: AnimationSpec<Float> =
|
|
tween(durationMillis = 700, easing = LinearOutSlowInEasing, delayMillis = 40)
|
|
|
|
private val FocusedHighlightRotationStartAngleRadians: Double = Math.toRadians(-100.0)
|
|
|
|
private val FocusedHighlightRotationEndAngleRadians: Double = Math.toRadians(35.0)
|
|
|
|
private val PressedOverlayColor = Color.White
|
|
|
|
internal val DisabledOverlayColor = Color(0x8F191919)
|
|
|
|
private const val PressedOverlayAlpha = 0.16f
|
|
|
|
private val PressedOverlayEnterAnimationSpec: AnimationSpec<Float> =
|
|
spring(dampingRatio = 0.84f, stiffness = 8000f)
|
|
|
|
private val PressedOverlayExitAnimationSpec: AnimationSpec<Float> =
|
|
spring(dampingRatio = 0.85f, stiffness = 50f)
|
|
|
|
private const val PressedOverlayMinimumDurationMillis = 300L
|
|
|
|
@Language(value = "AGSL")
|
|
private const val FocusedHighlightShader =
|
|
"""
|
|
/**
|
|
* Rotating linear gradient shader, where rotation is controlled by iRotation uniform.
|
|
* This is essentially the same as:
|
|
* LinearGradientShader(
|
|
* colors = FocusedHighlightColors,
|
|
* colorStops = FocusedHighlightColorStops,
|
|
* from = Offset.Zero,
|
|
* to = Offset(size.width, size.height),
|
|
* )
|
|
* But allowing for efficient rotation, instead of needing to create a new shader / brush every
|
|
* frame with new coordinates.
|
|
*/
|
|
// Width / height
|
|
uniform float2 iResolution;
|
|
// Rotation in radians. 0 radians means a horizontal gradient.
|
|
// Positive values will have the effect of rotating the gradient clockwise.
|
|
uniform float iRotation;
|
|
// Alpha animation progress from 0 to 1. This will be applied to the color stops so that each
|
|
// color stop will fade in.
|
|
uniform float iAlphaProgress;
|
|
|
|
half4 main(float2 fragCoord) {
|
|
// Horizontal gradient
|
|
half4 colors[4];
|
|
colors[0] = half4(1.0, 1.0, 1.0, 1.0 * iAlphaProgress); // White with 100% alpha
|
|
colors[1] = half4(1.0, 1.0, 1.0, 0.2 * iAlphaProgress); // White with 20% alpha
|
|
colors[2] = half4(1.0, 1.0, 1.0, 0.2 * iAlphaProgress); // White with 20% alpha
|
|
colors[3] = half4(1.0, 1.0, 1.0, 0.8 * iAlphaProgress); // White with 80% alpha
|
|
|
|
// Stops for the horizontal gradient
|
|
float stops[4];
|
|
stops[0] = 0.0;
|
|
stops[1] = 0.3;
|
|
stops[2] = 0.66;
|
|
stops[3] = 1.0;
|
|
|
|
// Normalize
|
|
half2 uv = fragCoord.xy / iResolution.xy;
|
|
|
|
// Offset around a rotational center
|
|
half2 rotationCenter = half2(0.5, 0.5);
|
|
uv -= rotationCenter;
|
|
|
|
// Rotate
|
|
// We rotate in the opposite direction as we are rotating the coordinate we sample the gradient
|
|
// from. To create the effect of a gradient 'moving' clockwise, we need to move the
|
|
// coordinate in the opposite direction (counter-clockwise).
|
|
float2x2 matrix = float2x2(cos(-iRotation),-sin(-iRotation),sin(-iRotation),cos(-iRotation));
|
|
uv *= matrix;
|
|
|
|
// Translate back into [0,1] space
|
|
uv += rotationCenter;
|
|
|
|
// Blend through stops using the x coordinate, since we have a horizontal gradient
|
|
half4 color = mix(colors[0], colors[1], smoothstep(stops[0], stops[1], uv.x));
|
|
color = mix(color, colors[2], smoothstep(stops[1], stops[2], uv.x));
|
|
color = mix(color, colors[3], smoothstep(stops[2], stops[3], uv.x));
|
|
|
|
return color;
|
|
}
|
|
"""
|
|
|
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
|
private object HighlightShaderHelper {
|
|
@JvmStatic
|
|
fun configureShader(
|
|
shader: Shader?,
|
|
size: Size,
|
|
rotationRadians: Float,
|
|
progress: Float,
|
|
): Shader {
|
|
val shader = shader as? RuntimeShader ?: RuntimeShader(FocusedHighlightShader)
|
|
shader.setFloatUniform("iResolution", size.width, size.height)
|
|
shader.setFloatUniform("iRotation", rotationRadians)
|
|
shader.setFloatUniform("iAlphaProgress", progress)
|
|
return shader
|
|
}
|
|
}
|
|
```
|
|
|
|
<br /> |