Browse Source

Add a new experimental web-specific API to preload fonts and images resources (#5159)

## Release Notes
### Features - Resources

- Add new API to preload and cache font and image resources on web
targets: `preloadFont`, `preloadImageBitmap`, `preloadImageVector`
____

Add a new experimental web-specific API to preload fonts and images:

```kotlin

@Composable
fun preloadFont(
    resource: FontResource,
    weight: FontWeight = FontWeight.Normal,
    style: FontStyle = FontStyle.Normal
): State<Font?>

@Composable
fun preloadImageBitmap(
    resource: DrawableResource,
): State<ImageBitmap?>

@Composable
fun preloadImageVector(
    resource: DrawableResource,
): State<ImageVector?>
```

Using this methods in advance, it's possible to avoid FOUT (flash of
unstyled text), or flickering of images/icons.

Usage example:

```kotlin

val font1 by preloadFont(Res.font.Workbench_Regular)
val font2 by preloadFont(Res.font.font_awesome, FontWeight.Normal, FontStyle.Normal)

UseResources() // Main App that uses the above fonts

if (font1 != null && font2 != null) {
    println("Fonts are ready")
} else {
    Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.8f)).clickable {  }) {
        CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
    }
    println("Fonts are not ready yet")
}
```
pull/2336/merge
Oleksandr Karpovich 2 weeks ago committed by GitHub
parent
commit
b23f9904ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      components/gradle.properties
  2. BIN
      components/resources/demo/shared/src/commonMain/composeResources/drawable-dark/compose.png
  3. BIN
      components/resources/demo/shared/src/commonMain/composeResources/font-dark/Workbench-Regular.ttf
  4. BIN
      components/resources/demo/shared/src/commonMain/composeResources/font/NotoColorEmoji.ttf
  5. 46
      components/resources/demo/shared/src/wasmJsMain/kotlin/main.wasm.kt
  6. 6
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt
  7. 45
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt
  8. 2
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
  9. 141
      components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/Resource.web.kt
  10. 6
      components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt
  11. 76
      components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/TestResourcePreloading.kt

4
components/gradle.properties

@ -7,9 +7,9 @@ org.gradle.configuration-cache=true
android.useAndroidX=true
#Versions
kotlin.version=1.9.23
kotlin.version=1.9.24
agp.version=8.2.2
compose.version=1.6.10
compose.version=1.7.0
deploy.version=0.1.0-SNAPSHOT
#Compose

BIN
components/resources/demo/shared/src/commonMain/composeResources/drawable-dark/compose.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
components/resources/demo/shared/src/commonMain/composeResources/font-dark/Workbench-Regular.ttf

Binary file not shown.

BIN
components/resources/demo/shared/src/commonMain/composeResources/font/NotoColorEmoji.ttf

Binary file not shown.

46
components/resources/demo/shared/src/wasmJsMain/kotlin/main.wasm.kt

@ -1,15 +1,57 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.CanvasBasedWindow
import components.resources.demo.shared.generated.resources.*
import components.resources.demo.shared.generated.resources.NotoColorEmoji
import components.resources.demo.shared.generated.resources.Res
import components.resources.demo.shared.generated.resources.Workbench_Regular
import components.resources.demo.shared.generated.resources.font_awesome
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.configureWebResources
import org.jetbrains.compose.resources.demo.shared.UseResources
import org.jetbrains.compose.resources.preloadFont
@OptIn(ExperimentalComposeUiApi::class)
@OptIn(ExperimentalComposeUiApi::class, ExperimentalResourceApi::class, InternalComposeUiApi::class)
fun main() {
configureWebResources {
// Not necessary - It's the same as the default. We add it here just to present this feature.
resourcePathMapping { path -> "./$path" }
}
CanvasBasedWindow("Resources demo + K/Wasm") {
val font1 by preloadFont(Res.font.Workbench_Regular)
val font2 by preloadFont(Res.font.font_awesome, FontWeight.Normal, FontStyle.Normal)
val emojiFont = preloadFont(Res.font.NotoColorEmoji).value
var fontsFallbackInitialiazed by remember { mutableStateOf(false) }
UseResources()
if (font1 != null && font2 != null && emojiFont != null && fontsFallbackInitialiazed) {
println("Fonts are ready")
} else {
Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.8f)).clickable { }) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
println("Fonts are not ready yet")
}
val fontFamilyResolver = LocalFontFamilyResolver.current
LaunchedEffect(fontFamilyResolver, emojiFont) {
if (emojiFont != null) {
// we have an emoji on Strings tab
fontFamilyResolver.preload(FontFamily(listOf(emojiFont)))
fontsFallbackInitialiazed = true
}
}
}
}

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

@ -47,6 +47,9 @@ fun painterResource(resource: DrawableResource): Painter {
private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) }
internal val ImageBitmap.isEmptyPlaceholder: Boolean
get() = this == emptyImageBitmap
/**
* Retrieves an ImageBitmap using the specified drawable resource.
*
@ -77,6 +80,9 @@ private val emptyImageVector: ImageVector by lazy {
ImageVector.Builder("emptyImageVector", 1.dp, 1.dp, 1f, 1f).build()
}
internal val ImageVector.isEmptyPlaceholder: Boolean
get() = this == emptyImageVector
/**
* Retrieves an ImageVector using the specified drawable resource.
*

45
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt

@ -366,4 +366,49 @@ class ComposeResourceTest {
val systemEnvironment = getSystemResourceEnvironment()
assertEquals(systemEnvironment, environment)
}
@Test
fun rememberResourceStateAffectedByEnvironmentChanges() = runComposeUiTest {
val env2 = ResourceEnvironment(
language = LanguageQualifier("en"),
region = RegionQualifier("CA"),
theme = ThemeQualifier.DARK,
density = DensityQualifier.MDPI
)
val envState = mutableStateOf(TestComposeEnvironment)
var lastEnv1: ResourceEnvironment? = null
var lastEnv2: ResourceEnvironment? = null
var lastEnv3: ResourceEnvironment? = null
setContent {
CompositionLocalProvider(LocalComposeEnvironment provides envState.value) {
rememberResourceState(1, { "" }) {
lastEnv1 = it
}
rememberResourceState(1, 2, { "" }) {
lastEnv2 = it
}
rememberResourceState(1, 2, 3, { "" }) {
lastEnv3 = it
}
}
}
assertNotEquals(null, lastEnv1)
assertNotEquals(env2, lastEnv1)
assertEquals(lastEnv1, lastEnv2)
assertEquals(lastEnv2, lastEnv3)
val testEnv2 = object : ComposeEnvironment {
@Composable
override fun rememberEnvironment() = env2
}
envState.value = testEnv2
waitForIdle()
assertEquals(env2, lastEnv1)
assertEquals(env2, lastEnv2)
assertEquals(env2, lastEnv3)
}
}

2
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt

@ -30,6 +30,8 @@ private val emptyFontBase64 =
private val defaultEmptyFont by lazy { Font("org.jetbrains.compose.emptyFont", Base64.decode(emptyFontBase64)) }
private val fontCache = AsyncCache<String, Font>()
internal val Font.isEmptyPlaceholder: Boolean
get() = this == defaultEmptyFont
@Composable
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {

141
components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/Resource.web.kt

@ -1,5 +1,12 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
/**
* Represents the configuration object for web resources.
*
@ -51,3 +58,137 @@ internal fun getResourceUrl(windowOrigin: String, windowPathname: String, resour
else -> windowOrigin + windowPathname + path
}
}
/**
* Preloads a font resource and provides a [State] containing the loaded [Font] or `null` if not yet loaded.
*
* Internally, it reads font bytes, converts them to a [Font] object, and caches the result, speeding up future
* accesses to the same font resource when using @Composable Font function.
*
* **Usage Example:**
* ```
* @Composable
* fun MyApp() {
* val fontState by preloadFont(Res.font.HeavyFont)
*
* if (fontState != null) {
* MyText()
* } else {
* Box(modifier = Modifier.fillMaxSize()) {
* CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
* }
* }
* }
*
* @Composable
* fun MyText() {
* // the font is taken from the cache
* Text(text = "Hello, World!", fontFamily = FontFamily(Font(Res.font.HeavyFont)))
* }
* ```
*
* @param resource The font resource to be used.
* @param weight The weight of the font. Default value is [FontWeight.Normal].
* @param style The style of the font. Default value is [FontStyle.Normal].
* @return A [State]<[Font]?> object that holds the loaded [Font] when available,
* or `null` if the font is not yet ready.
*/
@ExperimentalResourceApi
@Composable
fun preloadFont(
resource: FontResource,
weight: FontWeight = FontWeight.Normal,
style: FontStyle = FontStyle.Normal
): State<Font?> {
val resState = remember(resource, weight, style) { mutableStateOf<Font?>(null) }.apply {
value = Font(resource, weight, style).takeIf { !it.isEmptyPlaceholder }
}
return resState
}
/**
* Preloads an image resource and provides a [State] containing the loaded [ImageBitmap] or `null` if not yet loaded.
*
* Internally, it reads the resource bytes, converts them to a [ImageBitmap] object, and caches the result,
* speeding up future accesses to the same resource when using @Composable [imageResource] or [painterResource] functions.
*
* **Usage Example:**
* ```
* @Composable
* fun MyApp() {
* val imageState by preloadImageBitmap(Res.drawable.heavy_drawable)
*
* if (imageState != null) {
* MyImage()
* } else {
* Box(modifier = Modifier.fillMaxSize()) {
* CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
* }
* }
* }
*
* @Composable
* fun MyImage() {
* // the image is taken from the cache thanks to preloadImageBitmap
* Image(painter = painterResource(Res.drawable.heavy_drawable), contentDescription = null)
* }
* ```
*
* @param resource The resource to be used.
* @return A [State]<[ImageBitmap]?> object that holds the loaded [ImageBitmap] when available,
* or `null` if the resource is not yet ready.
*/
@ExperimentalResourceApi
@Composable
fun preloadImageBitmap(
resource: DrawableResource,
): State<ImageBitmap?> {
val resState = remember(resource) { mutableStateOf<ImageBitmap?>(null) }.apply {
value = imageResource(resource).takeIf { !it.isEmptyPlaceholder }
}
return resState
}
/**
* Preloads a vector image resource and provides a [State] containing the loaded [ImageVector] or `null` if not yet loaded.
*
* Internally, it reads the resource bytes, converts them to a [ImageVector] object, and caches the result,
* speeding up future accesses to the same resource when using @Composable [vectorResource] or [painterResource] functions.
*
* **Usage Example:**
* ```
* @Composable
* fun MyApp() {
* val iconState by preloadImageVector(Res.drawable.heavy_vector_icon)
*
* if (iconState != null) {
* MyIcon()
* } else {
* Box(modifier = Modifier.fillMaxSize()) {
* CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
* }
* }
* }
*
* @Composable
* fun MyIcon() {
* // the icon is taken from the cache thanks to preloadImageVector
* Image(painter = painterResource(Res.drawable.heavy_vector_icon), contentDescription = null)
* }
* ```
*
* @param resource The resource to be used.
* @return A [State]<[ImageVector]?> object that holds the loaded [ImageVector] when available,
* or `null` if the resource is not yet ready.
*/
@ExperimentalResourceApi
@Composable
fun preloadImageVector(
resource: DrawableResource,
): State<ImageVector?> {
val resState = remember(resource) { mutableStateOf<ImageVector?>(null) }.apply {
value = vectorResource(resource).takeIf { !it.isEmptyPlaceholder }
}
return resState
}

6
components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt

@ -16,7 +16,7 @@ internal actual fun <T> rememberResourceState(
): State<T> {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val scope = rememberCoroutineScope()
return remember(key1) {
return remember(key1, environment) {
val mutableState = mutableStateOf(getDefault())
scope.launch(start = CoroutineStart.UNDISPATCHED) {
mutableState.value = block(environment)
@ -34,7 +34,7 @@ internal actual fun <T> rememberResourceState(
): State<T> {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val scope = rememberCoroutineScope()
return remember(key1, key2) {
return remember(key1, key2, environment) {
val mutableState = mutableStateOf(getDefault())
scope.launch(start = CoroutineStart.UNDISPATCHED) {
mutableState.value = block(environment)
@ -53,7 +53,7 @@ internal actual fun <T> rememberResourceState(
): State<T> {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val scope = rememberCoroutineScope()
return remember(key1, key2, key3) {
return remember(key1, key2, key3, environment) {
val mutableState = mutableStateOf(getDefault())
scope.launch(start = CoroutineStart.UNDISPATCHED) {
mutableState.value = block(environment)

76
components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/TestResourcePreloading.kt

@ -0,0 +1,76 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.font.Font
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
@OptIn(ExperimentalTestApi::class, ExperimentalEncodingApi::class)
class TestResourcePreloading {
@Test
fun testPreloadFont() = runComposeUiTest {
var loadContinuation: CancellableContinuation<ByteArray>? = null
val resLoader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
return suspendCancellableCoroutine {
loadContinuation = it
}
}
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
TODO("Not yet implemented")
}
override fun getUri(path: String): String {
TODO("Not yet implemented")
}
}
var font: Font? = null
var font2: Font? = null
var condition by mutableStateOf(false)
setContent {
CompositionLocalProvider(
LocalComposeEnvironment provides TestComposeEnvironment,
LocalResourceReader provides resLoader
) {
font = preloadFont(TestFontResource("sometestfont")).value
if (condition) {
font2 = Font(TestFontResource("sometestfont"))
}
}
}
waitForIdle()
assertEquals(null, font)
assertEquals(null, font2)
assertNotEquals(null, loadContinuation)
loadContinuation!!.resumeWith(Result.success(ByteArray(0)))
loadContinuation = null
waitForIdle()
assertNotEquals(null, font)
assertEquals(null, font2) // condition was false for now, so font2 should be not initialized
condition = true
waitForIdle()
assertNotEquals(null, font)
assertEquals(font, font2, "font2 is expected to be loaded from cache")
assertEquals(null, loadContinuation, "expected no more ResourceReader usages")
}
}
Loading…
Cancel
Save