diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index fde3c6cc..19d9efb2 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -20,6 +20,6 @@
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 2bf4dc26..b5081e6d 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -20,6 +20,6 @@
diff --git a/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt b/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt
index c1c5a70a..56da3ad8 100644
--- a/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt
+++ b/common-ui/src/main/java/com/paulrybitskyi/gamedge/common/ui/theme/Spaces.kt
@@ -42,6 +42,9 @@ class Spaces(
spacing_6_5: Dp = 26.dp,
spacing_7_0: Dp = 28.dp,
spacing_7_5: Dp = 30.dp,
+ spacing_8_0: Dp = 32.dp,
+ spacing_8_5: Dp = 34.dp,
+ spacing_9_0: Dp = 36.dp,
) {
var spacing_0_5 by mutableStateOf(spacing_0_5)
private set
@@ -73,6 +76,12 @@ class Spaces(
private set
var spacing_7_5 by mutableStateOf(spacing_7_5)
private set
+ var spacing_8_0 by mutableStateOf(spacing_8_0)
+ private set
+ var spacing_8_5 by mutableStateOf(spacing_8_5)
+ private set
+ var spacing_9_0 by mutableStateOf(spacing_9_0)
+ private set
override fun toString(): String {
return "Spaces(" +
@@ -90,7 +99,10 @@ class Spaces(
"spacing_6_0=$spacing_6_0, " +
"spacing_6_5=$spacing_6_5, " +
"spacing_7_0=$spacing_7_0, " +
- "spacing_7_5=$spacing_7_5" +
+ "spacing_7_5=$spacing_7_5, " +
+ "spacing_8_0=$spacing_8_0, " +
+ "spacing_8_5=$spacing_8_5, " +
+ "spacing_9_0=$spacing_9_0" +
")"
}
}
diff --git a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt
index 08278a83..f9c6edd8 100644
--- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt
+++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/GameInfoScreen.kt
@@ -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 a5f51723..00000000
--- 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 59e349e0..05c3d544 100644
--- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoHeader.kt
+++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/GameInfoHeader.kt
@@ -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 2ef5465a..9222f35e 100644
--- a/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/artworks/Artworks.kt
+++ b/feature-info/src/main/java/com/paulrybitskyi/gamedge/feature/info/presentation/widgets/header/artworks/Artworks.kt
@@ -92,9 +92,7 @@ private fun Artwork(
private fun ArtworksPreview() {
GamedgeTheme {
Artworks(
- artworks = listOf(
- GameInfoArtworkUiModel.DefaultImage,
- ),
+ artworks = listOf(GameInfoArtworkUiModel.DefaultImage),
isScrollingEnabled = true,
modifier = Modifier,
onArtworkChanged = {},