From f8155add547c7d9592078f4dc79390e009eef4f3 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 5 Dec 2024 16:35:02 +0100 Subject: [PATCH] [Paywalls V2] Adds `ImageComponentView` (#1959) ### Description This adds a new component `ImageComponentView` and the corresponding `ImageComponentStyle`. It doesn't include transformations from `ImageComponent` to `ImageComponentStyle`. - Placeholder to image loading: https://github.com/user-attachments/assets/ae010171-2ae4-4d82-b84c-68fa9015d2bd - Previews image --- .../components/image/ImageComponentView.kt | 115 ++++++++++++++++++ .../components/ktx/ThemeImageUrls.kt | 4 +- .../components/modifier/Overlay.kt | 28 +++++ .../components/stack/StackComponentView.kt | 3 + .../components/style/ImageComponentStyle.kt | 83 +++++++++++++ .../revenuecatui/composables/RemoteImage.kt | 40 ++++-- 6 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Overlay.kt create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ImageComponentStyle.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt new file mode 100644 index 0000000000..2a0af7c989 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/image/ImageComponentView.kt @@ -0,0 +1,115 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.image + +import android.graphics.Color +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.revenuecat.purchases.paywalls.components.properties.ImageUrls +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint +import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.urlsForCurrentTheme +import com.revenuecat.purchases.ui.revenuecatui.components.modifier.overlay +import com.revenuecat.purchases.ui.revenuecatui.components.modifier.size +import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyle +import com.revenuecat.purchases.ui.revenuecatui.components.style.ImageComponentStyle +import com.revenuecat.purchases.ui.revenuecatui.composables.RemoteImage +import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull +import java.net.URL +import androidx.compose.ui.graphics.Color as ComposeColor + +@JvmSynthetic +@Composable +internal fun ImageComponentView( + style: ImageComponentStyle, + modifier: Modifier = Modifier, +) { + if (style.visible) { + val currentUrls = style.themeImageUrls.urlsForCurrentTheme + RemoteImage( + urlString = currentUrls.webp.toString(), + modifier = modifier + .size(style.adjustedSize()) + .applyIfNotNull(style.overlay) { overlay(it, style.shape ?: RectangleShape) } + .applyIfNotNull(style.shape) { clip(it) }, + placeholderUrlString = currentUrls.webpLowRes.toString(), + contentScale = style.contentScale, + ) + } +} + +@Preview +@Composable +private fun ImageComponentView_Preview_Default() { + Box(modifier = Modifier.background(ComposeColor.Red)) { + ImageComponentView( + style = previewImageComponentStyle(), + ) + } +} + +@Suppress("MagicNumber") +@Preview +@Composable +private fun ImageComponentView_Preview_LinearGradient() { + Box(modifier = Modifier.background(ComposeColor.Red)) { + ImageComponentView( + style = previewImageComponentStyle( + overlay = ColorStyle.Gradient( + Brush.verticalGradient( + Pair(0f, ComposeColor(Color.parseColor("#88FF0000"))), + Pair(0.5f, ComposeColor(Color.parseColor("#8800FF00"))), + Pair(1f, ComposeColor(Color.parseColor("#880000FF"))), + ), + ), + ), + ) + } +} + +@Suppress("MagicNumber") +@Preview +@Composable +private fun ImageComponentView_Preview_RadialGradient() { + Box(modifier = Modifier.background(ComposeColor.Red)) { + ImageComponentView( + style = previewImageComponentStyle( + overlay = ColorStyle.Gradient( + Brush.radialGradient( + Pair(0f, ComposeColor(Color.parseColor("#88FF0000"))), + Pair(0.5f, ComposeColor(Color.parseColor("#8800FF00"))), + Pair(1f, ComposeColor(Color.parseColor("#880000FF"))), + ), + ), + ), + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun previewImageComponentStyle( + url: URL = URL("https://sample-videos.com/img/Sample-jpg-image-5mb.jpg"), + lowResURL: URL = URL("https://assets.pawwalls.com/954459_1701163461.jpg"), + visible: Boolean = true, + size: Size = Size(width = SizeConstraint.Fixed(400u), height = SizeConstraint.Fit), + contentScale: ContentScale = ContentScale.Crop, + overlay: ColorStyle? = null, +) = ImageComponentStyle( + visible = visible, + themeImageUrls = ThemeImageUrls(light = ImageUrls(url, url, lowResURL, 1000u, 1000u)), + size = size, + shape = RoundedCornerShape(20.dp, 20.dp, 20.dp, 20.dp), + overlay = overlay, + contentScale = contentScale, +) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/ThemeImageUrls.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/ThemeImageUrls.kt index 940f3a407e..8c06ce62d1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/ThemeImageUrls.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/ktx/ThemeImageUrls.kt @@ -4,8 +4,10 @@ package com.revenuecat.purchases.ui.revenuecatui.components.ktx import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import com.revenuecat.purchases.paywalls.components.properties.ImageUrls import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls internal val ThemeImageUrls.urlsForCurrentTheme: ImageUrls - @Composable get() = if (isSystemInDarkTheme()) dark ?: light else light + @ReadOnlyComposable @Composable + get() = if (isSystemInDarkTheme()) dark ?: light else light diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Overlay.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Overlay.kt new file mode 100644 index 0000000000..044aa24861 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/modifier/Overlay.kt @@ -0,0 +1,28 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.modifier + +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyle + +@JvmSynthetic +@Stable +internal fun Modifier.overlay( + color: ColorStyle, + shape: Shape = RectangleShape, +): Modifier = this then drawWithCache { + val outline = shape.createOutline(size, layoutDirection, this) + + onDrawWithContent { + drawContent() + when (color) { + is ColorStyle.Solid -> drawOutline(outline, color.color) + is ColorStyle.Gradient -> drawOutline(outline, color.brush) + } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt index d3b6caff84..087a548949 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt @@ -28,6 +28,7 @@ import com.revenuecat.purchases.paywalls.components.properties.Size import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit import com.revenuecat.purchases.paywalls.components.properties.TwoDimensionalAlignment import com.revenuecat.purchases.paywalls.components.properties.VerticalAlignment +import com.revenuecat.purchases.ui.revenuecatui.components.image.ImageComponentView import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toAlignment import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toHorizontalAlignmentOrNull import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toHorizontalArrangement @@ -41,6 +42,7 @@ import com.revenuecat.purchases.ui.revenuecatui.components.properties.Background import com.revenuecat.purchases.ui.revenuecatui.components.properties.BorderStyle import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyle.Solid import com.revenuecat.purchases.ui.revenuecatui.components.properties.ShadowStyle +import com.revenuecat.purchases.ui.revenuecatui.components.style.ImageComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.text.TextComponentView @@ -70,6 +72,7 @@ internal fun StackComponentView( when (child) { is StackComponentStyle -> StackComponentView(style = child) is TextComponentStyle -> TextComponentView(style = child) + is ImageComponentStyle -> ImageComponentView(style = child) } } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ImageComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ImageComponentStyle.kt new file mode 100644 index 0000000000..0faa64d4df --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/ImageComponentStyle.kt @@ -0,0 +1,83 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.style + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import com.revenuecat.purchases.paywalls.components.properties.ImageUrls +import com.revenuecat.purchases.paywalls.components.properties.Size +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fill +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fit +import com.revenuecat.purchases.paywalls.components.properties.SizeConstraint.Fixed +import com.revenuecat.purchases.paywalls.components.properties.ThemeImageUrls +import com.revenuecat.purchases.ui.revenuecatui.components.ktx.urlsForCurrentTheme +import com.revenuecat.purchases.ui.revenuecatui.components.properties.ColorStyle + +@Suppress("LongParameterList") +@Immutable +internal class ImageComponentStyle private constructor( + @get:JvmSynthetic + val visible: Boolean, + @get:JvmSynthetic + val themeImageUrls: ThemeImageUrls, + @get:JvmSynthetic + val size: Size, + @get:JvmSynthetic + val shape: Shape?, + @get:JvmSynthetic + val overlay: ColorStyle?, + @get:JvmSynthetic + val contentScale: ContentScale, +) : ComponentStyle { + + companion object { + + @Suppress("LongParameterList") + @JvmSynthetic + @Composable + operator fun invoke( + visible: Boolean, + size: Size, + themeImageUrls: ThemeImageUrls, + shape: Shape?, + overlay: ColorStyle?, + contentScale: ContentScale, + ): ImageComponentStyle { + return ImageComponentStyle( + visible = visible, + size = size, + themeImageUrls = themeImageUrls, + shape = shape, + overlay = overlay, + contentScale = contentScale, + ) + } + } + + @JvmSynthetic + @ReadOnlyComposable + @Composable + fun adjustedSize(): Size { + val density = LocalDensity.current + return size.adjustForImage(imageUrls = themeImageUrls.urlsForCurrentTheme, density = density) + } +} + +private fun Size.adjustForImage(imageUrls: ImageUrls, density: Density): Size = + Size( + width = when (width) { + is Fit -> Fixed(with(density) { imageUrls.width.toInt().toDp().value.toUInt() }) + is Fill, + is Fixed, + -> width + }, + height = when (height) { + is Fit -> Fixed(with(density) { imageUrls.height.toInt().toDp().value.toUInt() }) + is Fill, + is Fixed, + -> height + }, + ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt index 5e4797d19f..ec6a7e4bfe 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/composables/RemoteImage.kt @@ -13,14 +13,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import coil.ImageLoader import coil.compose.AsyncImage import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter import coil.disk.DiskCache import coil.memory.MemoryCache import coil.request.CachePolicy import coil.request.ImageRequest import coil.transform.Transformation +import com.revenuecat.purchases.ui.revenuecatui.R import com.revenuecat.purchases.ui.revenuecatui.UIConstant import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger import com.revenuecat.purchases.ui.revenuecatui.helpers.isInPreviewMode @@ -37,6 +40,7 @@ internal fun LocalImage( ) { Image( source = ImageSource.Local(resource), + placeholderSource = null, modifier = modifier, contentScale = contentScale, contentDescription = contentDescription, @@ -50,6 +54,7 @@ internal fun LocalImage( internal fun RemoteImage( urlString: String, modifier: Modifier = Modifier, + placeholderUrlString: String? = null, contentScale: ContentScale = ContentScale.Fit, contentDescription: String? = null, transformation: Transformation? = null, @@ -57,6 +62,7 @@ internal fun RemoteImage( ) { Image( source = ImageSource.Remote(urlString), + placeholderSource = placeholderUrlString?.let { ImageSource.Remote(it) }, modifier = modifier, contentScale = contentScale, contentDescription = contentDescription, @@ -80,6 +86,7 @@ private sealed class ImageSource { @Composable private fun Image( source: ImageSource, + placeholderSource: ImageSource?, modifier: Modifier = Modifier, contentScale: ContentScale, contentDescription: String?, @@ -106,6 +113,7 @@ private fun Image( if (useCache) { AsyncImage( source = source, + placeholderSource = placeholderSource, imageRequest = imageRequest, contentDescription = contentDescription, imageLoader = imageLoader, @@ -120,6 +128,7 @@ private fun Image( } else { AsyncImage( source = source, + placeholderSource = placeholderSource, imageRequest = imageRequest, contentDescription = contentDescription, imageLoader = imageLoader, @@ -134,6 +143,7 @@ private fun Image( @Composable private fun AsyncImage( source: ImageSource, + placeholderSource: ImageSource?, imageRequest: ImageRequest, imageLoader: ImageLoader, modifier: Modifier = Modifier, @@ -145,23 +155,29 @@ private fun AsyncImage( AsyncImage( model = imageRequest, contentDescription = contentDescription, + placeholder = placeholderSource?.let { + rememberAsyncImagePainter( + model = it.data, + placeholder = if (isInPreviewMode()) painterResource(R.drawable.android) else null, + imageLoader = imageLoader, + contentScale = contentScale, + onError = { errorState -> + Logger.e("Error loading placeholder image", errorState.result.throwable) + }, + ) + } ?: if (isInPreviewMode()) painterResource(R.drawable.android) else null, imageLoader = imageLoader, modifier = modifier, contentScale = contentScale, alpha = alpha, - onState = { - when (it) { - is AsyncImagePainter.State.Error -> { - val error = when (source) { - is ImageSource.Local -> "Error loading local image: '${source.resource}'" - is ImageSource.Remote -> "Error loading image from '${source.urlString}'" - } - - Logger.e(error, it.result.throwable) - onError?.invoke(it) - } - else -> {} + onError = { + val error = when (source) { + is ImageSource.Local -> "Error loading local image: '${source.resource}'" + is ImageSource.Remote -> "Error loading image from '${source.urlString}'" } + + Logger.e(error, it.result.throwable) + onError?.invoke(it) }, ) }