Browse Source

Added animated image component (#2015)

* Added animated image component

* Simplified network request

* Resource load is now done in the IO Dispatchers

* Renamed constant to express better its use

* Refactored animated image component to use the default Image component instead of creating our own component

* Added missing keys

* Created new module resources & adapted AnimatedImage to it
pull/2043/head
Abdelilah El Aissaoui 3 years ago committed by GitHub
parent
commit
96f0d98d27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 29
      components/AnimatedImage/demo/build.gradle.kts
  2. 45
      components/AnimatedImage/demo/src/jvmMain/kotlin/org/jetbrains/compose/animatedimage/demo/Main.kt
  3. BIN
      components/AnimatedImage/demo/src/jvmMain/resources/demo.webp
  4. 35
      components/AnimatedImage/library/build.gradle.kts
  5. 14
      components/AnimatedImage/library/src/commonMain/kotlin/org/jetbrains/compose/animatedimage/AnimatedImage.kt
  6. 24
      components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/AnimatedImageLoader.kt
  7. 86
      components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/DesktopAnimatedImage.kt
  8. 23
      components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/LocalAnimatedImageLoader.kt
  9. 21
      components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/NetworkAnimatedImageLoader.kt
  10. 11
      components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/ResourceAnimatedImageLoader.kt
  11. 33
      components/resources/library/build.gradle.kts
  12. 33
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/LoadState.kt
  13. 5
      components/settings.gradle.kts
  14. 3
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt

29
components/AnimatedImage/demo/build.gradle.kts

@ -0,0 +1,29 @@
import org.jetbrains.compose.compose
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
}
kotlin {
jvm {}
sourceSets {
named("jvmMain") {
dependencies {
implementation(compose.desktop.currentOs)
implementation(project(":AnimatedImage:library"))
}
}
}
}
compose.desktop {
application {
mainClass = "org.jetbrains.compose.animatedimage.demo.MainKt"
}
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}

45
components/AnimatedImage/demo/src/jvmMain/kotlin/org/jetbrains/compose/animatedimage/demo/Main.kt

@ -0,0 +1,45 @@
package org.jetbrains.compose.animatedimage.demo
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.singleWindowApplication
import org.jetbrains.compose.animatedimage.Blank
import org.jetbrains.compose.animatedimage.animate
import org.jetbrains.compose.animatedimage.loadAnimatedImage
import org.jetbrains.compose.resources.LoadState
import org.jetbrains.compose.resources.load
import org.jetbrains.compose.resources.loadOrNull
fun main() = singleWindowApplication {
val url =
"https://raw.githubusercontent.com/JetBrains/skija/ccf303ebcf926e5ef000fc42d1a6b5b7f1e0b2b5/examples/scenes/images/codecs/animated.gif"
// Load an image async
val animatedImage =
load { loadAnimatedImage(url) } // use "load { loadResourceAnimatedImage(url) }" for resources
Column {
when (animatedImage) {
is LoadState.Success -> Image(
bitmap = animatedImage.value.animate(),
contentDescription = null,
)
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text("Error!")
}
Column {
Image(
loadOrNull { loadAnimatedImage(url) }?.animate() ?: ImageBitmap.Blank,
contentDescription = null,
Modifier.size(100.dp)
)
}
}
}

BIN
components/AnimatedImage/demo/src/jvmMain/resources/demo.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

35
components/AnimatedImage/library/build.gradle.kts

@ -0,0 +1,35 @@
import org.jetbrains.compose.compose
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("maven-publish")
}
kotlin {
jvm("desktop")
sourceSets {
named("commonMain") {
dependencies {
api(compose.runtime)
api(compose.foundation)
api(project(":resources:library"))
}
}
named("desktopMain") {}
}
}
// TODO it seems that argument isn't applied to the common sourceSet. Figure out why
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
configureMavenPublication(
groupId = "org.jetbrains.compose.components",
artifactId = "components-animatedimage",
name = "AnimatedImage for Compose JB"
)

14
components/AnimatedImage/library/src/commonMain/kotlin/org/jetbrains/compose/animatedimage/AnimatedImage.kt

@ -0,0 +1,14 @@
package org.jetbrains.compose.animatedimage
import androidx.compose.ui.graphics.ImageBitmap
expect class AnimatedImage
expect suspend fun loadAnimatedImage(path: String): AnimatedImage
expect suspend fun loadResourceAnimatedImage(path: String): AnimatedImage
expect fun AnimatedImage.animate(): ImageBitmap
private val BlankBitmap = ImageBitmap(1, 1)
val ImageBitmap.Companion.Blank get() = BlankBitmap

24
components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/AnimatedImageLoader.kt

@ -0,0 +1,24 @@
/*
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.animatedimage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Codec
import org.jetbrains.skia.Data
internal abstract class AnimatedImageLoader {
suspend fun loadAnimatedImage(): AnimatedImage = withContext(Dispatchers.IO) {
val byteArray = generateByteArray()
val data = Data.makeFromBytes(byteArray)
val codec = Codec.makeFromData(data)
return@withContext AnimatedImage(codec)
}
abstract suspend fun generateByteArray(): ByteArray
}

86
components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/DesktopAnimatedImage.kt

@ -0,0 +1,86 @@
package org.jetbrains.compose.animatedimage
import androidx.compose.animation.core.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import org.jetbrains.skia.AnimationFrameInfo
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Codec
import java.net.MalformedURLException
import java.net.URL
private const val DEFAULT_FRAME_DURATION = 50
actual class AnimatedImage(val codec: Codec)
actual suspend fun loadAnimatedImage(path: String): AnimatedImage {
val loader = getAnimatedImageLoaderByPath(path)
return loader.loadAnimatedImage()
}
actual suspend fun loadResourceAnimatedImage(path: String): AnimatedImage {
val loader = ResourceAnimatedImageLoader(path)
return loader.loadAnimatedImage()
}
@Composable
actual fun AnimatedImage.animate(): ImageBitmap {
val transition = rememberInfiniteTransition()
val frameIndex by transition.animateValue(
initialValue = 0,
targetValue = codec.frameCount - 1,
Int.VectorConverter,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 0
for ((index, frame) in codec.framesInfo.withIndex()) {
index at durationMillis
val frameDuration = calcFrameDuration(frame)
durationMillis += frameDuration
}
}
)
)
val bitmap = remember(codec) { Bitmap().apply { allocPixels(codec.imageInfo) } }
remember(bitmap, frameIndex) {
codec.readPixels(bitmap, frameIndex)
}
return bitmap.asComposeImageBitmap()
}
private fun calcFrameDuration(frame: AnimationFrameInfo): Int {
var frameDuration = frame.duration
// If the frame does not contain information about a duration, set a reasonable constant duration
if (frameDuration == 0) {
frameDuration = DEFAULT_FRAME_DURATION
}
return frameDuration
}
/**
* Depending on the [path], provide a specific implementation of [AnimatedImageLoader]
* @return [NetworkAnimatedImageLoader] if it is a network URL, [LocalAnimatedImageLoader] otherwise
*/
private fun getAnimatedImageLoaderByPath(path: String): AnimatedImageLoader {
return if (isNetworkPath(path)) {
NetworkAnimatedImageLoader(path)
} else {
LocalAnimatedImageLoader(path)
}
}
private fun isNetworkPath(path: String): Boolean {
return try {
URL(path)
true
} catch (e: MalformedURLException) {
false
}
}

23
components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/LocalAnimatedImageLoader.kt

@ -0,0 +1,23 @@
package org.jetbrains.compose.animatedimage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileInputStream
internal class LocalAnimatedImageLoader(private val imageUrl: String) : AnimatedImageLoader() {
var cachedBytes: ByteArray? = null
override suspend fun generateByteArray(): ByteArray = withContext(Dispatchers.IO) {
var bytesArray: ByteArray? = cachedBytes
if (bytesArray == null) {
bytesArray = FileInputStream(imageUrl).use { fileInputStream ->
fileInputStream.readBytes()
}
cachedBytes = bytesArray
}
return@withContext bytesArray
}
}

21
components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/NetworkAnimatedImageLoader.kt

@ -0,0 +1,21 @@
package org.jetbrains.compose.animatedimage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
internal class NetworkAnimatedImageLoader(private val imageUrl: String) : AnimatedImageLoader() {
var cachedBytes: ByteArray? = null
override suspend fun generateByteArray(): ByteArray = withContext(Dispatchers.IO) {
var bytesArray: ByteArray? = cachedBytes
if (bytesArray == null) {
bytesArray = URL(imageUrl).readBytes()
cachedBytes = bytesArray
}
return@withContext bytesArray
}
}

11
components/AnimatedImage/library/src/desktopMain/kotlin/org/jetbrains/compose/animatedimage/ResourceAnimatedImageLoader.kt

@ -0,0 +1,11 @@
package org.jetbrains.compose.animatedimage
import androidx.compose.ui.res.useResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class ResourceAnimatedImageLoader(private val resourcePath: String) : AnimatedImageLoader() {
override suspend fun generateByteArray(): ByteArray = withContext(Dispatchers.IO) {
return@withContext useResource(resourcePath) { it.readAllBytes() }
}
}

33
components/resources/library/build.gradle.kts

@ -0,0 +1,33 @@
import org.jetbrains.compose.compose
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("maven-publish")
}
kotlin {
jvm("desktop")
sourceSets {
named("commonMain") {
dependencies {
api(compose.runtime)
api(compose.foundation)
}
}
named("desktopMain") {}
}
}
// TODO it seems that argument isn't applied to the common sourceSet. Figure out why
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
}
configureMavenPublication(
groupId = "org.jetbrains.compose.components",
artifactId = "components-resources",
name = "Resources for Compose JB"
)

33
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/LoadState.kt

@ -0,0 +1,33 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
sealed class LoadState<T> {
class Loading<T> : LoadState<T>()
data class Success<T>(val value: T) : LoadState<T>()
data class Error<T>(val exception: Exception) : LoadState<T>()
}
@Composable
fun <T> load(load: suspend () -> T): LoadState<T> {
var state: LoadState<T> by remember(load) { mutableStateOf(LoadState.Loading()) }
LaunchedEffect(load) {
state = try {
LoadState.Success(load())
} catch (e: Exception) {
LoadState.Error(e)
}
}
return state
}
@Composable
fun <T: Any> loadOrNull(load: suspend () -> T): T? {
val state = load(load)
return (state as? LoadState.Success<T>)?.value
}

5
components/settings.gradle.kts

@ -14,4 +14,7 @@ pluginManagement {
}
include(":SplitPane:library")
include(":SplitPane:demo")
include(":SplitPane:demo")
include(":AnimatedImage:library")
include("AnimatedImage:demo")
include("resources:library")

3
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt

@ -196,6 +196,9 @@ class ComposePlugin : Plugin<Project> {
object DesktopComponentsDependencies {
@ExperimentalComposeLibrary
val splitPane = composeDependency("org.jetbrains.compose.components:components-splitpane")
@ExperimentalComposeLibrary
val animatedImage = composeDependency("org.jetbrains.compose.components:components-animatedimage")
}
object WebDependencies {

Loading…
Cancel
Save