diff --git a/components/gradle.properties b/components/gradle.properties index 478504a76fa..79c1a9328e1 100644 --- a/components/gradle.properties +++ b/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 diff --git a/components/resources/demo/shared/src/commonMain/composeResources/drawable-dark/compose.png b/components/resources/demo/shared/src/commonMain/composeResources/drawable-dark/compose.png new file mode 100644 index 00000000000..36a746dc7da Binary files /dev/null and b/components/resources/demo/shared/src/commonMain/composeResources/drawable-dark/compose.png differ diff --git a/components/resources/demo/shared/src/commonMain/composeResources/font-dark/Workbench-Regular.ttf b/components/resources/demo/shared/src/commonMain/composeResources/font-dark/Workbench-Regular.ttf new file mode 100644 index 00000000000..2d116d92058 Binary files /dev/null and b/components/resources/demo/shared/src/commonMain/composeResources/font-dark/Workbench-Regular.ttf differ diff --git a/components/resources/demo/shared/src/commonMain/composeResources/font/NotoColorEmoji.ttf b/components/resources/demo/shared/src/commonMain/composeResources/font/NotoColorEmoji.ttf new file mode 100644 index 00000000000..9d82f522266 Binary files /dev/null and b/components/resources/demo/shared/src/commonMain/composeResources/font/NotoColorEmoji.ttf differ diff --git a/components/resources/demo/shared/src/wasmJsMain/kotlin/main.wasm.kt b/components/resources/demo/shared/src/wasmJsMain/kotlin/main.wasm.kt index 13e78a309a5..20125cd6a17 100644 --- a/components/resources/demo/shared/src/wasmJsMain/kotlin/main.wasm.kt +++ b/components/resources/demo/shared/src/wasmJsMain/kotlin/main.wasm.kt @@ -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}") + 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 + } + } } } \ 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 9a2de241837..d938d302af0 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 @@ -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. * diff --git a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt b/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt index bc7c7ddb85c..0cc4b8651a7 100644 --- a/components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt +++ b/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) + } } diff --git a/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt b/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt index c524faf0cde..6d697ae2530 100644 --- a/components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt +++ b/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() +internal val Font.isEmptyPlaceholder: Boolean + get() = this == defaultEmptyFont @Composable actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font { diff --git a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/Resource.web.kt b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/Resource.web.kt index aa441119874..ee9416da3d3 100644 --- a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/Resource.web.kt +++ b/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. * @@ -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 { + val resState = remember(resource, weight, style) { mutableStateOf(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 { + val resState = remember(resource) { mutableStateOf(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 { + val resState = remember(resource) { mutableStateOf(null) }.apply { + value = vectorResource(resource).takeIf { !it.isEmptyPlaceholder } + } + return resState } \ No newline at end of file diff --git a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt index 3ef99c9a736..9386783106a 100644 --- a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt +++ b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceState.web.kt @@ -16,7 +16,7 @@ internal actual fun rememberResourceState( ): State { 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 rememberResourceState( ): State { 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 rememberResourceState( ): State { 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) diff --git a/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/TestResourcePreloading.kt b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/TestResourcePreloading.kt new file mode 100644 index 00000000000..3d481d8a605 --- /dev/null +++ b/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? = 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") + } +} \ No newline at end of file