diff --git a/components/gradle.properties b/components/gradle.properties index 82482a1c16..d8b21eb066 100644 --- a/components/gradle.properties +++ b/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 diff --git a/components/resources/demo/shared/build.gradle.kts b/components/resources/demo/shared/build.gradle.kts index dfa29aca24..cecd7b3f1d 100644 --- a/components/resources/demo/shared/build.gradle.kts +++ b/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) + } } } diff --git a/components/resources/demo/shared/src/androidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.android.kt b/components/resources/demo/shared/src/androidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.android.kt new file mode 100644 index 0000000000..4d28aabc79 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt index fb05488b8e..d7be3ddefb 100644 --- a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.kt +++ b/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) { } } } -} \ No newline at end of file +} + +@Composable +expect fun SvgShowcase() \ No newline at end of file diff --git a/components/resources/demo/shared/src/nonAndroidMain/composeResources/drawable/sailing.svg b/components/resources/demo/shared/src/nonAndroidMain/composeResources/drawable/sailing.svg new file mode 100644 index 0000000000..ba23d0fe09 --- /dev/null +++ b/components/resources/demo/shared/src/nonAndroidMain/composeResources/drawable/sailing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/resources/demo/shared/src/nonAndroidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.nonAndroid.kt b/components/resources/demo/shared/src/nonAndroidMain/kotlin/org/jetbrains/compose/resources/demo/shared/ImagesRes.nonAndroid.kt new file mode 100644 index 0000000000..97aed78e9e --- /dev/null +++ b/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() + ) + } + } +} \ No newline at end of file diff --git a/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ImageResources.android.kt b/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ImageResources.android.kt index b6caca1ea5..0e411ce719 100644 --- a/components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ImageResources.android.kt +++ b/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() \ No newline at end of file + 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.") +} \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt index 9f2db79983..2940095041 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt +++ b/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() diff --git a/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/DrawCache.kt b/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/DrawCache.kt new file mode 100644 index 0000000000..e1dfb4d82e --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt b/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt index be168978eb..a046534554 100644 --- a/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt +++ b/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() \ No newline at end of file + 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) \ No newline at end of file diff --git a/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt b/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt new file mode 100644 index 0000000000..21f389dad4 --- /dev/null +++ b/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) + } + } +} \ No newline at end of file