Browse Source

[resources] Support SVG drawables for non android platforms (#4605)

pull/4628/head v1.6.10-dev1583
Konstantin 7 months ago committed by GitHub
parent
commit
f0c1094dc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      components/gradle.properties
  2. 13
      components/resources/demo/shared/build.gradle.kts
  3. 8
      components/resources/demo/shared/src/androidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.android.kt
  4. 7
      components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt
  5. 1
      components/resources/demo/shared/src/nonAndroidMain/composeResources/drawable/sailing.svg
  6. 39
      components/resources/demo/shared/src/nonAndroidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.nonAndroid.kt
  7. 14
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ImageResources.android.kt
  8. 32
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt
  9. 95
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/DrawCache.kt
  10. 14
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
  11. 91
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt

2
components/gradle.properties

@ -8,7 +8,7 @@ android.useAndroidX=true
#Versions
kotlin.version=1.9.23
compose.version=1.6.10-dev1566
compose.version=1.6.10-dev1575
agp.version=8.1.2
#Compose

13
components/resources/demo/shared/build.gradle.kts

@ -50,21 +50,32 @@ kotlin {
}
}
applyDefaultHierarchyTemplate()
sourceSets {
all {
languageSettings {
optIn("org.jetbrains.compose.resources.ExperimentalResourceApi")
}
}
val desktopMain by getting
val wasmJsMain by getting
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.material3)
implementation(project(":resources:library"))
}
val desktopMain by getting
desktopMain.dependencies {
implementation(compose.desktop.common)
}
val nonAndroidMain by creating {
dependsOn(commonMain.get())
wasmJsMain.dependsOn(this)
desktopMain.dependsOn(this)
nativeMain.get().dependsOn(this)
jsMain.get().dependsOn(this)
}
}
}

8
components/resources/demo/shared/src/androidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.android.kt

@ -0,0 +1,8 @@
package org.jetbrains.compose.resources.demo.shared
import androidx.compose.runtime.Composable
@Composable
actual fun SvgShowcase() {
//Android platform doesn't support SVG resources
}

7
components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt

@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import components.resources.demo.shared.generated.resources.Res
import components.resources.demo.shared.generated.resources.*
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.imageResource
import org.jetbrains.compose.resources.vectorResource
import org.jetbrains.compose.resources.painterResource
@ -22,6 +23,7 @@ fun ImagesRes(contentPadding: PaddingValues) {
Column(
modifier = Modifier.padding(contentPadding).verticalScroll(rememberScrollState()),
) {
SvgShowcase()
OutlinedCard(modifier = Modifier.padding(8.dp)) {
Column(
modifier = Modifier.padding(16.dp).fillMaxWidth().fillMaxWidth(),
@ -175,4 +177,7 @@ fun ImagesRes(contentPadding: PaddingValues) {
}
}
}
}
}
@Composable
expect fun SvgShowcase()

1
components/resources/demo/shared/src/nonAndroidMain/composeResources/drawable/sailing.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m120-420 320-460v460H120Zm153-80h87v-125l-87 125Zm227 80q12-28 26-98t14-142q0-72-13.5-148T500-920q61 18 121.5 67t109 117q48.5 68 79 149.5T840-420H500Zm104-80h148q-17-77-55.5-141T615-750q2 21 3.5 43.5T620-660q0 47-4.5 87T604-500ZM360-200q-36 0-67-17t-53-43q-14 15-30.5 28T173-211q-35-26-59.5-64.5T80-360h800q-9 46-33.5 84.5T787-211q-20-8-36.5-21T720-260q-23 26-53.5 43T600-200q-36 0-67-17t-53-43q-22 26-53 43t-67 17ZM80-40v-80h40q32 0 62.5-10t57.5-30q27 20 57.5 29.5T360-121q32 0 62-9.5t58-29.5q27 20 57.5 29.5T600-121q32 0 62-9.5t58-29.5q28 20 58 30t62 10h40v80h-40q-31 0-61-7.5T720-70q-29 15-59 22.5T600-40q-31 0-61-7.5T480-70q-29 15-59 22.5T360-40q-31 0-61-7.5T240-70q-29 15-59 22.5T120-40H80Zm280-460Zm244 0Z"/></svg>

After

Width:  |  Height:  |  Size: 817 B

39
components/resources/demo/shared/src/nonAndroidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.nonAndroid.kt

@ -0,0 +1,39 @@
package org.jetbrains.compose.resources.demo.shared
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import components.resources.demo.shared.generated.resources.Res
import components.resources.demo.shared.generated.resources.sailing
import org.jetbrains.compose.resources.painterResource
@Composable
actual fun SvgShowcase() {
OutlinedCard(modifier = Modifier.padding(8.dp)) {
Column(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
modifier = Modifier.size(100.dp),
painter = painterResource(Res.drawable.sailing),
contentDescription = null
)
Text(
"""
Image(
painter = painterResource(Res.drawable.sailing)
)
""".trimIndent()
)
}
}
}

14
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ImageResources.android.kt

@ -3,6 +3,18 @@ package org.jetbrains.compose.resources
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Density
internal actual fun ByteArray.toImageBitmap(): ImageBitmap =
BitmapFactory.decodeByteArray(this, 0, size).asImageBitmap()
BitmapFactory.decodeByteArray(this, 0, size).asImageBitmap()
internal actual class SvgElement
internal actual fun ByteArray.toSvgElement(): SvgElement {
error("Android platform doesn't support SVG format.")
}
internal actual fun SvgElement.toSvgPainter(density: Density): Painter {
error("Android platform doesn't support SVG format.")
}

32
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt

@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@ -49,9 +50,10 @@ fun DrawableResource(path: String): DrawableResource = DrawableResource(
fun painterResource(resource: DrawableResource): Painter {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val filePath = remember(resource, environment) { resource.getResourceItemByEnvironment(environment).path }
val isXml = filePath.endsWith(".xml", true)
if (isXml) {
if (filePath.endsWith(".xml", true)) {
return rememberVectorPainter(vectorResource(resource))
} else if (filePath.endsWith(".svg", true)) {
return svgPainter(resource)
} else {
return BitmapPainter(imageResource(resource))
}
@ -69,7 +71,7 @@ private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) }
@Composable
fun imageResource(resource: DrawableResource): ImageBitmap {
val resourceReader = LocalResourceReader.current
val imageBitmap by rememberResourceState(resource, { emptyImageBitmap }) { env ->
val imageBitmap by rememberResourceState(resource, resourceReader, { emptyImageBitmap }) { env ->
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Bitmap(it.toImageBitmap())
@ -94,7 +96,7 @@ private val emptyImageVector: ImageVector by lazy {
fun vectorResource(resource: DrawableResource): ImageVector {
val resourceReader = LocalResourceReader.current
val density = LocalDensity.current
val imageVector by rememberResourceState(resource, { emptyImageVector }) { env ->
val imageVector by rememberResourceState(resource, resourceReader, density, { emptyImageVector }) { env ->
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Vector(it.toXmlElement().toImageVector(density))
@ -104,12 +106,34 @@ fun vectorResource(resource: DrawableResource): ImageVector {
return imageVector
}
internal expect class SvgElement
internal expect fun SvgElement.toSvgPainter(density: Density): Painter
private val emptySvgPainter: Painter by lazy { BitmapPainter(emptyImageBitmap) }
@OptIn(ExperimentalResourceApi::class)
@Composable
private fun svgPainter(resource: DrawableResource): Painter {
val resourceReader = LocalResourceReader.current
val density = LocalDensity.current
val svgPainter by rememberResourceState(resource, resourceReader, density, { emptySvgPainter }) { env ->
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Svg(it.toSvgElement().toSvgPainter(density))
} as ImageCache.Svg
cached.painter
}
return svgPainter
}
internal expect fun ByteArray.toImageBitmap(): ImageBitmap
internal expect fun ByteArray.toXmlElement(): Element
internal expect fun ByteArray.toSvgElement(): SvgElement
private sealed interface ImageCache {
class Bitmap(val bitmap: ImageBitmap) : ImageCache
class Vector(val vector: ImageVector) : ImageCache
class Svg(val painter: Painter) : ImageCache
}
private val imageCacheMutex = Mutex()

95
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/DrawCache.kt

@ -0,0 +1,95 @@
package org.jetbrains.compose.resources
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.toSize
/**
* Creates a drawing environment that directs its drawing commands to an [ImageBitmap]
* which can be drawn directly in another [DrawScope] instance. This is useful to cache
* complicated drawing commands across frames especially if the content has not changed.
* Additionally some drawing operations such as rendering paths are done purely in
* software so it is beneficial to cache the result and render the contents
* directly through a texture as done by [DrawScope.drawImage]
*/
internal class DrawCache {
@PublishedApi internal var mCachedImage: ImageBitmap? = null
private var cachedCanvas: Canvas? = null
private var scopeDensity: Density? = null
private var layoutDirection: LayoutDirection = LayoutDirection.Ltr
private var size: IntSize = IntSize.Zero
private var config: ImageBitmapConfig = ImageBitmapConfig.Argb8888
private val cacheScope = CanvasDrawScope()
/**
* Draw the contents of the lambda with receiver scope into an [ImageBitmap] with the provided
* size. If the same size is provided across calls, the same [ImageBitmap] instance is
* re-used and the contents are cleared out before drawing content in it again
*/
fun drawCachedImage(
config: ImageBitmapConfig,
size: IntSize,
density: Density,
layoutDirection: LayoutDirection,
block: DrawScope.() -> Unit
) {
this.scopeDensity = density
this.layoutDirection = layoutDirection
var targetImage = mCachedImage
var targetCanvas = cachedCanvas
if (targetImage == null ||
targetCanvas == null ||
size.width > targetImage.width ||
size.height > targetImage.height ||
this.config != config
) {
targetImage = ImageBitmap(size.width, size.height, config = config)
targetCanvas = Canvas(targetImage)
mCachedImage = targetImage
cachedCanvas = targetCanvas
this.config = config
}
this.size = size
cacheScope.draw(density, layoutDirection, targetCanvas, size.toSize()) {
clear()
block()
}
targetImage.prepareToDraw()
}
/**
* Draw the cached content into the provided [DrawScope] instance
*/
fun drawInto(
target: DrawScope,
alpha: Float = 1.0f,
colorFilter: ColorFilter? = null
) {
val targetImage = mCachedImage
check(targetImage != null) {
"drawCachedImage must be invoked first before attempting to draw the result " +
"into another destination"
}
target.drawImage(targetImage, srcSize = size, alpha = alpha, colorFilter = colorFilter)
}
/**
* Helper method to clear contents of the draw environment from the given bounds of the
* DrawScope
*/
private fun DrawScope.clear() {
drawRect(color = Color.Black, blendMode = BlendMode.Clear)
}
}

14
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt

@ -1,8 +1,20 @@
package org.jetbrains.compose.resources
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.Data
import org.jetbrains.skia.Image
import org.jetbrains.skia.svg.SVGDOM
internal actual fun ByteArray.toImageBitmap(): ImageBitmap =
Image.makeFromEncoded(this).toComposeImageBitmap()
Image.makeFromEncoded(this).toComposeImageBitmap()
internal actual class SvgElement(val svgdom: SVGDOM)
internal actual fun ByteArray.toSvgElement(): SvgElement =
SvgElement(SVGDOM(Data.makeFromBytes(this)))
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
SvgPainter(svgdom, density)

91
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt

@ -0,0 +1,91 @@
package org.jetbrains.compose.resources
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import org.jetbrains.skia.Rect
import org.jetbrains.skia.svg.SVGDOM
import org.jetbrains.skia.svg.SVGLength
import org.jetbrains.skia.svg.SVGLengthUnit
import org.jetbrains.skia.svg.SVGPreserveAspectRatio
import org.jetbrains.skia.svg.SVGPreserveAspectRatioAlign
import kotlin.math.ceil
internal class SvgPainter(
private val dom: SVGDOM,
private val density: Density
) : Painter() {
private val root = dom.root
private val defaultSizePx: Size = run {
val width = root?.width?.withUnit(SVGLengthUnit.PX)?.value ?: 0f
val height = root?.height?.withUnit(SVGLengthUnit.PX)?.value ?: 0f
if (width == 0f && height == 0f) {
Size.Unspecified
} else {
Size(width, height)
}
}
init {
if (root?.viewBox == null && defaultSizePx.isSpecified) {
root?.viewBox = Rect.makeXYWH(0f, 0f, defaultSizePx.width, defaultSizePx.height)
}
}
override val intrinsicSize: Size get() {
return if (defaultSizePx.isSpecified) {
defaultSizePx * density.density
} else {
Size.Unspecified
}
}
private var previousDrawSize: Size = Size.Unspecified
private var alpha: Float = 1.0f
private var colorFilter: ColorFilter? = null
// with caching into bitmap FPS is 3x-4x higher (tested with idea-logo.svg with 30x30 icons)
private val drawCache = DrawCache()
override fun applyAlpha(alpha: Float): Boolean {
this.alpha = alpha
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
this.colorFilter = colorFilter
return true
}
override fun DrawScope.onDraw() {
if (previousDrawSize != size) {
drawCache.drawCachedImage(
ImageBitmapConfig.Argb8888,
IntSize(ceil(size.width).toInt(), ceil(size.height).toInt()),
density = this,
layoutDirection,
) {
drawSvg(size)
}
}
drawCache.drawInto(this, alpha, colorFilter)
}
private fun DrawScope.drawSvg(size: Size) {
drawIntoCanvas { canvas ->
root?.width = SVGLength(size.width, SVGLengthUnit.PX)
root?.height = SVGLength(size.height, SVGLengthUnit.PX)
root?.preserveAspectRatio = SVGPreserveAspectRatio(SVGPreserveAspectRatioAlign.NONE)
dom.render(canvas.nativeCanvas)
}
}
}
Loading…
Cancel
Save