draw the play indicator shadow with a framework shadow layer

Works below API 31, unlike Modifier.blur. Also give the playing cover
art an inset shadow along its border, drawn from the same layer.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent c3874ed1c3
commit 7555344057
@@ -1,5 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,11 +23,27 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.graphics.vector.VectorGroup
import androidx.compose.ui.graphics.vector.VectorPath
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -44,6 +61,26 @@ private val TRACK_COLUMN_WIDTH = 24.dp
private val TIME_COLUMN_WIDTH = 36.dp private val TIME_COLUMN_WIDTH = 36.dp
private val PLAY_ICON_SIZE = 22.dp private val PLAY_ICON_SIZE = 22.dp
private val MIN_SMALL_FONT = 8.sp private val MIN_SMALL_FONT = 8.sp
private val COVER_CORNER = 6.dp
private val PLAY_SHADOW_RADIUS = 3.dp
private val PLAY_SHADOW = Color.Black.copy(alpha = 0.6f)
private val PLAY_ARROW = Icons.Rounded.PlayArrow
private val PLAY_ARROW_PATH: Path by lazy { buildVectorPath(PLAY_ARROW) }
private fun buildVectorPath(vector: ImageVector): Path {
val parser = PathParser()
fun collect(group: VectorGroup) {
for (node in group) {
when (node) {
is VectorPath -> parser.addPathNodes(node.pathData)
is VectorGroup -> collect(node)
}
}
}
collect(vector.root)
return parser.toPath()
}
@Composable @Composable
fun SongRow( fun SongRow(
@@ -123,7 +160,7 @@ private fun CoverArtCell(
name: String, name: String,
isPlaying: Boolean, isPlaying: Boolean,
) { ) {
val shape = RoundedCornerShape(6.dp) val shape = RoundedCornerShape(COVER_CORNER)
Box(Modifier.size(48.dp)) { Box(Modifier.size(48.dp)) {
CoverArt( CoverArt(
ref = ref, ref = ref,
@@ -131,6 +168,7 @@ private fun CoverArtCell(
modifier = Modifier.size(48.dp).clip(shape), modifier = Modifier.size(48.dp).clip(shape),
) )
if (isPlaying) { if (isPlaying) {
Box(Modifier.matchParentSize().drawBehind { drawInnerShadow() })
Box( Box(
Modifier Modifier
.matchParentSize() .matchParentSize()
@@ -144,31 +182,61 @@ private fun CoverArtCell(
} }
} }
private fun DrawScope.drawInnerShadow() {
val corner = COVER_CORNER.toPx()
val shapePath =
Path().apply {
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(corner, corner)))
}
val outer =
Path().apply {
addRect(Rect(-size.width, -size.height, size.width * 2f, size.height * 2f))
}
val ring =
Path().apply {
op(outer, shapePath, PathOperation.Difference)
}
clipPath(shapePath) {
drawIntoCanvas { canvas ->
val paint =
android.graphics.Paint().apply {
isAntiAlias = true
color = android.graphics.Color.BLACK
setShadowLayer(PLAY_SHADOW_RADIUS.toPx(), 0f, 0f, PLAY_SHADOW.toArgb())
}
canvas.nativeCanvas.drawPath(ring.asAndroidPath(), paint)
}
}
}
@Composable @Composable
private fun PlayIndicator( private fun PlayIndicator(
shadow: Boolean, shadow: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier) { val tint = MaterialTheme.colorScheme.primary
if (shadow) { if (shadow) {
Icon( val radius = with(LocalDensity.current) { PLAY_SHADOW_RADIUS.toPx() }
imageVector = Icons.Rounded.PlayArrow, Canvas(modifier.semantics { contentDescription = "Playing" }) {
contentDescription = null, val scale = minOf(size.width / PLAY_ARROW.viewportWidth, size.height / PLAY_ARROW.viewportHeight)
tint = Color.Black.copy(alpha = 0.5f), withTransform({ scale(scale, scale, pivot = Offset.Zero) }) {
modifier = drawIntoCanvas { canvas ->
Modifier val paint =
.matchParentSize() android.graphics.Paint().apply {
.graphicsLayer { isAntiAlias = true
scaleX = 1.35f color = tint.toArgb()
scaleY = 1.35f setShadowLayer(radius, 0f, 0f, PLAY_SHADOW.toArgb())
}.blur(3.dp, BlurredEdgeTreatment.Unbounded), }
) canvas.nativeCanvas.drawPath(PLAY_ARROW_PATH.asAndroidPath(), paint)
}
}
} }
} else {
Icon( Icon(
imageVector = Icons.Rounded.PlayArrow, imageVector = PLAY_ARROW,
contentDescription = "Playing", contentDescription = "Playing",
tint = MaterialTheme.colorScheme.primary, tint = tint,
modifier = Modifier.matchParentSize(), modifier = modifier,
) )
} }
} }