From ce65e3739a2c9866d71acdbef363f3d7c049a207 Mon Sep 17 00:00:00 2001 From: mars885 Date: Sat, 28 Sep 2024 01:49:14 +0300 Subject: [PATCH 01/12] Implement the header with the OnSwipe helper --- .../info/presentation/GameInfoScreen.kt | 120 ++-- .../header/GameInfoAnimatableHeader.kt | 603 +++++++++++------- .../widgets/header/GameInfoHeader.kt | 6 +- 3 files changed, 456 insertions(+), 273 deletions(-) diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt index 08278a83..9fc8f55f 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding @@ -39,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -64,6 +67,7 @@ import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.companies.Gam import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.companies.GameInfoCompanyUiModel import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.details.GameInfoDetails import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.details.GameInfoDetailsUiModel +import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.GameInfoAnimatableHeader import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.GameInfoHeader import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.GameInfoHeaderUiModel import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.GameInfoArtworkUiModel @@ -233,67 +237,75 @@ private fun SuccessState( onCompanyClicked: (GameInfoCompanyUiModel) -> Unit, onRelatedGameClicked: (GameInfoRelatedGameUiModel) -> Unit, ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(GamedgeTheme.spaces.spacing_3_5), - ) { - headerItem( - model = gameInfo.headerModel, - onArtworkClicked = onArtworkClicked, - onBackButtonClicked = onBackButtonClicked, - onCoverClicked = onCoverClicked, - onLikeButtonClicked = onLikeButtonClicked, - ) - - if (gameInfo.hasVideos) { - videosItem( - videos = gameInfo.videoModels, - onVideoClicked = onVideoClicked, - ) - } + GameInfoAnimatableHeader( + headerInfo = gameInfo.headerModel, + onArtworkClicked = onArtworkClicked, + onBackButtonClicked = onBackButtonClicked, + onCoverClicked = onCoverClicked, + onLikeButtonClicked = onLikeButtonClicked, + ) { modifier -> + val layoutDirection = LocalLayoutDirection.current + val spacing = GamedgeTheme.spaces.spacing_3_5 + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues( + start = contentPadding.calculateStartPadding(layoutDirection), + top = contentPadding.calculateTopPadding().plus(spacing), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding(), + ), + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + if (gameInfo.hasVideos) { + videosItem( + videos = gameInfo.videoModels, + onVideoClicked = onVideoClicked, + ) + } - if (gameInfo.hasScreenshots) { - screenshotsItem( - screenshots = gameInfo.screenshotModels, - onScreenshotClicked = onScreenshotClicked, - ) - } + if (gameInfo.hasScreenshots) { + screenshotsItem( + screenshots = gameInfo.screenshotModels, + onScreenshotClicked = onScreenshotClicked, + ) + } - if (gameInfo.hasSummary) { - summaryItem(model = checkNotNull(gameInfo.summary)) - } + if (gameInfo.hasSummary) { + summaryItem(model = checkNotNull(gameInfo.summary)) + } - if (gameInfo.hasDetails) { - detailsItem(model = checkNotNull(gameInfo.detailsModel)) - } + if (gameInfo.hasDetails) { + detailsItem(model = checkNotNull(gameInfo.detailsModel)) + } - if (gameInfo.hasLinks) { - linksItem( - model = gameInfo.linkModels, - onLinkClicked = onLinkClicked, - ) - } + if (gameInfo.hasLinks) { + linksItem( + model = gameInfo.linkModels, + onLinkClicked = onLinkClicked, + ) + } - if (gameInfo.hasCompanies) { - companiesItem( - model = gameInfo.companyModels, - onCompanyClicked = onCompanyClicked, - ) - } + if (gameInfo.hasCompanies) { + companiesItem( + model = gameInfo.companyModels, + onCompanyClicked = onCompanyClicked, + ) + } - if (gameInfo.hasOtherCompanyGames) { - relatedGamesItem( - model = checkNotNull(gameInfo.otherCompanyGames), - onGameClicked = onRelatedGameClicked, - ) - } + if (gameInfo.hasOtherCompanyGames) { + relatedGamesItem( + model = checkNotNull(gameInfo.otherCompanyGames), + onGameClicked = onRelatedGameClicked, + ) + } - if (gameInfo.hasSimilarGames) { - relatedGamesItem( - model = checkNotNull(gameInfo.similarGames), - onGameClicked = onRelatedGameClicked, - ) + if (gameInfo.hasSimilarGames) { + relatedGamesItem( + model = checkNotNull(gameInfo.similarGames), + onGameClicked = onRelatedGameClicked, + ) + } } } } diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt index a5f51723..1f23b030 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt @@ -18,12 +18,10 @@ package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header -import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -35,7 +33,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 @@ -61,16 +58,31 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.constraintlayout.compose.ConstrainScope +import androidx.constraintlayout.compose.ConstrainedLayoutReference import androidx.constraintlayout.compose.ConstraintSet import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.InvalidationStrategy import androidx.constraintlayout.compose.MotionLayout +import androidx.constraintlayout.compose.MotionScene +import androidx.constraintlayout.compose.MotionSceneScope +import androidx.constraintlayout.compose.OnSwipe +import androidx.constraintlayout.compose.SwipeDirection +import androidx.constraintlayout.compose.SwipeMode +import androidx.constraintlayout.compose.SwipeSide +import androidx.constraintlayout.compose.SwipeTouchUp import androidx.constraintlayout.compose.Transition import androidx.constraintlayout.compose.Visibility +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.paulrybitskyi.commons.ktx.getCompatDrawable +import com.paulrybitskyi.commons.ktx.onClick +import com.paulrybitskyi.commons.ktx.postAction import com.paulrybitskyi.commons.ktx.statusBarHeight import com.paulrybitskyi.gamedge.common.ui.clickable import com.paulrybitskyi.gamedge.common.ui.theme.GamedgeTheme +import com.paulrybitskyi.gamedge.common.ui.theme.Spaces import com.paulrybitskyi.gamedge.common.ui.theme.darkScrim import com.paulrybitskyi.gamedge.common.ui.theme.lightScrim import com.paulrybitskyi.gamedge.common.ui.theme.subtitle3 @@ -82,6 +94,10 @@ import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artwor import org.intellij.lang.annotations.Language import com.paulrybitskyi.gamedge.core.R as CoreR +private const val NameSetExpanded = "expanded" +private const val NameSetCollapsed = "collapsed" +private const val NameTransition = "expanded_to_collapsed" + private const val ConstraintIdArtworks = "artworks" private const val ConstraintIdArtworksScrim = "artworks_scrim" private const val ConstraintIdBackButton = "back_button" @@ -100,6 +116,10 @@ private const val ConstraintIdAgeRating = "age_rating" private const val ConstraintIdGameCategory = "game_category" private const val ConstraintIdList = "list" +private const val CustomAttributeTextColor = "text_color" + +private val ScrimContentColor = Color.White + private val CoverSpace = 40.dp private val InfoIconSize = 34.dp @@ -110,9 +130,9 @@ private val ArtworksHeightExpanded = 240.dp private val ArtworksHeightCollapsed = 56.dp private val PageIndicatorDeltaXCollapsed = 60.dp -private val CoverDeltaXCollapsed = -130.dp -private val CoverDeltaYCollapsed = -60.dp -private val SecondaryTextDeltaXCollapsed = -8.dp +private val CoverDeltaXCollapsed = (-130).dp +private val CoverDeltaYCollapsed = (-60).dp +private val SecondaryTextDeltaXCollapsed = (-8).dp private enum class State { Expanded, @@ -137,6 +157,8 @@ internal fun GameInfoAnimatableHeader( label = "GameInfoAnimatableHeaderProgress", ) + val colors = GamedgeTheme.colors + val density = LocalDensity.current val artworks = headerInfo.artworks val isPageIndicatorVisible = remember(artworks) { artworks.size > 1 } val hasDefaultPlaceholderArtwork = remember(artworks) { @@ -166,17 +188,21 @@ internal fun GameInfoAnimatableHeader( } MotionLayout( - // motionScene = MotionScene(constructJson()), - start = constructExpandedConstraintSet( + motionScene = rememberMotionScene( hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, isSecondTitleVisible = isSecondTitleVisible, ), - end = constructCollapsedConstraintSet( - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - ), - transition = Transition(constructTransition()), - modifier = Modifier.fillMaxSize(), progress = progress, + modifier = Modifier.fillMaxSize(), + transitionName = NameTransition, + invalidationStrategy = remember { + InvalidationStrategy( + onObservedStateChange = { + @Suppress("UNUSED_EXPRESSION") + headerInfo + }, + ) + } ) { Artworks( artworks = artworks, @@ -218,7 +244,7 @@ internal fun GameInfoAnimatableHeader( shape = CircleShape, ) .padding(GamedgeTheme.spaces.spacing_1_5), - tint = GamedgeTheme.colors.onPrimary, + tint = ScrimContentColor, ) if (isPageIndicatorVisible) { @@ -239,7 +265,7 @@ internal fun GameInfoAnimatableHeader( vertical = GamedgeTheme.spaces.spacing_1_5, horizontal = GamedgeTheme.spaces.spacing_2_0, ), - color = GamedgeTheme.colors.onPrimary, + color = ScrimContentColor, style = GamedgeTheme.typography.subtitle3, ) } @@ -269,36 +295,36 @@ internal fun GameInfoAnimatableHeader( 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) .drawOnTop(), - 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, - ) - } + update = { view -> + view.isLiked = headerInfo.isLiked + }, + ) Text( text = headerInfo.title, modifier = Modifier .layoutId(ConstraintIdFirstTitle) .drawOnTop(), - color = GamedgeTheme.colors.onPrimary, + color = customColor(ConstraintIdFirstTitle, CustomAttributeTextColor), overflow = firstTitleOverflowMode, maxLines = 1, onTextLayout = { textLayoutResult -> @@ -307,7 +333,9 @@ internal fun GameInfoAnimatableHeader( val firstTitleOffset = Offset(firstTitleWidth, 0f) val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 - secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) + if (firstTitleVisibleTextEndIndex in headerInfo.title.indices) { + secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) + } } }, style = GamedgeTheme.typography.h6, @@ -319,8 +347,6 @@ internal fun GameInfoAnimatableHeader( .drawOnTop(), ) { if (isSecondTitleVisible) { - // Remove font padding once https://issuetracker.google.com/issues/171394808 - // is implemented (includeFontPadding="false" in XML) Text( text = secondTitleText, color = GamedgeTheme.colors.onPrimary, @@ -405,308 +431,461 @@ internal fun GameInfoAnimatableHeader( } @Composable -private fun constructExpandedConstraintSet( - hasDefaultPlaceholderArtwork: Boolean = false, - isSecondTitleVisible: Boolean = false, +private fun rememberMotionScene( + hasDefaultPlaceholderArtwork: Boolean, + isSecondTitleVisible: Boolean, +): MotionScene { + val spaces = GamedgeTheme.spaces + val artworksHeightInCollapsedState = calculateArtworksHeightInCollapsedState() + val statusBarHeight = calculateStatusBarHeightInDp() + val firstTitleColorInExpandedState = GamedgeTheme.colors.onPrimary + val firstTitleColorInCollapsedState = ScrimContentColor + + return MotionScene { + val refs = ConstraintLayoutRefs(this) + + addConstraintSet( + constraintSet = constructExpandedConstraintSet( + refs = refs, + spaces = spaces, + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, + firstTitleTextColor = firstTitleColorInExpandedState, + ), + name = NameSetExpanded, + ) + addConstraintSet( + constraintSet = constructCollapsedConstraintSet( + refs = refs, + spaces = spaces, + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + artworksHeight = artworksHeightInCollapsedState, + statusBarHeight = statusBarHeight, + firstTitleTextColor = firstTitleColorInCollapsedState, + ), + name = NameSetCollapsed, + ) + addTransition( + transition = constructTransition( + refs = refs, + firstTitleColorInExpandedState = firstTitleColorInExpandedState, + firstTitleColorInCollapsedState = firstTitleColorInCollapsedState, + ), + name = NameTransition, + ) + } +} + +@Composable +private fun calculateArtworksHeightInCollapsedState(): Dp { + return ArtworksHeightCollapsed + calculateStatusBarHeightInDp() +} + +@Composable +private fun calculateStatusBarHeightInDp(): Dp { + val statusBarHeight = LocalContext.current.statusBarHeight + + return with(LocalDensity.current) { statusBarHeight.toDp() } +} + +private class ConstraintLayoutRefs( + val artworks: ConstrainedLayoutReference, + val artworksScrim: ConstrainedLayoutReference, + val backButton: ConstrainedLayoutReference, + val pageIndicator: ConstrainedLayoutReference, + val backdrop: ConstrainedLayoutReference, + val coverSpace: ConstrainedLayoutReference, + val cover: ConstrainedLayoutReference, + val likeButton: ConstrainedLayoutReference, + val firstTitle: ConstrainedLayoutReference, + val secondTitle: ConstrainedLayoutReference, + val releaseDate: ConstrainedLayoutReference, + val developerName: ConstrainedLayoutReference, + val rating: ConstrainedLayoutReference, + val likeCount: ConstrainedLayoutReference, + val ageRating: ConstrainedLayoutReference, + val gameCategory: ConstrainedLayoutReference, + val list: ConstrainedLayoutReference, +) { + constructor(motionSceneScope: MotionSceneScope): this( + artworks = motionSceneScope.createRefFor(ConstraintIdArtworks), + artworksScrim = motionSceneScope.createRefFor(ConstraintIdArtworksScrim), + backButton = motionSceneScope.createRefFor(ConstraintIdBackButton), + pageIndicator = motionSceneScope.createRefFor(ConstraintIdPageIndicator), + backdrop = motionSceneScope.createRefFor(ConstraintIdBackdrop), + coverSpace = motionSceneScope.createRefFor(ConstraintIdCoverSpace), + cover = motionSceneScope.createRefFor(ConstraintIdCover), + likeButton = motionSceneScope.createRefFor(ConstraintIdLikeButton), + firstTitle = motionSceneScope.createRefFor(ConstraintIdFirstTitle), + secondTitle = motionSceneScope.createRefFor(ConstraintIdSecondTitle), + releaseDate = motionSceneScope.createRefFor(ConstraintIdReleaseDate), + developerName = motionSceneScope.createRefFor(ConstraintIdDeveloperName), + rating = motionSceneScope.createRefFor(ConstraintIdRating), + likeCount = motionSceneScope.createRefFor(ConstraintIdLikeCount), + ageRating = motionSceneScope.createRefFor(ConstraintIdAgeRating), + gameCategory = motionSceneScope.createRefFor(ConstraintIdGameCategory), + list = motionSceneScope.createRefFor(ConstraintIdList), + ) +} + +private fun MotionSceneScope.constructExpandedConstraintSet( + refs: ConstraintLayoutRefs, + spaces: Spaces, + hasDefaultPlaceholderArtwork: Boolean, + isSecondTitleVisible: Boolean, + firstTitleTextColor: Color, ): ConstraintSet { - val pageIndicatorMargin = GamedgeTheme.spaces.spacing_2_5 - val backdropElevation = GamedgeTheme.spaces.spacing_0_5 + val pageIndicatorMargin = spaces.spacing_2_5 + val backdropElevation = spaces.spacing_0_5 val coverSpaceMargin = CoverSpace - val coverMarginStart = GamedgeTheme.spaces.spacing_3_5 - val likeBtnMarginEnd = GamedgeTheme.spaces.spacing_2_5 - val titleMarginStart = GamedgeTheme.spaces.spacing_3_5 + val coverMarginStart = spaces.spacing_3_5 + val likeBtnMarginEnd = spaces.spacing_2_5 + val titleMarginStart = spaces.spacing_3_5 val firstTitleMarginTop = titleMarginStart - val firstTitleMarginEnd = GamedgeTheme.spaces.spacing_1_0 - val secondTitleMarginEnd = GamedgeTheme.spaces.spacing_3_5 - val releaseDateMarginTop = GamedgeTheme.spaces.spacing_2_5 - val releaseDateMarginHorizontal = GamedgeTheme.spaces.spacing_3_5 - val developerNameMarginHorizontal = GamedgeTheme.spaces.spacing_3_5 - val bottomBarrierMargin = GamedgeTheme.spaces.spacing_5_0 - val infoItemMarginBottom = GamedgeTheme.spaces.spacing_3_5 + val firstTitleMarginEnd = spaces.spacing_1_0 + val secondTitleMarginEnd = spaces.spacing_3_5 + val releaseDateMarginTop = spaces.spacing_2_5 + val releaseDateMarginHorizontal = spaces.spacing_3_5 + val developerNameMarginHorizontal = spaces.spacing_3_5 + val bottomBarrierMargin = spaces.spacing_5_0 + val infoItemMarginBottom = spaces.spacing_3_5 return ConstraintSet { - val artworks = createRefFor(ConstraintIdArtworks) - val artworksScrim = createRefFor(ConstraintIdArtworksScrim) - val backButton = createRefFor(ConstraintIdBackButton) - val pageIndicator = createRefFor(ConstraintIdPageIndicator) - val backdrop = createRefFor(ConstraintIdBackdrop) - val coverSpace = createRefFor(ConstraintIdCoverSpace) - val cover = createRefFor(ConstraintIdCover) - val likeButton = createRefFor(ConstraintIdLikeButton) - val firstTitle = createRefFor(ConstraintIdFirstTitle) - val secondTitle = createRefFor(ConstraintIdSecondTitle) - val releaseDate = createRefFor(ConstraintIdReleaseDate) - val developerName = createRefFor(ConstraintIdDeveloperName) - val bottomBarrier = createBottomBarrier(cover, developerName, margin = bottomBarrierMargin) - val rating = createRefFor(ConstraintIdRating) - val likeCount = createRefFor(ConstraintIdLikeCount) - val ageRating = createRefFor(ConstraintIdAgeRating) - val gameCategory = createRefFor(ConstraintIdGameCategory) - val list = createRefFor(ConstraintIdList) + val bottomBarrier = createBottomBarrier( + refs.cover, + refs.developerName, + margin = bottomBarrierMargin, + ) - constrain(artworks) { + constrain(refs.artworks) { width = Dimension.fillToConstraints height = Dimension.value(ArtworksHeightExpanded) top.linkTo(parent.top) centerHorizontallyTo(parent) } - constrain(artworksScrim) { + constrain(refs.artworksScrim) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - centerVerticallyTo(artworks) - centerHorizontallyTo(artworks) + centerVerticallyTo(refs.artworks) + centerHorizontallyTo(refs.artworks) visibility = if (hasDefaultPlaceholderArtwork) { Visibility.Gone } else { Visibility.Invisible } } - constrain(backButton) { + constrain(refs.backButton) { top.linkTo(parent.top) start.linkTo(parent.start) } - constrain(pageIndicator) { + constrain(refs.pageIndicator) { top.linkTo(parent.top, pageIndicatorMargin) end.linkTo(parent.end, pageIndicatorMargin) } - constrain(backdrop) { + constrain(refs.backdrop) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - top.linkTo(artworks.bottom) - bottom.linkTo(list.top) + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.list.top) centerHorizontallyTo(parent) translationZ = backdropElevation } - constrain(coverSpace) { + constrain(refs.coverSpace) { start.linkTo(parent.start) - bottom.linkTo(artworks.bottom, coverSpaceMargin) + bottom.linkTo(refs.artworks.bottom, coverSpaceMargin) } - constrain(cover) { - top.linkTo(coverSpace.bottom) + constrain(refs.cover) { + top.linkTo(refs.coverSpace.bottom) start.linkTo(parent.start, coverMarginStart) } - constrain(likeButton) { - top.linkTo(artworks.bottom) - bottom.linkTo(artworks.bottom) + constrain(refs.likeButton) { + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.artworks.bottom) end.linkTo(parent.end, likeBtnMarginEnd) } - constrain(firstTitle) { + constrain(refs.firstTitle) { width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, firstTitleMarginTop) - start.linkTo(cover.end, titleMarginStart) - end.linkTo(likeButton.start, firstTitleMarginEnd) + top.linkTo(refs.artworks.bottom, firstTitleMarginTop) + start.linkTo(refs.cover.end, titleMarginStart) + end.linkTo(refs.likeButton.start, firstTitleMarginEnd) + customColor(CustomAttributeTextColor, firstTitleTextColor) } - constrain(secondTitle) { + constrain(refs.secondTitle) { width = Dimension.fillToConstraints - top.linkTo(firstTitle.bottom) - start.linkTo(cover.end, titleMarginStart) + top.linkTo(refs.firstTitle.bottom) + start.linkTo(refs.cover.end, titleMarginStart) end.linkTo(parent.end, secondTitleMarginEnd) isVisible = isSecondTitleVisible } - constrain(releaseDate) { + constrain(refs.releaseDate) { width = Dimension.fillToConstraints - top.linkTo(secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(cover.end, releaseDateMarginHorizontal) + top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) + start.linkTo(refs.cover.end, releaseDateMarginHorizontal) end.linkTo(parent.end, releaseDateMarginHorizontal) } - constrain(developerName) { + constrain(refs.developerName) { width = Dimension.fillToConstraints - top.linkTo(releaseDate.bottom) - start.linkTo(cover.end, developerNameMarginHorizontal) + top.linkTo(refs.releaseDate.bottom) + start.linkTo(refs.cover.end, developerNameMarginHorizontal) end.linkTo(parent.end, developerNameMarginHorizontal) } - constrain(rating) { + constrain(refs.rating) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = parent.start, end = likeCount.start, bias = 0.25f) + bottom.linkTo(refs.list.top, infoItemMarginBottom) + linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) } - constrain(likeCount) { + constrain(refs.likeCount) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = rating.end, end = ageRating.start, bias = 0.25f) + bottom.linkTo(refs.list.top, infoItemMarginBottom) + linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) } - constrain(ageRating) { + constrain(refs.ageRating) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = likeCount.end, end = gameCategory.start, bias = 0.25f) + bottom.linkTo(refs.list.top, infoItemMarginBottom) + linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) } - constrain(gameCategory) { + constrain(refs.gameCategory) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = ageRating.end, end = parent.end, bias = 0.25f) + bottom.linkTo(refs.list.top, infoItemMarginBottom) + linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) } - constrain(list) { + constrain(refs.list) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - top.linkTo(rating.bottom) + top.linkTo(refs.rating.bottom) bottom.linkTo(parent.bottom) centerHorizontallyTo(parent) } } } -@SuppressLint("Range") -@Composable -private fun constructCollapsedConstraintSet( - hasDefaultPlaceholderArtwork: Boolean = false, +private fun MotionSceneScope.constructCollapsedConstraintSet( + refs: ConstraintLayoutRefs, + spaces: Spaces, + hasDefaultPlaceholderArtwork: Boolean, + artworksHeight: Dp, + statusBarHeight: Dp, + firstTitleTextColor: Color, ): ConstraintSet { - val artworksHeight = calculateArtworksHeightInCollapsedState() - val statusBarHeight = calculateStatusBarHeightInDp() - val pageIndicatorMargin = GamedgeTheme.spaces.spacing_2_5 - val backdropElevation = GamedgeTheme.spaces.spacing_1_0 + val pageIndicatorMargin = spaces.spacing_2_5 + val backdropElevation = spaces.spacing_1_0 val coverSpaceMargin = CoverSpace - val coverMarginStart = GamedgeTheme.spaces.spacing_3_5 - val likeBtnMarginEnd = GamedgeTheme.spaces.spacing_2_5 - val titleMarginStart = GamedgeTheme.spaces.spacing_3_5 - val firstTitleMarginStart = GamedgeTheme.spaces.spacing_7_5 - val firstTitleMarginEnd = GamedgeTheme.spaces.spacing_6_0 - val secondTitleMarginEnd = GamedgeTheme.spaces.spacing_3_5 - val releaseDateMarginTop = GamedgeTheme.spaces.spacing_2_5 - val releaseDateMarginHorizontal = GamedgeTheme.spaces.spacing_3_5 - val developerNameMarginHorizontal = GamedgeTheme.spaces.spacing_3_5 - val infoItemVerticalMargin = GamedgeTheme.spaces.spacing_3_5 + val coverMarginStart = spaces.spacing_3_5 + val likeBtnMarginEnd = spaces.spacing_2_5 + val titleMarginStart = spaces.spacing_3_5 + val firstTitleMarginStart = spaces.spacing_7_5 + val firstTitleMarginEnd = spaces.spacing_6_0 + val secondTitleMarginEnd = spaces.spacing_3_5 + val releaseDateMarginTop = spaces.spacing_2_5 + val releaseDateMarginHorizontal = spaces.spacing_3_5 + val developerNameMarginHorizontal = spaces.spacing_3_5 + val infoItemVerticalMargin = spaces.spacing_3_5 return ConstraintSet { - val artworks = createRefFor(ConstraintIdArtworks) - val artworksScrim = createRefFor(ConstraintIdArtworksScrim) - val backButton = createRefFor(ConstraintIdBackButton) - val pageIndicator = createRefFor(ConstraintIdPageIndicator) - val backdrop = createRefFor(ConstraintIdBackdrop) - val coverSpace = createRefFor(ConstraintIdCoverSpace) - val cover = createRefFor(ConstraintIdCover) - val likeButton = createRefFor(ConstraintIdLikeButton) - val firstTitle = createRefFor(ConstraintIdFirstTitle) - val secondTitle = createRefFor(ConstraintIdSecondTitle) - val releaseDate = createRefFor(ConstraintIdReleaseDate) - val developerName = createRefFor(ConstraintIdDeveloperName) - val rating = createRefFor(ConstraintIdRating) - val likeCount = createRefFor(ConstraintIdLikeCount) - val ageRating = createRefFor(ConstraintIdAgeRating) - val gameCategory = createRefFor(ConstraintIdGameCategory) - val list = createRefFor(ConstraintIdList) - - constrain(artworks) { + constrain(refs.artworks) { width = Dimension.fillToConstraints height = Dimension.value(artworksHeight) top.linkTo(parent.top) - bottom.linkTo(backdrop.top) + bottom.linkTo(refs.backdrop.top) centerHorizontallyTo(parent) } - constrain(artworksScrim) { + constrain(refs.artworksScrim) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - centerVerticallyTo(artworks) - centerHorizontallyTo(artworks) + centerVerticallyTo(refs.artworks) + centerHorizontallyTo(refs.artworks) visibility = if (hasDefaultPlaceholderArtwork) { Visibility.Gone } else { Visibility.Visible } } - constrain(backButton) { + constrain(refs.backButton) { top.linkTo(parent.top) start.linkTo(parent.start) } - constrain(pageIndicator) { + constrain(refs.pageIndicator) { top.linkTo(parent.top, pageIndicatorMargin) end.linkTo(parent.end, pageIndicatorMargin) translationX = PageIndicatorDeltaXCollapsed } - constrain(backdrop) { + constrain(refs.backdrop) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - top.linkTo(artworks.bottom) - bottom.linkTo(list.top) + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.list.top) centerHorizontallyTo(parent) translationZ = backdropElevation } - constrain(coverSpace) { + constrain(refs.coverSpace) { start.linkTo(parent.start) - bottom.linkTo(artworks.bottom, coverSpaceMargin) + bottom.linkTo(refs.artworks.bottom, coverSpaceMargin) } - constrain(cover) { - top.linkTo(coverSpace.bottom) + constrain(refs.cover) { + top.linkTo(refs.coverSpace.bottom) start.linkTo(parent.start, coverMarginStart) translationX = CoverDeltaXCollapsed translationY = CoverDeltaYCollapsed } - constrain(likeButton) { - top.linkTo(artworks.bottom) - bottom.linkTo(artworks.bottom) + constrain(refs.likeButton) { + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.artworks.bottom) end.linkTo(parent.end, likeBtnMarginEnd) alpha = 0f setScale(LikeButtonScaleCollapsed) } - constrain(firstTitle) { + constrain(refs.firstTitle) { width = Dimension.fillToConstraints - top.linkTo(artworks.top, statusBarHeight) - bottom.linkTo(artworks.bottom) - start.linkTo(backButton.end, firstTitleMarginStart) + top.linkTo(refs.artworks.top, statusBarHeight) + bottom.linkTo(refs.artworks.bottom) + start.linkTo(refs.backButton.end, firstTitleMarginStart) end.linkTo(parent.end, firstTitleMarginEnd) setScale(FirstTitleScaleCollapsed) + customColor(CustomAttributeTextColor, firstTitleTextColor) } - constrain(secondTitle) { + constrain(refs.secondTitle) { width = Dimension.fillToConstraints - top.linkTo(firstTitle.bottom) - start.linkTo(cover.end, titleMarginStart) + top.linkTo(refs.firstTitle.bottom) + start.linkTo(refs.cover.end, titleMarginStart) end.linkTo(parent.end, secondTitleMarginEnd) alpha = 0f translationX = SecondaryTextDeltaXCollapsed } - constrain(releaseDate) { + constrain(refs.releaseDate) { width = Dimension.fillToConstraints - top.linkTo(secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(cover.end, releaseDateMarginHorizontal) + top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) + start.linkTo(refs.cover.end, releaseDateMarginHorizontal) end.linkTo(parent.end, releaseDateMarginHorizontal) alpha = 0f translationX = SecondaryTextDeltaXCollapsed } - constrain(developerName) { + constrain(refs.developerName) { width = Dimension.fillToConstraints - top.linkTo(releaseDate.bottom) - start.linkTo(cover.end, developerNameMarginHorizontal) + top.linkTo(refs.releaseDate.bottom) + start.linkTo(refs.cover.end, developerNameMarginHorizontal) end.linkTo(parent.end, developerNameMarginHorizontal) alpha = 0f translationX = SecondaryTextDeltaXCollapsed } - constrain(rating) { + constrain(refs.rating) { width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = parent.start, end = likeCount.start, bias = 0.25f) + top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) + bottom.linkTo(refs.list.top, infoItemVerticalMargin) + linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) } - constrain(likeCount) { + constrain(refs.likeCount) { width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = rating.end, end = ageRating.start, bias = 0.25f) + top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) + bottom.linkTo(refs.list.top, infoItemVerticalMargin) + linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) } - constrain(ageRating) { + constrain(refs.ageRating) { width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = likeCount.end, end = gameCategory.start, bias = 0.25f) + top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) + bottom.linkTo(refs.list.top, infoItemVerticalMargin) + linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) } - constrain(gameCategory) { + constrain(refs.gameCategory) { width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = ageRating.end, end = parent.end, bias = 0.25f) + top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) + bottom.linkTo(refs.list.top, infoItemVerticalMargin) + linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) } - constrain(list) { + constrain(refs.list) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - top.linkTo(rating.bottom) + top.linkTo(refs.rating.bottom) bottom.linkTo(parent.bottom) centerHorizontallyTo(parent) } } } +private fun MotionSceneScope.constructTransition( + refs: ConstraintLayoutRefs, + firstTitleColorInExpandedState: Color, + firstTitleColorInCollapsedState: Color, +): Transition { + return Transition(from = NameSetExpanded, to = NameSetCollapsed) { + keyAttributes(refs.secondTitle) { + frame(frame = 15) { + alpha = 0f + translationX = SecondaryTextDeltaXCollapsed + } + } + keyAttributes(refs.releaseDate) { + frame(frame = 15) { + alpha = 0f + translationX = SecondaryTextDeltaXCollapsed + } + } + keyAttributes(refs.developerName) { + frame(frame = 15) { + alpha = 0f + translationX = SecondaryTextDeltaXCollapsed + } + } + keyAttributes(refs.cover) { + frame(frame = 50) { + alpha = 0f + translationX = CoverDeltaXCollapsed + translationY = CoverDeltaYCollapsed + } + } + keyAttributes(refs.firstTitle) { + frame(frame = 40) { + customColor(CustomAttributeTextColor, firstTitleColorInExpandedState) + } + frame(frame = 60) { + customColor(CustomAttributeTextColor, firstTitleColorInCollapsedState) + } + } + keyAttributes(refs.likeButton) { + frame(frame = 60) { + alpha = 0f + scaleX = 0f + scaleY = 0f + } + } + keyAttributes(refs.pageIndicator) { + frame(frame = 80) { + translationX = PageIndicatorDeltaXCollapsed + } + } + + onSwipe = OnSwipe( + anchor = refs.list, + side = SwipeSide.Top, + direction = SwipeDirection.Up, + onTouchUp = SwipeTouchUp.AutoComplete, + mode = SwipeMode.Velocity, + ) + } +} + +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) { + // Just calling setImageState() directly doesn't work, so we need + // to postpone it just a bit. + postAction { + setImageState(intArrayOf(if (value) STATE_CHECKED_ON else STATE_CHECKED_OFF), true) + } + } + get() = drawableState.contains(STATE_CHECKED_ON) +} + private var ConstrainScope.isVisible: Boolean set(isVisible) { visibility = if (isVisible) Visibility.Visible else Visibility.Gone @@ -719,8 +898,7 @@ private fun ConstrainScope.setScale(scale: Float) { } @Language("json5") -@Composable -private fun constructTransition(): String { +private fun constructTransitionJson(): String { /* onSwipe: { direction: "up", @@ -738,6 +916,13 @@ private fun constructTransition(): String { easing: "easeInOut", duration: 400, pathMotionArc: "none", + onSwipe: { + direction: "up", + touchUp: "autocomplete", + anchor: "$ConstraintIdList", + side: "top", + mode: "velocity", + }, KeyFrames: { KeyAttributes: [ { @@ -1135,18 +1320,6 @@ private fun constructJson(): String { """.trimIndent() } -@Composable -private fun calculateArtworksHeightInCollapsedState(): Dp { - return ArtworksHeightCollapsed + calculateStatusBarHeightInDp() -} - -@Composable -private fun calculateStatusBarHeightInDp(): Dp { - val statusBarHeight = LocalContext.current.statusBarHeight - - return with(LocalDensity.current) { statusBarHeight.toDp() } -} - private fun Modifier.drawOnTop(): Modifier { return zIndex(Float.MAX_VALUE) } 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 59e349e0..a5da024f 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 @@ -215,7 +215,7 @@ internal fun GameInfoHeader( // arrives. AndroidView( factory = { context -> - LikeButton(context).apply { + LikeButton0(context).apply { supportBackgroundTintList = ColorStateList.valueOf(colors.secondary.toArgb()) size = FloatingActionButton.SIZE_NORMAL setMaxImageSize(with(density) { 52.dp.toPx().toInt() }) @@ -251,8 +251,6 @@ internal fun GameInfoHeader( Box(modifier = Modifier.layoutId(ConstraintIdSecondTitle)) { if (isSecondTitleVisible) { - // Remove font padding once https://issuetracker.google.com/issues/171394808 - // is implemented (includeFontPadding="false" in XML) Text( text = secondTitleText, color = GamedgeTheme.colors.onPrimary, @@ -311,7 +309,7 @@ internal fun GameInfoHeader( } } -private class LikeButton(context: Context) : FloatingActionButton(context) { +private class LikeButton0(context: Context) : FloatingActionButton(context) { private companion object { const val STATE_CHECKED = android.R.attr.state_checked From 9f281d7ae87fa1be1a5023c631288b02678d7039 Mon Sep 17 00:00:00 2001 From: mars885 Date: Sun, 29 Sep 2024 18:33:21 +0300 Subject: [PATCH 02/12] Implement the header by intercepting scroll gestures --- .../info/presentation/GameInfoScreen.kt | 15 +- .../header/GameInfoAnimatableHeader.kt | 245 ++++++++++++------ 2 files changed, 184 insertions(+), 76 deletions(-) diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt index 9fc8f55f..47931239 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt @@ -17,6 +17,7 @@ package com.paulrybitskyi.gamedge.feature.info.presentation import android.content.res.Configuration +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -34,18 +35,24 @@ import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Velocity import androidx.hilt.navigation.compose.hiltViewModel import com.paulrybitskyi.commons.ktx.showShortToast import com.paulrybitskyi.gamedge.common.ui.CommandsHandler @@ -237,18 +244,22 @@ private fun SuccessState( onCompanyClicked: (GameInfoCompanyUiModel) -> Unit, onRelatedGameClicked: (GameInfoRelatedGameUiModel) -> Unit, ) { + val listState = rememberLazyListState() + GameInfoAnimatableHeader( headerInfo = gameInfo.headerModel, + listState = listState, onArtworkClicked = onArtworkClicked, onBackButtonClicked = onBackButtonClicked, onCoverClicked = onCoverClicked, onLikeButtonClicked = onLikeButtonClicked, - ) { modifier -> + ) { modifier, nestedConnection -> val layoutDirection = LocalLayoutDirection.current val spacing = GamedgeTheme.spaces.spacing_3_5 LazyColumn( - modifier = modifier, + modifier = modifier.nestedScroll(nestedConnection), + state = listState, contentPadding = PaddingValues( start = contentPadding.calculateStartPadding(layoutDirection), top = contentPadding.calculateTopPadding().plus(spacing), diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt index 1f23b030..92efa7bb 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt @@ -20,10 +20,10 @@ package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header import android.content.Context import android.content.res.ColorStateList -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -31,26 +31,35 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -68,11 +77,6 @@ import androidx.constraintlayout.compose.InvalidationStrategy import androidx.constraintlayout.compose.MotionLayout import androidx.constraintlayout.compose.MotionScene import androidx.constraintlayout.compose.MotionSceneScope -import androidx.constraintlayout.compose.OnSwipe -import androidx.constraintlayout.compose.SwipeDirection -import androidx.constraintlayout.compose.SwipeMode -import androidx.constraintlayout.compose.SwipeSide -import androidx.constraintlayout.compose.SwipeTouchUp import androidx.constraintlayout.compose.Transition import androidx.constraintlayout.compose.Visibility import com.google.android.material.floatingactionbutton.FloatingActionButton @@ -91,12 +95,15 @@ import com.paulrybitskyi.gamedge.common.ui.widgets.Info import com.paulrybitskyi.gamedge.feature.info.R import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.Artworks import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.GameInfoArtworkUiModel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.launch import org.intellij.lang.annotations.Language import com.paulrybitskyi.gamedge.core.R as CoreR -private const val NameSetExpanded = "expanded" -private const val NameSetCollapsed = "collapsed" -private const val NameTransition = "expanded_to_collapsed" +private const val ConstraintSetNameExpanded = "expanded" +private const val ConstraintSetNameCollapsed = "collapsed" +private const val TransitionName = "fancy_transition" private const val ConstraintIdArtworks = "artworks" private const val ConstraintIdArtworksScrim = "artworks_scrim" @@ -134,28 +141,34 @@ private val CoverDeltaXCollapsed = (-130).dp private val CoverDeltaYCollapsed = (-60).dp private val SecondaryTextDeltaXCollapsed = (-8).dp -private enum class State { - Expanded, - Collapsed, -} +private val AnimatableSaver = Saver( + save = { animatable -> animatable.value }, + restore = ::Animatable, +) -// Try out this again when a new version of MotionLayout for compose -// comes out (as of 12.06.2022, the latest is 1.1.0-alpha02). @Composable internal fun GameInfoAnimatableHeader( headerInfo: GameInfoHeaderUiModel, + listState: LazyListState, onArtworkClicked: (artworkIndex: Int) -> Unit, onBackButtonClicked: () -> Unit, onCoverClicked: () -> Unit, onLikeButtonClicked: () -> Unit, - content: @Composable (Modifier) -> Unit, + content: @Composable (Modifier, NestedScrollConnection) -> Unit, ) { - var state by remember { mutableStateOf(State.Expanded) } - val progress by animateFloatAsState( - targetValue = if (state == State.Expanded) 0f else 1f, - animationSpec = tween(3000), - label = "GameInfoAnimatableHeaderProgress", - ) + val maxPx = 555f + val minPx = 228f + val progress = rememberSaveable(saver = AnimatableSaver) { + Animatable(0f).apply { + updateBounds(0f, 1f) + } + } + var headerHeight by remember { + mutableFloatStateOf( + if (progress.value == 0f) maxPx else minPx + ) + } + val coroutineScope = rememberCoroutineScope() val colors = GamedgeTheme.colors val density = LocalDensity.current @@ -174,12 +187,12 @@ internal fun GameInfoAnimatableHeader( } val isArtworkInteractionEnabled by remember { derivedStateOf { - progress < 0.01f + progress.value < 0.01f } } val firstTitleOverflowMode by remember { derivedStateOf { - if (progress < 0.95f) { + if (progress.value < 0.95f) { TextOverflow.Clip } else { TextOverflow.Ellipsis @@ -187,14 +200,88 @@ internal fun GameInfoAnimatableHeader( } } + LaunchedEffect(Unit) { + snapshotFlow { listState.isScrollInProgress } + .distinctUntilChanged() + .filterNot { it } + .collect { + val currentProgress = progress.value + + if (currentProgress != 0f && currentProgress != 1f) { + val newProgress = if (currentProgress < 0.5f) 0f else 1f + val duration = (300 + (1200 - 300) * 4 * currentProgress * (1 - currentProgress)).toInt() + + launch { + progress.animateTo( + targetValue = newProgress, + animationSpec = tween(duration, easing = EaseInOut), + block = { + headerHeight = minPx + (1 - value) * (maxPx - minPx) + } + ) + } + } + } + } + + val nestedConnection = remember(listState, coroutineScope) { + object : NestedScrollConnection { + + private fun consume(available: Offset): Offset { + val height = headerHeight + + if (height + available.y > maxPx) { + headerHeight = maxPx + updateProgress() + return Offset(0f, maxPx - height) + } + + if (height + available.y < minPx) { + headerHeight = minPx + updateProgress() + return Offset(0f, minPx - height) + } + + headerHeight += available.y / 2 + updateProgress() + + return Offset(0f, available.y) + } + + private fun updateProgress() { + val newProgress = 1 - (headerHeight - minPx) / (maxPx - minPx) + + coroutineScope.launch { + progress.snapTo(newProgress) + } + } + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (listState.canScrollBackward) { + return Offset.Zero + } + + return consume(available) + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + return if (available.y > 0 && !listState.canScrollBackward) { + consume(available) + } else { + Offset.Zero + } + } + } + } + MotionLayout( motionScene = rememberMotionScene( hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, isSecondTitleVisible = isSecondTitleVisible, ), - progress = progress, + progress = progress.value, modifier = Modifier.fillMaxSize(), - transitionName = NameTransition, + transitionName = TransitionName, invalidationStrategy = remember { InvalidationStrategy( onObservedStateChange = { @@ -277,7 +364,18 @@ internal fun GameInfoAnimatableHeader( color = GamedgeTheme.colors.surface, shape = RectangleShape, ) - .clip(RectangleShape), + .clip(RectangleShape) + .onGloballyPositioned { +/* Log.e("kimahri", "onGloballyPositioned, progress = $progress, size = ${it.size}") + if (progress == 0f) { + expandedHeight = it.size.height + } + + if (progress == 1f) { + collapsedHeight = it.size.height + } + Log.e("wakka", "backdrop = ${it.size}")*/ + }, ) Spacer( @@ -385,16 +483,7 @@ internal fun GameInfoAnimatableHeader( title = headerInfo.rating, modifier = Modifier .layoutId(ConstraintIdRating) - .drawOnTop() - .clickable { - @Suppress("ForbiddenComment") - // TODO: To be removed, only for debugging purposes - state = if (state == State.Expanded) { - State.Collapsed - } else { - State.Expanded - } - }, + .drawOnTop(), iconSize = InfoIconSize, titleTextStyle = GamedgeTheme.typography.caption, ) @@ -426,7 +515,7 @@ internal fun GameInfoAnimatableHeader( titleTextStyle = GamedgeTheme.typography.caption, ) - content(Modifier.layoutId(ConstraintIdList)) + content(Modifier.layoutId(ConstraintIdList), nestedConnection) } } @@ -441,38 +530,46 @@ private fun rememberMotionScene( val firstTitleColorInExpandedState = GamedgeTheme.colors.onPrimary val firstTitleColorInCollapsedState = ScrimContentColor - return MotionScene { - val refs = ConstraintLayoutRefs(this) - - addConstraintSet( - constraintSet = constructExpandedConstraintSet( - refs = refs, - spaces = spaces, - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - isSecondTitleVisible = isSecondTitleVisible, - firstTitleTextColor = firstTitleColorInExpandedState, - ), - name = NameSetExpanded, - ) - addConstraintSet( - constraintSet = constructCollapsedConstraintSet( - refs = refs, - spaces = spaces, - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - artworksHeight = artworksHeightInCollapsedState, - statusBarHeight = statusBarHeight, - firstTitleTextColor = firstTitleColorInCollapsedState, - ), - name = NameSetCollapsed, - ) - addTransition( - transition = constructTransition( - refs = refs, - firstTitleColorInExpandedState = firstTitleColorInExpandedState, - firstTitleColorInCollapsedState = firstTitleColorInCollapsedState, - ), - name = NameTransition, - ) + return remember( + spaces, + artworksHeightInCollapsedState, + statusBarHeight, + firstTitleColorInExpandedState, + firstTitleColorInCollapsedState + ) { + MotionScene { + val refs = ConstraintLayoutRefs(this) + + addConstraintSet( + constraintSet = constructExpandedConstraintSet( + refs = refs, + spaces = spaces, + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, + firstTitleTextColor = firstTitleColorInExpandedState, + ), + name = ConstraintSetNameExpanded, + ) + addConstraintSet( + constraintSet = constructCollapsedConstraintSet( + refs = refs, + spaces = spaces, + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + artworksHeight = artworksHeightInCollapsedState, + statusBarHeight = statusBarHeight, + firstTitleTextColor = firstTitleColorInCollapsedState, + ), + name = ConstraintSetNameCollapsed, + ) + addTransition( + transition = constructTransition( + refs = refs, + firstTitleColorInExpandedState = firstTitleColorInExpandedState, + firstTitleColorInCollapsedState = firstTitleColorInCollapsedState, + ), + name = TransitionName, + ) + } } } @@ -810,7 +907,7 @@ private fun MotionSceneScope.constructTransition( firstTitleColorInExpandedState: Color, firstTitleColorInCollapsedState: Color, ): Transition { - return Transition(from = NameSetExpanded, to = NameSetCollapsed) { + return Transition(from = ConstraintSetNameExpanded, to = ConstraintSetNameCollapsed) { keyAttributes(refs.secondTitle) { frame(frame = 15) { alpha = 0f @@ -857,13 +954,13 @@ private fun MotionSceneScope.constructTransition( } } - onSwipe = OnSwipe( +/* onSwipe = OnSwipe( anchor = refs.list, side = SwipeSide.Top, direction = SwipeDirection.Up, onTouchUp = SwipeTouchUp.AutoComplete, mode = SwipeMode.Velocity, - ) + )*/ } } From 4054cc219390a89d66f3ab754f12335320433925 Mon Sep 17 00:00:00 2001 From: mars885 Date: Tue, 1 Oct 2024 13:06:18 +0300 Subject: [PATCH 03/12] Clean up the interception solution --- .../header/GameInfoAnimatableHeader.kt | 558 +++--------------- 1 file changed, 84 insertions(+), 474 deletions(-) diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt index 92efa7bb..d09baf5f 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -98,9 +97,11 @@ import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artwor import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.launch -import org.intellij.lang.annotations.Language import com.paulrybitskyi.gamedge.core.R as CoreR +private const val AutoTransitionAnimationDurationMin = 300 +private const val AutoTransitionAnimationDurationMax = 1_200 + private const val ConstraintSetNameExpanded = "expanded" private const val ConstraintSetNameCollapsed = "collapsed" private const val TransitionName = "fancy_transition" @@ -141,6 +142,18 @@ private val CoverDeltaXCollapsed = (-130).dp private val CoverDeltaYCollapsed = (-60).dp private val SecondaryTextDeltaXCollapsed = (-8).dp +private enum class State(val progress: Float) { + Expanded(progress = 0f), + Collapsed(progress = 1f); + + companion object { + + fun fromProgressOrNull(progress: Float): State? { + return entries.firstOrNull { state -> state.progress == progress } + } + } +} + private val AnimatableSaver = Saver( save = { animatable -> animatable.value }, restore = ::Animatable, @@ -156,22 +169,22 @@ internal fun GameInfoAnimatableHeader( onLikeButtonClicked: () -> Unit, content: @Composable (Modifier, NestedScrollConnection) -> Unit, ) { - val maxPx = 555f + // minHeaderHeight = ArtworksHeightInCollapsedState + val minPx = 228f + val maxPx = 555f + + + val colors = GamedgeTheme.colors + val density = LocalDensity.current val progress = rememberSaveable(saver = AnimatableSaver) { - Animatable(0f).apply { - updateBounds(0f, 1f) - } + Animatable(State.Expanded.progress) } var headerHeight by remember { mutableFloatStateOf( - if (progress.value == 0f) maxPx else minPx + if (progress.value == State.Expanded.progress) maxPx else minPx ) } val coroutineScope = rememberCoroutineScope() - - val colors = GamedgeTheme.colors - val density = LocalDensity.current val artworks = headerInfo.artworks val isPageIndicatorVisible = remember(artworks) { artworks.size > 1 } val hasDefaultPlaceholderArtwork = remember(artworks) { @@ -203,20 +216,24 @@ internal fun GameInfoAnimatableHeader( LaunchedEffect(Unit) { snapshotFlow { listState.isScrollInProgress } .distinctUntilChanged() - .filterNot { it } + .filterNot { isScrolling -> isScrolling } .collect { val currentProgress = progress.value - if (currentProgress != 0f && currentProgress != 1f) { - val newProgress = if (currentProgress < 0.5f) 0f else 1f - val duration = (300 + (1200 - 300) * 4 * currentProgress * (1 - currentProgress)).toInt() + if (State.fromProgressOrNull(currentProgress) == null) { + val newState = if (currentProgress < 0.5f) State.Expanded else State.Collapsed + val duration = calculateAutoTransitionDuration(currentProgress) launch { progress.animateTo( - targetValue = newProgress, - animationSpec = tween(duration, easing = EaseInOut), + targetValue = newState.progress, + animationSpec = tween(durationMillis = duration, easing = EaseInOut), block = { - headerHeight = minPx + (1 - value) * (maxPx - minPx) + headerHeight = calculateHeaderHeightGivenProgress( + progress = value, + minHeaderHeight = minPx, + maxHeaderHeight = maxPx, + ) } ) } @@ -249,7 +266,11 @@ internal fun GameInfoAnimatableHeader( } private fun updateProgress() { - val newProgress = 1 - (headerHeight - minPx) / (maxPx - minPx) + val newProgress = calculateProgressGivenHeaderHeight( + headerHeight = headerHeight, + minHeaderHeight = minPx, + maxHeaderHeight = maxPx, + ) coroutineScope.launch { progress.snapTo(newProgress) @@ -257,11 +278,11 @@ internal fun GameInfoAnimatableHeader( } override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (listState.canScrollBackward) { - return Offset.Zero + return if (listState.canScrollBackward) { + Offset.Zero + } else { + consume(available) } - - return consume(available) } override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { @@ -364,18 +385,7 @@ internal fun GameInfoAnimatableHeader( color = GamedgeTheme.colors.surface, shape = RectangleShape, ) - .clip(RectangleShape) - .onGloballyPositioned { -/* Log.e("kimahri", "onGloballyPositioned, progress = $progress, size = ${it.size}") - if (progress == 0f) { - expandedHeight = it.size.height - } - - if (progress == 1f) { - collapsedHeight = it.size.height - } - Log.e("wakka", "backdrop = ${it.size}")*/ - }, + .clip(RectangleShape), ) Spacer( @@ -406,6 +416,8 @@ internal fun GameInfoAnimatableHeader( setMaxImageSize(with(density) { 52.dp.toPx().toInt() }) setImageDrawable(context.getCompatDrawable(CoreR.drawable.heart_animated_selector)) supportImageTintList = ColorStateList.valueOf(colors.onSecondary.toArgb()) + // Disabling the ripple because it cripples the animation a bit + rippleColor = colors.secondary.toArgb() onClick { onLikeButtonClicked() } } }, @@ -573,18 +585,6 @@ private fun rememberMotionScene( } } -@Composable -private fun calculateArtworksHeightInCollapsedState(): Dp { - return ArtworksHeightCollapsed + calculateStatusBarHeightInDp() -} - -@Composable -private fun calculateStatusBarHeightInDp(): Dp { - val statusBarHeight = LocalContext.current.statusBarHeight - - return with(LocalDensity.current) { statusBarHeight.toDp() } -} - private class ConstraintLayoutRefs( val artworks: ConstrainedLayoutReference, val artworksScrim: ConstrainedLayoutReference, @@ -953,14 +953,6 @@ private fun MotionSceneScope.constructTransition( translationX = PageIndicatorDeltaXCollapsed } } - -/* onSwipe = OnSwipe( - anchor = refs.list, - side = SwipeSide.Top, - direction = SwipeDirection.Up, - onTouchUp = SwipeTouchUp.AutoComplete, - mode = SwipeMode.Velocity, - )*/ } } @@ -994,429 +986,47 @@ private fun ConstrainScope.setScale(scale: Float) { scaleY = scale } -@Language("json5") -private fun constructTransitionJson(): String { - /* - onSwipe: { - direction: "up", - touchUp: "decelerateComplete", - anchor: "$ConstraintIdList", - side: "top", - mode: "velocity", - }, - */ - - return """ - { - from: "start", - to: "end", - easing: "easeInOut", - duration: 400, - pathMotionArc: "none", - onSwipe: { - direction: "up", - touchUp: "autocomplete", - anchor: "$ConstraintIdList", - side: "top", - mode: "velocity", - }, - KeyFrames: { - KeyAttributes: [ - { - target: ["$ConstraintIdSecondTitle"], - frames: [15, 100], - alpha: [0, 0], - translationX: ${SecondaryTextDeltaXCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdReleaseDate"], - frames: [15, 100], - alpha: [0, 0], - translationX: ${SecondaryTextDeltaXCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdDeveloperName"], - frames: [15, 100], - alpha: [0, 0], - translationX: ${SecondaryTextDeltaXCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdCover"], - frames: [50], - alpha: 0, - translationX: ${CoverDeltaXCollapsed.value.toInt()}, - translationY: ${CoverDeltaYCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdLikeButton"], - frames: [60], - alpha: 0, - scaleX: 0, - scaleY: 0, - }, - { - target: ["$ConstraintIdPageIndicator"], - frames: [80], - translationX: ${PageIndicatorDeltaXCollapsed.value.toInt()}, - } - ] - } - } - """.trimIndent() +private fun Modifier.drawOnTop(): Modifier { + return zIndex(Float.MAX_VALUE) } -@Suppress("UnusedPrivateMember") -@Language("json5") -@Composable -private fun constructJson(): String { - /* - For it to work properly, the following things must be completed: - 1. Creating a bottom barrier of cover & developerName components. - 2. Custom properties (like scrim color & backdrop elevation) must - referenced from the MotionLayout's content parameter. - 3. Scrim color should not be hardcoded in raw JSON. - 4. Scrim color should not be applied when default artwork image is used. - */ - - val statusBarHeight = calculateStatusBarHeightInDp().value.toInt() - val density = LocalDensity.current +/** + * Calculates a duration for the auto transition in the following way: + * - for progress that is zero, the duration is minimal (0f -> min) + * - for progress that is half way, the duration is maximal (0.5f - max) + * - for progress that is one, the duration is minimal (1f - min) + **/ +private fun calculateAutoTransitionDuration(progress: Float): Int { + val minDuration = AutoTransitionAnimationDurationMin + val maxDuration = AutoTransitionAnimationDurationMax + + return (minDuration + (maxDuration - minDuration) * 4 * progress * (1 - progress)).toInt() +} - val scrimColorExpanded = Integer.toHexString(Color.Transparent.toArgb()) - val pageIndicatorMargin = GamedgeTheme.spaces.spacing_2_5.value.toInt() - val backdropElevationExpanded = GamedgeTheme.spaces.spacing_0_5.value.toInt() - val coverSpaceMargin = CoverSpace.value.toInt() - val coverMarginStart = GamedgeTheme.spaces.spacing_3_5.value.toInt() - val likeBtnMarginEnd = GamedgeTheme.spaces.spacing_2_5.value.toInt() - val titleMarginStart = GamedgeTheme.spaces.spacing_3_5.value.toInt() - val firstTitleMarginTop = titleMarginStart - val firstTitleMarginEnd = GamedgeTheme.spaces.spacing_1_0.value.toInt() - val secondTitleMarginEnd = GamedgeTheme.spaces.spacing_3_5.value.toInt() - val releaseDateMarginTop = GamedgeTheme.spaces.spacing_2_5.value.toInt() - val releaseDateMarginHorizontal = GamedgeTheme.spaces.spacing_3_5.value.toInt() - val developerNameMarginHorizontal = GamedgeTheme.spaces.spacing_3_5.value.toInt() - val bottomBarrierMargin = GamedgeTheme.spaces.spacing_5_0.value.toInt() - val infoItemMarginBottom = GamedgeTheme.spaces.spacing_3_5.value.toInt() - - val scrimColorCollapsed = Integer.toHexString(GamedgeTheme.colors.darkScrim.toArgb()) - val artworksHeightCollapsed = calculateArtworksHeightInCollapsedState().value.toInt() - val backdropElevationCollapsed = GamedgeTheme.spaces.spacing_1_0.value.toInt() - val firstTitleMarginStartCollapsed = GamedgeTheme.spaces.spacing_7_5.value.toInt() - val firstTitleMarginEndCollapsed = GamedgeTheme.spaces.spacing_6_0.value.toInt() - val infoItemVerticalMarginCollapsed = GamedgeTheme.spaces.spacing_3_5.value.toInt() - val pageIndicatorDeltaXInPx = with(density) { PageIndicatorDeltaXCollapsed.roundToPx() } - val coverDeltaXInPx = with(density) { CoverDeltaXCollapsed.roundToPx() } - val coverDeltaYInPx = with(density) { CoverDeltaYCollapsed.roundToPx() } - val secondaryTextDeltaXInPx = with(density) { SecondaryTextDeltaXCollapsed.roundToPx() } - - return """ - { - ConstraintSets: { - start: { - $ConstraintIdArtworks: { - width: "spread", - height: ${ArtworksHeightExpanded.value.toInt()}, - top: ["parent", "top"], - start: ["parent", "start"], - end: ["parent", "end"], - }, - $ConstraintIdArtworksScrim: { - width: "spread", - height: "spread", - top: ["artworks", "top"], - bottom: ["artworks", "bottom"], - start: ["artworks", "start"], - end: ["artworks", "end"], - visibility: "invisible", - custom: { - scrim_color: "#$scrimColorExpanded", - }, - }, - $ConstraintIdBackButton: { - top: ["parent", "top"], - start: ["parent", "start"], - }, - $ConstraintIdPageIndicator: { - top: ["parent", "top", $pageIndicatorMargin], - end: ["parent", "end", $pageIndicatorMargin], - }, - $ConstraintIdBackdrop: { - width: "spread", - height: "spread", - top: ["artworks", "bottom"], - bottom: ["list", "top"], - start: ["parent", "start"], - end: ["parent", "end"], - custom: { - elevation: $backdropElevationExpanded, - }, - }, - $ConstraintIdCoverSpace: { - start: ["parent", "start"], - bottom: ["artworks", "bottom", $coverSpaceMargin], - }, - $ConstraintIdCover: { - top: ["cover_space", "bottom"], - start: ["parent", "start", $coverMarginStart], - }, - $ConstraintIdLikeButton: { - top: ["artworks", "bottom"], - bottom: ["artworks", "bottom"], - end: ["parent", "end", $likeBtnMarginEnd], - }, - $ConstraintIdFirstTitle: { - width: "spread", - top: ["artworks", "bottom", $firstTitleMarginTop], - start: ["cover", "end", $titleMarginStart], - end: ["like_button", "start", $firstTitleMarginEnd], - }, - $ConstraintIdSecondTitle: { - width: "spread", - top: ["first_title", "bottom"], - start: ["cover", "end", $titleMarginStart], - end: ["parent", "end", $secondTitleMarginEnd], - }, - $ConstraintIdReleaseDate: { - width: "spread", - top: ["second_title", "bottom", $releaseDateMarginTop], - start: ["cover", "end", $releaseDateMarginHorizontal], - end: ["parent", "end", $releaseDateMarginHorizontal], - }, - $ConstraintIdDeveloperName: { - width: "spread", - top: ["release_date", "bottom"], - start: ["cover", "end", $developerNameMarginHorizontal], - end: ["parent", "end", $developerNameMarginHorizontal], - }, - $ConstraintIdRating: { - width: "spread", - top: ["cover", "bottom", $bottomBarrierMargin], - bottom: ["list", "top", $infoItemMarginBottom], - start: ["parent", "start"], - end: ["like_count", "start"], - hBias: 0.25 - }, - $ConstraintIdLikeCount: { - width: "spread", - top: ["cover", "bottom", $bottomBarrierMargin], - bottom: ["list", "top", $infoItemMarginBottom], - start: ["rating", "end"], - end: ["age_rating", "start"], - hBias: 0.25 - }, - $ConstraintIdAgeRating: { - width: "spread", - top: ["cover", "bottom", $bottomBarrierMargin], - bottom: ["list", "top", $infoItemMarginBottom], - start: ["like_count", "end"], - end: ["game_category", "start"], - hBias: 0.25 - }, - $ConstraintIdGameCategory: { - width: "spread", - top: ["cover", "bottom", $bottomBarrierMargin], - bottom: ["list", "top", $infoItemMarginBottom], - start: ["age_rating", "end"], - end: ["parent", "end"], - hBias: 0.25 - }, - $ConstraintIdList: { - width: "spread", - height: "spread", - top: ["rating", "bottom"], - bottom: ["parent", "bottom"], - start: ["parent", "start"], - end: ["parent", "end"], - }, - }, - end: { - $ConstraintIdArtworks: { - width: "spread", - height: $artworksHeightCollapsed, - top: ["parent", "top"], - bottom: ["backdrop", "top"], - start: ["parent", "start"], - end: ["parent", "end"], - }, - $ConstraintIdArtworksScrim: { - width: "spread", - height: "spread", - top: ["artworks", "top"], - bottom: ["artworks", "bottom"], - start: ["artworks", "start"], - end: ["artworks", "end"], - visibility: "visible", - custom: { - scrim_color: "#$scrimColorCollapsed", - }, - }, - $ConstraintIdBackButton: { - top: ["parent", "top"], - start: ["parent", "start"], - }, - $ConstraintIdPageIndicator: { - top: ["parent", "top", $pageIndicatorMargin], - end: ["parent", "end", $pageIndicatorMargin], - translationX: $pageIndicatorDeltaXInPx, - }, - $ConstraintIdBackdrop: { - width: "spread", - height: "spread", - top: ["artworks", "bottom"], - bottom: ["list", "top"], - start: ["parent", "start"], - end: ["parent", "end"], - custom: { - elevation: $backdropElevationCollapsed, - }, - }, - $ConstraintIdCoverSpace: { - start: ["parent", "start"], - bottom: ["artworks", "bottom", $coverSpaceMargin], - }, - $ConstraintIdCover: { - top: ["cover_space", "bottom"], - start: ["parent", "start", $coverMarginStart], - translationX: $coverDeltaXInPx, - translationY: $coverDeltaYInPx, - visibility: "invisible", - }, - $ConstraintIdLikeButton: { - top: ["artworks", "bottom"], - bottom: ["artworks", "bottom"], - end: ["parent", "end", $likeBtnMarginEnd], - alpha: 0, - scaleX: 0, - scaleY: 0, - }, - $ConstraintIdFirstTitle: { - width: "spread", - top: ["artworks", "top", $statusBarHeight], - bottom: ["artworks", "bottom"], - start: ["back_button", "end", $firstTitleMarginStartCollapsed], - end: ["parent", "end", $firstTitleMarginEndCollapsed], - scaleX: 1.1, - scaleY: 1.1, - }, - $ConstraintIdSecondTitle: { - width: "spread", - top: ["first_title", "bottom"], - start: ["cover", "end", $titleMarginStart], - end: ["parent", "end", $secondTitleMarginEnd], - alpha: 0, - translationX: $secondaryTextDeltaXInPx, - }, - $ConstraintIdReleaseDate: { - width: "spread", - top: ["second_title", "bottom", $releaseDateMarginTop], - start: ["cover", "end", $releaseDateMarginHorizontal], - end: ["parent", "end", $releaseDateMarginHorizontal], - alpha: 0, - translationX: $secondaryTextDeltaXInPx, - }, - $ConstraintIdDeveloperName: { - width: "spread", - top: ["release_date", "bottom"], - start: ["cover", "end", $developerNameMarginHorizontal], - end: ["parent", "end", $developerNameMarginHorizontal], - alpha: 0, - translationX: $secondaryTextDeltaXInPx, - }, - $ConstraintIdRating: { - width: "spread", - top: ["artworks", "bottom", $infoItemVerticalMarginCollapsed], - bottom: ["list", "top", $infoItemVerticalMarginCollapsed], - start: ["parent", "start"], - end: ["like_count", "start"], - hBias: 0.25 - }, - $ConstraintIdLikeCount: { - width: "spread", - top: ["artworks", "bottom", $infoItemVerticalMarginCollapsed], - bottom: ["list", "top", $infoItemVerticalMarginCollapsed], - start: ["rating", "end"], - end: ["age_rating", "start"], - hBias: 0.25 - }, - $ConstraintIdAgeRating: { - width: "spread", - top: ["artworks", "bottom", $infoItemVerticalMarginCollapsed], - bottom: ["list", "top", $infoItemVerticalMarginCollapsed], - start: ["like_count", "end"], - end: ["game_category", "start"], - hBias: 0.25 - }, - $ConstraintIdGameCategory: { - width: "spread", - top: ["artworks", "bottom", $infoItemVerticalMarginCollapsed], - bottom: ["list", "top", $infoItemVerticalMarginCollapsed], - start: ["age_rating", "end"], - end: ["parent", "end"], - hBias: 0.25 - }, - $ConstraintIdList: { - width: "spread", - height: "spread", - top: ["rating", "bottom"], - bottom: ["parent", "bottom"], - start: ["parent", "start"], - end: ["parent", "end"], - }, - } - }, - Transitions: { - default: { - from: "start", - to: "end", - easing: "easeInOut", - duration: 400, - pathMotionArc: "none", - KeyFrames: { - KeyAttributes: [ - { - target: ["$ConstraintIdSecondTitle"], - frames: [15, 100], - alpha: [0, 0], - translationX: ${SecondaryTextDeltaXCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdReleaseDate"], - frames: [15, 100], - alpha: [0, 0], - translationX: ${SecondaryTextDeltaXCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdDeveloperName"], - frames: [15, 100], - alpha: [0, 0], - translationX: ${SecondaryTextDeltaXCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdCover"], - frames: [50], - alpha: 0, - translationX: ${CoverDeltaXCollapsed.value.toInt()}, - translationY: ${CoverDeltaYCollapsed.value.toInt()}, - }, - { - target: ["$ConstraintIdLikeButton"], - frames: [60], - alpha: 0, - scaleX: 0, - scaleY: 0, - }, - { - target: ["$ConstraintIdPageIndicator"], - frames: [80], - translationX: ${PageIndicatorDeltaXCollapsed.value.toInt()}, - } - ] - } - } - } - } - """.trimIndent() +private fun calculateHeaderHeightGivenProgress( + progress: Float, + minHeaderHeight: Float, + maxHeaderHeight: Float, +): Float { + return minHeaderHeight + (1 - progress) * (maxHeaderHeight - minHeaderHeight) } -private fun Modifier.drawOnTop(): Modifier { - return zIndex(Float.MAX_VALUE) +private fun calculateProgressGivenHeaderHeight( + headerHeight: Float, + minHeaderHeight: Float, + maxHeaderHeight: Float, +): Float { + return 1 - (headerHeight - minHeaderHeight) / (maxHeaderHeight - minHeaderHeight) +} + +@Composable +private fun calculateArtworksHeightInCollapsedState(): Dp { + return ArtworksHeightCollapsed + calculateStatusBarHeightInDp() +} + +@Composable +private fun calculateStatusBarHeightInDp(): Dp { + val statusBarHeight = LocalContext.current.statusBarHeight + + return with(LocalDensity.current) { statusBarHeight.toDp() } } From 2c0492d8d9efe028728698c4d9a662957f9472cc Mon Sep 17 00:00:00 2001 From: mars885 Date: Tue, 22 Oct 2024 22:05:13 +0300 Subject: [PATCH 04/12] Fix bugs --- .../header/GameInfoAnimatableHeader.kt | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt index d09baf5f..4d1cf9a1 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt @@ -212,6 +212,11 @@ internal fun GameInfoAnimatableHeader( } } } + val isInCollapsedState by remember { + derivedStateOf { + State.fromProgressOrNull(progress.value) == State.Collapsed + } + } LaunchedEffect(Unit) { snapshotFlow { listState.isScrollInProgress } @@ -418,6 +423,9 @@ internal fun GameInfoAnimatableHeader( supportImageTintList = ColorStateList.valueOf(colors.onSecondary.toArgb()) // Disabling the ripple because it cripples the animation a bit rippleColor = colors.secondary.toArgb() + // Disabling the shadow to avoid it being clipped when animating to collapsed state + // (especially can be seen on the light theme) + compatElevation = 0f onClick { onLikeButtonClicked() } } }, @@ -434,11 +442,17 @@ internal fun GameInfoAnimatableHeader( modifier = Modifier .layoutId(ConstraintIdFirstTitle) .drawOnTop(), - color = customColor(ConstraintIdFirstTitle, CustomAttributeTextColor), + // When restoring state, customColor function returns invalid color (black color + // when in collapsed state), so a little fix here to set the correct color + color = if (isInCollapsedState) { + ScrimContentColor + } else { + customColor(ConstraintIdFirstTitle, CustomAttributeTextColor) + }, overflow = firstTitleOverflowMode, maxLines = 1, onTextLayout = { textLayoutResult -> - if (textLayoutResult.hasVisualOverflow) { + if (textLayoutResult.hasVisualOverflow && secondTitleText.isEmpty()) { val firstTitleWidth = textLayoutResult.size.width.toFloat() val firstTitleOffset = Offset(firstTitleWidth, 0f) val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 @@ -451,21 +465,16 @@ internal fun GameInfoAnimatableHeader( style = GamedgeTheme.typography.h6, ) - Box( + Text( + text = secondTitleText, modifier = Modifier .layoutId(ConstraintIdSecondTitle) .drawOnTop(), - ) { - if (isSecondTitleVisible) { - Text( - text = secondTitleText, - color = GamedgeTheme.colors.onPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - style = GamedgeTheme.typography.h6, - ) - } - } + color = GamedgeTheme.colors.onPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = GamedgeTheme.typography.h6, + ) Text( text = headerInfo.releaseDate, @@ -476,18 +485,15 @@ internal fun GameInfoAnimatableHeader( style = GamedgeTheme.typography.subtitle3, ) - Box( - modifier = Modifier - .layoutId(ConstraintIdDeveloperName) - .drawOnTop(), - ) { - if (headerInfo.hasDeveloperName) { - Text( - text = checkNotNull(headerInfo.developerName), - color = GamedgeTheme.colors.onSurface, - style = GamedgeTheme.typography.subtitle3, - ) - } + if (headerInfo.hasDeveloperName) { + Text( + text = checkNotNull(headerInfo.developerName), + modifier = Modifier + .layoutId(ConstraintIdDeveloperName) + .drawOnTop(), + color = GamedgeTheme.colors.onSurface, + style = GamedgeTheme.typography.subtitle3, + ) } Info( @@ -543,11 +549,12 @@ private fun rememberMotionScene( val firstTitleColorInCollapsedState = ScrimContentColor return remember( + hasDefaultPlaceholderArtwork, + isSecondTitleVisible, spaces, artworksHeightInCollapsedState, statusBarHeight, firstTitleColorInExpandedState, - firstTitleColorInCollapsedState ) { MotionScene { val refs = ConstraintLayoutRefs(this) @@ -576,6 +583,7 @@ private fun rememberMotionScene( addTransition( transition = constructTransition( refs = refs, + isSecondTitleVisible = isSecondTitleVisible, firstTitleColorInExpandedState = firstTitleColorInExpandedState, firstTitleColorInCollapsedState = firstTitleColorInCollapsedState, ), @@ -904,6 +912,7 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( private fun MotionSceneScope.constructTransition( refs: ConstraintLayoutRefs, + isSecondTitleVisible: Boolean, firstTitleColorInExpandedState: Color, firstTitleColorInCollapsedState: Color, ): Transition { @@ -941,6 +950,16 @@ private fun MotionSceneScope.constructTransition( customColor(CustomAttributeTextColor, firstTitleColorInCollapsedState) } } + + if (isSecondTitleVisible) { + // To prevent the first title overlapping with the like button + keyPositions(refs.firstTitle) { + frame(frame = 60) { + percentWidth = 0.5f + } + } + } + keyAttributes(refs.likeButton) { frame(frame = 60) { alpha = 0f From f23c4b6ae8d2aba7de3eb23e22c13f06c73c87b6 Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 01:08:34 +0300 Subject: [PATCH 05/12] Remove LazyColumn as child of MotionLayout --- .../info/presentation/GameInfoScreen.kt | 4 +- .../header/GameInfoAnimatableHeader.kt | 541 +++++++++--------- 2 files changed, 268 insertions(+), 277 deletions(-) diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt index 47931239..b7be3652 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt @@ -253,12 +253,12 @@ private fun SuccessState( onBackButtonClicked = onBackButtonClicked, onCoverClicked = onCoverClicked, onLikeButtonClicked = onLikeButtonClicked, - ) { modifier, nestedConnection -> + ) { modifier -> val layoutDirection = LocalLayoutDirection.current val spacing = GamedgeTheme.spaces.spacing_3_5 LazyColumn( - modifier = modifier.nestedScroll(nestedConnection), + modifier = modifier, state = listState, contentPadding = PaddingValues( start = contentPadding.calculateStartPadding(layoutDirection), diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt index 4d1cf9a1..07d9ede5 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt @@ -25,8 +25,9 @@ import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -58,6 +59,7 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -73,6 +75,8 @@ import androidx.constraintlayout.compose.ConstrainedLayoutReference import androidx.constraintlayout.compose.ConstraintSet import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.InvalidationStrategy +import androidx.constraintlayout.compose.KeyAttributeScope +import androidx.constraintlayout.compose.KeyPositionScope import androidx.constraintlayout.compose.MotionLayout import androidx.constraintlayout.compose.MotionScene import androidx.constraintlayout.compose.MotionSceneScope @@ -122,7 +126,6 @@ private const val ConstraintIdRating = "rating" private const val ConstraintIdLikeCount = "like_count" private const val ConstraintIdAgeRating = "age_rating" private const val ConstraintIdGameCategory = "game_category" -private const val ConstraintIdList = "list" private const val CustomAttributeTextColor = "text_color" @@ -131,6 +134,7 @@ private val ScrimContentColor = Color.White private val CoverSpace = 40.dp private val InfoIconSize = 34.dp +private const val FirstTitleScaleExpanded = 1f private const val FirstTitleScaleCollapsed = 1.1f private const val LikeButtonScaleCollapsed = 0f @@ -139,8 +143,7 @@ private val ArtworksHeightCollapsed = 56.dp private val PageIndicatorDeltaXCollapsed = 60.dp private val CoverDeltaXCollapsed = (-130).dp -private val CoverDeltaYCollapsed = (-60).dp -private val SecondaryTextDeltaXCollapsed = (-8).dp +private val CoverDeltaYCollapsed = (-40).dp private enum class State(val progress: Float) { Expanded(progress = 0f), @@ -167,7 +170,7 @@ internal fun GameInfoAnimatableHeader( onBackButtonClicked: () -> Unit, onCoverClicked: () -> Unit, onLikeButtonClicked: () -> Unit, - content: @Composable (Modifier, NestedScrollConnection) -> Unit, + content: @Composable (Modifier) -> Unit, ) { // minHeaderHeight = ArtworksHeightInCollapsedState + val minPx = 228f @@ -300,240 +303,239 @@ internal fun GameInfoAnimatableHeader( } } - MotionLayout( - motionScene = rememberMotionScene( - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - isSecondTitleVisible = isSecondTitleVisible, - ), - progress = progress.value, - modifier = Modifier.fillMaxSize(), - transitionName = TransitionName, - invalidationStrategy = remember { - InvalidationStrategy( - onObservedStateChange = { - @Suppress("UNUSED_EXPRESSION") - headerInfo + Column { + MotionLayout( + motionScene = rememberMotionScene( + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, + ), + progress = progress.value, + modifier = Modifier + .fillMaxWidth() + .drawOnTop(), + transitionName = TransitionName, + invalidationStrategy = remember { + InvalidationStrategy( + onObservedStateChange = @Suppress("UNUSED_EXPRESSION") { + headerInfo + }, + ) + } + ) { + Artworks( + artworks = artworks, + isScrollingEnabled = isArtworkInteractionEnabled, + modifier = Modifier.layoutId(ConstraintIdArtworks), + onArtworkChanged = { page -> + selectedArtworkPage = page + }, + onArtworkClicked = { artworkIndex -> + if (isArtworkInteractionEnabled) { + onArtworkClicked(artworkIndex) + } }, ) - } - ) { - Artworks( - artworks = artworks, - isScrollingEnabled = isArtworkInteractionEnabled, - modifier = Modifier.layoutId(ConstraintIdArtworks), - onArtworkChanged = { page -> - selectedArtworkPage = page - }, - onArtworkClicked = { artworkIndex -> - if (isArtworkInteractionEnabled) { - onArtworkClicked(artworkIndex) - } - }, - ) - - Box( - modifier = Modifier - .layoutId(ConstraintIdArtworksScrim) - .background(GamedgeTheme.colors.darkScrim), - ) - Icon( - painter = painterResource(CoreR.drawable.arrow_left), - contentDescription = null, - modifier = Modifier - .layoutId(ConstraintIdBackButton) - .statusBarsPadding() - .size(56.dp) - .clickable( - indication = ripple( - bounded = false, - radius = 18.dp, - ), - onClick = onBackButtonClicked, - ) - .padding(GamedgeTheme.spaces.spacing_2_5) - .background( - color = GamedgeTheme.colors.lightScrim, - shape = CircleShape, - ) - .padding(GamedgeTheme.spaces.spacing_1_5), - tint = ScrimContentColor, - ) + Box( + modifier = Modifier + .layoutId(ConstraintIdArtworksScrim) + .background(GamedgeTheme.colors.darkScrim), + ) - if (isPageIndicatorVisible) { - Text( - text = stringResource( - R.string.game_info_header_page_indicator_template, - selectedArtworkPage + 1, - headerInfo.artworks.size, - ), + Icon( + painter = painterResource(CoreR.drawable.arrow_left), + contentDescription = null, modifier = Modifier - .layoutId(ConstraintIdPageIndicator) + .layoutId(ConstraintIdBackButton) .statusBarsPadding() + .size(56.dp) + .clickable( + indication = ripple( + bounded = false, + radius = 18.dp, + ), + onClick = onBackButtonClicked, + ) + .padding(GamedgeTheme.spaces.spacing_2_5) .background( color = GamedgeTheme.colors.lightScrim, - shape = RoundedCornerShape(20.dp), + shape = CircleShape, ) - .padding( - vertical = GamedgeTheme.spaces.spacing_1_5, - horizontal = GamedgeTheme.spaces.spacing_2_0, - ), - color = ScrimContentColor, - style = GamedgeTheme.typography.subtitle3, + .padding(GamedgeTheme.spaces.spacing_1_5), + tint = ScrimContentColor, ) - } - Box( - modifier = Modifier - .layoutId(ConstraintIdBackdrop) - .background( - color = GamedgeTheme.colors.surface, - shape = RectangleShape, + if (isPageIndicatorVisible) { + Text( + text = stringResource( + R.string.game_info_header_page_indicator_template, + selectedArtworkPage + 1, + headerInfo.artworks.size, + ), + modifier = Modifier + .layoutId(ConstraintIdPageIndicator) + .statusBarsPadding() + .background( + color = GamedgeTheme.colors.lightScrim, + shape = RoundedCornerShape(20.dp), + ) + .padding( + vertical = GamedgeTheme.spaces.spacing_1_5, + horizontal = GamedgeTheme.spaces.spacing_2_0, + ), + color = ScrimContentColor, + style = GamedgeTheme.typography.subtitle3, ) - .clip(RectangleShape), - ) + } - Spacer( - Modifier - .layoutId(ConstraintIdCoverSpace) - .height(CoverSpace), - ) + Box( + modifier = Modifier + .layoutId(ConstraintIdBackdrop) + .background( + color = GamedgeTheme.colors.surface, + shape = RectangleShape, + ) + .clip(RectangleShape), + ) - GameCover( - title = null, - imageUrl = headerInfo.coverImageUrl, - modifier = Modifier - .layoutId(ConstraintIdCover) - .drawOnTop(), - onCoverClicked = if (headerInfo.hasCoverImageUrl) onCoverClicked else null, - ) + Spacer( + Modifier + .layoutId(ConstraintIdCoverSpace) + .height(CoverSpace), + ) - // 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()) - // Disabling the ripple because it cripples the animation a bit - rippleColor = colors.secondary.toArgb() - // Disabling the shadow to avoid it being clipped when animating to collapsed state - // (especially can be seen on the light theme) - compatElevation = 0f - onClick { onLikeButtonClicked() } - } - }, - modifier = Modifier - .layoutId(ConstraintIdLikeButton) - .drawOnTop(), - update = { view -> - view.isLiked = headerInfo.isLiked - }, - ) + GameCover( + title = null, + imageUrl = headerInfo.coverImageUrl, + modifier = Modifier + .layoutId(ConstraintIdCover) + .drawOnTop(), + onCoverClicked = if (headerInfo.hasCoverImageUrl) onCoverClicked else null, + ) - Text( - text = headerInfo.title, - modifier = Modifier - .layoutId(ConstraintIdFirstTitle) - .drawOnTop(), - // When restoring state, customColor function returns invalid color (black color - // when in collapsed state), so a little fix here to set the correct color - color = if (isInCollapsedState) { - ScrimContentColor - } else { - customColor(ConstraintIdFirstTitle, CustomAttributeTextColor) - }, - overflow = firstTitleOverflowMode, - maxLines = 1, - onTextLayout = { textLayoutResult -> - if (textLayoutResult.hasVisualOverflow && secondTitleText.isEmpty()) { - val firstTitleWidth = textLayoutResult.size.width.toFloat() - val firstTitleOffset = Offset(firstTitleWidth, 0f) - val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 - - if (firstTitleVisibleTextEndIndex in headerInfo.title.indices) { - secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) + // 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()) + // Disabling the ripple because it cripples the animation a bit + rippleColor = colors.secondary.toArgb() + // Disabling the shadow to avoid it being clipped when animating to collapsed state + // (especially can be seen on the light theme) + compatElevation = 0f + onClick { onLikeButtonClicked() } } - } - }, - style = GamedgeTheme.typography.h6, - ) + }, + modifier = Modifier + .layoutId(ConstraintIdLikeButton) + .drawOnTop(), + update = { view -> + view.isLiked = headerInfo.isLiked + }, + ) - Text( - text = secondTitleText, - modifier = Modifier - .layoutId(ConstraintIdSecondTitle) - .drawOnTop(), - color = GamedgeTheme.colors.onPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - style = GamedgeTheme.typography.h6, - ) + Text( + text = headerInfo.title, + modifier = Modifier + .layoutId(ConstraintIdFirstTitle) + .drawOnTop(), + // When restoring state, customColor function returns invalid color (black color + // when in collapsed state), so a little fix here to set the correct color + color = if (isInCollapsedState) { + ScrimContentColor + } else { + customColor(ConstraintIdFirstTitle, CustomAttributeTextColor) + }, + overflow = firstTitleOverflowMode, + maxLines = 1, + onTextLayout = { textLayoutResult -> + if (textLayoutResult.hasVisualOverflow && secondTitleText.isEmpty()) { + val firstTitleWidth = textLayoutResult.size.width.toFloat() + val firstTitleOffset = Offset(firstTitleWidth, 0f) + val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 + + if (firstTitleVisibleTextEndIndex in headerInfo.title.indices) { + secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) + } + } + }, + style = GamedgeTheme.typography.h6, + ) - Text( - text = headerInfo.releaseDate, - modifier = Modifier - .layoutId(ConstraintIdReleaseDate) - .drawOnTop(), - color = GamedgeTheme.colors.onSurface, - style = GamedgeTheme.typography.subtitle3, - ) + Text( + text = secondTitleText, + modifier = Modifier + .layoutId(ConstraintIdSecondTitle) + .drawOnTop(), + color = GamedgeTheme.colors.onPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = GamedgeTheme.typography.h6, + ) - if (headerInfo.hasDeveloperName) { Text( - text = checkNotNull(headerInfo.developerName), + text = headerInfo.releaseDate, modifier = Modifier - .layoutId(ConstraintIdDeveloperName) + .layoutId(ConstraintIdReleaseDate) .drawOnTop(), color = GamedgeTheme.colors.onSurface, style = GamedgeTheme.typography.subtitle3, ) - } - Info( - icon = painterResource(CoreR.drawable.star_circle_outline), - title = headerInfo.rating, - modifier = Modifier - .layoutId(ConstraintIdRating) - .drawOnTop(), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) - Info( - icon = painterResource(CoreR.drawable.account_heart_outline), - title = headerInfo.likeCount, - modifier = Modifier - .layoutId(ConstraintIdLikeCount) - .drawOnTop(), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) - Info( - icon = painterResource(CoreR.drawable.age_rating_outline), - title = headerInfo.ageRating, - modifier = Modifier - .layoutId(ConstraintIdAgeRating) - .drawOnTop(), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) - Info( - icon = painterResource(CoreR.drawable.shape_outline), - title = headerInfo.gameCategory, - modifier = Modifier - .layoutId(ConstraintIdGameCategory) - .drawOnTop(), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) + if (headerInfo.hasDeveloperName) { + Text( + text = checkNotNull(headerInfo.developerName), + modifier = Modifier + .layoutId(ConstraintIdDeveloperName) + .drawOnTop(), + color = GamedgeTheme.colors.onSurface, + style = GamedgeTheme.typography.subtitle3, + ) + } + + val infoItemModifier = Modifier + .drawOnTop() + .padding(vertical = GamedgeTheme.spaces.spacing_3_5) - content(Modifier.layoutId(ConstraintIdList), nestedConnection) + Info( + icon = painterResource(CoreR.drawable.star_circle_outline), + title = headerInfo.rating, + modifier = infoItemModifier.layoutId(ConstraintIdRating), + iconSize = InfoIconSize, + titleTextStyle = GamedgeTheme.typography.caption, + ) + Info( + icon = painterResource(CoreR.drawable.account_heart_outline), + title = headerInfo.likeCount, + modifier = infoItemModifier.layoutId(ConstraintIdLikeCount), + iconSize = InfoIconSize, + titleTextStyle = GamedgeTheme.typography.caption, + ) + Info( + icon = painterResource(CoreR.drawable.age_rating_outline), + title = headerInfo.ageRating, + modifier = infoItemModifier.layoutId(ConstraintIdAgeRating), + iconSize = InfoIconSize, + titleTextStyle = GamedgeTheme.typography.caption, + ) + Info( + icon = painterResource(CoreR.drawable.shape_outline), + title = headerInfo.gameCategory, + modifier = infoItemModifier.layoutId(ConstraintIdGameCategory), + iconSize = InfoIconSize, + titleTextStyle = GamedgeTheme.typography.caption, + ) + } + + content(Modifier.nestedScroll(nestedConnection)) } } @@ -574,6 +576,7 @@ private fun rememberMotionScene( refs = refs, spaces = spaces, hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, artworksHeight = artworksHeightInCollapsedState, statusBarHeight = statusBarHeight, firstTitleTextColor = firstTitleColorInCollapsedState, @@ -610,7 +613,6 @@ private class ConstraintLayoutRefs( val likeCount: ConstrainedLayoutReference, val ageRating: ConstrainedLayoutReference, val gameCategory: ConstrainedLayoutReference, - val list: ConstrainedLayoutReference, ) { constructor(motionSceneScope: MotionSceneScope): this( artworks = motionSceneScope.createRefFor(ConstraintIdArtworks), @@ -629,7 +631,6 @@ private class ConstraintLayoutRefs( likeCount = motionSceneScope.createRefFor(ConstraintIdLikeCount), ageRating = motionSceneScope.createRefFor(ConstraintIdAgeRating), gameCategory = motionSceneScope.createRefFor(ConstraintIdGameCategory), - list = motionSceneScope.createRefFor(ConstraintIdList), ) } @@ -645,15 +646,11 @@ private fun MotionSceneScope.constructExpandedConstraintSet( val coverSpaceMargin = CoverSpace val coverMarginStart = spaces.spacing_3_5 val likeBtnMarginEnd = spaces.spacing_2_5 - val titleMarginStart = spaces.spacing_3_5 - val firstTitleMarginTop = titleMarginStart + val textHorizontalMargin = spaces.spacing_3_5 + val firstTitleMarginTop = textHorizontalMargin val firstTitleMarginEnd = spaces.spacing_1_0 - val secondTitleMarginEnd = spaces.spacing_3_5 val releaseDateMarginTop = spaces.spacing_2_5 - val releaseDateMarginHorizontal = spaces.spacing_3_5 - val developerNameMarginHorizontal = spaces.spacing_3_5 - val bottomBarrierMargin = spaces.spacing_5_0 - val infoItemMarginBottom = spaces.spacing_3_5 + val bottomBarrierMargin = spaces.spacing_1_5 return ConstraintSet { val bottomBarrier = createBottomBarrier( @@ -691,7 +688,7 @@ private fun MotionSceneScope.constructExpandedConstraintSet( width = Dimension.fillToConstraints height = Dimension.fillToConstraints top.linkTo(refs.artworks.bottom) - bottom.linkTo(refs.list.top) + bottom.linkTo(refs.rating.bottom) centerHorizontallyTo(parent) translationZ = backdropElevation } @@ -711,60 +708,50 @@ private fun MotionSceneScope.constructExpandedConstraintSet( constrain(refs.firstTitle) { width = Dimension.fillToConstraints top.linkTo(refs.artworks.bottom, firstTitleMarginTop) - start.linkTo(refs.cover.end, titleMarginStart) + start.linkTo(refs.cover.end, textHorizontalMargin) end.linkTo(refs.likeButton.start, firstTitleMarginEnd) + setScale(FirstTitleScaleExpanded) customColor(CustomAttributeTextColor, firstTitleTextColor) } constrain(refs.secondTitle) { width = Dimension.fillToConstraints top.linkTo(refs.firstTitle.bottom) - start.linkTo(refs.cover.end, titleMarginStart) - end.linkTo(parent.end, secondTitleMarginEnd) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(parent.end, textHorizontalMargin) isVisible = isSecondTitleVisible } constrain(refs.releaseDate) { width = Dimension.fillToConstraints top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(refs.cover.end, releaseDateMarginHorizontal) - end.linkTo(parent.end, releaseDateMarginHorizontal) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(parent.end, textHorizontalMargin) } constrain(refs.developerName) { width = Dimension.fillToConstraints top.linkTo(refs.releaseDate.bottom) - start.linkTo(refs.cover.end, developerNameMarginHorizontal) - end.linkTo(parent.end, developerNameMarginHorizontal) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(parent.end, textHorizontalMargin) } constrain(refs.rating) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(refs.list.top, infoItemMarginBottom) linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) } constrain(refs.likeCount) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(refs.list.top, infoItemMarginBottom) linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) } constrain(refs.ageRating) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(refs.list.top, infoItemMarginBottom) linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) } constrain(refs.gameCategory) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(refs.list.top, infoItemMarginBottom) linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) } - constrain(refs.list) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(refs.rating.bottom) - bottom.linkTo(parent.bottom) - centerHorizontallyTo(parent) - } } } @@ -772,6 +759,7 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( refs: ConstraintLayoutRefs, spaces: Spaces, hasDefaultPlaceholderArtwork: Boolean, + isSecondTitleVisible: Boolean, artworksHeight: Dp, statusBarHeight: Dp, firstTitleTextColor: Color, @@ -781,14 +769,10 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( val coverSpaceMargin = CoverSpace val coverMarginStart = spaces.spacing_3_5 val likeBtnMarginEnd = spaces.spacing_2_5 - val titleMarginStart = spaces.spacing_3_5 + val textHorizontalMargin = spaces.spacing_3_5 val firstTitleMarginStart = spaces.spacing_7_5 val firstTitleMarginEnd = spaces.spacing_6_0 - val secondTitleMarginEnd = spaces.spacing_3_5 val releaseDateMarginTop = spaces.spacing_2_5 - val releaseDateMarginHorizontal = spaces.spacing_3_5 - val developerNameMarginHorizontal = spaces.spacing_3_5 - val infoItemVerticalMargin = spaces.spacing_3_5 return ConstraintSet { constrain(refs.artworks) { @@ -821,8 +805,7 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( constrain(refs.backdrop) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom) - bottom.linkTo(refs.list.top) + centerVerticallyTo(refs.rating) centerHorizontallyTo(parent) translationZ = backdropElevation } @@ -835,6 +818,9 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( start.linkTo(parent.start, coverMarginStart) translationX = CoverDeltaXCollapsed translationY = CoverDeltaYCollapsed + // We need to set it to Gone to avoid the cover taking up the vertical space, + // which increases the size of the header in collapsed state + visibility = Visibility.Gone } constrain(refs.likeButton) { top.linkTo(refs.artworks.bottom) @@ -855,58 +841,45 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( constrain(refs.secondTitle) { width = Dimension.fillToConstraints top.linkTo(refs.firstTitle.bottom) - start.linkTo(refs.cover.end, titleMarginStart) - end.linkTo(parent.end, secondTitleMarginEnd) + start.linkTo(refs.firstTitle.start) + end.linkTo(parent.end, textHorizontalMargin) + isVisible = isSecondTitleVisible alpha = 0f - translationX = SecondaryTextDeltaXCollapsed } constrain(refs.releaseDate) { width = Dimension.fillToConstraints top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(refs.cover.end, releaseDateMarginHorizontal) - end.linkTo(parent.end, releaseDateMarginHorizontal) + start.linkTo(refs.firstTitle.start) + end.linkTo(parent.end, textHorizontalMargin) alpha = 0f - translationX = SecondaryTextDeltaXCollapsed } constrain(refs.developerName) { width = Dimension.fillToConstraints top.linkTo(refs.releaseDate.bottom) - start.linkTo(refs.cover.end, developerNameMarginHorizontal) - end.linkTo(parent.end, developerNameMarginHorizontal) + start.linkTo(refs.firstTitle.start) + end.linkTo(parent.end, textHorizontalMargin) alpha = 0f - translationX = SecondaryTextDeltaXCollapsed } constrain(refs.rating) { width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(refs.list.top, infoItemVerticalMargin) + top.linkTo(refs.artworks.bottom) linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) } constrain(refs.likeCount) { width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(refs.list.top, infoItemVerticalMargin) + top.linkTo(refs.artworks.bottom) linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) } constrain(refs.ageRating) { width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(refs.list.top, infoItemVerticalMargin) + top.linkTo(refs.artworks.bottom) linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) } constrain(refs.gameCategory) { width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(refs.list.top, infoItemVerticalMargin) + top.linkTo(refs.artworks.bottom) linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) } - constrain(refs.list) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(refs.rating.bottom) - bottom.linkTo(parent.bottom) - centerHorizontallyTo(parent) - } } } @@ -917,22 +890,26 @@ private fun MotionSceneScope.constructTransition( firstTitleColorInCollapsedState: Color, ): Transition { return Transition(from = ConstraintSetNameExpanded, to = ConstraintSetNameCollapsed) { + // Don't scale the first title until the secondary texts (second title, + // release date and developer name) is gone + keyAttributes(refs.firstTitle) { + frame(frame = 15) { + setScale(FirstTitleScaleExpanded) + } + } keyAttributes(refs.secondTitle) { frame(frame = 15) { alpha = 0f - translationX = SecondaryTextDeltaXCollapsed } } keyAttributes(refs.releaseDate) { frame(frame = 15) { alpha = 0f - translationX = SecondaryTextDeltaXCollapsed } } keyAttributes(refs.developerName) { frame(frame = 15) { alpha = 0f - translationX = SecondaryTextDeltaXCollapsed } } keyAttributes(refs.cover) { @@ -942,6 +919,11 @@ private fun MotionSceneScope.constructTransition( translationY = CoverDeltaYCollapsed } } + keyPositions(refs.cover) { + frame(frame = 50) { + setSizePercentage(0f) + } + } keyAttributes(refs.firstTitle) { frame(frame = 40) { customColor(CustomAttributeTextColor, firstTitleColorInExpandedState) @@ -963,8 +945,7 @@ private fun MotionSceneScope.constructTransition( keyAttributes(refs.likeButton) { frame(frame = 60) { alpha = 0f - scaleX = 0f - scaleY = 0f + setScale(LikeButtonScaleCollapsed) } } keyAttributes(refs.pageIndicator) { @@ -1005,6 +986,16 @@ private fun ConstrainScope.setScale(scale: Float) { scaleY = scale } +private fun KeyAttributeScope.setScale(scale: Float) { + scaleX = scale + scaleY = scale +} + +private fun KeyPositionScope.setSizePercentage(percentage: Float) { + percentWidth = percentage + percentHeight = percentage +} + private fun Modifier.drawOnTop(): Modifier { return zIndex(Float.MAX_VALUE) } From 28965adefe0e468b2a495d85ff5ca05f9f9ed5fd Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 16:20:04 +0300 Subject: [PATCH 06/12] Calculate header's min and max heights properly --- .../header/GameInfoAnimatableHeader.kt | 203 +++++++++++------- 1 file changed, 123 insertions(+), 80 deletions(-) diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt index 07d9ede5..db18e0ad 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt @@ -20,6 +20,7 @@ package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header import android.content.Context import android.content.res.ColorStateList +import androidx.annotation.DrawableRes import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.tween @@ -27,10 +28,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape @@ -39,6 +42,7 @@ import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -61,7 +65,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -86,7 +90,6 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import com.paulrybitskyi.commons.ktx.getCompatDrawable import com.paulrybitskyi.commons.ktx.onClick import com.paulrybitskyi.commons.ktx.postAction -import com.paulrybitskyi.commons.ktx.statusBarHeight import com.paulrybitskyi.gamedge.common.ui.clickable import com.paulrybitskyi.gamedge.common.ui.theme.GamedgeTheme import com.paulrybitskyi.gamedge.common.ui.theme.Spaces @@ -129,6 +132,8 @@ private const val ConstraintIdGameCategory = "game_category" private const val CustomAttributeTextColor = "text_color" +private const val HeightUnspecified = -1f + private val ScrimContentColor = Color.White private val CoverSpace = 40.dp @@ -172,21 +177,17 @@ internal fun GameInfoAnimatableHeader( onLikeButtonClicked: () -> Unit, content: @Composable (Modifier) -> Unit, ) { - // minHeaderHeight = ArtworksHeightInCollapsedState + - val minPx = 228f - val maxPx = 555f - - val colors = GamedgeTheme.colors val density = LocalDensity.current val progress = rememberSaveable(saver = AnimatableSaver) { Animatable(State.Expanded.progress) } - var headerHeight by remember { - mutableFloatStateOf( - if (progress.value == State.Expanded.progress) maxPx else minPx - ) - } + + val artworksHeightInCollapsedState = calculateArtworksHeightInCollapsedState() + var minHeaderHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } + var maxHeaderHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } + var headerHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } + val coroutineScope = rememberCoroutineScope() val artworks = headerInfo.artworks val isPageIndicatorVisible = remember(artworks) { artworks.size > 1 } @@ -221,6 +222,22 @@ internal fun GameInfoAnimatableHeader( } } + DisposableEffect(minHeaderHeightInPx, maxHeaderHeightInPx) { + val shouldSetInitialHeaderHeight = minHeaderHeightInPx != HeightUnspecified && + maxHeaderHeightInPx != HeightUnspecified && + headerHeightInPx == HeightUnspecified + + if (shouldSetInitialHeaderHeight) { + headerHeightInPx = when (State.fromProgressOrNull(progress.value)) { + State.Expanded -> maxHeaderHeightInPx + State.Collapsed -> minHeaderHeightInPx + null -> error("Invalid progress value: ${progress.value}") + } + } + + onDispose {} + } + LaunchedEffect(Unit) { snapshotFlow { listState.isScrollInProgress } .distinctUntilChanged() @@ -237,10 +254,10 @@ internal fun GameInfoAnimatableHeader( targetValue = newState.progress, animationSpec = tween(durationMillis = duration, easing = EaseInOut), block = { - headerHeight = calculateHeaderHeightGivenProgress( + headerHeightInPx = calculateHeaderHeightGivenProgress( progress = value, - minHeaderHeight = minPx, - maxHeaderHeight = maxPx, + minHeaderHeight = minHeaderHeightInPx, + maxHeaderHeight = maxHeaderHeightInPx, ) } ) @@ -249,43 +266,12 @@ internal fun GameInfoAnimatableHeader( } } - val nestedConnection = remember(listState, coroutineScope) { + val nestedConnection = remember(listState) { object : NestedScrollConnection { - private fun consume(available: Offset): Offset { - val height = headerHeight - - if (height + available.y > maxPx) { - headerHeight = maxPx - updateProgress() - return Offset(0f, maxPx - height) - } - - if (height + available.y < minPx) { - headerHeight = minPx - updateProgress() - return Offset(0f, minPx - height) - } - - headerHeight += available.y / 2 - updateProgress() - - return Offset(0f, available.y) - } - - private fun updateProgress() { - val newProgress = calculateProgressGivenHeaderHeight( - headerHeight = headerHeight, - minHeaderHeight = minPx, - maxHeaderHeight = maxPx, - ) - - coroutineScope.launch { - progress.snapTo(newProgress) - } - } - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // If the list can scroll backward, then we need to allow it to consume the delta. + // Otherwise, we consume it to update the header height & progress. return if (listState.canScrollBackward) { Offset.Zero } else { @@ -294,12 +280,47 @@ internal fun GameInfoAnimatableHeader( } override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return if (available.y > 0 && !listState.canScrollBackward) { + // We need to handle the onPostScroll in order to consume the leftover delta after a user + // flings a list from the bottom to the top so that the header could expand automatically. + return if (!listState.canScrollBackward && available.y > 0) { consume(available) } else { Offset.Zero } } + + private fun consume(available: Offset): Offset { + val currentHeight = headerHeightInPx + + return when { + currentHeight + available.y > maxHeaderHeightInPx -> { + onUpdateValues(maxHeaderHeightInPx) + Offset(0f, maxHeaderHeightInPx - currentHeight) + } + currentHeight + available.y < minHeaderHeightInPx -> { + onUpdateValues(minHeaderHeightInPx) + Offset(0f, minHeaderHeightInPx - currentHeight) + } + else -> { + onUpdateValues(headerHeightInPx + available.y) + Offset(0f, available.y) + } + } + } + + private fun onUpdateValues(newHeaderHeight: Float) { + headerHeightInPx = newHeaderHeight + + val newProgress = calculateProgressGivenHeaderHeight( + headerHeight = newHeaderHeight, + minHeaderHeight = minHeaderHeightInPx, + maxHeaderHeight = maxHeaderHeightInPx, + ) + + coroutineScope.launch { + progress.snapTo(newProgress) + } + } } } @@ -308,11 +329,19 @@ internal fun GameInfoAnimatableHeader( motionScene = rememberMotionScene( hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, isSecondTitleVisible = isSecondTitleVisible, + artworksHeightInCollapsedState = artworksHeightInCollapsedState, ), progress = progress.value, modifier = Modifier .fillMaxWidth() - .drawOnTop(), + .drawOnTop() + .onGloballyPositioned { coordinates -> + val state = State.fromProgressOrNull(progress.value) + + if (state == State.Expanded && maxHeaderHeightInPx == HeightUnspecified) { + maxHeaderHeightInPx = coordinates.size.height.toFloat() + } + }, transitionName = TransitionName, invalidationStrategy = remember { InvalidationStrategy( @@ -501,37 +530,34 @@ internal fun GameInfoAnimatableHeader( ) } - val infoItemModifier = Modifier - .drawOnTop() - .padding(vertical = GamedgeTheme.spaces.spacing_3_5) - - Info( - icon = painterResource(CoreR.drawable.star_circle_outline), + InfoItem( + iconId = CoreR.drawable.star_circle_outline, title = headerInfo.rating, - modifier = infoItemModifier.layoutId(ConstraintIdRating), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, + modifier = Modifier + .layoutId(ConstraintIdRating) + // Grabbing the height of any info item here to calculate the min header height + .onGloballyPositioned { coordinates -> + if (minHeaderHeightInPx == HeightUnspecified) { + minHeaderHeightInPx = with(density) { + artworksHeightInCollapsedState.roundToPx() + coordinates.size.height.toFloat() + } + } + }, ) - Info( - icon = painterResource(CoreR.drawable.account_heart_outline), + InfoItem( + iconId = CoreR.drawable.account_heart_outline, title = headerInfo.likeCount, - modifier = infoItemModifier.layoutId(ConstraintIdLikeCount), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, + modifier = Modifier.layoutId(ConstraintIdLikeCount), ) - Info( - icon = painterResource(CoreR.drawable.age_rating_outline), + InfoItem( + iconId = CoreR.drawable.age_rating_outline, title = headerInfo.ageRating, - modifier = infoItemModifier.layoutId(ConstraintIdAgeRating), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, + modifier = Modifier.layoutId(ConstraintIdAgeRating), ) - Info( - icon = painterResource(CoreR.drawable.shape_outline), + InfoItem( + iconId = CoreR.drawable.shape_outline, title = headerInfo.gameCategory, - modifier = infoItemModifier.layoutId(ConstraintIdGameCategory), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, + modifier = Modifier.layoutId(ConstraintIdGameCategory), ) } @@ -543,9 +569,9 @@ internal fun GameInfoAnimatableHeader( private fun rememberMotionScene( hasDefaultPlaceholderArtwork: Boolean, isSecondTitleVisible: Boolean, + artworksHeightInCollapsedState: Dp, ): MotionScene { val spaces = GamedgeTheme.spaces - val artworksHeightInCollapsedState = calculateArtworksHeightInCollapsedState() val statusBarHeight = calculateStatusBarHeightInDp() val firstTitleColorInExpandedState = GamedgeTheme.colors.onPrimary val firstTitleColorInCollapsedState = ScrimContentColor @@ -975,6 +1001,23 @@ private class LikeButton(context: Context) : FloatingActionButton(context) { get() = drawableState.contains(STATE_CHECKED_ON) } +@Composable +private fun InfoItem( + @DrawableRes iconId: Int, + title: String, + modifier: Modifier, +) { + Info( + icon = painterResource(iconId), + title = title, + modifier = modifier + .padding(vertical = GamedgeTheme.spaces.spacing_3_5) + .drawOnTop(), + iconSize = InfoIconSize, + titleTextStyle = GamedgeTheme.typography.caption, + ) +} + private var ConstrainScope.isVisible: Boolean set(isVisible) { visibility = if (isVisible) Visibility.Visible else Visibility.Gone @@ -1003,8 +1046,8 @@ private fun Modifier.drawOnTop(): Modifier { /** * Calculates a duration for the auto transition in the following way: * - for progress that is zero, the duration is minimal (0f -> min) - * - for progress that is half way, the duration is maximal (0.5f - max) - * - for progress that is one, the duration is minimal (1f - min) + * - for progress that is half way, the duration is maximal (0.5f -> max) + * - for progress that is one, the duration is minimal (1f -> min) **/ private fun calculateAutoTransitionDuration(progress: Float): Int { val minDuration = AutoTransitionAnimationDurationMin @@ -1036,7 +1079,7 @@ private fun calculateArtworksHeightInCollapsedState(): Dp { @Composable private fun calculateStatusBarHeightInDp(): Dp { - val statusBarHeight = LocalContext.current.statusBarHeight + val density = LocalDensity.current - return with(LocalDensity.current) { statusBarHeight.toDp() } + return with(density) { WindowInsets.statusBars.getTop(density).toDp() } } From 2b12be89db6ac502fa296ef6017e07f6c50376c2 Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 16:33:20 +0300 Subject: [PATCH 07/12] Delete old header & rename new one --- .../info/presentation/GameInfoScreen.kt | 44 +- .../header/GameInfoAnimatableHeader.kt | 1085 --------------- .../widgets/header/GameInfoHeader.kt | 1178 +++++++++++++---- 3 files changed, 895 insertions(+), 1412 deletions(-) delete mode 100644 feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt index b7be3652..f9c6edd8 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt @@ -17,7 +17,6 @@ package com.paulrybitskyi.gamedge.feature.info.presentation import android.content.res.Configuration -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -42,17 +41,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.Velocity import androidx.hilt.navigation.compose.hiltViewModel import com.paulrybitskyi.commons.ktx.showShortToast import com.paulrybitskyi.gamedge.common.ui.CommandsHandler @@ -74,7 +68,6 @@ import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.companies.Gam import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.companies.GameInfoCompanyUiModel import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.details.GameInfoDetails import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.details.GameInfoDetailsUiModel -import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.GameInfoAnimatableHeader import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.GameInfoHeader import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.GameInfoHeaderUiModel import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.GameInfoArtworkUiModel @@ -246,7 +239,7 @@ private fun SuccessState( ) { val listState = rememberLazyListState() - GameInfoAnimatableHeader( + GameInfoHeader( headerInfo = gameInfo.headerModel, listState = listState, onArtworkClicked = onArtworkClicked, @@ -321,24 +314,6 @@ private fun SuccessState( } } -private fun LazyListScope.headerItem( - model: GameInfoHeaderUiModel, - onArtworkClicked: (artworkIndex: Int) -> Unit, - onBackButtonClicked: () -> Unit, - onCoverClicked: () -> Unit, - onLikeButtonClicked: () -> Unit, -) { - gameInfoItem(item = GameInfoItem.Header) { - GameInfoHeader( - headerInfo = model, - onArtworkClicked = onArtworkClicked, - onBackButtonClicked = onBackButtonClicked, - onCoverClicked = onCoverClicked, - onLikeButtonClicked = onLikeButtonClicked, - ) - } -} - private fun LazyListScope.videosItem( videos: List, onVideoClicked: (GameInfoVideoUiModel) -> Unit, @@ -441,19 +416,18 @@ private enum class GameInfoItem( val key: Int, val contentType: Int, ) { - Header(key = 1, contentType = 1), - Videos(key = 2, contentType = 2), - Screenshots(key = 3, contentType = 3), - Summary(key = 4, contentType = 4), - Details(key = 5, contentType = 5), - Links(key = 6, contentType = 6), - Companies(key = 7, contentType = 7), + Videos(key = 1, contentType = 1), + Screenshots(key = 2, contentType = 2), + Summary(key = 3, contentType = 3), + Details(key = 4, contentType = 4), + Links(key = 5, contentType = 5), + Companies(key = 6, contentType = 6), // Both other & similar games is the same composable // filled with different data. That's why contentType // is the same for them two. - OtherCompanyGames(key = 8, contentType = 8), - SimilarGames(key = 9, contentType = 8), + OtherCompanyGames(key = 7, contentType = 7), + SimilarGames(key = 8, contentType = 7), } // TODO (02.01.2022): Currently, preview height is limited to 2k DP. diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt deleted file mode 100644 index db18e0ad..00000000 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ /dev/null @@ -1,1085 +0,0 @@ -/* - * Copyright 2022 Paul Rybitskyi, paul.rybitskyi.work@gmail.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("LongMethod") - -package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header - -import android.content.Context -import android.content.res.ColorStateList -import androidx.annotation.DrawableRes -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.EaseInOut -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.layout.onGloballyPositioned -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.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.zIndex -import androidx.constraintlayout.compose.ConstrainScope -import androidx.constraintlayout.compose.ConstrainedLayoutReference -import androidx.constraintlayout.compose.ConstraintSet -import androidx.constraintlayout.compose.Dimension -import androidx.constraintlayout.compose.InvalidationStrategy -import androidx.constraintlayout.compose.KeyAttributeScope -import androidx.constraintlayout.compose.KeyPositionScope -import androidx.constraintlayout.compose.MotionLayout -import androidx.constraintlayout.compose.MotionScene -import androidx.constraintlayout.compose.MotionSceneScope -import androidx.constraintlayout.compose.Transition -import androidx.constraintlayout.compose.Visibility -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.paulrybitskyi.commons.ktx.getCompatDrawable -import com.paulrybitskyi.commons.ktx.onClick -import com.paulrybitskyi.commons.ktx.postAction -import com.paulrybitskyi.gamedge.common.ui.clickable -import com.paulrybitskyi.gamedge.common.ui.theme.GamedgeTheme -import com.paulrybitskyi.gamedge.common.ui.theme.Spaces -import com.paulrybitskyi.gamedge.common.ui.theme.darkScrim -import com.paulrybitskyi.gamedge.common.ui.theme.lightScrim -import com.paulrybitskyi.gamedge.common.ui.theme.subtitle3 -import com.paulrybitskyi.gamedge.common.ui.widgets.GameCover -import com.paulrybitskyi.gamedge.common.ui.widgets.Info -import com.paulrybitskyi.gamedge.feature.info.R -import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.Artworks -import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.GameInfoArtworkUiModel -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.launch -import com.paulrybitskyi.gamedge.core.R as CoreR - -private const val AutoTransitionAnimationDurationMin = 300 -private const val AutoTransitionAnimationDurationMax = 1_200 - -private const val ConstraintSetNameExpanded = "expanded" -private const val ConstraintSetNameCollapsed = "collapsed" -private const val TransitionName = "fancy_transition" - -private const val ConstraintIdArtworks = "artworks" -private const val ConstraintIdArtworksScrim = "artworks_scrim" -private const val ConstraintIdBackButton = "back_button" -private const val ConstraintIdPageIndicator = "page_indicator" -private const val ConstraintIdBackdrop = "backdrop" -private const val ConstraintIdCoverSpace = "cover_space" -private const val ConstraintIdCover = "cover" -private const val ConstraintIdLikeButton = "like_button" -private const val ConstraintIdFirstTitle = "first_title" -private const val ConstraintIdSecondTitle = "second_title" -private const val ConstraintIdReleaseDate = "release_date" -private const val ConstraintIdDeveloperName = "developer_name" -private const val ConstraintIdRating = "rating" -private const val ConstraintIdLikeCount = "like_count" -private const val ConstraintIdAgeRating = "age_rating" -private const val ConstraintIdGameCategory = "game_category" - -private const val CustomAttributeTextColor = "text_color" - -private const val HeightUnspecified = -1f - -private val ScrimContentColor = Color.White - -private val CoverSpace = 40.dp -private val InfoIconSize = 34.dp - -private const val FirstTitleScaleExpanded = 1f -private const val FirstTitleScaleCollapsed = 1.1f -private const val LikeButtonScaleCollapsed = 0f - -private val ArtworksHeightExpanded = 240.dp -private val ArtworksHeightCollapsed = 56.dp - -private val PageIndicatorDeltaXCollapsed = 60.dp -private val CoverDeltaXCollapsed = (-130).dp -private val CoverDeltaYCollapsed = (-40).dp - -private enum class State(val progress: Float) { - Expanded(progress = 0f), - Collapsed(progress = 1f); - - companion object { - - fun fromProgressOrNull(progress: Float): State? { - return entries.firstOrNull { state -> state.progress == progress } - } - } -} - -private val AnimatableSaver = Saver( - save = { animatable -> animatable.value }, - restore = ::Animatable, -) - -@Composable -internal fun GameInfoAnimatableHeader( - headerInfo: GameInfoHeaderUiModel, - listState: LazyListState, - onArtworkClicked: (artworkIndex: Int) -> Unit, - onBackButtonClicked: () -> Unit, - onCoverClicked: () -> Unit, - onLikeButtonClicked: () -> Unit, - content: @Composable (Modifier) -> Unit, -) { - val colors = GamedgeTheme.colors - val density = LocalDensity.current - val progress = rememberSaveable(saver = AnimatableSaver) { - Animatable(State.Expanded.progress) - } - - val artworksHeightInCollapsedState = calculateArtworksHeightInCollapsedState() - var minHeaderHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } - var maxHeaderHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } - var headerHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } - - val coroutineScope = rememberCoroutineScope() - val artworks = headerInfo.artworks - val isPageIndicatorVisible = remember(artworks) { artworks.size > 1 } - val hasDefaultPlaceholderArtwork = remember(artworks) { - artworks.size == 1 && - artworks.single() is GameInfoArtworkUiModel.DefaultImage - } - var selectedArtworkPage by remember { mutableIntStateOf(0) } - var secondTitleText by rememberSaveable { mutableStateOf("") } - val isSecondTitleVisible by remember { - derivedStateOf { - secondTitleText.isNotEmpty() - } - } - val isArtworkInteractionEnabled by remember { - derivedStateOf { - progress.value < 0.01f - } - } - val firstTitleOverflowMode by remember { - derivedStateOf { - if (progress.value < 0.95f) { - TextOverflow.Clip - } else { - TextOverflow.Ellipsis - } - } - } - val isInCollapsedState by remember { - derivedStateOf { - State.fromProgressOrNull(progress.value) == State.Collapsed - } - } - - DisposableEffect(minHeaderHeightInPx, maxHeaderHeightInPx) { - val shouldSetInitialHeaderHeight = minHeaderHeightInPx != HeightUnspecified && - maxHeaderHeightInPx != HeightUnspecified && - headerHeightInPx == HeightUnspecified - - if (shouldSetInitialHeaderHeight) { - headerHeightInPx = when (State.fromProgressOrNull(progress.value)) { - State.Expanded -> maxHeaderHeightInPx - State.Collapsed -> minHeaderHeightInPx - null -> error("Invalid progress value: ${progress.value}") - } - } - - onDispose {} - } - - LaunchedEffect(Unit) { - snapshotFlow { listState.isScrollInProgress } - .distinctUntilChanged() - .filterNot { isScrolling -> isScrolling } - .collect { - val currentProgress = progress.value - - if (State.fromProgressOrNull(currentProgress) == null) { - val newState = if (currentProgress < 0.5f) State.Expanded else State.Collapsed - val duration = calculateAutoTransitionDuration(currentProgress) - - launch { - progress.animateTo( - targetValue = newState.progress, - animationSpec = tween(durationMillis = duration, easing = EaseInOut), - block = { - headerHeightInPx = calculateHeaderHeightGivenProgress( - progress = value, - minHeaderHeight = minHeaderHeightInPx, - maxHeaderHeight = maxHeaderHeightInPx, - ) - } - ) - } - } - } - } - - val nestedConnection = remember(listState) { - object : NestedScrollConnection { - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - // If the list can scroll backward, then we need to allow it to consume the delta. - // Otherwise, we consume it to update the header height & progress. - return if (listState.canScrollBackward) { - Offset.Zero - } else { - consume(available) - } - } - - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - // We need to handle the onPostScroll in order to consume the leftover delta after a user - // flings a list from the bottom to the top so that the header could expand automatically. - return if (!listState.canScrollBackward && available.y > 0) { - consume(available) - } else { - Offset.Zero - } - } - - private fun consume(available: Offset): Offset { - val currentHeight = headerHeightInPx - - return when { - currentHeight + available.y > maxHeaderHeightInPx -> { - onUpdateValues(maxHeaderHeightInPx) - Offset(0f, maxHeaderHeightInPx - currentHeight) - } - currentHeight + available.y < minHeaderHeightInPx -> { - onUpdateValues(minHeaderHeightInPx) - Offset(0f, minHeaderHeightInPx - currentHeight) - } - else -> { - onUpdateValues(headerHeightInPx + available.y) - Offset(0f, available.y) - } - } - } - - private fun onUpdateValues(newHeaderHeight: Float) { - headerHeightInPx = newHeaderHeight - - val newProgress = calculateProgressGivenHeaderHeight( - headerHeight = newHeaderHeight, - minHeaderHeight = minHeaderHeightInPx, - maxHeaderHeight = maxHeaderHeightInPx, - ) - - coroutineScope.launch { - progress.snapTo(newProgress) - } - } - } - } - - Column { - MotionLayout( - motionScene = rememberMotionScene( - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - isSecondTitleVisible = isSecondTitleVisible, - artworksHeightInCollapsedState = artworksHeightInCollapsedState, - ), - progress = progress.value, - modifier = Modifier - .fillMaxWidth() - .drawOnTop() - .onGloballyPositioned { coordinates -> - val state = State.fromProgressOrNull(progress.value) - - if (state == State.Expanded && maxHeaderHeightInPx == HeightUnspecified) { - maxHeaderHeightInPx = coordinates.size.height.toFloat() - } - }, - transitionName = TransitionName, - invalidationStrategy = remember { - InvalidationStrategy( - onObservedStateChange = @Suppress("UNUSED_EXPRESSION") { - headerInfo - }, - ) - } - ) { - Artworks( - artworks = artworks, - isScrollingEnabled = isArtworkInteractionEnabled, - modifier = Modifier.layoutId(ConstraintIdArtworks), - onArtworkChanged = { page -> - selectedArtworkPage = page - }, - onArtworkClicked = { artworkIndex -> - if (isArtworkInteractionEnabled) { - onArtworkClicked(artworkIndex) - } - }, - ) - - Box( - modifier = Modifier - .layoutId(ConstraintIdArtworksScrim) - .background(GamedgeTheme.colors.darkScrim), - ) - - Icon( - painter = painterResource(CoreR.drawable.arrow_left), - contentDescription = null, - modifier = Modifier - .layoutId(ConstraintIdBackButton) - .statusBarsPadding() - .size(56.dp) - .clickable( - indication = ripple( - bounded = false, - radius = 18.dp, - ), - onClick = onBackButtonClicked, - ) - .padding(GamedgeTheme.spaces.spacing_2_5) - .background( - color = GamedgeTheme.colors.lightScrim, - shape = CircleShape, - ) - .padding(GamedgeTheme.spaces.spacing_1_5), - tint = ScrimContentColor, - ) - - if (isPageIndicatorVisible) { - Text( - text = stringResource( - R.string.game_info_header_page_indicator_template, - selectedArtworkPage + 1, - headerInfo.artworks.size, - ), - modifier = Modifier - .layoutId(ConstraintIdPageIndicator) - .statusBarsPadding() - .background( - color = GamedgeTheme.colors.lightScrim, - shape = RoundedCornerShape(20.dp), - ) - .padding( - vertical = GamedgeTheme.spaces.spacing_1_5, - horizontal = GamedgeTheme.spaces.spacing_2_0, - ), - color = ScrimContentColor, - style = GamedgeTheme.typography.subtitle3, - ) - } - - Box( - modifier = Modifier - .layoutId(ConstraintIdBackdrop) - .background( - color = GamedgeTheme.colors.surface, - shape = RectangleShape, - ) - .clip(RectangleShape), - ) - - Spacer( - Modifier - .layoutId(ConstraintIdCoverSpace) - .height(CoverSpace), - ) - - GameCover( - title = null, - imageUrl = headerInfo.coverImageUrl, - modifier = Modifier - .layoutId(ConstraintIdCover) - .drawOnTop(), - onCoverClicked = if (headerInfo.hasCoverImageUrl) onCoverClicked else null, - ) - - // 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()) - // Disabling the ripple because it cripples the animation a bit - rippleColor = colors.secondary.toArgb() - // Disabling the shadow to avoid it being clipped when animating to collapsed state - // (especially can be seen on the light theme) - compatElevation = 0f - onClick { onLikeButtonClicked() } - } - }, - modifier = Modifier - .layoutId(ConstraintIdLikeButton) - .drawOnTop(), - update = { view -> - view.isLiked = headerInfo.isLiked - }, - ) - - Text( - text = headerInfo.title, - modifier = Modifier - .layoutId(ConstraintIdFirstTitle) - .drawOnTop(), - // When restoring state, customColor function returns invalid color (black color - // when in collapsed state), so a little fix here to set the correct color - color = if (isInCollapsedState) { - ScrimContentColor - } else { - customColor(ConstraintIdFirstTitle, CustomAttributeTextColor) - }, - overflow = firstTitleOverflowMode, - maxLines = 1, - onTextLayout = { textLayoutResult -> - if (textLayoutResult.hasVisualOverflow && secondTitleText.isEmpty()) { - val firstTitleWidth = textLayoutResult.size.width.toFloat() - val firstTitleOffset = Offset(firstTitleWidth, 0f) - val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 - - if (firstTitleVisibleTextEndIndex in headerInfo.title.indices) { - secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) - } - } - }, - style = GamedgeTheme.typography.h6, - ) - - Text( - text = secondTitleText, - modifier = Modifier - .layoutId(ConstraintIdSecondTitle) - .drawOnTop(), - color = GamedgeTheme.colors.onPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - style = GamedgeTheme.typography.h6, - ) - - Text( - text = headerInfo.releaseDate, - modifier = Modifier - .layoutId(ConstraintIdReleaseDate) - .drawOnTop(), - color = GamedgeTheme.colors.onSurface, - style = GamedgeTheme.typography.subtitle3, - ) - - if (headerInfo.hasDeveloperName) { - Text( - text = checkNotNull(headerInfo.developerName), - modifier = Modifier - .layoutId(ConstraintIdDeveloperName) - .drawOnTop(), - color = GamedgeTheme.colors.onSurface, - style = GamedgeTheme.typography.subtitle3, - ) - } - - InfoItem( - iconId = CoreR.drawable.star_circle_outline, - title = headerInfo.rating, - modifier = Modifier - .layoutId(ConstraintIdRating) - // Grabbing the height of any info item here to calculate the min header height - .onGloballyPositioned { coordinates -> - if (minHeaderHeightInPx == HeightUnspecified) { - minHeaderHeightInPx = with(density) { - artworksHeightInCollapsedState.roundToPx() + coordinates.size.height.toFloat() - } - } - }, - ) - InfoItem( - iconId = CoreR.drawable.account_heart_outline, - title = headerInfo.likeCount, - modifier = Modifier.layoutId(ConstraintIdLikeCount), - ) - InfoItem( - iconId = CoreR.drawable.age_rating_outline, - title = headerInfo.ageRating, - modifier = Modifier.layoutId(ConstraintIdAgeRating), - ) - InfoItem( - iconId = CoreR.drawable.shape_outline, - title = headerInfo.gameCategory, - modifier = Modifier.layoutId(ConstraintIdGameCategory), - ) - } - - content(Modifier.nestedScroll(nestedConnection)) - } -} - -@Composable -private fun rememberMotionScene( - hasDefaultPlaceholderArtwork: Boolean, - isSecondTitleVisible: Boolean, - artworksHeightInCollapsedState: Dp, -): MotionScene { - val spaces = GamedgeTheme.spaces - val statusBarHeight = calculateStatusBarHeightInDp() - val firstTitleColorInExpandedState = GamedgeTheme.colors.onPrimary - val firstTitleColorInCollapsedState = ScrimContentColor - - return remember( - hasDefaultPlaceholderArtwork, - isSecondTitleVisible, - spaces, - artworksHeightInCollapsedState, - statusBarHeight, - firstTitleColorInExpandedState, - ) { - MotionScene { - val refs = ConstraintLayoutRefs(this) - - addConstraintSet( - constraintSet = constructExpandedConstraintSet( - refs = refs, - spaces = spaces, - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - isSecondTitleVisible = isSecondTitleVisible, - firstTitleTextColor = firstTitleColorInExpandedState, - ), - name = ConstraintSetNameExpanded, - ) - addConstraintSet( - constraintSet = constructCollapsedConstraintSet( - refs = refs, - spaces = spaces, - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - isSecondTitleVisible = isSecondTitleVisible, - artworksHeight = artworksHeightInCollapsedState, - statusBarHeight = statusBarHeight, - firstTitleTextColor = firstTitleColorInCollapsedState, - ), - name = ConstraintSetNameCollapsed, - ) - addTransition( - transition = constructTransition( - refs = refs, - isSecondTitleVisible = isSecondTitleVisible, - firstTitleColorInExpandedState = firstTitleColorInExpandedState, - firstTitleColorInCollapsedState = firstTitleColorInCollapsedState, - ), - name = TransitionName, - ) - } - } -} - -private class ConstraintLayoutRefs( - val artworks: ConstrainedLayoutReference, - val artworksScrim: ConstrainedLayoutReference, - val backButton: ConstrainedLayoutReference, - val pageIndicator: ConstrainedLayoutReference, - val backdrop: ConstrainedLayoutReference, - val coverSpace: ConstrainedLayoutReference, - val cover: ConstrainedLayoutReference, - val likeButton: ConstrainedLayoutReference, - val firstTitle: ConstrainedLayoutReference, - val secondTitle: ConstrainedLayoutReference, - val releaseDate: ConstrainedLayoutReference, - val developerName: ConstrainedLayoutReference, - val rating: ConstrainedLayoutReference, - val likeCount: ConstrainedLayoutReference, - val ageRating: ConstrainedLayoutReference, - val gameCategory: ConstrainedLayoutReference, -) { - constructor(motionSceneScope: MotionSceneScope): this( - artworks = motionSceneScope.createRefFor(ConstraintIdArtworks), - artworksScrim = motionSceneScope.createRefFor(ConstraintIdArtworksScrim), - backButton = motionSceneScope.createRefFor(ConstraintIdBackButton), - pageIndicator = motionSceneScope.createRefFor(ConstraintIdPageIndicator), - backdrop = motionSceneScope.createRefFor(ConstraintIdBackdrop), - coverSpace = motionSceneScope.createRefFor(ConstraintIdCoverSpace), - cover = motionSceneScope.createRefFor(ConstraintIdCover), - likeButton = motionSceneScope.createRefFor(ConstraintIdLikeButton), - firstTitle = motionSceneScope.createRefFor(ConstraintIdFirstTitle), - secondTitle = motionSceneScope.createRefFor(ConstraintIdSecondTitle), - releaseDate = motionSceneScope.createRefFor(ConstraintIdReleaseDate), - developerName = motionSceneScope.createRefFor(ConstraintIdDeveloperName), - rating = motionSceneScope.createRefFor(ConstraintIdRating), - likeCount = motionSceneScope.createRefFor(ConstraintIdLikeCount), - ageRating = motionSceneScope.createRefFor(ConstraintIdAgeRating), - gameCategory = motionSceneScope.createRefFor(ConstraintIdGameCategory), - ) -} - -private fun MotionSceneScope.constructExpandedConstraintSet( - refs: ConstraintLayoutRefs, - spaces: Spaces, - hasDefaultPlaceholderArtwork: Boolean, - isSecondTitleVisible: Boolean, - firstTitleTextColor: Color, -): ConstraintSet { - val pageIndicatorMargin = spaces.spacing_2_5 - val backdropElevation = spaces.spacing_0_5 - val coverSpaceMargin = CoverSpace - val coverMarginStart = spaces.spacing_3_5 - val likeBtnMarginEnd = spaces.spacing_2_5 - val textHorizontalMargin = spaces.spacing_3_5 - val firstTitleMarginTop = textHorizontalMargin - val firstTitleMarginEnd = spaces.spacing_1_0 - val releaseDateMarginTop = spaces.spacing_2_5 - val bottomBarrierMargin = spaces.spacing_1_5 - - return ConstraintSet { - val bottomBarrier = createBottomBarrier( - refs.cover, - refs.developerName, - margin = bottomBarrierMargin, - ) - - constrain(refs.artworks) { - width = Dimension.fillToConstraints - height = Dimension.value(ArtworksHeightExpanded) - top.linkTo(parent.top) - centerHorizontallyTo(parent) - } - constrain(refs.artworksScrim) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - centerVerticallyTo(refs.artworks) - centerHorizontallyTo(refs.artworks) - visibility = if (hasDefaultPlaceholderArtwork) { - Visibility.Gone - } else { - Visibility.Invisible - } - } - constrain(refs.backButton) { - top.linkTo(parent.top) - start.linkTo(parent.start) - } - constrain(refs.pageIndicator) { - top.linkTo(parent.top, pageIndicatorMargin) - end.linkTo(parent.end, pageIndicatorMargin) - } - constrain(refs.backdrop) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom) - bottom.linkTo(refs.rating.bottom) - centerHorizontallyTo(parent) - translationZ = backdropElevation - } - constrain(refs.coverSpace) { - start.linkTo(parent.start) - bottom.linkTo(refs.artworks.bottom, coverSpaceMargin) - } - constrain(refs.cover) { - top.linkTo(refs.coverSpace.bottom) - start.linkTo(parent.start, coverMarginStart) - } - constrain(refs.likeButton) { - top.linkTo(refs.artworks.bottom) - bottom.linkTo(refs.artworks.bottom) - end.linkTo(parent.end, likeBtnMarginEnd) - } - constrain(refs.firstTitle) { - width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom, firstTitleMarginTop) - start.linkTo(refs.cover.end, textHorizontalMargin) - end.linkTo(refs.likeButton.start, firstTitleMarginEnd) - setScale(FirstTitleScaleExpanded) - customColor(CustomAttributeTextColor, firstTitleTextColor) - } - constrain(refs.secondTitle) { - width = Dimension.fillToConstraints - top.linkTo(refs.firstTitle.bottom) - start.linkTo(refs.cover.end, textHorizontalMargin) - end.linkTo(parent.end, textHorizontalMargin) - isVisible = isSecondTitleVisible - } - constrain(refs.releaseDate) { - width = Dimension.fillToConstraints - top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(refs.cover.end, textHorizontalMargin) - end.linkTo(parent.end, textHorizontalMargin) - } - constrain(refs.developerName) { - width = Dimension.fillToConstraints - top.linkTo(refs.releaseDate.bottom) - start.linkTo(refs.cover.end, textHorizontalMargin) - end.linkTo(parent.end, textHorizontalMargin) - } - constrain(refs.rating) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) - } - constrain(refs.likeCount) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) - } - constrain(refs.ageRating) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) - } - constrain(refs.gameCategory) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) - } - } -} - -private fun MotionSceneScope.constructCollapsedConstraintSet( - refs: ConstraintLayoutRefs, - spaces: Spaces, - hasDefaultPlaceholderArtwork: Boolean, - isSecondTitleVisible: Boolean, - artworksHeight: Dp, - statusBarHeight: Dp, - firstTitleTextColor: Color, -): ConstraintSet { - val pageIndicatorMargin = spaces.spacing_2_5 - val backdropElevation = spaces.spacing_1_0 - val coverSpaceMargin = CoverSpace - val coverMarginStart = spaces.spacing_3_5 - val likeBtnMarginEnd = spaces.spacing_2_5 - val textHorizontalMargin = spaces.spacing_3_5 - val firstTitleMarginStart = spaces.spacing_7_5 - val firstTitleMarginEnd = spaces.spacing_6_0 - val releaseDateMarginTop = spaces.spacing_2_5 - - return ConstraintSet { - constrain(refs.artworks) { - width = Dimension.fillToConstraints - height = Dimension.value(artworksHeight) - top.linkTo(parent.top) - bottom.linkTo(refs.backdrop.top) - centerHorizontallyTo(parent) - } - constrain(refs.artworksScrim) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - centerVerticallyTo(refs.artworks) - centerHorizontallyTo(refs.artworks) - visibility = if (hasDefaultPlaceholderArtwork) { - Visibility.Gone - } else { - Visibility.Visible - } - } - constrain(refs.backButton) { - top.linkTo(parent.top) - start.linkTo(parent.start) - } - constrain(refs.pageIndicator) { - top.linkTo(parent.top, pageIndicatorMargin) - end.linkTo(parent.end, pageIndicatorMargin) - translationX = PageIndicatorDeltaXCollapsed - } - constrain(refs.backdrop) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - centerVerticallyTo(refs.rating) - centerHorizontallyTo(parent) - translationZ = backdropElevation - } - constrain(refs.coverSpace) { - start.linkTo(parent.start) - bottom.linkTo(refs.artworks.bottom, coverSpaceMargin) - } - constrain(refs.cover) { - top.linkTo(refs.coverSpace.bottom) - start.linkTo(parent.start, coverMarginStart) - translationX = CoverDeltaXCollapsed - translationY = CoverDeltaYCollapsed - // We need to set it to Gone to avoid the cover taking up the vertical space, - // which increases the size of the header in collapsed state - visibility = Visibility.Gone - } - constrain(refs.likeButton) { - top.linkTo(refs.artworks.bottom) - bottom.linkTo(refs.artworks.bottom) - end.linkTo(parent.end, likeBtnMarginEnd) - alpha = 0f - setScale(LikeButtonScaleCollapsed) - } - constrain(refs.firstTitle) { - width = Dimension.fillToConstraints - top.linkTo(refs.artworks.top, statusBarHeight) - bottom.linkTo(refs.artworks.bottom) - start.linkTo(refs.backButton.end, firstTitleMarginStart) - end.linkTo(parent.end, firstTitleMarginEnd) - setScale(FirstTitleScaleCollapsed) - customColor(CustomAttributeTextColor, firstTitleTextColor) - } - constrain(refs.secondTitle) { - width = Dimension.fillToConstraints - top.linkTo(refs.firstTitle.bottom) - start.linkTo(refs.firstTitle.start) - end.linkTo(parent.end, textHorizontalMargin) - isVisible = isSecondTitleVisible - alpha = 0f - } - constrain(refs.releaseDate) { - width = Dimension.fillToConstraints - top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(refs.firstTitle.start) - end.linkTo(parent.end, textHorizontalMargin) - alpha = 0f - } - constrain(refs.developerName) { - width = Dimension.fillToConstraints - top.linkTo(refs.releaseDate.bottom) - start.linkTo(refs.firstTitle.start) - end.linkTo(parent.end, textHorizontalMargin) - alpha = 0f - } - constrain(refs.rating) { - width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom) - linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) - } - constrain(refs.likeCount) { - width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom) - linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) - } - constrain(refs.ageRating) { - width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom) - linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) - } - constrain(refs.gameCategory) { - width = Dimension.fillToConstraints - top.linkTo(refs.artworks.bottom) - linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) - } - } -} - -private fun MotionSceneScope.constructTransition( - refs: ConstraintLayoutRefs, - isSecondTitleVisible: Boolean, - firstTitleColorInExpandedState: Color, - firstTitleColorInCollapsedState: Color, -): Transition { - return Transition(from = ConstraintSetNameExpanded, to = ConstraintSetNameCollapsed) { - // Don't scale the first title until the secondary texts (second title, - // release date and developer name) is gone - keyAttributes(refs.firstTitle) { - frame(frame = 15) { - setScale(FirstTitleScaleExpanded) - } - } - keyAttributes(refs.secondTitle) { - frame(frame = 15) { - alpha = 0f - } - } - keyAttributes(refs.releaseDate) { - frame(frame = 15) { - alpha = 0f - } - } - keyAttributes(refs.developerName) { - frame(frame = 15) { - alpha = 0f - } - } - keyAttributes(refs.cover) { - frame(frame = 50) { - alpha = 0f - translationX = CoverDeltaXCollapsed - translationY = CoverDeltaYCollapsed - } - } - keyPositions(refs.cover) { - frame(frame = 50) { - setSizePercentage(0f) - } - } - keyAttributes(refs.firstTitle) { - frame(frame = 40) { - customColor(CustomAttributeTextColor, firstTitleColorInExpandedState) - } - frame(frame = 60) { - customColor(CustomAttributeTextColor, firstTitleColorInCollapsedState) - } - } - - if (isSecondTitleVisible) { - // To prevent the first title overlapping with the like button - keyPositions(refs.firstTitle) { - frame(frame = 60) { - percentWidth = 0.5f - } - } - } - - keyAttributes(refs.likeButton) { - frame(frame = 60) { - alpha = 0f - setScale(LikeButtonScaleCollapsed) - } - } - keyAttributes(refs.pageIndicator) { - frame(frame = 80) { - translationX = PageIndicatorDeltaXCollapsed - } - } - } -} - -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) { - // Just calling setImageState() directly doesn't work, so we need - // to postpone it just a bit. - postAction { - setImageState(intArrayOf(if (value) STATE_CHECKED_ON else STATE_CHECKED_OFF), true) - } - } - get() = drawableState.contains(STATE_CHECKED_ON) -} - -@Composable -private fun InfoItem( - @DrawableRes iconId: Int, - title: String, - modifier: Modifier, -) { - Info( - icon = painterResource(iconId), - title = title, - modifier = modifier - .padding(vertical = GamedgeTheme.spaces.spacing_3_5) - .drawOnTop(), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) -} - -private var ConstrainScope.isVisible: Boolean - set(isVisible) { - visibility = if (isVisible) Visibility.Visible else Visibility.Gone - } - get() = visibility == Visibility.Visible - -private fun ConstrainScope.setScale(scale: Float) { - scaleX = scale - scaleY = scale -} - -private fun KeyAttributeScope.setScale(scale: Float) { - scaleX = scale - scaleY = scale -} - -private fun KeyPositionScope.setSizePercentage(percentage: Float) { - percentWidth = percentage - percentHeight = percentage -} - -private fun Modifier.drawOnTop(): Modifier { - return zIndex(Float.MAX_VALUE) -} - -/** - * Calculates a duration for the auto transition in the following way: - * - for progress that is zero, the duration is minimal (0f -> min) - * - for progress that is half way, the duration is maximal (0.5f -> max) - * - for progress that is one, the duration is minimal (1f -> min) - **/ -private fun calculateAutoTransitionDuration(progress: Float): Int { - val minDuration = AutoTransitionAnimationDurationMin - val maxDuration = AutoTransitionAnimationDurationMax - - return (minDuration + (maxDuration - minDuration) * 4 * progress * (1 - progress)).toInt() -} - -private fun calculateHeaderHeightGivenProgress( - progress: Float, - minHeaderHeight: Float, - maxHeaderHeight: Float, -): Float { - return minHeaderHeight + (1 - progress) * (maxHeaderHeight - minHeaderHeight) -} - -private fun calculateProgressGivenHeaderHeight( - headerHeight: Float, - minHeaderHeight: Float, - maxHeaderHeight: Float, -): Float { - return 1 - (headerHeight - minHeaderHeight) / (maxHeaderHeight - minHeaderHeight) -} - -@Composable -private fun calculateArtworksHeightInCollapsedState(): Dp { - return ArtworksHeightCollapsed + calculateStatusBarHeightInDp() -} - -@Composable -private fun calculateStatusBarHeightInDp(): Dp { - val density = LocalDensity.current - - return with(density) { WindowInsets.statusBars.getTop(density).toDp() } -} 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 a5da024f..46f0fc92 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021 Paul Rybitskyi, paul.rybitskyi.work@gmail.com + * Copyright 2022 Paul Rybitskyi, paul.rybitskyi.work@gmail.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,51 +20,80 @@ package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header import android.content.Context import android.content.res.ColorStateList +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -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.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onGloballyPositioned 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.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.viewinterop.NoOpUpdate -import androidx.constraintlayout.compose.ConstraintLayout +import androidx.compose.ui.zIndex +import androidx.constraintlayout.compose.ConstrainScope +import androidx.constraintlayout.compose.ConstrainedLayoutReference import androidx.constraintlayout.compose.ConstraintSet import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.InvalidationStrategy +import androidx.constraintlayout.compose.KeyAttributeScope +import androidx.constraintlayout.compose.KeyPositionScope +import androidx.constraintlayout.compose.MotionLayout +import androidx.constraintlayout.compose.MotionScene +import androidx.constraintlayout.compose.MotionSceneScope +import androidx.constraintlayout.compose.Transition +import androidx.constraintlayout.compose.Visibility import com.google.android.material.floatingactionbutton.FloatingActionButton import com.paulrybitskyi.commons.ktx.getCompatDrawable import com.paulrybitskyi.commons.ktx.onClick +import com.paulrybitskyi.commons.ktx.postAction import com.paulrybitskyi.gamedge.common.ui.clickable import com.paulrybitskyi.gamedge.common.ui.theme.GamedgeTheme +import com.paulrybitskyi.gamedge.common.ui.theme.Spaces +import com.paulrybitskyi.gamedge.common.ui.theme.darkScrim import com.paulrybitskyi.gamedge.common.ui.theme.lightScrim import com.paulrybitskyi.gamedge.common.ui.theme.subtitle3 import com.paulrybitskyi.gamedge.common.ui.widgets.GameCover @@ -72,8 +101,18 @@ import com.paulrybitskyi.gamedge.common.ui.widgets.Info import com.paulrybitskyi.gamedge.feature.info.R import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.Artworks import com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header.artworks.GameInfoArtworkUiModel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.launch import com.paulrybitskyi.gamedge.core.R as CoreR +private const val AutoTransitionAnimationDurationMin = 300 +private const val AutoTransitionAnimationDurationMax = 1_200 + +private const val ConstraintSetNameExpanded = "expanded" +private const val ConstraintSetNameCollapsed = "collapsed" +private const val TransitionName = "fancy_transition" + private const val ConstraintIdArtworks = "artworks" private const val ConstraintIdArtworksScrim = "artworks_scrim" private const val ConstraintIdBackButton = "back_button" @@ -91,401 +130,956 @@ private const val ConstraintIdLikeCount = "like_count" private const val ConstraintIdAgeRating = "age_rating" private const val ConstraintIdGameCategory = "game_category" +private const val CustomAttributeTextColor = "text_color" + +private const val HeightUnspecified = -1f + +private val ScrimContentColor = Color.White + private val CoverSpace = 40.dp private val InfoIconSize = 34.dp +private const val FirstTitleScaleExpanded = 1f +private const val FirstTitleScaleCollapsed = 1.1f +private const val LikeButtonScaleCollapsed = 0f + +private val ArtworksHeightExpanded = 240.dp +private val ArtworksHeightCollapsed = 56.dp + +private val PageIndicatorDeltaXCollapsed = 60.dp +private val CoverDeltaXCollapsed = (-130).dp +private val CoverDeltaYCollapsed = (-40).dp + +private enum class State(val progress: Float) { + Expanded(progress = 0f), + Collapsed(progress = 1f); + + companion object { + + fun fromProgressOrNull(progress: Float): State? { + return entries.firstOrNull { state -> state.progress == progress } + } + } +} + +private val AnimatableSaver = Saver( + save = { animatable -> animatable.value }, + restore = ::Animatable, +) + @Composable internal fun GameInfoHeader( headerInfo: GameInfoHeaderUiModel, + listState: LazyListState, onArtworkClicked: (artworkIndex: Int) -> Unit, onBackButtonClicked: () -> Unit, onCoverClicked: () -> Unit, onLikeButtonClicked: () -> Unit, + content: @Composable (Modifier) -> Unit, ) { val colors = GamedgeTheme.colors val density = LocalDensity.current + val progress = rememberSaveable(saver = AnimatableSaver) { + Animatable(State.Expanded.progress) + } + + val artworksHeightInCollapsedState = calculateArtworksHeightInCollapsedState() + var minHeaderHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } + var maxHeaderHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } + var headerHeightInPx by rememberSaveable { mutableFloatStateOf(HeightUnspecified) } + + val coroutineScope = rememberCoroutineScope() val artworks = headerInfo.artworks - val isPageIndicatorVisible by remember(artworks) { mutableStateOf(artworks.size > 1) } - var selectedArtworkPage by rememberSaveable { mutableIntStateOf(0) } + val isPageIndicatorVisible = remember(artworks) { artworks.size > 1 } + val hasDefaultPlaceholderArtwork = remember(artworks) { + artworks.size == 1 && + artworks.single() is GameInfoArtworkUiModel.DefaultImage + } + var selectedArtworkPage by remember { mutableIntStateOf(0) } var secondTitleText by rememberSaveable { mutableStateOf("") } val isSecondTitleVisible by remember { derivedStateOf { secondTitleText.isNotEmpty() } } + val isArtworkInteractionEnabled by remember { + derivedStateOf { + progress.value < 0.01f + } + } + val firstTitleOverflowMode by remember { + derivedStateOf { + if (progress.value < 0.95f) { + TextOverflow.Clip + } else { + TextOverflow.Ellipsis + } + } + } + val isInCollapsedState by remember { + derivedStateOf { + State.fromProgressOrNull(progress.value) == State.Collapsed + } + } - ConstraintLayout( - constraintSet = constructExpandedConstraintSet(), - modifier = Modifier.fillMaxWidth(), - ) { - Artworks( - artworks = artworks, - isScrollingEnabled = true, - modifier = Modifier.layoutId(ConstraintIdArtworks), - onArtworkChanged = { page -> - selectedArtworkPage = page - }, - onArtworkClicked = onArtworkClicked, - ) + DisposableEffect(minHeaderHeightInPx, maxHeaderHeightInPx) { + val shouldSetInitialHeaderHeight = minHeaderHeightInPx != HeightUnspecified && + maxHeaderHeightInPx != HeightUnspecified && + headerHeightInPx == HeightUnspecified - Box( - modifier = Modifier - .layoutId(ConstraintIdArtworksScrim) - .background(Color.Transparent), - ) + if (shouldSetInitialHeaderHeight) { + headerHeightInPx = when (State.fromProgressOrNull(progress.value)) { + State.Expanded -> maxHeaderHeightInPx + State.Collapsed -> minHeaderHeightInPx + null -> error("Invalid progress value: ${progress.value}") + } + } - Icon( - painter = painterResource(CoreR.drawable.arrow_left), - contentDescription = null, - modifier = Modifier - .layoutId(ConstraintIdBackButton) - .statusBarsPadding() - .size(56.dp) - .clickable( - indication = ripple( - bounded = false, - radius = 18.dp, - ), - onClick = onBackButtonClicked, + onDispose {} + } + + LaunchedEffect(Unit) { + snapshotFlow { listState.isScrollInProgress } + .distinctUntilChanged() + .filterNot { isScrolling -> isScrolling } + .collect { + val currentProgress = progress.value + + if (State.fromProgressOrNull(currentProgress) == null) { + val newState = if (currentProgress < 0.5f) State.Expanded else State.Collapsed + val duration = calculateAutoTransitionDuration(currentProgress) + + launch { + progress.animateTo( + targetValue = newState.progress, + animationSpec = tween(durationMillis = duration, easing = EaseInOut), + block = { + headerHeightInPx = calculateHeaderHeightGivenProgress( + progress = value, + minHeaderHeight = minHeaderHeightInPx, + maxHeaderHeight = maxHeaderHeightInPx, + ) + } + ) + } + } + } + } + + val nestedConnection = remember(listState) { + object : NestedScrollConnection { + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // If the list can scroll backward, then we need to allow it to consume the delta. + // Otherwise, we consume it to update the header height & progress. + return if (listState.canScrollBackward) { + Offset.Zero + } else { + consume(available) + } + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + // We need to handle the onPostScroll in order to consume the leftover delta after a user + // flings a list from the bottom to the top so that the header could expand automatically. + return if (!listState.canScrollBackward && available.y > 0) { + consume(available) + } else { + Offset.Zero + } + } + + private fun consume(available: Offset): Offset { + val currentHeight = headerHeightInPx + + return when { + currentHeight + available.y > maxHeaderHeightInPx -> { + onUpdateValues(maxHeaderHeightInPx) + Offset(0f, maxHeaderHeightInPx - currentHeight) + } + currentHeight + available.y < minHeaderHeightInPx -> { + onUpdateValues(minHeaderHeightInPx) + Offset(0f, minHeaderHeightInPx - currentHeight) + } + else -> { + onUpdateValues(headerHeightInPx + available.y) + Offset(0f, available.y) + } + } + } + + private fun onUpdateValues(newHeaderHeight: Float) { + headerHeightInPx = newHeaderHeight + + val newProgress = calculateProgressGivenHeaderHeight( + headerHeight = newHeaderHeight, + minHeaderHeight = minHeaderHeightInPx, + maxHeaderHeight = maxHeaderHeightInPx, ) - .padding(GamedgeTheme.spaces.spacing_2_5) - .background( - color = GamedgeTheme.colors.lightScrim, - shape = CircleShape, + + coroutineScope.launch { + progress.snapTo(newProgress) + } + } + } + } + + Column { + MotionLayout( + motionScene = rememberMotionScene( + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, + artworksHeightInCollapsedState = artworksHeightInCollapsedState, + ), + progress = progress.value, + modifier = Modifier + .fillMaxWidth() + .drawOnTop() + .onGloballyPositioned { coordinates -> + val state = State.fromProgressOrNull(progress.value) + + if (state == State.Expanded && maxHeaderHeightInPx == HeightUnspecified) { + maxHeaderHeightInPx = coordinates.size.height.toFloat() + } + }, + transitionName = TransitionName, + invalidationStrategy = remember { + InvalidationStrategy( + onObservedStateChange = @Suppress("UNUSED_EXPRESSION") { + headerInfo + }, ) - .padding(GamedgeTheme.spaces.spacing_1_5), - tint = Color.White, - ) + } + ) { + Artworks( + artworks = artworks, + isScrollingEnabled = isArtworkInteractionEnabled, + modifier = Modifier.layoutId(ConstraintIdArtworks), + onArtworkChanged = { page -> + selectedArtworkPage = page + }, + onArtworkClicked = { artworkIndex -> + if (isArtworkInteractionEnabled) { + onArtworkClicked(artworkIndex) + } + }, + ) - if (isPageIndicatorVisible) { - Text( - text = stringResource( - R.string.game_info_header_page_indicator_template, - selectedArtworkPage + 1, - headerInfo.artworks.size, - ), + Box( modifier = Modifier - .layoutId(ConstraintIdPageIndicator) + .layoutId(ConstraintIdArtworksScrim) + .background(GamedgeTheme.colors.darkScrim), + ) + + Icon( + painter = painterResource(CoreR.drawable.arrow_left), + contentDescription = null, + modifier = Modifier + .layoutId(ConstraintIdBackButton) .statusBarsPadding() + .size(56.dp) + .clickable( + indication = ripple( + bounded = false, + radius = 18.dp, + ), + onClick = onBackButtonClicked, + ) + .padding(GamedgeTheme.spaces.spacing_2_5) .background( color = GamedgeTheme.colors.lightScrim, - shape = RoundedCornerShape(20.dp), + shape = CircleShape, ) - .padding( - vertical = GamedgeTheme.spaces.spacing_1_5, - horizontal = GamedgeTheme.spaces.spacing_2_0, - ), - color = Color.White, - style = GamedgeTheme.typography.subtitle3, + .padding(GamedgeTheme.spaces.spacing_1_5), + tint = ScrimContentColor, ) - } - Box( - modifier = Modifier - .layoutId(ConstraintIdBackdrop) - .shadow( - elevation = GamedgeTheme.spaces.spacing_0_5, - shape = RectangleShape, - clip = false, - ) - .background( - color = GamedgeTheme.colors.surface, - shape = RectangleShape, + if (isPageIndicatorVisible) { + Text( + text = stringResource( + R.string.game_info_header_page_indicator_template, + selectedArtworkPage + 1, + headerInfo.artworks.size, + ), + modifier = Modifier + .layoutId(ConstraintIdPageIndicator) + .statusBarsPadding() + .background( + color = GamedgeTheme.colors.lightScrim, + shape = RoundedCornerShape(20.dp), + ) + .padding( + vertical = GamedgeTheme.spaces.spacing_1_5, + horizontal = GamedgeTheme.spaces.spacing_2_0, + ), + color = ScrimContentColor, + style = GamedgeTheme.typography.subtitle3, ) - .clip(RectangleShape), - ) + } - Spacer( - modifier = Modifier - .layoutId(ConstraintIdCoverSpace) - .height(CoverSpace), - ) + Box( + modifier = Modifier + .layoutId(ConstraintIdBackdrop) + .background( + color = GamedgeTheme.colors.surface, + shape = RectangleShape, + ) + .clip(RectangleShape), + ) - GameCover( - title = null, - imageUrl = headerInfo.coverImageUrl, - modifier = Modifier.layoutId(ConstraintIdCover), - onCoverClicked = if (headerInfo.hasCoverImageUrl) onCoverClicked else null, - ) + Spacer( + Modifier + .layoutId(ConstraintIdCoverSpace) + .height(CoverSpace), + ) - // 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 -> - LikeButton0(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), - // 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 - }, - ) + GameCover( + title = null, + imageUrl = headerInfo.coverImageUrl, + modifier = Modifier + .layoutId(ConstraintIdCover) + .drawOnTop(), + onCoverClicked = if (headerInfo.hasCoverImageUrl) onCoverClicked else null, + ) - Text( - text = headerInfo.title, - modifier = Modifier.layoutId(ConstraintIdFirstTitle), - color = GamedgeTheme.colors.onPrimary, - maxLines = 1, - onTextLayout = { textLayoutResult -> - if (textLayoutResult.hasVisualOverflow) { - val firstTitleWidth = textLayoutResult.size.width.toFloat() - val firstTitleOffset = Offset(firstTitleWidth, 0f) - val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 - - secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) - } - }, - style = GamedgeTheme.typography.h6, - ) + // 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()) + // Disabling the ripple because it cripples the animation a bit + rippleColor = colors.secondary.toArgb() + // Disabling the shadow to avoid it being clipped when animating to collapsed state + // (especially can be seen on the light theme) + compatElevation = 0f + onClick { onLikeButtonClicked() } + } + }, + modifier = Modifier + .layoutId(ConstraintIdLikeButton) + .drawOnTop(), + update = { view -> + view.isLiked = headerInfo.isLiked + }, + ) - Box(modifier = Modifier.layoutId(ConstraintIdSecondTitle)) { - if (isSecondTitleVisible) { - Text( - text = secondTitleText, - color = GamedgeTheme.colors.onPrimary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = GamedgeTheme.typography.h6, - ) - } - } + Text( + text = headerInfo.title, + modifier = Modifier + .layoutId(ConstraintIdFirstTitle) + .drawOnTop(), + // When restoring state, customColor function returns invalid color (black color + // when in collapsed state), so a little fix here to set the correct color + color = if (isInCollapsedState) { + ScrimContentColor + } else { + customColor(ConstraintIdFirstTitle, CustomAttributeTextColor) + }, + overflow = firstTitleOverflowMode, + maxLines = 1, + onTextLayout = { textLayoutResult -> + if (textLayoutResult.hasVisualOverflow && secondTitleText.isEmpty()) { + val firstTitleWidth = textLayoutResult.size.width.toFloat() + val firstTitleOffset = Offset(firstTitleWidth, 0f) + val firstTitleVisibleTextEndIndex = textLayoutResult.getOffsetForPosition(firstTitleOffset) + 1 - Text( - text = headerInfo.releaseDate, - modifier = Modifier.layoutId(ConstraintIdReleaseDate), - color = GamedgeTheme.colors.onSurface, - style = GamedgeTheme.typography.subtitle3, - ) + if (firstTitleVisibleTextEndIndex in headerInfo.title.indices) { + secondTitleText = headerInfo.title.substring(firstTitleVisibleTextEndIndex) + } + } + }, + style = GamedgeTheme.typography.h6, + ) + + Text( + text = secondTitleText, + modifier = Modifier + .layoutId(ConstraintIdSecondTitle) + .drawOnTop(), + color = GamedgeTheme.colors.onPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = GamedgeTheme.typography.h6, + ) + + Text( + text = headerInfo.releaseDate, + modifier = Modifier + .layoutId(ConstraintIdReleaseDate) + .drawOnTop(), + color = GamedgeTheme.colors.onSurface, + style = GamedgeTheme.typography.subtitle3, + ) - Box(modifier = Modifier.layoutId(ConstraintIdDeveloperName)) { if (headerInfo.hasDeveloperName) { Text( text = checkNotNull(headerInfo.developerName), + modifier = Modifier + .layoutId(ConstraintIdDeveloperName) + .drawOnTop(), color = GamedgeTheme.colors.onSurface, style = GamedgeTheme.typography.subtitle3, ) } + + InfoItem( + iconId = CoreR.drawable.star_circle_outline, + title = headerInfo.rating, + modifier = Modifier + .layoutId(ConstraintIdRating) + // Grabbing the height of any info item here to calculate the min header height + .onGloballyPositioned { coordinates -> + if (minHeaderHeightInPx == HeightUnspecified) { + minHeaderHeightInPx = with(density) { + artworksHeightInCollapsedState.roundToPx() + coordinates.size.height.toFloat() + } + } + }, + ) + InfoItem( + iconId = CoreR.drawable.account_heart_outline, + title = headerInfo.likeCount, + modifier = Modifier.layoutId(ConstraintIdLikeCount), + ) + InfoItem( + iconId = CoreR.drawable.age_rating_outline, + title = headerInfo.ageRating, + modifier = Modifier.layoutId(ConstraintIdAgeRating), + ) + InfoItem( + iconId = CoreR.drawable.shape_outline, + title = headerInfo.gameCategory, + modifier = Modifier.layoutId(ConstraintIdGameCategory), + ) } - Info( - icon = painterResource(CoreR.drawable.star_circle_outline), - title = headerInfo.rating, - modifier = Modifier.layoutId(ConstraintIdRating), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) - Info( - icon = painterResource(CoreR.drawable.account_heart_outline), - title = headerInfo.likeCount, - modifier = Modifier.layoutId(ConstraintIdLikeCount), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) - Info( - icon = painterResource(CoreR.drawable.age_rating_outline), - title = headerInfo.ageRating, - modifier = Modifier.layoutId(ConstraintIdAgeRating), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) - Info( - icon = painterResource(CoreR.drawable.shape_outline), - title = headerInfo.gameCategory, - modifier = Modifier.layoutId(ConstraintIdGameCategory), - iconSize = InfoIconSize, - titleTextStyle = GamedgeTheme.typography.caption, - ) + content(Modifier.nestedScroll(nestedConnection)) } } -private class LikeButton0(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) +@Composable +private fun rememberMotionScene( + hasDefaultPlaceholderArtwork: Boolean, + isSecondTitleVisible: Boolean, + artworksHeightInCollapsedState: Dp, +): MotionScene { + val spaces = GamedgeTheme.spaces + val statusBarHeight = calculateStatusBarHeightInDp() + val firstTitleColorInExpandedState = GamedgeTheme.colors.onPrimary + val firstTitleColorInCollapsedState = ScrimContentColor - override fun onAttachedToWindow() { - super.onAttachedToWindow() + return remember( + hasDefaultPlaceholderArtwork, + isSecondTitleVisible, + spaces, + artworksHeightInCollapsedState, + statusBarHeight, + firstTitleColorInExpandedState, + ) { + MotionScene { + val refs = ConstraintLayoutRefs(this) - // 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 + addConstraintSet( + constraintSet = constructExpandedConstraintSet( + refs = refs, + spaces = spaces, + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, + firstTitleTextColor = firstTitleColorInExpandedState, + ), + name = ConstraintSetNameExpanded, + ) + addConstraintSet( + constraintSet = constructCollapsedConstraintSet( + refs = refs, + spaces = spaces, + hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, + isSecondTitleVisible = isSecondTitleVisible, + artworksHeight = artworksHeightInCollapsedState, + statusBarHeight = statusBarHeight, + firstTitleTextColor = firstTitleColorInCollapsedState, + ), + name = ConstraintSetNameCollapsed, + ) + addTransition( + transition = constructTransition( + refs = refs, + isSecondTitleVisible = isSecondTitleVisible, + firstTitleColorInExpandedState = firstTitleColorInExpandedState, + firstTitleColorInCollapsedState = firstTitleColorInCollapsedState, + ), + name = TransitionName, + ) } } } -@Composable -private fun constructExpandedConstraintSet(): ConstraintSet { - val artworksHeight = 240.dp - val pageIndicatorMargin = GamedgeTheme.spaces.spacing_2_5 +private class ConstraintLayoutRefs( + val artworks: ConstrainedLayoutReference, + val artworksScrim: ConstrainedLayoutReference, + val backButton: ConstrainedLayoutReference, + val pageIndicator: ConstrainedLayoutReference, + val backdrop: ConstrainedLayoutReference, + val coverSpace: ConstrainedLayoutReference, + val cover: ConstrainedLayoutReference, + val likeButton: ConstrainedLayoutReference, + val firstTitle: ConstrainedLayoutReference, + val secondTitle: ConstrainedLayoutReference, + val releaseDate: ConstrainedLayoutReference, + val developerName: ConstrainedLayoutReference, + val rating: ConstrainedLayoutReference, + val likeCount: ConstrainedLayoutReference, + val ageRating: ConstrainedLayoutReference, + val gameCategory: ConstrainedLayoutReference, +) { + constructor(motionSceneScope: MotionSceneScope): this( + artworks = motionSceneScope.createRefFor(ConstraintIdArtworks), + artworksScrim = motionSceneScope.createRefFor(ConstraintIdArtworksScrim), + backButton = motionSceneScope.createRefFor(ConstraintIdBackButton), + pageIndicator = motionSceneScope.createRefFor(ConstraintIdPageIndicator), + backdrop = motionSceneScope.createRefFor(ConstraintIdBackdrop), + coverSpace = motionSceneScope.createRefFor(ConstraintIdCoverSpace), + cover = motionSceneScope.createRefFor(ConstraintIdCover), + likeButton = motionSceneScope.createRefFor(ConstraintIdLikeButton), + firstTitle = motionSceneScope.createRefFor(ConstraintIdFirstTitle), + secondTitle = motionSceneScope.createRefFor(ConstraintIdSecondTitle), + releaseDate = motionSceneScope.createRefFor(ConstraintIdReleaseDate), + developerName = motionSceneScope.createRefFor(ConstraintIdDeveloperName), + rating = motionSceneScope.createRefFor(ConstraintIdRating), + likeCount = motionSceneScope.createRefFor(ConstraintIdLikeCount), + ageRating = motionSceneScope.createRefFor(ConstraintIdAgeRating), + gameCategory = motionSceneScope.createRefFor(ConstraintIdGameCategory), + ) +} + +private fun MotionSceneScope.constructExpandedConstraintSet( + refs: ConstraintLayoutRefs, + spaces: Spaces, + hasDefaultPlaceholderArtwork: Boolean, + isSecondTitleVisible: Boolean, + firstTitleTextColor: Color, +): ConstraintSet { + val pageIndicatorMargin = spaces.spacing_2_5 + val backdropElevation = spaces.spacing_0_5 val coverSpaceMargin = CoverSpace - val coverMarginStart = GamedgeTheme.spaces.spacing_3_5 - val likeBtnMarginEnd = GamedgeTheme.spaces.spacing_2_5 - val titleMarginStart = GamedgeTheme.spaces.spacing_3_5 - val firstTitleMarginTop = titleMarginStart - val firstTitleMarginEnd = GamedgeTheme.spaces.spacing_1_0 - val secondTitleMarginEnd = GamedgeTheme.spaces.spacing_3_5 - val releaseDateMarginTop = GamedgeTheme.spaces.spacing_2_5 - val releaseDateMarginHorizontal = GamedgeTheme.spaces.spacing_3_5 - val developerNameMarginHorizontal = GamedgeTheme.spaces.spacing_3_5 - val bottomBarrierMargin = GamedgeTheme.spaces.spacing_5_0 - val infoItemMarginBottom = GamedgeTheme.spaces.spacing_3_5 + val coverMarginStart = spaces.spacing_3_5 + val likeBtnMarginEnd = spaces.spacing_2_5 + val textHorizontalMargin = spaces.spacing_3_5 + val firstTitleMarginTop = textHorizontalMargin + val firstTitleMarginEnd = spaces.spacing_1_0 + val releaseDateMarginTop = spaces.spacing_2_5 + val bottomBarrierMargin = spaces.spacing_1_5 return ConstraintSet { - val artworks = createRefFor(ConstraintIdArtworks) - val artworksScrim = createRefFor(ConstraintIdArtworksScrim) - val backButton = createRefFor(ConstraintIdBackButton) - val pageIndicator = createRefFor(ConstraintIdPageIndicator) - val backdrop = createRefFor(ConstraintIdBackdrop) - val coverSpace = createRefFor(ConstraintIdCoverSpace) - val cover = createRefFor(ConstraintIdCover) - val likeButton = createRefFor(ConstraintIdLikeButton) - val firstTitle = createRefFor(ConstraintIdFirstTitle) - val secondTitle = createRefFor(ConstraintIdSecondTitle) - val releaseDate = createRefFor(ConstraintIdReleaseDate) - val developerName = createRefFor(ConstraintIdDeveloperName) - val bottomBarrier = createBottomBarrier(cover, developerName, margin = bottomBarrierMargin) - val rating = createRefFor(ConstraintIdRating) - val likeCount = createRefFor(ConstraintIdLikeCount) - val ageRating = createRefFor(ConstraintIdAgeRating) - val gameCategory = createRefFor(ConstraintIdGameCategory) - - constrain(artworks) { + val bottomBarrier = createBottomBarrier( + refs.cover, + refs.developerName, + margin = bottomBarrierMargin, + ) + + constrain(refs.artworks) { width = Dimension.fillToConstraints - height = Dimension.value(artworksHeight) + height = Dimension.value(ArtworksHeightExpanded) top.linkTo(parent.top) centerHorizontallyTo(parent) } - constrain(artworksScrim) { + constrain(refs.artworksScrim) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - centerVerticallyTo(artworks) - centerHorizontallyTo(artworks) + centerVerticallyTo(refs.artworks) + centerHorizontallyTo(refs.artworks) + visibility = if (hasDefaultPlaceholderArtwork) { + Visibility.Gone + } else { + Visibility.Invisible + } } - constrain(backButton) { + constrain(refs.backButton) { top.linkTo(parent.top) start.linkTo(parent.start) } - constrain(pageIndicator) { + constrain(refs.pageIndicator) { top.linkTo(parent.top, pageIndicatorMargin) end.linkTo(parent.end, pageIndicatorMargin) } - constrain(backdrop) { + constrain(refs.backdrop) { width = Dimension.fillToConstraints height = Dimension.fillToConstraints - top.linkTo(artworks.bottom) - bottom.linkTo(parent.bottom) + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.rating.bottom) centerHorizontallyTo(parent) + translationZ = backdropElevation } - constrain(coverSpace) { + constrain(refs.coverSpace) { start.linkTo(parent.start) - bottom.linkTo(artworks.bottom, coverSpaceMargin) + bottom.linkTo(refs.artworks.bottom, coverSpaceMargin) } - constrain(cover) { - top.linkTo(coverSpace.bottom) + constrain(refs.cover) { + top.linkTo(refs.coverSpace.bottom) start.linkTo(parent.start, coverMarginStart) } - constrain(likeButton) { - top.linkTo(artworks.bottom) - bottom.linkTo(artworks.bottom) + constrain(refs.likeButton) { + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.artworks.bottom) end.linkTo(parent.end, likeBtnMarginEnd) } - constrain(firstTitle) { + constrain(refs.firstTitle) { width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, firstTitleMarginTop) - start.linkTo(cover.end, titleMarginStart) - end.linkTo(likeButton.start, firstTitleMarginEnd) + top.linkTo(refs.artworks.bottom, firstTitleMarginTop) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(refs.likeButton.start, firstTitleMarginEnd) + setScale(FirstTitleScaleExpanded) + customColor(CustomAttributeTextColor, firstTitleTextColor) } - constrain(secondTitle) { + constrain(refs.secondTitle) { width = Dimension.fillToConstraints - top.linkTo(firstTitle.bottom) - start.linkTo(cover.end, titleMarginStart) - end.linkTo(parent.end, secondTitleMarginEnd) + top.linkTo(refs.firstTitle.bottom) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(parent.end, textHorizontalMargin) + isVisible = isSecondTitleVisible } - constrain(releaseDate) { + constrain(refs.releaseDate) { width = Dimension.fillToConstraints - top.linkTo(secondTitle.bottom, releaseDateMarginTop) - start.linkTo(cover.end, releaseDateMarginHorizontal) - end.linkTo(parent.end, releaseDateMarginHorizontal) + top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(parent.end, textHorizontalMargin) } - constrain(developerName) { + constrain(refs.developerName) { width = Dimension.fillToConstraints - top.linkTo(releaseDate.bottom) - start.linkTo(cover.end, developerNameMarginHorizontal) - end.linkTo(parent.end, developerNameMarginHorizontal) + top.linkTo(refs.releaseDate.bottom) + start.linkTo(refs.cover.end, textHorizontalMargin) + end.linkTo(parent.end, textHorizontalMargin) } - constrain(rating) { + constrain(refs.rating) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(parent.bottom, infoItemMarginBottom) - linkTo(start = parent.start, end = likeCount.start, bias = 0.25f) + linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) } - constrain(likeCount) { + constrain(refs.likeCount) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(parent.bottom, infoItemMarginBottom) - linkTo(start = rating.end, end = ageRating.start, bias = 0.25f) + linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) } - constrain(ageRating) { + constrain(refs.ageRating) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(parent.bottom, infoItemMarginBottom) - linkTo(start = likeCount.end, end = gameCategory.start, bias = 0.25f) + linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) } - constrain(gameCategory) { + constrain(refs.gameCategory) { width = Dimension.fillToConstraints top.linkTo(bottomBarrier) - bottom.linkTo(parent.bottom, infoItemMarginBottom) - linkTo(start = ageRating.end, end = parent.end, bias = 0.25f) + linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) + } + } +} + +private fun MotionSceneScope.constructCollapsedConstraintSet( + refs: ConstraintLayoutRefs, + spaces: Spaces, + hasDefaultPlaceholderArtwork: Boolean, + isSecondTitleVisible: Boolean, + artworksHeight: Dp, + statusBarHeight: Dp, + firstTitleTextColor: Color, +): ConstraintSet { + val pageIndicatorMargin = spaces.spacing_2_5 + val backdropElevation = spaces.spacing_1_0 + val coverSpaceMargin = CoverSpace + val coverMarginStart = spaces.spacing_3_5 + val likeBtnMarginEnd = spaces.spacing_2_5 + val textHorizontalMargin = spaces.spacing_3_5 + val firstTitleMarginStart = spaces.spacing_7_5 + val firstTitleMarginEnd = spaces.spacing_6_0 + val releaseDateMarginTop = spaces.spacing_2_5 + + return ConstraintSet { + constrain(refs.artworks) { + width = Dimension.fillToConstraints + height = Dimension.value(artworksHeight) + top.linkTo(parent.top) + bottom.linkTo(refs.backdrop.top) + centerHorizontallyTo(parent) + } + constrain(refs.artworksScrim) { + width = Dimension.fillToConstraints + height = Dimension.fillToConstraints + centerVerticallyTo(refs.artworks) + centerHorizontallyTo(refs.artworks) + visibility = if (hasDefaultPlaceholderArtwork) { + Visibility.Gone + } else { + Visibility.Visible + } + } + constrain(refs.backButton) { + top.linkTo(parent.top) + start.linkTo(parent.start) + } + constrain(refs.pageIndicator) { + top.linkTo(parent.top, pageIndicatorMargin) + end.linkTo(parent.end, pageIndicatorMargin) + translationX = PageIndicatorDeltaXCollapsed + } + constrain(refs.backdrop) { + width = Dimension.fillToConstraints + height = Dimension.fillToConstraints + centerVerticallyTo(refs.rating) + centerHorizontallyTo(parent) + translationZ = backdropElevation + } + constrain(refs.coverSpace) { + start.linkTo(parent.start) + bottom.linkTo(refs.artworks.bottom, coverSpaceMargin) + } + constrain(refs.cover) { + top.linkTo(refs.coverSpace.bottom) + start.linkTo(parent.start, coverMarginStart) + translationX = CoverDeltaXCollapsed + translationY = CoverDeltaYCollapsed + // We need to set it to Gone to avoid the cover taking up the vertical space, + // which increases the size of the header in collapsed state + visibility = Visibility.Gone + } + constrain(refs.likeButton) { + top.linkTo(refs.artworks.bottom) + bottom.linkTo(refs.artworks.bottom) + end.linkTo(parent.end, likeBtnMarginEnd) + alpha = 0f + setScale(LikeButtonScaleCollapsed) + } + constrain(refs.firstTitle) { + width = Dimension.fillToConstraints + top.linkTo(refs.artworks.top, statusBarHeight) + bottom.linkTo(refs.artworks.bottom) + start.linkTo(refs.backButton.end, firstTitleMarginStart) + end.linkTo(parent.end, firstTitleMarginEnd) + setScale(FirstTitleScaleCollapsed) + customColor(CustomAttributeTextColor, firstTitleTextColor) + } + constrain(refs.secondTitle) { + width = Dimension.fillToConstraints + top.linkTo(refs.firstTitle.bottom) + start.linkTo(refs.firstTitle.start) + end.linkTo(parent.end, textHorizontalMargin) + isVisible = isSecondTitleVisible + alpha = 0f + } + constrain(refs.releaseDate) { + width = Dimension.fillToConstraints + top.linkTo(refs.secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) + start.linkTo(refs.firstTitle.start) + end.linkTo(parent.end, textHorizontalMargin) + alpha = 0f + } + constrain(refs.developerName) { + width = Dimension.fillToConstraints + top.linkTo(refs.releaseDate.bottom) + start.linkTo(refs.firstTitle.start) + end.linkTo(parent.end, textHorizontalMargin) + alpha = 0f + } + constrain(refs.rating) { + width = Dimension.fillToConstraints + top.linkTo(refs.artworks.bottom) + linkTo(start = parent.start, end = refs.likeCount.start, bias = 0.25f) + } + constrain(refs.likeCount) { + width = Dimension.fillToConstraints + top.linkTo(refs.artworks.bottom) + linkTo(start = refs.rating.end, end = refs.ageRating.start, bias = 0.25f) + } + constrain(refs.ageRating) { + width = Dimension.fillToConstraints + top.linkTo(refs.artworks.bottom) + linkTo(start = refs.likeCount.end, end = refs.gameCategory.start, bias = 0.25f) + } + constrain(refs.gameCategory) { + width = Dimension.fillToConstraints + top.linkTo(refs.artworks.bottom) + linkTo(start = refs.ageRating.end, end = parent.end, bias = 0.25f) } } } -@PreviewLightDark +private fun MotionSceneScope.constructTransition( + refs: ConstraintLayoutRefs, + isSecondTitleVisible: Boolean, + firstTitleColorInExpandedState: Color, + firstTitleColorInCollapsedState: Color, +): Transition { + return Transition(from = ConstraintSetNameExpanded, to = ConstraintSetNameCollapsed) { + // Don't scale the first title until the secondary texts (second title, + // release date and developer name) is gone + keyAttributes(refs.firstTitle) { + frame(frame = 15) { + setScale(FirstTitleScaleExpanded) + } + } + keyAttributes(refs.secondTitle) { + frame(frame = 15) { + alpha = 0f + } + } + keyAttributes(refs.releaseDate) { + frame(frame = 15) { + alpha = 0f + } + } + keyAttributes(refs.developerName) { + frame(frame = 15) { + alpha = 0f + } + } + keyAttributes(refs.cover) { + frame(frame = 50) { + alpha = 0f + translationX = CoverDeltaXCollapsed + translationY = CoverDeltaYCollapsed + } + } + keyPositions(refs.cover) { + frame(frame = 50) { + setSizePercentage(0f) + } + } + keyAttributes(refs.firstTitle) { + frame(frame = 40) { + customColor(CustomAttributeTextColor, firstTitleColorInExpandedState) + } + frame(frame = 60) { + customColor(CustomAttributeTextColor, firstTitleColorInCollapsedState) + } + } + + if (isSecondTitleVisible) { + // To prevent the first title overlapping with the like button + keyPositions(refs.firstTitle) { + frame(frame = 60) { + percentWidth = 0.5f + } + } + } + + keyAttributes(refs.likeButton) { + frame(frame = 60) { + alpha = 0f + setScale(LikeButtonScaleCollapsed) + } + } + keyAttributes(refs.pageIndicator) { + frame(frame = 80) { + translationX = PageIndicatorDeltaXCollapsed + } + } + } +} + +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) { + // Just calling setImageState() directly doesn't work, so we need + // to postpone it just a bit. + postAction { + setImageState(intArrayOf(if (value) STATE_CHECKED_ON else STATE_CHECKED_OFF), true) + } + } + get() = drawableState.contains(STATE_CHECKED_ON) +} + @Composable -private fun GameInfoHeaderPreview() { - GamedgeTheme { - GameInfoHeader( - headerInfo = GameInfoHeaderUiModel( - artworks = listOf(GameInfoArtworkUiModel.DefaultImage), - isLiked = true, - coverImageUrl = null, - title = "Elden Ring", - releaseDate = "Feb 25, 2022 (in a month)", - developerName = "FromSoftware", - rating = "N/A", - likeCount = "92", - ageRating = "N/A", - gameCategory = "Main", - ), - onArtworkClicked = {}, - onBackButtonClicked = {}, - onCoverClicked = {}, - onLikeButtonClicked = {}, - ) +private fun InfoItem( + @DrawableRes iconId: Int, + title: String, + modifier: Modifier, +) { + Info( + icon = painterResource(iconId), + title = title, + modifier = modifier + .padding(vertical = GamedgeTheme.spaces.spacing_3_5) + .drawOnTop(), + iconSize = InfoIconSize, + titleTextStyle = GamedgeTheme.typography.caption, + ) +} + +private var ConstrainScope.isVisible: Boolean + set(isVisible) { + visibility = if (isVisible) Visibility.Visible else Visibility.Gone } + get() = visibility == Visibility.Visible + +private fun ConstrainScope.setScale(scale: Float) { + scaleX = scale + scaleY = scale +} + +private fun KeyAttributeScope.setScale(scale: Float) { + scaleX = scale + scaleY = scale +} + +private fun KeyPositionScope.setSizePercentage(percentage: Float) { + percentWidth = percentage + percentHeight = percentage +} + +private fun Modifier.drawOnTop(): Modifier { + return zIndex(Float.MAX_VALUE) +} + +/** + * Calculates a duration for the auto transition in the following way: + * - for progress that is zero, the duration is minimal (0f -> min) + * - for progress that is half way, the duration is maximal (0.5f -> max) + * - for progress that is one, the duration is minimal (1f -> min) + **/ +private fun calculateAutoTransitionDuration(progress: Float): Int { + val minDuration = AutoTransitionAnimationDurationMin + val maxDuration = AutoTransitionAnimationDurationMax + + return (minDuration + (maxDuration - minDuration) * 4 * progress * (1 - progress)).toInt() +} + +private fun calculateHeaderHeightGivenProgress( + progress: Float, + minHeaderHeight: Float, + maxHeaderHeight: Float, +): Float { + return minHeaderHeight + (1 - progress) * (maxHeaderHeight - minHeaderHeight) +} + +private fun calculateProgressGivenHeaderHeight( + headerHeight: Float, + minHeaderHeight: Float, + maxHeaderHeight: Float, +): Float { + return 1 - (headerHeight - minHeaderHeight) / (maxHeaderHeight - minHeaderHeight) +} + +@Composable +private fun calculateArtworksHeightInCollapsedState(): Dp { + return ArtworksHeightCollapsed + calculateStatusBarHeightInDp() +} + +@Composable +private fun calculateStatusBarHeightInDp(): Dp { + val density = LocalDensity.current + + return with(density) { WindowInsets.statusBars.getTop(density).toDp() } } From fd27ab8be4334bf78791967d5e62caf1d66158ff Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 16:44:16 +0300 Subject: [PATCH 08/12] Fix like button theming issue --- app/src/main/res/values-night/themes.xml | 2 +- app/src/main/res/values/themes.xml | 2 +- .../presentation/widgets/header/GameInfoHeader.kt | 11 ++++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index fde3c6cc..19d9efb2 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 2bf4dc26..b5081e6d 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/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 46f0fc92..92314525 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 @@ -21,6 +21,7 @@ package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header import android.content.Context import android.content.res.ColorStateList import androidx.annotation.DrawableRes +import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.tween @@ -982,7 +983,15 @@ private fun MotionSceneScope.constructTransition( } } -private class LikeButton(context: Context) : FloatingActionButton(context) { +private class LikeButton(context: Context) : FloatingActionButton( + // Have to wrap the context in the MaterialComponents theme, because otherwise + // the view is going to crash on initialization requesting to be wrapped in either + // Theme.AppCompat or Theme.MaterialComponents. + ContextThemeWrapper( + context, + com.google.android.material.R.style.Theme_MaterialComponents, + ), +) { private companion object { const val STATE_CHECKED = android.R.attr.state_checked From 967bf614eb38b4c8998a490c17ea24e986ef9a74 Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 16:53:30 +0300 Subject: [PATCH 09/12] Add preview --- .../widgets/header/GameInfoHeader.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 92314525..56f0bcea 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 @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon @@ -71,6 +72,7 @@ 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.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -1092,3 +1094,30 @@ private fun calculateStatusBarHeightInDp(): Dp { return with(density) { WindowInsets.statusBars.getTop(density).toDp() } } + +@PreviewLightDark +@Composable +private fun GameInfoHeaderPreview() { + GamedgeTheme { + GameInfoHeader( + headerInfo = GameInfoHeaderUiModel( + artworks = listOf(GameInfoArtworkUiModel.DefaultImage), + isLiked = true, + coverImageUrl = null, + title = "God of War Ragnarok", + releaseDate = "Nov 09, 2022 (a year ago)", + developerName = "SIE Santa Monica Studio", + rating = "93%", + likeCount = "77", + ageRating = "PEGI-18", + gameCategory = "Main", + ), + listState = rememberLazyListState(), + onArtworkClicked = {}, + onBackButtonClicked = {}, + onCoverClicked = {}, + onLikeButtonClicked = {}, + content = {}, + ) + } +} From fc3d687e323fc52f6d89149d850e6353ab1caaed Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 17:47:57 +0300 Subject: [PATCH 10/12] Accentuate header banner scaling effect more --- .../info/presentation/widgets/header/GameInfoHeader.kt | 2 +- .../info/presentation/widgets/header/artworks/Artworks.kt | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) 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 56f0bcea..0d6a62b3 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 @@ -146,7 +146,7 @@ private const val FirstTitleScaleExpanded = 1f private const val FirstTitleScaleCollapsed = 1.1f private const val LikeButtonScaleCollapsed = 0f -private val ArtworksHeightExpanded = 240.dp +private val ArtworksHeightExpanded = 246.dp private val ArtworksHeightCollapsed = 56.dp private val PageIndicatorDeltaXCollapsed = 60.dp diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/artworks/Artworks.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/artworks/Artworks.kt index 2ef5465a..9222f35e 100644 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/artworks/Artworks.kt +++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/artworks/Artworks.kt @@ -92,9 +92,7 @@ private fun Artwork( private fun ArtworksPreview() { GamedgeTheme { Artworks( - artworks = listOf( - GameInfoArtworkUiModel.DefaultImage, - ), + artworks = listOf(GameInfoArtworkUiModel.DefaultImage), isScrollingEnabled = true, modifier = Modifier, onArtworkChanged = {}, From f0733c580f403cbf621bc518e06654d874b87a85 Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 18:41:00 +0300 Subject: [PATCH 11/12] Fix end margin bug for first title when collapsed --- .../gamedge/common/ui/theme/Spaces.kt | 14 +++++++++++++- .../presentation/widgets/header/GameInfoHeader.kt | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt b/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt index c1c5a70a..56da3ad8 100644 --- a/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt +++ b/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt @@ -42,6 +42,9 @@ class Spaces( spacing_6_5: Dp = 26.dp, spacing_7_0: Dp = 28.dp, spacing_7_5: Dp = 30.dp, + spacing_8_0: Dp = 32.dp, + spacing_8_5: Dp = 34.dp, + spacing_9_0: Dp = 36.dp, ) { var spacing_0_5 by mutableStateOf(spacing_0_5) private set @@ -73,6 +76,12 @@ class Spaces( private set var spacing_7_5 by mutableStateOf(spacing_7_5) private set + var spacing_8_0 by mutableStateOf(spacing_8_0) + private set + var spacing_8_5 by mutableStateOf(spacing_8_5) + private set + var spacing_9_0 by mutableStateOf(spacing_9_0) + private set override fun toString(): String { return "Spaces(" + @@ -90,7 +99,10 @@ class Spaces( "spacing_6_0=$spacing_6_0, " + "spacing_6_5=$spacing_6_5, " + "spacing_7_0=$spacing_7_0, " + - "spacing_7_5=$spacing_7_5" + + "spacing_7_5=$spacing_7_5, " + + "spacing_8_0=$spacing_8_0, " + + "spacing_8_5=$spacing_8_5, " + + "spacing_9_0=$spacing_9_0" + ")" } } 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 0d6a62b3..5fb1f452 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 @@ -800,7 +800,8 @@ private fun MotionSceneScope.constructCollapsedConstraintSet( val likeBtnMarginEnd = spaces.spacing_2_5 val textHorizontalMargin = spaces.spacing_3_5 val firstTitleMarginStart = spaces.spacing_7_5 - val firstTitleMarginEnd = spaces.spacing_6_0 + // Have to set a bigger end margin because of the scaling applied + val firstTitleMarginEnd = spaces.spacing_9_0 val releaseDateMarginTop = spaces.spacing_2_5 return ConstraintSet { From 200fbe3b90fd1ed4c3021ad8fb38b2b4d4ae13ce Mon Sep 17 00:00:00 2001 From: mars885 Date: Wed, 23 Oct 2024 18:52:44 +0300 Subject: [PATCH 12/12] Fix linters --- .../presentation/widgets/header/GameInfoHeader.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 5fb1f452..05c3d544 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 @@ -14,7 +14,7 @@ * limitations under the License. */ -@file:Suppress("LongMethod") +@file:Suppress("LongMethod", "MagicNumber", "LongParameterList") package com.paulrybitskyi.gamedge.feature.info.presentation.widgets.header @@ -155,7 +155,8 @@ private val CoverDeltaYCollapsed = (-40).dp private enum class State(val progress: Float) { Expanded(progress = 0f), - Collapsed(progress = 1f); + Collapsed(progress = 1f), + ; companion object { @@ -171,6 +172,7 @@ private val AnimatableSaver = Saver( ) @Composable +@Suppress("CyclomaticComplexMethod") internal fun GameInfoHeader( headerInfo: GameInfoHeaderUiModel, listState: LazyListState, @@ -262,7 +264,7 @@ internal fun GameInfoHeader( minHeaderHeight = minHeaderHeightInPx, maxHeaderHeight = maxHeaderHeightInPx, ) - } + }, ) } } @@ -352,7 +354,7 @@ internal fun GameInfoHeader( headerInfo }, ) - } + }, ) { Artworks( artworks = artworks, @@ -643,7 +645,7 @@ private class ConstraintLayoutRefs( val ageRating: ConstrainedLayoutReference, val gameCategory: ConstrainedLayoutReference, ) { - constructor(motionSceneScope: MotionSceneScope): this( + constructor(motionSceneScope: MotionSceneScope) : this( artworks = motionSceneScope.createRefFor(ConstraintIdArtworks), artworksScrim = motionSceneScope.createRefFor(ConstraintIdArtworksScrim), backButton = motionSceneScope.createRefFor(ConstraintIdBackButton),