TP-61881 CYCS Screen Transitions Changes (#11191)
This commit is contained in:
@@ -22,7 +22,6 @@ androidx-arch-core-testing = "2.2.0"
|
||||
androidx-browser = "1.3.0"
|
||||
androidx-camera = "1.3.3"
|
||||
androidx-camera-mlkit = "1.3.0-beta02"
|
||||
androidx-compose-animation = "1.7.0-beta02"
|
||||
androidx-constraintlayout = "2.1.4"
|
||||
androidx-constraintlayoutCompose = "1.1.0-alpha10"
|
||||
androidx-core-ktx = "1.8.0"
|
||||
@@ -166,7 +165,6 @@ androidx-camera-mlkit-vision = { module = "androidx.camera:camera-mlkit-vision",
|
||||
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" }
|
||||
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "androidx-compose-animation" }
|
||||
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
androidx-compose-material = { module = "androidx.compose.material:material" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
|
||||
@@ -68,7 +68,6 @@ dependencies {
|
||||
implementation libs.accompanist.systemuicontroller
|
||||
implementation libs.android.material
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.compose.animation
|
||||
implementation libs.androidx.compose.material3
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.lifecycle.viewmodel.ktx
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
|
||||
package com.navi.cycs.common.ui
|
||||
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -47,6 +44,8 @@ import com.navi.cycs.feature.insight.InsightShimmer
|
||||
import com.navi.cycs.feature.issue.IssueShimmer
|
||||
import com.navi.cycs.feature.report.ReportShimmer
|
||||
import com.navi.cycs.feature.trend.TrendShimmer
|
||||
import com.navi.cycs.sharedelements.FadeMode
|
||||
import com.navi.cycs.sharedelements.MaterialContainerTransformSpec
|
||||
import com.navi.cycs.theme.CycsColor
|
||||
import com.navi.design.font.FontWeightEnum
|
||||
import com.navi.design.theme.getFontWeight
|
||||
@@ -201,8 +200,19 @@ fun CycsFullWidthShimmerBox(height: Dp) {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
val creditBoundsTransform = BoundsTransform { _, _ -> tween(450, easing = LinearOutSlowInEasing) }
|
||||
val FadeInTransitionSpec =
|
||||
MaterialContainerTransformSpec(
|
||||
durationMillis = 450,
|
||||
fadeMode = FadeMode.In,
|
||||
easing = LinearOutSlowInEasing
|
||||
)
|
||||
|
||||
val FadeOutTransitionSpec =
|
||||
MaterialContainerTransformSpec(
|
||||
durationMillis = 450,
|
||||
fadeMode = FadeMode.Out,
|
||||
easing = LinearOutSlowInEasing
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CreditGenericLottie(
|
||||
|
||||
@@ -10,8 +10,6 @@ package com.navi.cycs.feature.landing
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -44,6 +42,7 @@ import com.navi.cycs.entry.CycsActivity
|
||||
import com.navi.cycs.entry.CycsMainViewModel
|
||||
import com.navi.cycs.feature.consent.ConsentScreenContent
|
||||
import com.navi.cycs.feature.score.ScoreScreenComposable
|
||||
import com.navi.cycs.sharedelements.SharedElementsRoot
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootNavGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
@@ -160,7 +159,6 @@ fun LandingScreen(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun ScreenToShow(
|
||||
viewModel: CycsMainViewModel,
|
||||
@@ -172,7 +170,7 @@ private fun ScreenToShow(
|
||||
cycsAnalytics: CycsAnalytics = CycsAnalytics.INSTANCE
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
SharedTransitionLayout {
|
||||
SharedElementsRoot {
|
||||
AnimatedContent(
|
||||
transitionSpec = { EnterTransition.None togetherWith fadeOut(tween(10)) },
|
||||
targetState = hasLoadingEnded.value,
|
||||
@@ -183,9 +181,7 @@ private fun ScreenToShow(
|
||||
Loader(
|
||||
isReadyToStopLoader = {
|
||||
isReadyToStopLoader(screenDefinitionState, bottomSheetState)
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@AnimatedContent,
|
||||
}
|
||||
) {
|
||||
hasLoadingEnded.value = it
|
||||
}
|
||||
@@ -206,9 +202,7 @@ private fun ScreenToShow(
|
||||
LaunchedEffect(Unit) { cycsAnalytics.Score().onLanded() }
|
||||
ScoreScreenComposable(
|
||||
viewModel = viewModel,
|
||||
data = screenDefinitionState.data,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@AnimatedContent
|
||||
data = screenDefinitionState.data
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
|
||||
package com.navi.cycs.feature.landing
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -46,18 +41,16 @@ import com.navi.cycs.CycsConstants.LottieKey.SHARED_LOTTIE_LOADER_KEY
|
||||
import com.navi.cycs.CycsConstants.LottieKey.SHARED_LOTTIE_METER_KEY
|
||||
import com.navi.cycs.R
|
||||
import com.navi.cycs.common.ui.CreditGenericLottie
|
||||
import com.navi.cycs.common.ui.creditBoundsTransform
|
||||
import com.navi.cycs.common.ui.FadeOutTransitionSpec
|
||||
import com.navi.cycs.sharedelements.SharedMaterialContainer
|
||||
import com.navi.cycs.theme.CycsColor
|
||||
import com.navi.design.font.FontWeightEnum
|
||||
import com.navi.design.theme.getFontWeight
|
||||
import com.navi.design.theme.ttComposeFontFamily
|
||||
import com.navi.naviwidgets.extensions.NaviText
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun Loader(
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
isReadyToStopLoader: () -> Boolean,
|
||||
hasLoadingEnded: (Boolean) -> Unit,
|
||||
) {
|
||||
@@ -69,17 +62,10 @@ fun Loader(
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.align(Alignment.Center), contentAlignment = Alignment.BottomCenter) {
|
||||
with(sharedTransitionScope) {
|
||||
LoaderScreenMeterLottie(
|
||||
isReadyToStopLoader = isReadyToStopLoader,
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
) {
|
||||
creditMeterCallback = it
|
||||
}
|
||||
LoaderScreenLottie(isReadyToStopLoader, animatedVisibilityScope) {
|
||||
creditLoaderCallback = it
|
||||
}
|
||||
LoaderScreenMeterLottie(isReadyToStopLoader = isReadyToStopLoader) {
|
||||
creditMeterCallback = it
|
||||
}
|
||||
LoaderScreenLottie(isReadyToStopLoader) { creditLoaderCallback = it }
|
||||
}
|
||||
Row(
|
||||
Modifier.align(Alignment.TopCenter)
|
||||
@@ -113,11 +99,9 @@ fun Loader(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun SharedTransitionScope.LoaderScreenLottie(
|
||||
private fun LoaderScreenLottie(
|
||||
isReadyToStopLoader: () -> Boolean,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
creditLoaderCallback: (Boolean) -> Unit
|
||||
) {
|
||||
val creditLoaderLottie by
|
||||
@@ -132,27 +116,25 @@ private fun SharedTransitionScope.LoaderScreenLottie(
|
||||
reverseOnRepeat = true,
|
||||
iterations = if (isReadyToStopLoader()) 1 else LottieConstants.IterateForever,
|
||||
)
|
||||
CreditGenericLottie(
|
||||
modifier =
|
||||
Modifier.size(48.dp)
|
||||
.sharedBounds(
|
||||
rememberSharedContentState(SHARED_LOTTIE_LOADER_KEY),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
boundsTransform = creditBoundsTransform,
|
||||
exit = fadeOut()
|
||||
),
|
||||
lottieComposition = creditLoaderLottie,
|
||||
checkAtProgress = 1f,
|
||||
progress = { loaderLottieProgress },
|
||||
SharedMaterialContainer(
|
||||
key = SHARED_LOTTIE_LOADER_KEY,
|
||||
screenKey = "LoaderScreen",
|
||||
transitionSpec = FadeOutTransitionSpec,
|
||||
color = Color.Transparent
|
||||
) {
|
||||
creditLoaderCallback(it)
|
||||
CreditGenericLottie(
|
||||
modifier = Modifier.size(48.dp),
|
||||
lottieComposition = creditLoaderLottie,
|
||||
checkAtProgress = 1f,
|
||||
progress = { loaderLottieProgress },
|
||||
) {
|
||||
creditLoaderCallback(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun SharedTransitionScope.LoaderScreenMeterLottie(
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
private fun LoaderScreenMeterLottie(
|
||||
isReadyToStopLoader: () -> Boolean,
|
||||
creditMeterCallback: (Boolean) -> Unit
|
||||
) {
|
||||
@@ -171,20 +153,19 @@ private fun SharedTransitionScope.LoaderScreenMeterLottie(
|
||||
reverseOnRepeat = true,
|
||||
iterations = if (isReadyToStopLoader()) 1 else LottieConstants.IterateForever,
|
||||
)
|
||||
CreditGenericLottie(
|
||||
modifier =
|
||||
Modifier.width(255.dp)
|
||||
.height(137.dp)
|
||||
.sharedBounds(
|
||||
rememberSharedContentState(SHARED_LOTTIE_METER_KEY),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
boundsTransform = creditBoundsTransform,
|
||||
exit = ExitTransition.None
|
||||
),
|
||||
lottieComposition = creditMeterLottie,
|
||||
checkAtProgress = 0.67f,
|
||||
progress = { meterLottieProgress }
|
||||
SharedMaterialContainer(
|
||||
key = SHARED_LOTTIE_METER_KEY,
|
||||
screenKey = "LoaderScreen",
|
||||
transitionSpec = FadeOutTransitionSpec,
|
||||
color = Color.Transparent
|
||||
) {
|
||||
creditMeterCallback(it)
|
||||
CreditGenericLottie(
|
||||
modifier = Modifier.width(255.dp).height(137.dp),
|
||||
lottieComposition = creditMeterLottie,
|
||||
checkAtProgress = 0.67f,
|
||||
progress = { meterLottieProgress }
|
||||
) {
|
||||
creditMeterCallback(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
package com.navi.cycs.feature.score
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.EaseInOutQuart
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
@@ -60,20 +56,15 @@ import com.navi.cycs.CycsConstants.LottieKey.SHARED_LOTTIE_METER_KEY
|
||||
import com.navi.cycs.R
|
||||
import com.navi.cycs.common.model.ScreenDefinition
|
||||
import com.navi.cycs.common.ui.CreditGenericLottie
|
||||
import com.navi.cycs.common.ui.FadeInTransitionSpec
|
||||
import com.navi.cycs.common.ui.WidgetRenderer
|
||||
import com.navi.cycs.common.ui.creditBoundsTransform
|
||||
import com.navi.cycs.common.utils.enableScrollOnCondition
|
||||
import com.navi.cycs.common.utils.getCreditScoreData
|
||||
import com.navi.cycs.entry.CycsMainViewModel
|
||||
import com.navi.cycs.sharedelements.SharedMaterialContainer
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun ScoreScreenComposable(
|
||||
viewModel: CycsMainViewModel,
|
||||
data: ScreenDefinition,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
) {
|
||||
fun ScoreScreenComposable(viewModel: CycsMainViewModel, data: ScreenDefinition) {
|
||||
val creditData = remember { getCreditScoreData(data) }
|
||||
val skipAnimation by remember {
|
||||
mutableStateOf(viewModel.handle.get<Boolean>(SKIP_ANIMATION).orFalse())
|
||||
@@ -106,9 +97,7 @@ fun ScoreScreenComposable(
|
||||
?.forEach { widget -> WidgetRenderer(widget = widget, viewModel = viewModel) }
|
||||
},
|
||||
skipAnimation = skipAnimation,
|
||||
creditData = creditData,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
creditData = creditData
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,15 +113,12 @@ private fun ScoreScreenHeader(headerContent: @Composable () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun ScoreScreenContent(
|
||||
scoreSectionWidgets: @Composable () -> Unit,
|
||||
scoreMainContent: @Composable ColumnScope.() -> Unit,
|
||||
creditData: Triple<String, String, Color>,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
skipAnimation: Boolean,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
) {
|
||||
val hasContentAnimationEnded = remember { mutableStateOf(skipAnimation) }
|
||||
Column(
|
||||
@@ -144,9 +130,7 @@ fun ScoreScreenContent(
|
||||
scoreSectionWidgets = scoreSectionWidgets,
|
||||
hasContentAnimationEnded = hasContentAnimationEnded.value,
|
||||
creditData = creditData,
|
||||
skipAnimation = skipAnimation,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
skipAnimation = skipAnimation
|
||||
)
|
||||
ScoreMainContent(
|
||||
scoreMainContent = scoreMainContent,
|
||||
@@ -156,15 +140,12 @@ fun ScoreScreenContent(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun ScoreSection(
|
||||
creditData: Triple<String, String, Color>,
|
||||
scoreSectionWidgets: @Composable () -> Unit,
|
||||
skipAnimation: Boolean,
|
||||
hasContentAnimationEnded: Boolean,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope
|
||||
hasContentAnimationEnded: Boolean
|
||||
) {
|
||||
val hasCreditMeterProgressEnded = remember { mutableStateOf(skipAnimation) }
|
||||
val hasCreditScoreDisplayed = remember { mutableStateOf(skipAnimation) }
|
||||
@@ -185,40 +166,21 @@ fun ScoreSection(
|
||||
scoreSectionWidgets()
|
||||
}
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
with(sharedTransitionScope) {
|
||||
CreditMeterLottie(
|
||||
modifier =
|
||||
Modifier.width(202.dp)
|
||||
.height(109.dp)
|
||||
.sharedBounds(
|
||||
rememberSharedContentState(SHARED_LOTTIE_METER_KEY),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
boundsTransform = creditBoundsTransform,
|
||||
exit = ExitTransition.None
|
||||
),
|
||||
creditScore = creditData.second,
|
||||
hasContentAnimationEnded = hasContentAnimationEnded,
|
||||
hasCreditMeterProgressEnded = { hasCreditMeterProgressEnded.value = it }
|
||||
CreditMeterLottie(
|
||||
modifier = Modifier.width(202.dp).height(109.dp),
|
||||
creditScore = creditData.second,
|
||||
hasContentAnimationEnded = hasContentAnimationEnded,
|
||||
hasCreditMeterProgressEnded = { hasCreditMeterProgressEnded.value = it }
|
||||
)
|
||||
val loaderViewAnimationState = remember { mutableStateOf(skipAnimation) }
|
||||
LaunchedEffect(Unit) { loaderViewAnimationState.value = true }
|
||||
val transparency =
|
||||
animateFloatAsState(
|
||||
animationSpec = tween(450),
|
||||
targetValue = if (loaderViewAnimationState.value) 0f else 1f,
|
||||
label = ""
|
||||
)
|
||||
val loaderViewAnimationState = remember { mutableStateOf(skipAnimation) }
|
||||
LaunchedEffect(Unit) { loaderViewAnimationState.value = true }
|
||||
val transparency =
|
||||
animateFloatAsState(
|
||||
animationSpec = tween(450),
|
||||
targetValue = if (loaderViewAnimationState.value) 0f else 1f,
|
||||
label = ""
|
||||
)
|
||||
CreditLoaderLottie(
|
||||
Modifier.size(48.dp)
|
||||
.sharedBounds(
|
||||
rememberSharedContentState(SHARED_LOTTIE_LOADER_KEY),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
boundsTransform = creditBoundsTransform,
|
||||
enter = fadeIn()
|
||||
)
|
||||
.graphicsLayer { alpha = transparency.value }
|
||||
)
|
||||
}
|
||||
CreditLoaderLottie(Modifier.size(48.dp).graphicsLayer { alpha = transparency.value })
|
||||
CreditScoreRolodex(
|
||||
modifier = Modifier.padding(bottom = 24.dp),
|
||||
skipAnimation = skipAnimation,
|
||||
@@ -244,12 +206,19 @@ fun CreditLoaderLottie(modifier: Modifier) {
|
||||
reverseOnRepeat = true,
|
||||
iterations = 1
|
||||
)
|
||||
CreditGenericLottie(
|
||||
modifier = modifier,
|
||||
lottieComposition = creditLoaderLottie,
|
||||
checkAtProgress = 1f,
|
||||
progress = { creditLoaderProgress }
|
||||
) {}
|
||||
SharedMaterialContainer(
|
||||
key = SHARED_LOTTIE_LOADER_KEY,
|
||||
screenKey = "ScoreScreen",
|
||||
transitionSpec = FadeInTransitionSpec,
|
||||
color = Color.Transparent
|
||||
) {
|
||||
CreditGenericLottie(
|
||||
modifier = modifier,
|
||||
lottieComposition = creditLoaderLottie,
|
||||
checkAtProgress = 1f,
|
||||
progress = { creditLoaderProgress }
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -271,13 +240,20 @@ fun CreditMeterLottie(
|
||||
targetValue = targetValue,
|
||||
label = SCORE_ANIMATION,
|
||||
)
|
||||
CreditGenericLottie(
|
||||
modifier = modifier,
|
||||
lottieComposition = creditMeterLottie,
|
||||
checkAtProgress = calculatedCreditScoreProgress,
|
||||
progress = { creditScoreProgress }
|
||||
SharedMaterialContainer(
|
||||
key = SHARED_LOTTIE_METER_KEY,
|
||||
screenKey = "ScoreScreen",
|
||||
transitionSpec = FadeInTransitionSpec,
|
||||
color = Color.Transparent
|
||||
) {
|
||||
hasCreditMeterProgressEnded(it)
|
||||
CreditGenericLottie(
|
||||
modifier = modifier,
|
||||
lottieComposition = creditMeterLottie,
|
||||
checkAtProgress = calculatedCreditScoreProgress,
|
||||
progress = { creditScoreProgress }
|
||||
) {
|
||||
hasCreditMeterProgressEnded(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
internal fun ElementContainer(
|
||||
modifier: Modifier,
|
||||
relaxMaxSize: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(content, modifier) { measurables, constraints ->
|
||||
if (measurables.size > 1) {
|
||||
throw IllegalStateException("SharedElement can have only one direct measurable child!")
|
||||
}
|
||||
val placeable =
|
||||
measurables
|
||||
.firstOrNull()
|
||||
?.measure(
|
||||
Constraints(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxWidth = if (relaxMaxSize) Constraints.Infinity else constraints.maxWidth,
|
||||
maxHeight =
|
||||
if (relaxMaxSize) Constraints.Infinity else constraints.maxHeight
|
||||
)
|
||||
)
|
||||
val width = min(max(constraints.minWidth, placeable?.width ?: 0), constraints.maxWidth)
|
||||
val height = min(max(constraints.minHeight, placeable?.height ?: 0), constraints.maxHeight)
|
||||
layout(width, height) { placeable?.place(0, 0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
|
||||
abstract class KeyframeBasedMotion : PathMotion {
|
||||
|
||||
private var start = Offset.Unspecified
|
||||
private var end = Offset.Unspecified
|
||||
private var keyframes: Pair<FloatArray, LongArray>? = null
|
||||
|
||||
protected abstract fun getKeyframes(start: Offset, end: Offset): Pair<FloatArray, LongArray>
|
||||
|
||||
private fun LongArray.getOffset(index: Int) = @Suppress("INVISIBLE_MEMBER") Offset(get(index))
|
||||
|
||||
override fun invoke(start: Offset, end: Offset, fraction: Float): Offset {
|
||||
var frac = fraction
|
||||
if (start != this.start || end != this.end) {
|
||||
if (start == this.end && end == this.start) {
|
||||
frac = 1 - frac
|
||||
} else {
|
||||
keyframes = null
|
||||
this.start = start
|
||||
this.end = end
|
||||
}
|
||||
}
|
||||
val (fractions, offsets) = keyframes ?: getKeyframes(start, end).also { keyframes = it }
|
||||
val count = fractions.size
|
||||
|
||||
return when {
|
||||
frac < 0f -> interpolateInRange(fractions, offsets, frac, 0, 1)
|
||||
frac > 1f -> interpolateInRange(fractions, offsets, frac, count - 2, count - 1)
|
||||
frac == 0f -> offsets.getOffset(0)
|
||||
frac == 1f -> offsets.getOffset(count - 1)
|
||||
else -> {
|
||||
// Binary search for the correct section
|
||||
var low = 0
|
||||
var high = count - 1
|
||||
while (low <= high) {
|
||||
val mid = (low + high) / 2
|
||||
val midFraction = fractions[mid]
|
||||
|
||||
when {
|
||||
frac < midFraction -> high = mid - 1
|
||||
frac > midFraction -> low = mid + 1
|
||||
else -> return offsets.getOffset(mid)
|
||||
}
|
||||
}
|
||||
|
||||
// now high is below the fraction and low is above the fraction
|
||||
interpolateInRange(fractions, offsets, frac, high, low)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interpolateInRange(
|
||||
fractions: FloatArray,
|
||||
offsets: LongArray,
|
||||
fraction: Float,
|
||||
startIndex: Int,
|
||||
endIndex: Int
|
||||
): Offset {
|
||||
val startFraction = fractions[startIndex]
|
||||
val endFraction = fractions[endIndex]
|
||||
val intervalFraction = (fraction - startFraction) / (endFraction - startFraction)
|
||||
val start = offsets.getOffset(startIndex)
|
||||
val end = offsets.getOffset(endIndex)
|
||||
return lerp(start, end, intervalFraction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
class MaterialArcMotion : KeyframeBasedMotion() {
|
||||
|
||||
override fun getKeyframes(start: Offset, end: Offset): Pair<FloatArray, LongArray> =
|
||||
QuadraticBezier.approximate(
|
||||
start,
|
||||
if (start.y > end.y) Offset(end.x, start.y) else Offset(start.x, end.y),
|
||||
end,
|
||||
0.5f
|
||||
)
|
||||
}
|
||||
|
||||
val MaterialArcMotionFactory: PathMotionFactory = { MaterialArcMotion() }
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.ScaleFactor
|
||||
import androidx.compose.ui.layout.lerp
|
||||
|
||||
internal val Rect.area: Float
|
||||
get() = width * height
|
||||
|
||||
internal operator fun Size.div(operand: Size): ScaleFactor =
|
||||
ScaleFactor(width / operand.width, height / operand.height)
|
||||
|
||||
internal fun calculateDirection(start: Rect, end: Rect): TransitionDirection =
|
||||
if (end.area > start.area) TransitionDirection.Enter else TransitionDirection.Return
|
||||
|
||||
internal fun calculateAlpha(
|
||||
direction: TransitionDirection?,
|
||||
fadeMode: FadeMode?,
|
||||
fraction: Float, // Absolute
|
||||
isStart: Boolean
|
||||
) =
|
||||
when (fadeMode) {
|
||||
FadeMode.In,
|
||||
null -> if (isStart) 1f else fraction
|
||||
FadeMode.Out -> if (isStart) 1 - fraction else 1f
|
||||
FadeMode.Cross -> if (isStart) 1 - fraction else fraction
|
||||
FadeMode.Through -> {
|
||||
val threshold =
|
||||
if (direction == TransitionDirection.Enter) FadeThroughProgressThreshold
|
||||
else 1 - FadeThroughProgressThreshold
|
||||
if (fraction < threshold) {
|
||||
if (isStart) 1 - fraction / threshold else 0f
|
||||
} else {
|
||||
if (isStart) 0f else (fraction - threshold) / (1 - threshold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateOffset(
|
||||
start: Rect,
|
||||
end: Rect?,
|
||||
fraction: Float, // Relative
|
||||
pathMotion: PathMotion?,
|
||||
width: Float
|
||||
): Offset =
|
||||
if (end == null) start.topLeft
|
||||
else {
|
||||
val topCenter = pathMotion!!.invoke(start.topCenter, end.topCenter, fraction)
|
||||
Offset(topCenter.x - width / 2, topCenter.y)
|
||||
}
|
||||
|
||||
internal val Identity = ScaleFactor(1f, 1f)
|
||||
|
||||
internal fun calculateScale(
|
||||
start: Rect,
|
||||
end: Rect?,
|
||||
fraction: Float // Relative
|
||||
): ScaleFactor = if (end == null) Identity else lerp(Identity, end.size / start.size, fraction)
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
|
||||
typealias PathMotion = (start: Offset, end: Offset, fraction: Float) -> Offset
|
||||
|
||||
typealias PathMotionFactory = () -> PathMotion
|
||||
|
||||
val LinearMotion: PathMotion = ::lerp
|
||||
|
||||
val LinearMotionFactory: PathMotionFactory = { LinearMotion }
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.util.packFloats
|
||||
import androidx.compose.ui.util.unpackFloat1
|
||||
import androidx.compose.ui.util.unpackFloat2
|
||||
|
||||
@JvmInline
|
||||
@Immutable
|
||||
value class ProgressThresholds(private val packedValue: Long) {
|
||||
|
||||
@Stable
|
||||
val start: Float
|
||||
get() = unpackFloat1(packedValue)
|
||||
|
||||
@Stable
|
||||
val end: Float
|
||||
get() = unpackFloat2(packedValue)
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE") @Stable inline operator fun component1(): Float = start
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE") @Stable inline operator fun component2(): Float = end
|
||||
}
|
||||
|
||||
@Stable
|
||||
fun ProgressThresholds(start: Float, end: Float) = ProgressThresholds(packFloats(start, end))
|
||||
|
||||
@Stable
|
||||
internal fun ProgressThresholds.applyTo(fraction: Float): Float =
|
||||
when {
|
||||
fraction < start -> 0f
|
||||
fraction in start..end -> (fraction - start) / (end - start)
|
||||
else -> 1f
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
internal object QuadraticBezier {
|
||||
|
||||
private class PointEntry(val t: Float, val point: Offset) {
|
||||
var next: PointEntry? = null
|
||||
}
|
||||
|
||||
private fun calculate(t: Float, p0: Float, p1: Float, p2: Float): Float {
|
||||
val oneMinusT = 1 - t
|
||||
return oneMinusT * (oneMinusT * p0 + t * p1) + t * (oneMinusT * p1 + t * p2)
|
||||
}
|
||||
|
||||
private fun coordinate(t: Float, p0: Offset, p1: Offset, p2: Offset): Offset =
|
||||
Offset(calculate(t, p0.x, p1.x, p2.x), calculate(t, p0.y, p1.y, p2.y))
|
||||
|
||||
fun approximate(
|
||||
p0: Offset,
|
||||
p1: Offset,
|
||||
p2: Offset,
|
||||
acceptableError: Float
|
||||
): Pair<FloatArray, LongArray> {
|
||||
val errorSquared = acceptableError * acceptableError
|
||||
|
||||
val start = PointEntry(0f, coordinate(0f, p0, p1, p2))
|
||||
var cur = start
|
||||
var next = PointEntry(1f, coordinate(1f, p0, p1, p2))
|
||||
start.next = next
|
||||
var count = 2
|
||||
while (true) {
|
||||
var needsSubdivision: Boolean
|
||||
do {
|
||||
val midT = (cur.t + next.t) / 2
|
||||
val midX = (cur.point.x + next.point.x) / 2
|
||||
val midY = (cur.point.y + next.point.y) / 2
|
||||
|
||||
val midPoint = coordinate(midT, p0, p1, p2)
|
||||
val xError = midPoint.x - midX
|
||||
val yError = midPoint.y - midY
|
||||
val midErrorSquared = (xError * xError) + (yError * yError)
|
||||
needsSubdivision = midErrorSquared > errorSquared
|
||||
|
||||
if (needsSubdivision) {
|
||||
val new = PointEntry(midT, midPoint)
|
||||
cur.next = new
|
||||
new.next = next
|
||||
next = new
|
||||
count++
|
||||
}
|
||||
} while (needsSubdivision)
|
||||
cur = next
|
||||
next = cur.next ?: break
|
||||
}
|
||||
|
||||
cur = start
|
||||
var length = 0f
|
||||
var last = Offset.Unspecified
|
||||
val result = LongArray(count)
|
||||
val lengths = FloatArray(count)
|
||||
for (i in result.indices) {
|
||||
val point = cur.point
|
||||
@Suppress("INVISIBLE_MEMBER")
|
||||
result[i] = point.packedValue
|
||||
if (i > 0) {
|
||||
val distance = (point - last).getDistance()
|
||||
length += distance
|
||||
lengths[i] = length
|
||||
}
|
||||
cur = cur.next ?: break
|
||||
last = point
|
||||
}
|
||||
|
||||
if (length > 0) {
|
||||
for (index in lengths.indices) {
|
||||
lengths[index] /= length
|
||||
}
|
||||
}
|
||||
|
||||
return lengths to result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import android.view.Choreographer
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import com.navi.cycs.sharedelements.SharedElementTransition.InProgress
|
||||
import com.navi.cycs.sharedelements.SharedElementTransition.WaitingForEndElementPosition
|
||||
import com.navi.cycs.sharedelements.SharedElementsTracker.State.*
|
||||
|
||||
@Composable
|
||||
internal fun BaseSharedElement(
|
||||
elementInfo: SharedElementInfo,
|
||||
isFullscreen: Boolean,
|
||||
placeholder: @Composable () -> Unit,
|
||||
overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||
content: @Composable (Modifier) -> Unit
|
||||
) {
|
||||
val (savedShouldHide, setShouldHide) = remember { mutableStateOf(false) }
|
||||
val rootState = LocalSharedElementsRootState.current
|
||||
val shouldHide = rootState.onElementRegistered(elementInfo)
|
||||
setShouldHide(shouldHide)
|
||||
|
||||
val compositionLocalContext = currentCompositionLocalContext
|
||||
if (isFullscreen) {
|
||||
rootState.onElementPositioned(
|
||||
elementInfo,
|
||||
compositionLocalContext,
|
||||
placeholder,
|
||||
overlay,
|
||||
null,
|
||||
setShouldHide
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
val contentModifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
rootState.onElementPositioned(
|
||||
elementInfo,
|
||||
compositionLocalContext,
|
||||
placeholder,
|
||||
overlay,
|
||||
coordinates,
|
||||
setShouldHide
|
||||
)
|
||||
}
|
||||
.run { if (shouldHide || savedShouldHide) alpha(0f) else this }
|
||||
|
||||
content(contentModifier)
|
||||
}
|
||||
|
||||
DisposableEffect(elementInfo) { onDispose { rootState.onElementDisposed(elementInfo) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedElementsRoot(content: @Composable SharedElementsRootScope.() -> Unit) {
|
||||
val rootState = remember { SharedElementsRootState() }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { layoutCoordinates ->
|
||||
rootState.rootCoordinates = layoutCoordinates
|
||||
rootState.rootBounds = Rect(Offset.Zero, layoutCoordinates.size.toSize())
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedElementsRootState provides rootState,
|
||||
LocalSharedElementsRootScope provides rootState.scope
|
||||
) {
|
||||
rootState.scope.content()
|
||||
UnboundedBox { SharedElementTransitionsOverlay(rootState) }
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { rootState.onDispose() } }
|
||||
}
|
||||
|
||||
interface SharedElementsRootScope {
|
||||
val isRunningTransition: Boolean
|
||||
|
||||
fun prepareTransition(vararg elements: Any)
|
||||
}
|
||||
|
||||
val LocalSharedElementsRootScope = staticCompositionLocalOf<SharedElementsRootScope?> { null }
|
||||
|
||||
@Composable
|
||||
private fun UnboundedBox(content: @Composable () -> Unit) {
|
||||
Layout(content) { measurables, constraints ->
|
||||
val infiniteConstraints = Constraints()
|
||||
val placeables =
|
||||
measurables.fastMap {
|
||||
val isFullscreen = it.layoutId === FullscreenLayoutId
|
||||
it.measure(if (isFullscreen) constraints else infiniteConstraints)
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeables.fastForEach { it.place(0, 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedElementTransitionsOverlay(rootState: SharedElementsRootState) {
|
||||
rootState.recomposeScope = currentRecomposeScope
|
||||
rootState.trackers.forEach { (key, tracker) ->
|
||||
key(key) {
|
||||
val transition = tracker.transition
|
||||
val start = (tracker.state as? StartElementPositioned)?.startElement
|
||||
if (transition != null || (start != null && start.bounds == null)) {
|
||||
val startElement = start ?: transition!!.startElement
|
||||
val startScreenKey = startElement.info.screenKey
|
||||
val endElement = (transition as? InProgress)?.endElement
|
||||
val spec = startElement.info.spec
|
||||
val animated = remember(startScreenKey) { Animatable(0f) }
|
||||
val fraction = animated.value
|
||||
startElement.info.onFractionChanged?.invoke(fraction)
|
||||
endElement?.info?.onFractionChanged?.invoke(1 - fraction)
|
||||
|
||||
val direction =
|
||||
if (endElement == null) null
|
||||
else
|
||||
remember(startScreenKey) {
|
||||
val direction = spec.direction
|
||||
if (direction != TransitionDirection.Auto) direction
|
||||
else
|
||||
calculateDirection(
|
||||
startElement.bounds ?: rootState.rootBounds!!,
|
||||
endElement.bounds ?: rootState.rootBounds!!
|
||||
)
|
||||
}
|
||||
|
||||
startElement.Placeholder(
|
||||
rootState,
|
||||
fraction,
|
||||
endElement,
|
||||
direction,
|
||||
spec,
|
||||
tracker.pathMotion
|
||||
)
|
||||
|
||||
if (transition is InProgress) {
|
||||
LaunchedEffect(transition, animated) {
|
||||
repeat(spec.waitForFrames) { withFrameNanos {} }
|
||||
animated.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis = spec.durationMillis,
|
||||
delayMillis = spec.delayMillis,
|
||||
easing = spec.easing
|
||||
)
|
||||
)
|
||||
transition.onTransitionFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionedSharedElement.Placeholder(
|
||||
rootState: SharedElementsRootState,
|
||||
fraction: Float,
|
||||
end: PositionedSharedElement? = null,
|
||||
direction: TransitionDirection? = null,
|
||||
spec: SharedElementsTransitionSpec? = null,
|
||||
pathMotion: PathMotion? = null
|
||||
) {
|
||||
overlay(
|
||||
SharedElementsTransitionState(
|
||||
fraction = fraction,
|
||||
startInfo = info,
|
||||
startBounds = if (end == null) bounds else bounds ?: rootState.rootBounds,
|
||||
startCompositionLocalContext = compositionLocalContext,
|
||||
startPlaceholder = placeholder,
|
||||
endInfo = end?.info,
|
||||
endBounds = end?.run { bounds ?: rootState.rootBounds },
|
||||
endCompositionLocalContext = end?.compositionLocalContext,
|
||||
endPlaceholder = end?.placeholder,
|
||||
direction = direction,
|
||||
spec = spec,
|
||||
pathMotion = pathMotion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private val LocalSharedElementsRootState =
|
||||
staticCompositionLocalOf<SharedElementsRootState> {
|
||||
error("SharedElementsRoot not found. SharedElement must be hosted in SharedElementsRoot.")
|
||||
}
|
||||
|
||||
private class SharedElementsRootState {
|
||||
private val choreographer = ChoreographerWrapper()
|
||||
val scope: SharedElementsRootScope = Scope()
|
||||
var trackers by mutableStateOf(mapOf<Any, SharedElementsTracker>())
|
||||
var recomposeScope: RecomposeScope? = null
|
||||
var rootCoordinates: LayoutCoordinates? = null
|
||||
var rootBounds: Rect? = null
|
||||
|
||||
fun onElementRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
choreographer.removeCallback(elementInfo)
|
||||
return getTracker(elementInfo).onElementRegistered(elementInfo)
|
||||
}
|
||||
|
||||
fun onElementPositioned(
|
||||
elementInfo: SharedElementInfo,
|
||||
compositionLocalContext: CompositionLocalContext,
|
||||
placeholder: @Composable () -> Unit,
|
||||
overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||
coordinates: LayoutCoordinates?,
|
||||
setShouldHide: (Boolean) -> Unit
|
||||
) {
|
||||
val element =
|
||||
PositionedSharedElement(
|
||||
info = elementInfo,
|
||||
compositionLocalContext = compositionLocalContext,
|
||||
placeholder = placeholder,
|
||||
overlay = overlay,
|
||||
bounds = coordinates?.calculateBoundsInRoot()
|
||||
)
|
||||
getTracker(elementInfo).onElementPositioned(element, setShouldHide)
|
||||
}
|
||||
|
||||
fun onElementDisposed(elementInfo: SharedElementInfo) {
|
||||
choreographer.postCallback(elementInfo) {
|
||||
val tracker = getTracker(elementInfo)
|
||||
tracker.onElementUnregistered(elementInfo)
|
||||
if (tracker.isEmpty) trackers = trackers - elementInfo.key
|
||||
}
|
||||
}
|
||||
|
||||
fun onDispose() {
|
||||
choreographer.clear()
|
||||
}
|
||||
|
||||
private fun getTracker(elementInfo: SharedElementInfo): SharedElementsTracker {
|
||||
return trackers[elementInfo.key]
|
||||
?: SharedElementsTracker { transition ->
|
||||
recomposeScope?.invalidate()
|
||||
(scope as Scope).isRunningTransition =
|
||||
if (transition != null) true
|
||||
else trackers.values.any { it.transition != null }
|
||||
}
|
||||
.also { trackers = trackers + (elementInfo.key to it) }
|
||||
}
|
||||
|
||||
private fun LayoutCoordinates.calculateBoundsInRoot(): Rect =
|
||||
Rect(rootCoordinates?.localPositionOf(this, Offset.Zero) ?: positionInRoot(), size.toSize())
|
||||
|
||||
private inner class Scope : SharedElementsRootScope {
|
||||
|
||||
override var isRunningTransition: Boolean by mutableStateOf(false)
|
||||
|
||||
override fun prepareTransition(vararg elements: Any) {
|
||||
elements.forEach { trackers[it]?.prepareTransition() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedElementsTracker(
|
||||
private val onTransitionChanged: (SharedElementTransition?) -> Unit
|
||||
) {
|
||||
var state: State = Empty
|
||||
|
||||
var pathMotion: PathMotion? = null
|
||||
|
||||
// Use snapshot state to trigger recomposition of start element when transition starts
|
||||
private var _transition: SharedElementTransition? by mutableStateOf(null)
|
||||
var transition: SharedElementTransition?
|
||||
get() = _transition
|
||||
set(value) {
|
||||
if (_transition != value) {
|
||||
_transition = value
|
||||
if (value == null) pathMotion = null
|
||||
onTransitionChanged(value)
|
||||
}
|
||||
}
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = state is Empty
|
||||
|
||||
private fun StartElementPositioned.prepareTransition() {
|
||||
if (transition !is WaitingForEndElementPosition) {
|
||||
transition = WaitingForEndElementPosition(startElement)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareTransition() {
|
||||
(state as? StartElementPositioned)?.prepareTransition()
|
||||
}
|
||||
|
||||
fun onElementRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
var shouldHide = false
|
||||
|
||||
val transition = transition
|
||||
if (
|
||||
transition is InProgress &&
|
||||
elementInfo != transition.startElement.info &&
|
||||
elementInfo != transition.endElement.info
|
||||
) {
|
||||
state = StartElementPositioned(startElement = transition.endElement)
|
||||
this.transition = null
|
||||
}
|
||||
|
||||
when (val state = state) {
|
||||
is StartElementPositioned -> {
|
||||
if (!state.isRegistered(elementInfo)) {
|
||||
shouldHide = true
|
||||
this.state =
|
||||
EndElementRegistered(
|
||||
startElement = state.startElement,
|
||||
endElementInfo = elementInfo
|
||||
)
|
||||
state.prepareTransition()
|
||||
}
|
||||
}
|
||||
is StartElementRegistered -> {
|
||||
if (elementInfo != state.startElementInfo) {
|
||||
this.state = StartElementRegistered(startElementInfo = elementInfo)
|
||||
}
|
||||
}
|
||||
is Empty -> {
|
||||
this.state = StartElementRegistered(startElementInfo = elementInfo)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return shouldHide || transition != null
|
||||
}
|
||||
|
||||
fun onElementPositioned(element: PositionedSharedElement, setShouldHide: (Boolean) -> Unit) {
|
||||
val state = state
|
||||
if (state is StartElementPositioned && element.info == state.startElementInfo) {
|
||||
state.startElement = element
|
||||
return
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is EndElementRegistered -> {
|
||||
if (element.info == state.endElementInfo) {
|
||||
this.state = InTransition
|
||||
val spec = element.info.spec
|
||||
this.pathMotion = spec.pathMotionFactory()
|
||||
transition =
|
||||
InProgress(
|
||||
startElement = state.startElement,
|
||||
endElement = element,
|
||||
onTransitionFinished = {
|
||||
this.state = StartElementPositioned(startElement = element)
|
||||
transition = null
|
||||
setShouldHide(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is StartElementRegistered -> {
|
||||
if (element.info == state.startElementInfo) {
|
||||
this.state = StartElementPositioned(startElement = element)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun onElementUnregistered(elementInfo: SharedElementInfo) {
|
||||
when (val state = state) {
|
||||
is EndElementRegistered -> {
|
||||
if (elementInfo == state.endElementInfo) {
|
||||
this.state = StartElementPositioned(startElement = state.startElement)
|
||||
transition = null
|
||||
} else if (elementInfo == state.startElement.info) {
|
||||
this.state = StartElementRegistered(startElementInfo = state.endElementInfo)
|
||||
transition = null
|
||||
}
|
||||
}
|
||||
is StartElementRegistered -> {
|
||||
if (elementInfo == state.startElementInfo) {
|
||||
this.state = Empty
|
||||
transition = null
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
sealed class State {
|
||||
object Empty : State()
|
||||
|
||||
open class StartElementRegistered(val startElementInfo: SharedElementInfo) : State() {
|
||||
open fun isRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
return elementInfo == startElementInfo
|
||||
}
|
||||
}
|
||||
|
||||
open class StartElementPositioned(var startElement: PositionedSharedElement) :
|
||||
StartElementRegistered(startElement.info)
|
||||
|
||||
class EndElementRegistered(
|
||||
startElement: PositionedSharedElement,
|
||||
val endElementInfo: SharedElementInfo
|
||||
) : StartElementPositioned(startElement) {
|
||||
override fun isRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||
return super.isRegistered(elementInfo) || elementInfo == endElementInfo
|
||||
}
|
||||
}
|
||||
|
||||
object InTransition : State()
|
||||
}
|
||||
}
|
||||
|
||||
enum class TransitionDirection {
|
||||
Auto,
|
||||
Enter,
|
||||
Return
|
||||
}
|
||||
|
||||
enum class FadeMode {
|
||||
In,
|
||||
Out,
|
||||
Cross,
|
||||
Through
|
||||
}
|
||||
|
||||
const val FadeThroughProgressThreshold = 0.35f
|
||||
|
||||
internal class SharedElementsTransitionState(
|
||||
val fraction: Float,
|
||||
val startInfo: SharedElementInfo,
|
||||
val startBounds: Rect?,
|
||||
val startCompositionLocalContext: CompositionLocalContext,
|
||||
val startPlaceholder: @Composable () -> Unit,
|
||||
val endInfo: SharedElementInfo?,
|
||||
val endBounds: Rect?,
|
||||
val endCompositionLocalContext: CompositionLocalContext?,
|
||||
val endPlaceholder: (@Composable () -> Unit)?,
|
||||
val direction: TransitionDirection?,
|
||||
val spec: SharedElementsTransitionSpec?,
|
||||
val pathMotion: PathMotion?
|
||||
)
|
||||
|
||||
internal val TopLeft = TransformOrigin(0f, 0f)
|
||||
|
||||
internal open class SharedElementInfo(
|
||||
val key: Any,
|
||||
val screenKey: Any,
|
||||
val spec: SharedElementsTransitionSpec,
|
||||
val onFractionChanged: ((Float) -> Unit)?
|
||||
) {
|
||||
|
||||
final override fun equals(other: Any?): Boolean =
|
||||
other is SharedElementInfo && other.key == key && other.screenKey == screenKey
|
||||
|
||||
final override fun hashCode(): Int = 31 * key.hashCode() + screenKey.hashCode()
|
||||
}
|
||||
|
||||
private class PositionedSharedElement(
|
||||
val info: SharedElementInfo,
|
||||
val compositionLocalContext: CompositionLocalContext,
|
||||
val placeholder: @Composable () -> Unit,
|
||||
val overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||
val bounds: Rect?
|
||||
)
|
||||
|
||||
private sealed class SharedElementTransition(val startElement: PositionedSharedElement) {
|
||||
|
||||
class WaitingForEndElementPosition(startElement: PositionedSharedElement) :
|
||||
SharedElementTransition(startElement)
|
||||
|
||||
class InProgress(
|
||||
startElement: PositionedSharedElement,
|
||||
val endElement: PositionedSharedElement,
|
||||
val onTransitionFinished: () -> Unit
|
||||
) : SharedElementTransition(startElement)
|
||||
}
|
||||
|
||||
private class ChoreographerWrapper {
|
||||
private val callbacks = mutableMapOf<SharedElementInfo, Choreographer.FrameCallback>()
|
||||
private val choreographer = Choreographer.getInstance()
|
||||
|
||||
fun postCallback(elementInfo: SharedElementInfo, callback: () -> Unit) {
|
||||
if (callbacks.containsKey(elementInfo)) return
|
||||
|
||||
val frameCallback =
|
||||
Choreographer.FrameCallback {
|
||||
callbacks.remove(elementInfo)
|
||||
callback()
|
||||
}
|
||||
callbacks[elementInfo] = frameCallback
|
||||
choreographer.postFrameCallback(frameCallback)
|
||||
}
|
||||
|
||||
fun removeCallback(elementInfo: SharedElementInfo) {
|
||||
callbacks.remove(elementInfo)?.also(choreographer::removeFrameCallback)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
callbacks.values.forEach(choreographer::removeFrameCallback)
|
||||
callbacks.clear()
|
||||
}
|
||||
}
|
||||
|
||||
internal val Fullscreen = Modifier.fillMaxSize()
|
||||
internal val FullscreenLayoutId = Any()
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.animation.core.AnimationConstants
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
|
||||
open class SharedElementsTransitionSpec(
|
||||
val pathMotionFactory: PathMotionFactory = LinearMotionFactory,
|
||||
/**
|
||||
* Frames to wait for before starting transition. Useful when the frame skip caused by rendering
|
||||
* the new screen makes the animation not smooth.
|
||||
*/
|
||||
val waitForFrames: Int = 1,
|
||||
val durationMillis: Int = AnimationConstants.DefaultDurationMillis,
|
||||
val delayMillis: Int = 0,
|
||||
val easing: Easing = FastOutSlowInEasing,
|
||||
val direction: TransitionDirection = TransitionDirection.Auto,
|
||||
val fadeMode: FadeMode = FadeMode.Cross,
|
||||
val fadeProgressThresholds: ProgressThresholds? = null,
|
||||
val scaleProgressThresholds: ProgressThresholds? = null
|
||||
)
|
||||
|
||||
val DefaultSharedElementsTransitionSpec = SharedElementsTransitionSpec()
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.cycs.sharedelements
|
||||
|
||||
import androidx.compose.animation.core.AnimationConstants
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.LocalAbsoluteElevation
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.LocalElevationOverlay
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.contentColorFor
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SharedMaterialContainer(
|
||||
key: Any,
|
||||
screenKey: Any,
|
||||
isFullscreen: Boolean = false,
|
||||
shape: Shape = RectangleShape,
|
||||
color: Color = MaterialTheme.colors.surface,
|
||||
contentColor: Color = contentColorFor(color),
|
||||
border: BorderStroke? = null,
|
||||
elevation: Dp = 0.dp,
|
||||
transitionSpec: MaterialContainerTransformSpec = DefaultMaterialContainerTransformSpec,
|
||||
onFractionChanged: ((Float) -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val elementInfo =
|
||||
MaterialContainerInfo(
|
||||
key,
|
||||
screenKey,
|
||||
shape,
|
||||
color,
|
||||
contentColor,
|
||||
border,
|
||||
elevation,
|
||||
transitionSpec,
|
||||
onFractionChanged
|
||||
)
|
||||
val realPlaceholder = placeholder ?: content
|
||||
BaseSharedElement(
|
||||
elementInfo,
|
||||
isFullscreen,
|
||||
realPlaceholder,
|
||||
{ Placeholder(it) },
|
||||
{
|
||||
MaterialContainer(
|
||||
modifier = it,
|
||||
shape = shape,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
border = border,
|
||||
elevation = elevation,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MaterialContainer(
|
||||
modifier: Modifier,
|
||||
shape: Shape,
|
||||
color: Color,
|
||||
contentColor: Color,
|
||||
border: BorderStroke?,
|
||||
elevation: Dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val elevationOverlay = LocalElevationOverlay.current
|
||||
val absoluteElevation = LocalAbsoluteElevation.current + elevation
|
||||
val backgroundColor =
|
||||
if (color == MaterialTheme.colors.surface && elevationOverlay != null) {
|
||||
elevationOverlay.apply(color, absoluteElevation)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalAbsoluteElevation provides absoluteElevation
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.shadow(elevation, shape, clip = false)
|
||||
.then(if (border != null) Modifier.border(border, shape) else Modifier)
|
||||
.background(color = backgroundColor, shape = shape)
|
||||
.clip(shape),
|
||||
propagateMinConstraints = true
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Placeholder(state: SharedElementsTransitionState) {
|
||||
with(LocalDensity.current) {
|
||||
val startInfo = state.startInfo as MaterialContainerInfo
|
||||
val direction = state.direction
|
||||
val spec = state.spec as? MaterialContainerTransformSpec
|
||||
val start = state.startBounds
|
||||
val end = state.endBounds
|
||||
val fraction = state.fraction
|
||||
|
||||
val surfaceModifier: Modifier
|
||||
var startContentModifier = Fullscreen
|
||||
val elements = mutableListOf<ElementCall>()
|
||||
|
||||
var shape = startInfo.shape
|
||||
var color = startInfo.color
|
||||
var contentColor = startInfo.contentColor
|
||||
var border = startInfo.border
|
||||
var elevation = startInfo.elevation
|
||||
var startAlpha = 1f
|
||||
|
||||
if (start == null) {
|
||||
surfaceModifier = Modifier.layoutId(FullscreenLayoutId)
|
||||
} else {
|
||||
val fitMode =
|
||||
if (spec == null || end == null) null
|
||||
else
|
||||
remember {
|
||||
val mode = spec.fitMode
|
||||
if (mode != FitMode.Auto) mode
|
||||
else calculateFitMode(direction == TransitionDirection.Enter, start, end)
|
||||
}
|
||||
|
||||
val thresholds =
|
||||
if (spec == null || direction == null) DefaultEnterThresholds
|
||||
else remember { spec.progressThresholdsGroupFor(direction, state.pathMotion!!) }
|
||||
|
||||
val scaleFraction = thresholds.scale.applyTo(fraction)
|
||||
val scale = calculateScale(start, end, scaleFraction)
|
||||
val contentScale = if (fitMode == FitMode.Height) scale.scaleY else scale.scaleX
|
||||
val scaleMaskFraction = thresholds.scaleMask.applyTo(fraction)
|
||||
val (containerWidth, containerHeight) =
|
||||
if (end == null) start.size * contentScale
|
||||
else {
|
||||
if (fitMode == FitMode.Height)
|
||||
Size(
|
||||
width =
|
||||
lerp(
|
||||
start.width * contentScale,
|
||||
start.height * contentScale / end.height * end.width,
|
||||
scaleMaskFraction
|
||||
),
|
||||
height = start.height * contentScale
|
||||
)
|
||||
else
|
||||
Size(
|
||||
width = start.width * contentScale,
|
||||
height =
|
||||
lerp(
|
||||
start.height * contentScale,
|
||||
start.width * contentScale / end.width * end.height,
|
||||
scaleMaskFraction
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val offset =
|
||||
calculateOffset(start, end, fraction, state.pathMotion, containerWidth).round()
|
||||
|
||||
surfaceModifier =
|
||||
Modifier.size(containerWidth.toDp(), containerHeight.toDp()).offset { offset }
|
||||
|
||||
val endInfo = state.endInfo as? MaterialContainerInfo
|
||||
val fadeFraction = thresholds.fade.applyTo(fraction)
|
||||
if (end != null && endInfo != null) {
|
||||
val endAlpha = calculateAlpha(direction, state.spec?.fadeMode, fadeFraction, false)
|
||||
if (endAlpha > 0) {
|
||||
val endScale =
|
||||
calculateScale(end, start, 1 - scaleFraction).run {
|
||||
if (fitMode == FitMode.Height) scaleY else scaleX
|
||||
}
|
||||
val containerColor = spec?.endContainerColor ?: Color.Transparent
|
||||
val containerModifier =
|
||||
Modifier.fillMaxSize()
|
||||
.run {
|
||||
if (containerColor == Color.Transparent) this
|
||||
else
|
||||
background(
|
||||
containerColor.copy(alpha = containerColor.alpha * endAlpha)
|
||||
)
|
||||
}
|
||||
.run { if (state.spec?.fadeMode != FadeMode.Out) zIndex(1f) else this }
|
||||
val contentModifier =
|
||||
Modifier.size(end.width.toDp(), end.height.toDp())
|
||||
.run {
|
||||
if (fitMode == FitMode.Height)
|
||||
offset {
|
||||
IntOffset(
|
||||
((containerWidth - end.width * endScale) / 2)
|
||||
.roundToInt(),
|
||||
0
|
||||
)
|
||||
}
|
||||
else this
|
||||
}
|
||||
.graphicsLayer {
|
||||
this.transformOrigin = TopLeft
|
||||
this.scaleX = endScale
|
||||
this.scaleY = endScale
|
||||
this.alpha = endAlpha
|
||||
}
|
||||
|
||||
elements.add(
|
||||
ElementCall(
|
||||
endInfo.screenKey,
|
||||
containerModifier,
|
||||
true,
|
||||
contentModifier,
|
||||
state.endCompositionLocalContext!!,
|
||||
state.endPlaceholder!!
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val shapeFraction = thresholds.shapeMask.applyTo(fraction)
|
||||
shape = lerp(startInfo.shape, endInfo.shape, shapeFraction)
|
||||
color = lerp(startInfo.color, endInfo.color, shapeFraction)
|
||||
contentColor = lerp(startInfo.contentColor, endInfo.contentColor, shapeFraction)
|
||||
border =
|
||||
(startInfo.border ?: endInfo.border)?.copy(
|
||||
width =
|
||||
lerp(
|
||||
startInfo.border?.width ?: 0.dp,
|
||||
endInfo.border?.width ?: 0.dp,
|
||||
shapeFraction
|
||||
)
|
||||
)
|
||||
elevation = lerp(startInfo.elevation, endInfo.elevation, shapeFraction)
|
||||
}
|
||||
|
||||
startAlpha = calculateAlpha(direction, state.spec?.fadeMode, fadeFraction, true)
|
||||
if (startAlpha > 0) {
|
||||
startContentModifier =
|
||||
Modifier.size(start.width.toDp(), start.height.toDp())
|
||||
.run {
|
||||
if (fitMode == FitMode.Height)
|
||||
offset {
|
||||
IntOffset(
|
||||
((containerWidth - start.width * contentScale) / 2)
|
||||
.roundToInt(),
|
||||
0
|
||||
)
|
||||
}
|
||||
else this
|
||||
}
|
||||
.graphicsLayer {
|
||||
this.transformOrigin = TopLeft
|
||||
this.scaleX = contentScale
|
||||
this.scaleY = contentScale
|
||||
this.alpha = startAlpha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startAlpha > 0) {
|
||||
val containerColor = spec?.startContainerColor ?: Color.Transparent
|
||||
val containerModifier =
|
||||
Modifier.fillMaxSize().run {
|
||||
if (containerColor == Color.Transparent) this
|
||||
else background(containerColor.copy(alpha = containerColor.alpha * startAlpha))
|
||||
}
|
||||
|
||||
elements.add(
|
||||
ElementCall(
|
||||
startInfo.screenKey,
|
||||
containerModifier,
|
||||
start != null,
|
||||
startContentModifier,
|
||||
state.startCompositionLocalContext,
|
||||
state.startPlaceholder
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialContainer(
|
||||
modifier = surfaceModifier,
|
||||
shape = shape,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
border = border,
|
||||
elevation = elevation
|
||||
) {
|
||||
Box {
|
||||
elements.forEach { call ->
|
||||
key(call.screenKey) {
|
||||
ElementContainer(
|
||||
modifier = call.containerModifier,
|
||||
relaxMaxSize = call.relaxMaxSize
|
||||
) {
|
||||
ElementContainer(modifier = call.contentModifier) {
|
||||
CompositionLocalProvider(
|
||||
call.compositionLocalContext,
|
||||
content = call.content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ElementCall(
|
||||
val screenKey: Any,
|
||||
val containerModifier: Modifier,
|
||||
val relaxMaxSize: Boolean,
|
||||
val contentModifier: Modifier,
|
||||
val compositionLocalContext: CompositionLocalContext,
|
||||
val content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
private fun calculateFitMode(entering: Boolean, start: Rect, end: Rect): FitMode {
|
||||
val startWidth = start.width
|
||||
val startHeight = start.height
|
||||
val endWidth = end.width
|
||||
val endHeight = end.height
|
||||
|
||||
val endHeightFitToWidth = endHeight * startWidth / endWidth
|
||||
val startHeightFitToWidth = startHeight * endWidth / startWidth
|
||||
val fitWidth =
|
||||
if (entering) endHeightFitToWidth >= startHeight else startHeightFitToWidth >= endHeight
|
||||
return if (fitWidth) FitMode.Width else FitMode.Height
|
||||
}
|
||||
|
||||
private fun lerp(start: Shape, end: Shape, fraction: Float): Shape {
|
||||
if (
|
||||
(start == RectangleShape && end == RectangleShape) ||
|
||||
(start != RectangleShape && start !is CornerBasedShape) ||
|
||||
(end != RectangleShape && end !is CornerBasedShape)
|
||||
)
|
||||
return start
|
||||
val topStart =
|
||||
lerp((start as? CornerBasedShape)?.topStart, (end as? CornerBasedShape)?.topStart, fraction)
|
||||
?: ZeroCornerSize
|
||||
val topEnd =
|
||||
lerp((start as? CornerBasedShape)?.topEnd, (end as? CornerBasedShape)?.topEnd, fraction)
|
||||
?: ZeroCornerSize
|
||||
val bottomEnd =
|
||||
lerp(
|
||||
(start as? CornerBasedShape)?.bottomEnd,
|
||||
(end as? CornerBasedShape)?.bottomEnd,
|
||||
fraction
|
||||
) ?: ZeroCornerSize
|
||||
val bottomStart =
|
||||
lerp(
|
||||
(start as? CornerBasedShape)?.bottomStart,
|
||||
(end as? CornerBasedShape)?.bottomStart,
|
||||
fraction
|
||||
) ?: ZeroCornerSize
|
||||
return when {
|
||||
start is RoundedCornerShape || (start == RectangleShape && end is RoundedCornerShape) ->
|
||||
RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
start is CutCornerShape || (start == RectangleShape && end is CutCornerShape) ->
|
||||
CutCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
else -> start
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(start: CornerSize?, end: CornerSize?, fraction: Float): CornerSize? {
|
||||
if (start == null && end == null) return null
|
||||
return object : CornerSize {
|
||||
override fun toPx(shapeSize: Size, density: Density): Float =
|
||||
lerp(
|
||||
start?.toPx(shapeSize, density) ?: 0f,
|
||||
end?.toPx(shapeSize, density) ?: 0f,
|
||||
fraction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class MaterialContainerInfo(
|
||||
key: Any,
|
||||
screenKey: Any,
|
||||
val shape: Shape,
|
||||
val color: Color,
|
||||
val contentColor: Color,
|
||||
val border: BorderStroke?,
|
||||
val elevation: Dp,
|
||||
spec: SharedElementsTransitionSpec,
|
||||
onFractionChanged: ((Float) -> Unit)?,
|
||||
) : SharedElementInfo(key, screenKey, spec, onFractionChanged)
|
||||
|
||||
enum class FitMode {
|
||||
Auto,
|
||||
Width,
|
||||
Height
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private class ProgressThresholdsGroup(
|
||||
val fade: ProgressThresholds,
|
||||
val scale: ProgressThresholds,
|
||||
val scaleMask: ProgressThresholds,
|
||||
val shapeMask: ProgressThresholds
|
||||
)
|
||||
|
||||
// Default animation thresholds. Will be used by default when the default linear PathMotion is
|
||||
// being used or when no other progress thresholds are appropriate (e.g., the arc thresholds for
|
||||
// an arc path).
|
||||
private val DefaultEnterThresholds =
|
||||
ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0f, 0.25f),
|
||||
scale = ProgressThresholds(0f, 1f),
|
||||
scaleMask = ProgressThresholds(0f, 1f),
|
||||
shapeMask = ProgressThresholds(0f, 0.75f)
|
||||
)
|
||||
private val DefaultReturnThresholds =
|
||||
ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0.60f, 0.90f),
|
||||
scale = ProgressThresholds(0f, 1f),
|
||||
scaleMask = ProgressThresholds(0f, 0.90f),
|
||||
shapeMask = ProgressThresholds(0.30f, 0.90f)
|
||||
)
|
||||
|
||||
// Default animation thresholds for an arc path. Will be used by default when the PathMotion is
|
||||
// set to MaterialArcMotion.
|
||||
private val DefaultEnterThresholdsArc =
|
||||
ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0.10f, 0.40f),
|
||||
scale = ProgressThresholds(0.10f, 1f),
|
||||
scaleMask = ProgressThresholds(0.10f, 1f),
|
||||
shapeMask = ProgressThresholds(0.10f, 0.90f)
|
||||
)
|
||||
private val DefaultReturnThresholdsArc =
|
||||
ProgressThresholdsGroup(
|
||||
fade = ProgressThresholds(0.60f, 0.90f),
|
||||
scale = ProgressThresholds(0f, 0.90f),
|
||||
scaleMask = ProgressThresholds(0f, 0.90f),
|
||||
shapeMask = ProgressThresholds(0.20f, 0.90f)
|
||||
)
|
||||
|
||||
class MaterialContainerTransformSpec(
|
||||
pathMotionFactory: PathMotionFactory = LinearMotionFactory,
|
||||
/**
|
||||
* Frames to wait for before starting transition. Useful when the frame skip caused by rendering
|
||||
* the new screen makes the animation not smooth.
|
||||
*/
|
||||
waitForFrames: Int = 1,
|
||||
durationMillis: Int = AnimationConstants.DefaultDurationMillis,
|
||||
delayMillis: Int = 0,
|
||||
easing: Easing = FastOutSlowInEasing,
|
||||
direction: TransitionDirection = TransitionDirection.Auto,
|
||||
fadeMode: FadeMode = FadeMode.In,
|
||||
val fitMode: FitMode = FitMode.Auto,
|
||||
val startContainerColor: Color = Color.Transparent,
|
||||
val endContainerColor: Color = Color.Transparent,
|
||||
fadeProgressThresholds: ProgressThresholds? = null,
|
||||
scaleProgressThresholds: ProgressThresholds? = null,
|
||||
val scaleMaskProgressThresholds: ProgressThresholds? = null,
|
||||
val shapeMaskProgressThresholds: ProgressThresholds? = null
|
||||
) :
|
||||
SharedElementsTransitionSpec(
|
||||
pathMotionFactory,
|
||||
waitForFrames,
|
||||
durationMillis,
|
||||
delayMillis,
|
||||
easing,
|
||||
direction,
|
||||
fadeMode,
|
||||
fadeProgressThresholds,
|
||||
scaleProgressThresholds
|
||||
)
|
||||
|
||||
val DefaultMaterialContainerTransformSpec = MaterialContainerTransformSpec()
|
||||
|
||||
private fun MaterialContainerTransformSpec.progressThresholdsGroupFor(
|
||||
direction: TransitionDirection,
|
||||
pathMotion: PathMotion
|
||||
): ProgressThresholdsGroup {
|
||||
val default =
|
||||
if (pathMotion is MaterialArcMotion) {
|
||||
if (direction == TransitionDirection.Enter) DefaultEnterThresholdsArc
|
||||
else DefaultReturnThresholdsArc
|
||||
} else {
|
||||
if (direction == TransitionDirection.Enter) DefaultEnterThresholds
|
||||
else DefaultReturnThresholds
|
||||
}
|
||||
return ProgressThresholdsGroup(
|
||||
fadeProgressThresholds ?: default.fade,
|
||||
scaleProgressThresholds ?: default.scale,
|
||||
scaleMaskProgressThresholds ?: default.scaleMask,
|
||||
shapeMaskProgressThresholds ?: default.shapeMask
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user