I am trying to achieve the spotlight effect whenever an item in a recycler view is focused. I am attaching a image below to show what I mean.
I have achieved something like this using a background drawable as shown below.
This is the code for drawable
bg_glow.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:startColor="#80e8eaed"
android:gradientRadius="50dp"
android:endColor="#00000000"
android:type="radial" />
</shape>
</item>
</layer-list>
I have created a custom view that adds this drawable whenever the image is focused. Here is the code.
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
if (gainFocus) {
categorySpotlight.background = ContextCompat.getDrawable(context, R.drawable.bg_glow)
ivappCategory.strokeWidth = 2
} else {
categorySpotlight.background = ContextCompat.getDrawable(context, R.drawable.transparent)
ivappCategory.strokeWidth = 0
}
}
Now there are a few problems with this:
Finally I was able to figure this out. We have to use Palette to extract dominant color from image. Then, we can use elements from androidx.tv.material3 such as cards or other elements that support glow properity.
@Composable
fun PosterImage(
movie: Meta,
modifier: Modifier = Modifier,
isBackground: Boolean = false,
onDominantColor: (Color) -> Unit = {},
) {
val fallbackColor = Color.White
var dominantColor by remember { mutableStateOf(fallbackColor) }
val imageUrl = if (isBackground) movie.background else movie.poster
var hasError by remember { mutableStateOf(false) }
if (imageUrl != null) {
LoadImage(
imageUrl = imageUrl,
modifier = modifier
.fillMaxSize()
.customShadow(dominantColor),
onSuccess = { bitmap ->
extractDominantColor(bitmap, fallbackColor) {
dominantColor = it
onDominantColor(it)
}
},
onError = {
hasError = true
}
)
if (hasError) {
Text("Failed to load image")
}
}
}
@Composable
fun LoadImage(
imageUrl: String,
modifier: Modifier = Modifier,
onSuccess: (Bitmap) -> Unit = {},
onError: () -> Unit = {}
) {
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.crossfade(true)
.size(400, 320) // Adjust as needed
.data(imageUrl)
.placeholder(android.R.drawable.ic_menu_gallery)
.error(android.R.drawable.ic_menu_report_image)
.build()
)
LaunchedEffect(painter.state) {
if (painter.state is AsyncImagePainter.State.Success) {
val imageResult = (painter.state as AsyncImagePainter.State.Success).result
onSuccess(imageResult.drawable.toBitmap())
} else if (painter.state is AsyncImagePainter.State.Error) {
onError()
}
}
Image(
painter = painter,
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Crop
)
}
fun Modifier.customShadow(dominantColor: Color) = this.shadow(
elevation = 30.dp,
shape = RectangleShape,
clip = false,
ambientColor = dominantColor
)
private fun extractDominantColor(
bitmap: Bitmap,
fallbackColor: Color,
onColorExtracted: (Color) -> Unit
) {
// Run the palette extraction on a background thread
CoroutineScope(Dispatchers.IO).launch {
try {
var palette: Palette? = null
var dominantColor = fallbackColor
if (bitmap.config == Bitmap.Config.HARDWARE) {
// For hardware bitmaps, we need to convert to ARGB_8888 first
val convertedBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, false)
palette = Palette.from(convertedBitmap).generate()
dominantColor = Color(palette.getDominantColor(fallbackColor.toArgb()))
} else {
// For other bitmap configurations, we can work directly with the original bitmap
palette = Palette.from(bitmap).generate()
dominantColor = Color(palette.getDominantColor(fallbackColor.toArgb()))
}
val dominantColorInt = dominantColor.toArgb()
val lightenedColorInt = ColorUtils.blendARGB(dominantColorInt, 0xFFFFFFFF.toInt(), 0.2f)
val lightenedColor = Color(lightenedColorInt)
dominantColor = lightenedColor
// Switch back to the main thread to update the UI
withContext(Dispatchers.Main) {
onColorExtracted(dominantColor)
}
} catch (e: Exception) {
// In case of any errors, fallback to the provided fallbackColor
Log.e("PosterImage", "Error extracting color fallback to default", e)
withContext(Dispatchers.Main) {
onColorExtracted(fallbackColor)
}
}
}
}
Then use that dominantColor wherever you want.
@Composable
fun MovieCard(
modifier: Modifier = Modifier,
dominantColor: Color = Color.White,
onClick: () -> Unit,
title: @Composable () -> Unit = {},
image: @Composable BoxScope.() -> Unit,
) {
Log.d("MovieCard", "MovieCard dominantColor: ${dominantColor.value}")
StandardCardContainer(
modifier = modifier,
title = title,
imageCard = {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(JetStreamCardShape),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(
elevationColor = dominantColor,
elevation = 30.dp
)
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
content = image
)
},
)
}