TP-61881 CYCS Screen Transitions Changes (#11191)

This commit is contained in:
Aparna Vadlamani
2024-06-07 16:01:18 +05:30
committed by GitHub
parent 4bf0f8b73d
commit 39ec621ecd
16 changed files with 1529 additions and 140 deletions

View File

@@ -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" }

View File

@@ -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

View File

@@ -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(

View File

@@ -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 -> {}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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)
}
}

View File

@@ -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() }

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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
)
}