Browse Source
* 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 itpull/2043/head
Abdelilah El Aissaoui
3 years ago
committed by
GitHub
14 changed files with 361 additions and 1 deletions
@ -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" |
||||
} |
@ -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) |
||||
) |
||||
} |
||||
} |
||||
} |
After Width: | Height: | Size: 87 KiB |
@ -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" |
||||
) |
@ -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 |
@ -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 |
||||
} |
@ -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 |
||||
} |
||||
} |
@ -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 |
||||
} |
||||
} |
@ -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 |
||||
} |
||||
} |
@ -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() } |
||||
} |
||||
} |
@ -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" |
||||
) |
@ -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 |
||||
} |
Loading…
Reference in new issue