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 support for dark mode for images #624

Merged
merged 7 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ implement all you UI in Kotlin with Jetpack Compose and MOKO resources.
- **Strings, Plurals** to access the corresponding resources from common code;
- **Colors** with light/dark mode support;
- **Compose Multiplatform** support;
- **Images** support (`svg`, `png`, `jpg`);
- **Images** support (`svg`, `png`, `jpg`) with light/dark mode support;
- **Fonts** support (`ttf`, `otf`);
- **Files** support (as `raw` or `assets` for android);
- **StringDesc** for lifecycle-aware access to resources and unified localization on both platforms;
Expand Down Expand Up @@ -547,6 +547,13 @@ Then we get an autogenerated `MR.images.home_black_18` `ImageResource` in code.
- Android: `imageView.setImageResource(image.drawableResId)`
- iOS: `imageView.image = image.toUIImage()`

#### dark mode

To support Dark Mode images, you can add -dark and optionally -light to the name of an image. Make sure the rest of the name matches the corresponding light mode image:

- `car.svg`
- `car-dark.svg`

#### svg

The Image generator also supports `svg` files.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
package dev.icerock.moko.resources.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.LocalSystemTheme
import androidx.compose.ui.SystemTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
Expand All @@ -22,18 +25,25 @@ import org.jetbrains.skia.Data
import org.jetbrains.skia.Image
import org.jetbrains.skia.svg.SVGDOM

@OptIn(InternalComposeApi::class)
@Composable
actual fun painterResource(imageResource: ImageResource): Painter {
val bytes: ByteArray? by produceByteArray(url = imageResource.fileUrl)
val fileUrl: String = if (LocalSystemTheme.current == SystemTheme.Dark) {
imageResource.darkFileUrl ?: imageResource.fileUrl
} else {
imageResource.fileUrl
}

val bytes: ByteArray? by produceByteArray(url = fileUrl)
val localBytes: ByteArray? = bytes
val density: Density = LocalDensity.current
return remember(localBytes) {

return remember(localBytes, density, fileUrl) {
if (localBytes == null) {
return@remember ColorPainter(color = Color.Transparent)
}

if (imageResource.fileUrl.endsWith(".svg", ignoreCase = true)) {
if (fileUrl.endsWith(".svg", ignoreCase = true)) {
SVGPainter(SVGDOM(Data.makeFromBytes(localBytes)), density)
} else {
val skiaImage: Image = Image.makeFromEncoded(bytes = localBytes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@
package dev.icerock.moko.resources.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.ui.LocalSystemTheme
import androidx.compose.ui.SystemTheme
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import dev.icerock.moko.resources.ImageResource

@OptIn(InternalComposeApi::class)
@Composable
actual fun painterResource(imageResource: ImageResource): Painter =
painterResource(resourcePath = imageResource.filePath)
actual fun painterResource(imageResource: ImageResource): Painter {
val filePath: String = if (LocalSystemTheme.current == SystemTheme.Dark) {
imageResource.darkFilePath ?: imageResource.filePath
} else {
imageResource.filePath
}

return painterResource(resourcePath = filePath)
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ internal class AndroidImageResourceGenerator(
override fun generateResourceFiles(data: List<ImageMetadata>) {
data.flatMap { imageMetadata ->
imageMetadata.values.map { imageMetadata.key to it }
}.forEach { (key: String, item: ImageMetadata.ImageQualityItem) ->
val drawableDirName: String = "drawable" + when (item.quality) {
}.forEach { (key: String, item: ImageMetadata.ImageItem) ->
val densityRes = when (item.quality) {
"0.75" -> "-ldpi"
"1" -> "-mdpi"
"1.5" -> "-hdpi"
Expand All @@ -72,6 +72,8 @@ internal class AndroidImageResourceGenerator(
return@forEach
}
}
val themeSuffix = item.appearance.resourceSuffix
val drawableDirName = "drawable$themeSuffix$densityRes"

val drawableDir = File(resourcesGenerationDir, drawableDirName)
val processedKey: String = processKey(key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal class AppleImageResourceGenerator(
)
}

@Suppress("LongMethod")
override fun generateResourceFiles(data: List<ImageMetadata>) {
val assetsDirectory = File(assetsGenerationDir, Constants.Apple.assetsDirectoryName)

Expand All @@ -41,7 +42,7 @@ internal class AppleImageResourceGenerator(
assetDir.mkdirs()
val contentsFile = File(assetDir, "Contents.json")

val validItems: List<ImageMetadata.ImageQualityItem> =
val validItems: List<ImageMetadata.ImageItem> =
imageMetadata.values.filter { item ->
item.quality == null || VALID_SIZES.any { item.quality == it.toString() }
}
Expand All @@ -64,30 +65,59 @@ internal class AppleImageResourceGenerator(
val imagesContent: JsonArray = buildJsonArray {
validItems.map { item ->
buildJsonObject {
put("idiom", JsonPrimitive("universal"))
put("filename", JsonPrimitive(item.filePath.name))
put(
key = "idiom",
element = JsonPrimitive("universal")
)
put(
key = "filename",
element = JsonPrimitive(item.filePath.name)
)
item.quality?.let { quality ->
put("scale", JsonPrimitive(quality + "x"))
put(
key = "scale",
element = JsonPrimitive(quality + "x")
)
}
put(
key = "appearances",
element = buildJsonArray {
add(
buildJsonObject {
put(
key = "appearance",
element = JsonPrimitive("luminosity")
)
put(
key = "value",
element = JsonPrimitive(item.appearance.name.lowercase())
)
}
)
}
)
}
}.forEach { add(it) }
}

val content: String = buildJsonObject {
put("images", imagesContent)
put(key = "images", element = imagesContent)
put(
"info",
buildJsonObject {
put("version", JsonPrimitive(1))
put("author", JsonPrimitive("xcode"))
key = "info",
element = buildJsonObject {
put(key = "version", element = JsonPrimitive(1))
put(key = "author", element = JsonPrimitive("xcode"))
}
)

if (validItems.any { it.quality == null }) {
put(
"properties",
buildJsonObject {
put("preserves-vector-representation", JsonPrimitive(true))
key = "properties",
element = buildJsonObject {
put(
key = "preserves-vector-representation",
element = JsonPrimitive(true)
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import dev.icerock.gradle.generator.Constants
import dev.icerock.gradle.generator.ResourceGenerator
import dev.icerock.gradle.generator.generateKey
import dev.icerock.gradle.metadata.resource.ImageMetadata
import dev.icerock.gradle.metadata.resource.ImageMetadata.Appearance
import dev.icerock.gradle.utils.nameWithoutScale
import dev.icerock.gradle.utils.scale
import dev.icerock.gradle.utils.svg
import dev.icerock.gradle.utils.withoutAppearance
import java.io.File

internal class ImageResourceGenerator : ResourceGenerator<ImageMetadata> {
Expand All @@ -21,8 +23,9 @@ internal class ImageResourceGenerator : ResourceGenerator<ImageMetadata> {
ImageMetadata(
key = generateKey(key),
values = files.map { file ->
ImageMetadata.ImageQualityItem(
ImageMetadata.ImageItem(
quality = if (file.svg) null else file.scale,
appearance = Appearance.getFromFile(file),
filePath = file
)
}
Expand All @@ -39,6 +42,6 @@ internal class ImageResourceGenerator : ResourceGenerator<ImageMetadata> {
file.nameWithoutExtension
} else {
file.nameWithoutScale
}
}.withoutAppearance
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import dev.icerock.gradle.generator.Constants
import dev.icerock.gradle.generator.PlatformResourceGenerator
import dev.icerock.gradle.generator.addEmptyPlatformResourceProperty
import dev.icerock.gradle.metadata.resource.ImageMetadata
import dev.icerock.gradle.metadata.resource.ImageMetadata.Appearance
import java.io.File

internal class JsImageResourceGenerator(
Expand All @@ -23,14 +24,36 @@ internal class JsImageResourceGenerator(
override fun imports(): List<ClassName> = emptyList()

override fun generateInitializer(metadata: ImageMetadata): CodeBlock {
val item: ImageMetadata.ImageQualityItem = metadata.getHighestQualityItem()
val fileName = "${metadata.key}.${item.filePath.extension}"
val requireDeclaration = """require("$IMAGES_DIR/$fileName")"""
return CodeBlock.of(
"ImageResource(fileUrl = js(%S) as String, fileName = %S)",
requireDeclaration,
fileName
)
var fileName: String = ""
var darkFileName: String? = null

metadata.values.groupBy { it.appearance }.forEach { (theme, resources) ->
val item: ImageMetadata.ImageItem = resources.getHighestQualityItem(theme)

if (theme == Appearance.DARK) {
darkFileName = "${metadata.key}${theme.themeSuffix}.${item.filePath.extension}"
} else {
fileName = "${metadata.key}.${item.filePath.extension}"
}
}

val requireDeclaration: String = """require("$IMAGES_DIR/$fileName")"""
val darkRequireDeclaration: String = """require("$IMAGES_DIR/$darkFileName")"""

return if (darkFileName != null) {
CodeBlock.of(
"ImageResource(fileUrl = js(%S) as String, darkFileUrl = js(%S) as String, fileName = %S)",
requireDeclaration,
darkRequireDeclaration,
fileName
)
} else {
CodeBlock.of(
"ImageResource(fileUrl = js(%S) as String, fileName = %S)",
requireDeclaration,
fileName
)
}
}

override fun generateBeforeProperties(
Expand All @@ -52,7 +75,7 @@ internal class JsImageResourceGenerator(
override fun generateAfterProperties(
builder: Builder,
metadata: List<ImageMetadata>,
modifier: KModifier?
modifier: KModifier?,
) {
val languageKeysList: String = metadata.joinToString { it.key }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import dev.icerock.gradle.generator.PlatformResourceGenerator
import dev.icerock.gradle.generator.addJvmPlatformResourceClassLoaderProperty
import dev.icerock.gradle.generator.addValuesFunction
import dev.icerock.gradle.metadata.resource.ImageMetadata
import dev.icerock.gradle.metadata.resource.ImageMetadata.Appearance
import java.io.File

internal class JvmImageResourceGenerator(
Expand All @@ -23,17 +24,38 @@ internal class JvmImageResourceGenerator(
override fun imports(): List<ClassName> = emptyList()

override fun generateInitializer(metadata: ImageMetadata): CodeBlock {
val item: ImageMetadata.ImageQualityItem = metadata.getHighestQualityItem()
val fileName = "${metadata.key}.${item.filePath.extension}"
var fileName: String = ""
var darkFileName: String? = null

metadata.values.groupBy { it.appearance }.forEach { (theme, resources) ->
val item: ImageMetadata.ImageItem = resources.getHighestQualityItem(theme)

if (theme == Appearance.DARK) {
darkFileName = "${metadata.key}${theme.themeSuffix}.${item.filePath.extension}"
} else {
fileName = "${metadata.key}.${item.filePath.extension}"
}
}

val darkFilePath: String = if (darkFileName != null) {
"\"$IMAGES_DIR/$darkFileName\""
} else {
"null"
}

return CodeBlock.of(
"ImageResource(resourcesClassLoader = %L, filePath = %S)",
"ImageResource(resourcesClassLoader = %L, filePath = %S, darkFilePath = $darkFilePath)",
"${PlatformDetails.platformDetailsPropertyName}.${Jvm.resourcesClassLoaderPropertyName}",
"$IMAGES_DIR/$fileName"
)
}

override fun generateResourceFiles(data: List<ImageMetadata>) {
generateHighestQualityImageResources(resourcesGenerationDir, data, IMAGES_DIR)
generateHighestQualityImageResources(
resourcesGenerationDir = resourcesGenerationDir,
data = data,
imagesDirName = IMAGES_DIR
)
}

override fun generateBeforeProperties(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,42 @@
package dev.icerock.gradle.generator.resources.image

import dev.icerock.gradle.metadata.resource.ImageMetadata
import dev.icerock.gradle.metadata.resource.ImageMetadata.Appearance.DARK
import dev.icerock.gradle.metadata.resource.ImageMetadata.ImageItem
import java.io.File

internal fun generateHighestQualityImageResources(
resourcesGenerationDir: File,
data: List<ImageMetadata>,
imagesDirName: String
imagesDirName: String,
) {
val imagesDir = File(resourcesGenerationDir, imagesDirName)
imagesDir.mkdirs()

data.forEach { metadata ->
val item: ImageMetadata.ImageQualityItem = metadata.getHighestQualityItem()
val file: File = item.filePath
val key: String = metadata.key
metadata.values
.groupBy { it.appearance }
.forEach { (theme, list: List<ImageItem>) ->
val item: ImageMetadata.ImageItem = list.getHighestQualityItem(theme)
val file: File = item.filePath
val key: String = metadata.key

file.copyTo(File(imagesDir, "$key.${file.extension}"))
val fileName: String = if (theme == DARK) {
"$key${theme.themeSuffix}.${file.extension}"
} else {
"$key.${file.extension}"
}

file.copyTo(File(imagesDir, fileName))
}
}
}

internal fun ImageMetadata.getHighestQualityItem(): ImageMetadata.ImageQualityItem {
return values.singleOrNull { it.quality == null }
?: values.maxBy { it.quality!!.toDouble() }
internal fun List<ImageItem>.getHighestQualityItem(
appearance: ImageMetadata.Appearance,
): ImageMetadata.ImageItem {
val filteredList = filter { it.appearance == appearance }

return filteredList.singleOrNull { it.quality == null }
?: filteredList.maxBy { it.quality!!.toDouble() }
}
Loading
Loading