diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 19d9efb2..fde3c6cc 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -20,6 +20,6 @@ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b5081e6d..2bf4dc26 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -20,6 +20,6 @@ diff --git a/feature-info/build.gradle.kts b/feature-info/build.gradle.kts index 951ac7b2..5286b5ea 100644 --- a/feature-info/build.gradle.kts +++ b/feature-info/build.gradle.kts @@ -21,3 +21,7 @@ plugins { android { namespace = "com.paulrybitskyi.gamedge.feature.info" } + +dependencies { + implementation(libs.materialComponents) +} diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoHeader.kt index ab51ea66..59e349e0 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoHeader.kt @@ -18,9 +18,8 @@ package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector +import android.content.Context +import android.content.res.ColorStateList import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -31,7 +30,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.ripple @@ -49,15 +47,22 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.viewinterop.NoOpUpdate import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintSet import androidx.constraintlayout.compose.Dimension +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.paulrybitskyi.commons.ktx.getCompatDrawable +import com.paulrybitskyi.commons.ktx.onClick import com.paulrybitskyi.gamedge.common.ui.clickable import com.paulrybitskyi.gamedge.common.ui.theme.GamedgeTheme import com.paulrybitskyi.gamedge.common.ui.theme.lightScrim @@ -97,6 +102,8 @@ internal fun GameInfoHeader( onCoverClicked: () -> Unit, onLikeButtonClicked: () -> Unit, ) { + val colors = GamedgeTheme.colors + val density = LocalDensity.current val artworks = headerInfo.artworks val isPageIndicatorVisible by remember(artworks) { mutableStateOf(artworks.size > 1) } var selectedArtworkPage by rememberSaveable { mutableIntStateOf(0) } @@ -201,27 +208,29 @@ internal fun GameInfoHeader( onCoverClicked = if (headerInfo.hasCoverImageUrl) onCoverClicked else null, ) - FloatingActionButton( - onClick = onLikeButtonClicked, + // Animated selector drawables are not currently supported by the Jetpack Compose: + // https://issuetracker.google.com/issues/212418566. However, since the link/unlike + // animation is so gorgeous, it'd sad if we didn't use it, so we are using the legacy + // View here to render it. Consider to migrate to the Jetpack Compose when the support + // arrives. + AndroidView( + factory = { context -> + LikeButton(context).apply { + supportBackgroundTintList = ColorStateList.valueOf(colors.secondary.toArgb()) + size = FloatingActionButton.SIZE_NORMAL + setMaxImageSize(with(density) { 52.dp.toPx().toInt() }) + setImageDrawable(context.getCompatDrawable(CoreR.drawable.heart_animated_selector)) + supportImageTintList = ColorStateList.valueOf(colors.onSecondary.toArgb()) + onClick { onLikeButtonClicked() } + } + }, modifier = Modifier.layoutId(ConstraintIdLikeButton), - backgroundColor = GamedgeTheme.colors.secondary, - ) { - // Animated selector drawables are not currently supported by the Jetpack Compose. - // https://issuetracker.google.com/issues/212418566 - // Consider to use the R.drawable.heart_animated_selector when the support arrives. - - Icon( - painter = rememberAnimatedVectorPainter( - animatedImageVector = AnimatedImageVector.animatedVectorResource( - CoreR.drawable.heart_animated_fill, - ), - atEnd = headerInfo.isLiked, - ), - contentDescription = null, - modifier = Modifier.size(52.dp), - tint = GamedgeTheme.colors.onSecondary, - ) - } + // Have to provide any lambda here for optimizations to kick in (even if it's a no-op) + onReset = NoOpUpdate, + update = { view -> + view.isLiked = headerInfo.isLiked + }, + ) Text( text = headerInfo.title, @@ -302,6 +311,35 @@ internal fun GameInfoHeader( } } +private class LikeButton(context: Context) : FloatingActionButton(context) { + + private companion object { + const val STATE_CHECKED = android.R.attr.state_checked + const val STATE_CHECKED_ON = (STATE_CHECKED * 1) + const val STATE_CHECKED_OFF = (STATE_CHECKED * -1) + } + + var isLiked: Boolean + set(value) { + setImageState(intArrayOf(if (value) STATE_CHECKED_ON else STATE_CHECKED_OFF), true) + } + get() = drawableState.contains(STATE_CHECKED_ON) + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + // This is a hacky solution to fix a very strange case, where a user likes a game, + // scrolls the view out of the screen or goes to a another screen (e.g., a related game) + // and comes back, then the like button resets its icon from a filled heart to an empty + // heart. To fix it, when this view gets reattached to the window, we are asking the button + // to reset its state and then go to the liked state again. + if (isLiked) { + isLiked = false + isLiked = true + } + } +} + @Composable private fun constructExpandedConstraintSet(): ConstraintSet { val artworksHeight = 240.dp diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 539daa68..7f6ffbc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ constraintLayout = "1.1.0-beta01" composeHilt = "1.2.0" # Google +materialComponents = "1.12.0" protobuf = "4.28.2" # Square @@ -127,6 +128,7 @@ composeConstraintLayout = { module = "androidx.constraintlayout:constraintlayout composeHilt = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "composeHilt" } # Google +materialComponents = { module = "com.google.android.material:material", version.ref = "materialComponents" } daggerHiltCore = { module = "com.google.dagger:hilt-core", version.ref = "daggerHilt" } daggerHiltCoreCompiler = { module = "com.google.dagger:hilt-compiler", version.ref = "daggerHilt" } daggerHiltAndroid = { module = "com.google.dagger:hilt-android", version.ref = "daggerHilt" }