Remote image crossfade

Sunday 5th July 2026

Unused composable from a recent project that crossfades remote images. The use-case was a ui that periodically fetched a random image Url from a server.

package yorkshire.systems.demo

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.request.ImageRequest

@Composable
fun RemoteImageBackground(
    interfaceVisible: Boolean,
    imageUrl: String?
) {
    if (imageUrl == null) return

    var currentUrl by remember { mutableStateOf(imageUrl) }
    var incomingUrl by remember { mutableStateOf<String?>(null) }

    LaunchedEffect(imageUrl) {
        incomingUrl = if (imageUrl != currentUrl) imageUrl else null
    }

    var incomingLoaded by remember(incomingUrl) { mutableStateOf(false) }
    val incomingAlpha by animateFloatAsState(
        targetValue = if (incomingLoaded) 1f else 0f,
        animationSpec = tween(durationMillis = 2000),
        label = "ImageFade",
        finishedListener = { value ->
            val incoming = incomingUrl
            if (value == 1f && incoming != null) {
                currentUrl = incoming
                incomingUrl = null
            }
        }
    )

    Box(Modifier.fillMaxSize()) {
        AsyncImage(
            model = ImageRequest.Builder(LocalContext.current)
                .data(currentUrl)
                .build(),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )
        incomingUrl?.let { url ->
            AsyncImage(
                model = ImageRequest.Builder(LocalContext.current)
                    .data(url)
                    .build(),
                contentDescription = null,
                contentScale = ContentScale.Crop,
                onState = { state ->
                    if (state is AsyncImagePainter.State.Success) incomingLoaded = true
                },
                modifier = Modifier
                    .fillMaxSize()
                    .alpha(incomingAlpha)
            )
        }
        if(interfaceVisible) {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.background.copy(alpha = if(isSystemInDarkTheme()){
                        0.55f
                    }else{
                        0.65f
                    }))
            )
        }
    }
}