Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
4 changes: 2 additions & 2 deletions components/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,15 +1,58 @@
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") {
println("Theme = ${LocalSystemTheme.current}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

println is kinda debug style. do we need it yet?

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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -50,4 +57,138 @@ internal fun getResourceUrl(windowOrigin: String, windowPathname: String, resour
path.startsWith("http://") || path.startsWith("https://") -> path
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
terrakok marked this conversation as resolved.
Show resolved Hide resolved
val mutableState = mutableStateOf(getDefault())
scope.launch(start = CoroutineStart.UNDISPATCHED) {
mutableState.value = block(environment)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading