whisker-image 0.6.0

Network image component for Whisker — fetches HTTP(S) URLs and renders into a native image view (Kingfisher on iOS, Coil on Android) with built-in memory + disk caching.
Documentation
// Lynx UI subclass hosting an ImageView + Coil-driven URL loading.
// A plain `WhiskerUI` subclass — no Whisker annotations; registration
// is driven by `ImageModule`'s `definition()` (see `ImageModule.kt`).

package rs.whisker.elements.image

import android.content.Context
import android.widget.ImageView
import coil.dispose
import coil.load
import coil.request.CachePolicy
import coil.request.Disposable
import coil.transform.RoundedCornersTransformation
import com.lynx.tasm.behavior.StylesDiffMap
import rs.whisker.runtime.WhiskerContext
import rs.whisker.runtime.WhiskerUI

open class WhiskerImageView(context: WhiskerContext) : WhiskerUI<ImageView>(context) {

    private var currentSrc: String? = null
    private var currentRequest: Disposable? = null
    /// Active corner radius in **device pixels** — the value Lynx's
    /// CSS pipeline has already converted from the `8px` source. Fed
    /// directly to Coil's `RoundedCornersTransformation`, which also
    /// takes pixels. `0f` means "no rounding".
    private var cornerRadiusPx: Float = 0f

    override fun createView(context: Context): ImageView {
        val v = ImageView(context)
        // Default to `aspectFill` (`CENTER_CROP`) to match the Lynx
        // `mode` default; `setMode(_)` flips it as soon as a non-
        // default value lands.
        v.scaleType = ImageView.ScaleType.CENTER_CROP
        return v
    }

    /// Intercept the CSS `border-radius` cascade before the base
    /// implementation runs. Whisker-registered custom UIs ship without
    /// an APT-generated `$$PropsSetter`, so Lynx's per-key dispatch
    /// path can't reach the typed `setBorderRadius(int, ReadableArray)`
    /// hook on `LynxBaseUI`. The kebab-case `border-*-radius` entries
    /// DO reach `StylesDiffMap.mBackingMap` though — we pull them out
    /// here and forward to Coil's bitmap transformation.
    ///
    /// Lynx splits the CSS shorthand into four per-corner properties.
    /// Each value is a 4-element `[x_px, x_unit, y_px, y_unit]` array
    /// (PlatformLength quartet, x_px already density-multiplied).
    /// `RoundedCornersTransformation` takes one uniform float, so we
    /// collapse to the largest corner.
    override fun updatePropertiesInterval(props: StylesDiffMap?) {
        super.updatePropertiesInterval(props)
        val map = props?.mBackingMap ?: return
        var maxPx = 0f
        var sawAny = false
        for (k in CORNER_KEYS) {
            if (!map.hasKey(k)) continue
            val arr = map.getArray(k) ?: continue
            if (arr.size() < 1) continue
            sawAny = true
            val px = arr.getDouble(0).toFloat()
            if (px > maxPx) maxPx = px
        }
        if (sawAny && maxPx != cornerRadiusPx) {
            cornerRadiusPx = maxPx
            reload()
        }
    }

    /**
     * Backing of the `src` prop. Kicks off a Coil request bound to
     * the ImageView itself. A second `setSrc` cancels the in-flight
     * request automatically — `ImageView.load { ... }` returns a
     * Disposable we cancel before issuing the next one.
     */
    fun setSrc(value: String) {
        // No-op on equal — avoids re-fetching on a benign re-render
        // (e.g. a parent re-renders but the src signal didn't
        // actually change). Coil would short-circuit via its memory
        // cache, but the request construction itself is non-zero.
        if (currentSrc == value) return
        currentSrc = value
        reload()
    }

    /**
     * Backing of the `mode` prop. Maps the Lynx-convention mode
     * strings onto `ImageView.ScaleType`. Unknown values fall back
     * to `aspectFill` (CENTER_CROP).
     */
    fun setMode(value: String) {
        val imageView = view ?: return
        imageView.scaleType = when (value) {
            "aspectFill" -> ImageView.ScaleType.CENTER_CROP
            "aspectFit" -> ImageView.ScaleType.FIT_CENTER
            "scaleToFill" -> ImageView.ScaleType.FIT_XY
            "center" -> ImageView.ScaleType.CENTER
            else -> ImageView.ScaleType.CENTER_CROP
        }
    }

    /**
     * Issue (or re-issue) a Coil request for the current `src` with
     * the current `cornerRadiusPx`. Called from `setSrc` and from
     * `updatePropertiesInterval` when the radius changes.
     */
    private fun reload() {
        val src = currentSrc ?: return
        val imageView = view ?: return

        // Cancel any prior request. `dispose()` is a no-op if the
        // disposable has already completed.
        currentRequest?.dispose()
        imageView.dispose()

        if (src.isBlank()) {
            imageView.setImageDrawable(null)
            currentRequest = null
            return
        }

        currentRequest = imageView.load(src) {
            crossfade(200)
            memoryCachePolicy(CachePolicy.ENABLED)
            diskCachePolicy(CachePolicy.ENABLED)
            if (cornerRadiusPx > 0f) {
                transformations(RoundedCornersTransformation(cornerRadiusPx))
            }
        }
    }

    private companion object {
        val CORNER_KEYS = listOf(
            "border-top-left-radius",
            "border-top-right-radius",
            "border-bottom-right-radius",
            "border-bottom-left-radius",
        )
    }
}