From 0a582d4e8699bd931fbe269aeeccff6fe69b2697 Mon Sep 17 00:00:00 2001 From: Paul Rybitskyi Date: Wed, 23 Oct 2024 19:18:23 +0300 Subject: [PATCH] Make game's info header animatable using Compose's MotionLayout (#245) --- app/src/main/res/values-night/themes.xml | 2 +- app/src/main/res/values/themes.xml | 2 +- .../gamedge/common/ui/theme/Spaces.kt | 14 +- .../info/presentation/GameInfoScreen.kt | 159 ++- .../header/GameInfoAnimatableHeader.kt | 1152 ---------------- .../widgets/header/GameInfoHeader.kt | 1191 +++++++++++++---- .../widgets/header/artworks/Artworks.kt | 4 +- 7 files changed, 1006 insertions(+), 1518 deletions(-) delete mode 100644 feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index fde3c6cc6..19d9efb2c 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 2bf4dc265..b5081e6de 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -20,6 +20,6 @@ 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 c1c5a70ab..56da3ad84 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/GameInfoScreen.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt index 08278a830..f9c6edd85 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 @@ -32,6 +34,7 @@ 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 @@ -39,6 +42,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 @@ -233,89 +237,83 @@ 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, - ) - } + val listState = rememberLazyListState() + + GameInfoHeader( + headerInfo = gameInfo.headerModel, + listState = listState, + onArtworkClicked = onArtworkClicked, + onBackButtonClicked = onBackButtonClicked, + onCoverClicked = onCoverClicked, + onLikeButtonClicked = onLikeButtonClicked, + ) { modifier -> + val layoutDirection = LocalLayoutDirection.current + val spacing = GamedgeTheme.spaces.spacing_3_5 + + LazyColumn( + modifier = modifier, + state = listState, + 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, + ) + } } } } -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, @@ -418,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 a5f51723e..000000000 --- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoAnimatableHeader.kt +++ /dev/null @@ -1,1152 +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.annotation.SuppressLint -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 -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -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.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 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -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.layout.layoutId -import androidx.compose.ui.platform.LocalContext -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.zIndex -import androidx.constraintlayout.compose.ConstrainScope -import androidx.constraintlayout.compose.ConstraintSet -import androidx.constraintlayout.compose.Dimension -import androidx.constraintlayout.compose.MotionLayout -import androidx.constraintlayout.compose.Transition -import androidx.constraintlayout.compose.Visibility -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.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 org.intellij.lang.annotations.Language -import com.paulrybitskyi.gamedge.core.R as CoreR - -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 ConstraintIdList = "list" - -private val CoverSpace = 40.dp -private val InfoIconSize = 34.dp - -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 = -60.dp -private val SecondaryTextDeltaXCollapsed = -8.dp - -private enum class State { - Expanded, - Collapsed, -} - -// 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, - onArtworkClicked: (artworkIndex: Int) -> Unit, - onBackButtonClicked: () -> Unit, - onCoverClicked: () -> Unit, - onLikeButtonClicked: () -> Unit, - content: @Composable (Modifier) -> 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 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 < 0.01f - } - } - val firstTitleOverflowMode by remember { - derivedStateOf { - if (progress < 0.95f) { - TextOverflow.Clip - } else { - TextOverflow.Ellipsis - } - } - } - - MotionLayout( - // motionScene = MotionScene(constructJson()), - start = constructExpandedConstraintSet( - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - isSecondTitleVisible = isSecondTitleVisible, - ), - end = constructCollapsedConstraintSet( - hasDefaultPlaceholderArtwork = hasDefaultPlaceholderArtwork, - ), - transition = Transition(constructTransition()), - modifier = Modifier.fillMaxSize(), - progress = progress, - ) { - 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 = GamedgeTheme.colors.onPrimary, - ) - - 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 = GamedgeTheme.colors.onPrimary, - 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, - ) - - FloatingActionButton( - 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, - ) - } - - Text( - text = headerInfo.title, - modifier = Modifier - .layoutId(ConstraintIdFirstTitle) - .drawOnTop(), - color = GamedgeTheme.colors.onPrimary, - overflow = firstTitleOverflowMode, - 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, - ) - - Box( - modifier = Modifier - .layoutId(ConstraintIdSecondTitle) - .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, - 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) - .drawOnTop(), - ) { - if (headerInfo.hasDeveloperName) { - Text( - text = checkNotNull(headerInfo.developerName), - color = GamedgeTheme.colors.onSurface, - style = GamedgeTheme.typography.subtitle3, - ) - } - } - - Info( - icon = painterResource(CoreR.drawable.star_circle_outline), - 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 - } - }, - 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, - ) - - content(Modifier.layoutId(ConstraintIdList)) - } -} - -@Composable -private fun constructExpandedConstraintSet( - hasDefaultPlaceholderArtwork: Boolean = false, - isSecondTitleVisible: Boolean = false, -): ConstraintSet { - val pageIndicatorMargin = GamedgeTheme.spaces.spacing_2_5 - val backdropElevation = GamedgeTheme.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 - - 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) - - constrain(artworks) { - width = Dimension.fillToConstraints - height = Dimension.value(ArtworksHeightExpanded) - top.linkTo(parent.top) - centerHorizontallyTo(parent) - } - constrain(artworksScrim) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - centerVerticallyTo(artworks) - centerHorizontallyTo(artworks) - visibility = if (hasDefaultPlaceholderArtwork) { - Visibility.Gone - } else { - Visibility.Invisible - } - } - constrain(backButton) { - top.linkTo(parent.top) - start.linkTo(parent.start) - } - constrain(pageIndicator) { - top.linkTo(parent.top, pageIndicatorMargin) - end.linkTo(parent.end, pageIndicatorMargin) - } - constrain(backdrop) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(artworks.bottom) - bottom.linkTo(list.top) - centerHorizontallyTo(parent) - translationZ = backdropElevation - } - constrain(coverSpace) { - start.linkTo(parent.start) - bottom.linkTo(artworks.bottom, coverSpaceMargin) - } - constrain(cover) { - top.linkTo(coverSpace.bottom) - start.linkTo(parent.start, coverMarginStart) - } - constrain(likeButton) { - top.linkTo(artworks.bottom) - bottom.linkTo(artworks.bottom) - end.linkTo(parent.end, likeBtnMarginEnd) - } - constrain(firstTitle) { - width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, firstTitleMarginTop) - start.linkTo(cover.end, titleMarginStart) - end.linkTo(likeButton.start, firstTitleMarginEnd) - } - constrain(secondTitle) { - width = Dimension.fillToConstraints - top.linkTo(firstTitle.bottom) - start.linkTo(cover.end, titleMarginStart) - end.linkTo(parent.end, secondTitleMarginEnd) - isVisible = isSecondTitleVisible - } - constrain(releaseDate) { - width = Dimension.fillToConstraints - top.linkTo(secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(cover.end, releaseDateMarginHorizontal) - end.linkTo(parent.end, releaseDateMarginHorizontal) - } - constrain(developerName) { - width = Dimension.fillToConstraints - top.linkTo(releaseDate.bottom) - start.linkTo(cover.end, developerNameMarginHorizontal) - end.linkTo(parent.end, developerNameMarginHorizontal) - } - constrain(rating) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = parent.start, end = likeCount.start, bias = 0.25f) - } - constrain(likeCount) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = rating.end, end = ageRating.start, bias = 0.25f) - } - constrain(ageRating) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = likeCount.end, end = gameCategory.start, bias = 0.25f) - } - constrain(gameCategory) { - width = Dimension.fillToConstraints - top.linkTo(bottomBarrier) - bottom.linkTo(list.top, infoItemMarginBottom) - linkTo(start = ageRating.end, end = parent.end, bias = 0.25f) - } - constrain(list) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(rating.bottom) - bottom.linkTo(parent.bottom) - centerHorizontallyTo(parent) - } - } -} - -@SuppressLint("Range") -@Composable -private fun constructCollapsedConstraintSet( - hasDefaultPlaceholderArtwork: Boolean = false, -): ConstraintSet { - val artworksHeight = calculateArtworksHeightInCollapsedState() - val statusBarHeight = calculateStatusBarHeightInDp() - val pageIndicatorMargin = GamedgeTheme.spaces.spacing_2_5 - val backdropElevation = GamedgeTheme.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 - - 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) { - width = Dimension.fillToConstraints - height = Dimension.value(artworksHeight) - top.linkTo(parent.top) - bottom.linkTo(backdrop.top) - centerHorizontallyTo(parent) - } - constrain(artworksScrim) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - centerVerticallyTo(artworks) - centerHorizontallyTo(artworks) - visibility = if (hasDefaultPlaceholderArtwork) { - Visibility.Gone - } else { - Visibility.Visible - } - } - constrain(backButton) { - top.linkTo(parent.top) - start.linkTo(parent.start) - } - constrain(pageIndicator) { - top.linkTo(parent.top, pageIndicatorMargin) - end.linkTo(parent.end, pageIndicatorMargin) - translationX = PageIndicatorDeltaXCollapsed - } - constrain(backdrop) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(artworks.bottom) - bottom.linkTo(list.top) - centerHorizontallyTo(parent) - translationZ = backdropElevation - } - constrain(coverSpace) { - start.linkTo(parent.start) - bottom.linkTo(artworks.bottom, coverSpaceMargin) - } - constrain(cover) { - top.linkTo(coverSpace.bottom) - start.linkTo(parent.start, coverMarginStart) - translationX = CoverDeltaXCollapsed - translationY = CoverDeltaYCollapsed - } - constrain(likeButton) { - top.linkTo(artworks.bottom) - bottom.linkTo(artworks.bottom) - end.linkTo(parent.end, likeBtnMarginEnd) - alpha = 0f - setScale(LikeButtonScaleCollapsed) - } - constrain(firstTitle) { - width = Dimension.fillToConstraints - top.linkTo(artworks.top, statusBarHeight) - bottom.linkTo(artworks.bottom) - start.linkTo(backButton.end, firstTitleMarginStart) - end.linkTo(parent.end, firstTitleMarginEnd) - setScale(FirstTitleScaleCollapsed) - } - constrain(secondTitle) { - width = Dimension.fillToConstraints - top.linkTo(firstTitle.bottom) - start.linkTo(cover.end, titleMarginStart) - end.linkTo(parent.end, secondTitleMarginEnd) - alpha = 0f - translationX = SecondaryTextDeltaXCollapsed - } - constrain(releaseDate) { - width = Dimension.fillToConstraints - top.linkTo(secondTitle.bottom, releaseDateMarginTop, releaseDateMarginTop) - start.linkTo(cover.end, releaseDateMarginHorizontal) - end.linkTo(parent.end, releaseDateMarginHorizontal) - alpha = 0f - translationX = SecondaryTextDeltaXCollapsed - } - constrain(developerName) { - width = Dimension.fillToConstraints - top.linkTo(releaseDate.bottom) - start.linkTo(cover.end, developerNameMarginHorizontal) - end.linkTo(parent.end, developerNameMarginHorizontal) - alpha = 0f - translationX = SecondaryTextDeltaXCollapsed - } - constrain(rating) { - width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = parent.start, end = likeCount.start, bias = 0.25f) - } - constrain(likeCount) { - width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = rating.end, end = ageRating.start, bias = 0.25f) - } - constrain(ageRating) { - width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = likeCount.end, end = gameCategory.start, bias = 0.25f) - } - constrain(gameCategory) { - width = Dimension.fillToConstraints - top.linkTo(artworks.bottom, infoItemVerticalMargin) - bottom.linkTo(list.top, infoItemVerticalMargin) - linkTo(start = ageRating.end, end = parent.end, bias = 0.25f) - } - constrain(list) { - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - top.linkTo(rating.bottom) - bottom.linkTo(parent.bottom) - centerHorizontallyTo(parent) - } - } -} - -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 -} - -@Language("json5") -@Composable -private fun constructTransition(): String { - /* - onSwipe: { - direction: "up", - touchUp: "decelerateComplete", - anchor: "$ConstraintIdList", - side: "top", - mode: "velocity", - }, - */ - - return """ - { - 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() -} - -@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 - - 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() -} - -@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 59e349e07..05c3d544e 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. @@ -14,57 +14,89 @@ * limitations under the License. */ -@file:Suppress("LongMethod") +@file:Suppress("LongMethod", "MagicNumber", "LongParameterList") 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 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.lazy.rememberLazyListState 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 +104,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,382 +133,971 @@ 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 = 246.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 +@Suppress("CyclomaticComplexMethod") 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(ConstraintIdArtworksScrim) + .background(GamedgeTheme.colors.darkScrim), + ) + + 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 = 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 -> - 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), - // 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) { - // Remove font padding once https://issuetracker.google.com/issues/171394808 - // is implemented (includeFontPadding="false" in XML) - 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 LikeButton(context: Context) : FloatingActionButton(context) { - - private companion object { - const val STATE_CHECKED = android.R.attr.state_checked - const val STATE_CHECKED_ON = (STATE_CHECKED * 1) - const val STATE_CHECKED_OFF = (STATE_CHECKED * -1) - } - - var isLiked: Boolean - set(value) { - setImageState(intArrayOf(if (value) STATE_CHECKED_ON else STATE_CHECKED_OFF), true) - } - get() = drawableState.contains(STATE_CHECKED_ON) +@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 + // 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 { + 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( + // 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 + 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() } +} + @PreviewLightDark @Composable private fun GameInfoHeaderPreview() { @@ -476,18 +1107,20 @@ private fun GameInfoHeaderPreview() { 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", + 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 = {}, ) } } 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 2ef5465a7..9222f35e2 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 = {},