javaandroidandroid-animationandroid-resourcesandroid-tv

onFocus spotlight effect on android


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.
enter image description here

I have achieved something like this using a background drawable as shown below.
enter image description here

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:

  1. radial gradient doesn't exactly looks like what I am trying to achieve.
  2. It is not dynamic. I want to change the color of shadow based on dominant color in the image.

Solution

  • 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
                )
            },
        )
    }