All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.seiko.imageloader.ui.AutoSizeImage.kt Maven / Gradle / Ivy

The newest version!
package com.seiko.imageloader.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.withSave
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.times
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.node.invalidateMeasurement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.toOffset
import com.seiko.imageloader.ImageLoader
import com.seiko.imageloader.LocalImageLoader
import com.seiko.imageloader.model.ImageEvent
import com.seiko.imageloader.model.ImageRequest
import com.seiko.imageloader.model.ImageResult
import com.seiko.imageloader.option.AsyncSizeResolver
import com.seiko.imageloader.toPainter
import com.seiko.imageloader.util.AnimationPainter
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt

// AutoSizeImage ≈ AutoSizeBox + Painter
@Composable
fun AutoSizeImage(
    request: ImageRequest,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    alignment: Alignment = Alignment.Center,
    contentScale: ContentScale = ContentScale.Fit,
    alpha: Float = DefaultAlpha,
    colorFilter: ColorFilter? = null,
    imageLoader: ImageLoader = LocalImageLoader.current,
    placeholderPainter: (@Composable () -> Painter)? = null,
    errorPainter: (@Composable () -> Painter)? = null,
    isOnlyPostFirstEvent: Boolean = true,
) {
    val semantics = if (contentDescription != null) {
        Modifier.semantics {
            this.contentDescription = contentDescription
            this.role = Role.Image
        }
    } else {
        Modifier
    }
    Layout(
        modifier.then(semantics).clipToBounds().autoSizeImageNode(
            request = request,
            alignment = alignment,
            contentScale = contentScale,
            alpha = alpha,
            colorFilter = colorFilter,
            imageLoader = imageLoader,
            placeholderPainter = placeholderPainter?.invoke(),
            errorPainter = errorPainter?.invoke(),
            isOnlyPostFirstEvent = isOnlyPostFirstEvent,
        ),
    ) { _, constraints ->
        layout(constraints.minWidth, constraints.minHeight) {}
    }
}

private fun Modifier.autoSizeImageNode(
    request: ImageRequest,
    alignment: Alignment,
    contentScale: ContentScale,
    alpha: Float,
    colorFilter: ColorFilter?,
    imageLoader: ImageLoader,
    placeholderPainter: Painter?,
    errorPainter: Painter?,
    isOnlyPostFirstEvent: Boolean,
): Modifier = this then AutoSizeImageNodeElement(
    request = request,
    alignment = alignment,
    contentScale = contentScale,
    alpha = alpha,
    colorFilter = colorFilter,
    imageLoader = imageLoader,
    placeholderPainter = placeholderPainter,
    errorPainter = errorPainter,
    isOnlyPostFirstEvent = isOnlyPostFirstEvent,
)

private data class AutoSizeImageNodeElement(
    private val request: ImageRequest,
    private val alignment: Alignment,
    private val contentScale: ContentScale,
    private val alpha: Float,
    private val colorFilter: ColorFilter?,
    private val imageLoader: ImageLoader,
    private val placeholderPainter: Painter?,
    private val errorPainter: Painter?,
    private val isOnlyPostFirstEvent: Boolean,
) : ModifierNodeElement() {

    override fun create(): AutoSizeImageNode {
        return AutoSizeImageNode(
            request = request,
            alignment = alignment,
            contentScale = contentScale,
            alpha = alpha,
            colorFilter = colorFilter,
            imageLoader = imageLoader,
            placeholderPainter = placeholderPainter,
            errorPainter = errorPainter,
        )
    }

    override fun update(node: AutoSizeImageNode) {
        node.update(
            request = request,
            alignment = alignment,
            contentScale = contentScale,
            alpha = alpha,
            colorFilter = colorFilter,
            imageLoader = imageLoader,
            placeholderPainter = placeholderPainter,
            errorPainter = errorPainter,
            isOnlyPostFirstEvent = isOnlyPostFirstEvent,
        )
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "autoSizeImage"
        properties["request"] = request
        properties["alignment"] = alignment
        properties["contentScale"] = contentScale
        properties["alpha"] = alpha
        properties["colorFilter"] = colorFilter
        properties["imageLoader"] = imageLoader
        properties["placeholderPainter"] = placeholderPainter
        properties["errorPainter"] = errorPainter
    }
}

private class AutoSizeImageNode(
    request: ImageRequest,
    private var alignment: Alignment,
    private var contentScale: ContentScale,
    private var alpha: Float,
    private var colorFilter: ColorFilter?,
    private var imageLoader: ImageLoader,
    private var placeholderPainter: Painter?,
    private var errorPainter: Painter?,
) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, CompositionLocalConsumerModifierNode {

    private var currentImageJob: Job? = null
    private var currentPlayerJob: Job? = null

    private var cachedSize: Size? = null

    private var drawPainter: Painter? = placeholderPainter
    private var drawPainterPositionAndSize: CachedPositionAndSize? = null

    private var hasFixedSize: Boolean = false
    private var isReset: Boolean = false

    private var request: ImageRequest = modifyRequest(request, cachedSize)

    override val shouldAutoInvalidate: Boolean
        get() = false

    override fun onAttach() {
        super.onAttach()
        isReset = false
        launchImage()
    }

    override fun onReset() {
        super.onReset()
        isReset = true
    }

    override fun onDetach() {
        super.onDetach()
        // if this node is reset from pool, not need to reset size
        if (isReset) {
            updatePainter(placeholderPainter)
        } else {
            hasFixedSize = false
            cachedSize = null
            updatePainter(null)
        }
    }

    fun update(
        request: ImageRequest,
        alignment: Alignment,
        contentScale: ContentScale,
        alpha: Float,
        colorFilter: ColorFilter?,
        imageLoader: ImageLoader,
        placeholderPainter: Painter?,
        errorPainter: Painter?,
        isOnlyPostFirstEvent: Boolean,
    ) {
        val finalRequest = modifyRequest(
            request = request,
            cachedSize = cachedSize,
            skipEvent = isOnlyPostFirstEvent,
        )
        val isRequestChange = this.request != finalRequest

        this.request = finalRequest
        this.alignment = alignment
        this.contentScale = contentScale
        this.alpha = alpha
        this.colorFilter = colorFilter
        this.imageLoader = imageLoader
        this.placeholderPainter = placeholderPainter
        this.errorPainter = errorPainter

        if (isAttached && isRequestChange) {
            launchImage()
        }
    }

    private fun launchImage() {
        currentImageJob?.cancel()
        currentImageJob = coroutineScope.launch {
            imageLoader.async(request).collect { action ->
                when (action) {
                    is ImageEvent -> placeholderPainter
                    is ImageResult.OfPainter -> action.painter
                    is ImageResult.OfBitmap -> action.bitmap.toPainter()
                    is ImageResult.OfImage -> action.image.toPainter()
                    is ImageResult.OfError -> errorPainter
                    is ImageResult.OfSource -> errorPainter
                }?.let { painter ->
                    updatePainter(painter)
                }
            }
        }
    }

    private fun updatePainter(painter: Painter?) {
        (drawPainter as? RememberObserver)?.onAbandoned()
        currentPlayerJob?.cancel()
        currentPlayerJob = null

        drawPainter = painter

        if (isAttached) {
            (painter as? RememberObserver)?.onRemembered()
            checkPainterPlay()
        }

        drawPainterPositionAndSize = null

        if (!hasFixedSize) {
            invalidateMeasurement()
        }
        invalidateDraw()
    }

    private fun checkPainterPlay() {
        val painter = drawPainter ?: return
        if (painter is AnimationPainter && painter.isPlay()) {
            currentPlayerJob?.cancel()
            currentPlayerJob = coroutineScope.launch {
                while (painter.nextPlay()) {
                    withFrameMillis { frameTimeMillis ->
                        painter.update(frameTimeMillis)
                    }
                }
            }
        }
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        drawPainterPositionAndSize = null
        hasFixedSize = constraints.hasFixedSize()
        cachedSize = constraints.inferredSize()

        val sizeResolver = request.sizeResolver
        if (sizeResolver is AsyncSizeResolver) {
            sizeResolver.setSize(cachedSize ?: Size.Unspecified)
        }

        val placeable = measurable.measure(
            modifyConstraints(constraints, drawPainter, contentScale),
        )
        return layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
        }
    }

    override fun ContentDrawScope.draw() {
        drawPainter?.let { painter ->
            drawContext.canvas.withSave {
                val positionAndSize = drawPainterPositionAndSize
                    ?: calcPositionAndSize(painter).also {
                        drawPainterPositionAndSize = it
                    }
                val dx = positionAndSize.position.x
                val dy = positionAndSize.position.y
                val scaledSize = positionAndSize.size
                translate(dx, dy) {
                    with(painter) {
                        draw(scaledSize, alpha, colorFilter)
                    }
                }
            }
        }

        // Maintain the same pattern as Modifier.drawBehind to allow chaining of DrawModifiers
        drawContent()
    }

    private fun ContentDrawScope.calcPositionAndSize(painter: Painter): CachedPositionAndSize {
        val intrinsicSize = painter.intrinsicSize
        val srcWidth = if (intrinsicSize.hasSpecifiedAndFiniteWidth()) {
            intrinsicSize.width
        } else {
            size.width
        }

        val srcHeight = if (intrinsicSize.hasSpecifiedAndFiniteHeight()) {
            intrinsicSize.height
        } else {
            size.height
        }
        val srcSize = Size(srcWidth, srcHeight)

        // Compute the offset to translate the content based on the given alignment
        // and size to draw based on the ContentScale parameter
        val scaledSize = if (size.width != 0f && size.height != 0f) {
            srcSize * contentScale.computeScaleFactor(srcSize, size)
        } else {
            Size.Zero
        }

        val alignedPosition = alignment.align(
            IntSize(scaledSize.width.roundToInt(), scaledSize.height.roundToInt()),
            IntSize(size.width.roundToInt(), size.height.roundToInt()),
            layoutDirection,
        ).toOffset()
        return CachedPositionAndSize(
            alignedPosition,
            scaledSize,
        )
    }

    override fun equals(other: Any?): Boolean {
        return (other is AutoSizeImageNode) &&
            request == other.request &&
            alignment == other.alignment &&
            contentScale == other.contentScale &&
            alpha == other.alpha &&
            colorFilter == other.colorFilter &&
            imageLoader == other.imageLoader
    }

    override fun hashCode(): Int {
        var result = request.hashCode()
        result = 31 * result + alignment.hashCode()
        result = 31 * result + contentScale.hashCode()
        result = 31 * result + alpha.hashCode()
        result = 31 * result + colorFilter.hashCode()
        result = 31 * result + imageLoader.hashCode()
        result = 31 * result + placeholderPainter.hashCode()
        result = 31 * result + errorPainter.hashCode()
        return result
    }

    override fun toString(): String {
        return "AutoSizeImageModifier(" +
            "painter=$drawPainter, " +
            "alignment=$alignment, " +
            "contentScale=$contentScale, " +
            "alpha=$alpha, " +
            "colorFilter=$colorFilter, " +
            "imageLoader=$imageLoader, " +
            "placeholderPainter=$placeholderPainter, " +
            "errorPainter=$errorPainter)"
    }
}

private fun modifyConstraints(
    constraints: Constraints,
    painter: Painter?,
    contentScale: ContentScale,
): Constraints {
    if (constraints.hasFixedSize()) {
        return constraints.copy(
            minWidth = constraints.maxWidth,
            minHeight = constraints.maxHeight,
        )
    }

    val intrinsicSize = painter?.intrinsicSize ?: return constraints
    val intrinsicWidth = if (intrinsicSize.hasSpecifiedAndFiniteWidth()) {
        intrinsicSize.width.roundToInt()
    } else {
        constraints.minWidth
    }

    val intrinsicHeight = if (intrinsicSize.hasSpecifiedAndFiniteHeight()) {
        intrinsicSize.height.roundToInt()
    } else {
        constraints.minHeight
    }

    // Scale the width and height appropriately based on the given constraints
    // and ContentScale
    val constrainedWidth = constraints.constrainWidth(intrinsicWidth)
    val constrainedHeight = constraints.constrainHeight(intrinsicHeight)
    val scaledSize = calculateScaledSize(
        Size(constrainedWidth.toFloat(), constrainedHeight.toFloat()),
        painter,
        contentScale,
    )

    // For both width and height constraints, consume the minimum of the scaled width
    // and the maximum constraint as some scale types can scale larger than the maximum
    // available size (ex ContentScale.Crop)
    // In this case the larger of the 2 dimensions is used and the aspect ratio is
    // maintained. Even if the size of the composable is smaller, the painter will
    // draw its content clipped
    val minWidth = constraints.constrainWidth(scaledSize.width.roundToInt())
    val minHeight = constraints.constrainHeight(scaledSize.height.roundToInt())
    return constraints.copy(minWidth = minWidth, minHeight = minHeight)
}

private fun calculateScaledSize(dstSize: Size, painter: Painter, contentScale: ContentScale): Size {
    val srcWidth = if (!painter.intrinsicSize.hasSpecifiedAndFiniteWidth()) {
        dstSize.width
    } else {
        painter.intrinsicSize.width
    }

    val srcHeight = if (!painter.intrinsicSize.hasSpecifiedAndFiniteHeight()) {
        dstSize.height
    } else {
        painter.intrinsicSize.height
    }

    val srcSize = Size(srcWidth, srcHeight)
    return if (dstSize.width != 0f && dstSize.height != 0f) {
        srcSize * contentScale.computeScaleFactor(srcSize, dstSize)
    } else {
        Size.Zero
    }
}

private fun Constraints.hasFixedSize() = hasFixedWidth && hasFixedHeight

internal fun Constraints.inferredSize(): Size {
    if (!hasBoundedWidth || !hasBoundedHeight) return Size.Unspecified
    return Size(maxWidth.toFloat(), maxHeight.toFloat())
}

private fun Size.hasSpecifiedAndFiniteWidth() = this != Size.Unspecified && width.isFinite()
private fun Size.hasSpecifiedAndFiniteHeight() = this != Size.Unspecified && height.isFinite()




© 2015 - 2024 Weber Informatics LLC | Privacy Policy