From 39ec621ecdb26462c3305c272d389a94df7ae493 Mon Sep 17 00:00:00 2001 From: Aparna Vadlamani Date: Fri, 7 Jun 2024 16:01:18 +0530 Subject: [PATCH] TP-61881 CYCS Screen Transitions Changes (#11191) --- android/gradle/libs.versions.toml | 2 - android/navi-cycs/build.gradle | 1 - .../cycs/common/ui/CycsCommonComposable.kt | 20 +- .../cycs/feature/landing/LandingScreen.kt | 14 +- .../com/navi/cycs/feature/landing/Loader.kt | 85 ++- .../feature/score/ScoreScreenComposable.kt | 116 ++-- .../cycs/sharedelements/ElementContainer.kt | 43 ++ .../sharedelements/KeyframeBasedMotion.kt | 77 +++ .../cycs/sharedelements/MaterialArcMotion.kt | 23 + .../com/navi/cycs/sharedelements/MathUtils.kt | 67 +++ .../navi/cycs/sharedelements/PathMotion.kt | 19 + .../cycs/sharedelements/ProgressThresholds.kt | 42 ++ .../cycs/sharedelements/QuadraticBezier.kt | 90 +++ .../cycs/sharedelements/SharedElementsRoot.kt | 523 ++++++++++++++++++ .../SharedElementsTransitionSpec.kt | 30 + .../sharedelements/SharedMaterialContainer.kt | 517 +++++++++++++++++ 16 files changed, 1529 insertions(+), 140 deletions(-) create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ElementContainer.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/KeyframeBasedMotion.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MaterialArcMotion.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MathUtils.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/PathMotion.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ProgressThresholds.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/QuadraticBezier.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsRoot.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsTransitionSpec.kt create mode 100644 android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedMaterialContainer.kt diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3675f62ffa..17537123c2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/android/navi-cycs/build.gradle b/android/navi-cycs/build.gradle index 325cc77c93..829d4ccb32 100644 --- a/android/navi-cycs/build.gradle +++ b/android/navi-cycs/build.gradle @@ -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 diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/common/ui/CycsCommonComposable.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/common/ui/CycsCommonComposable.kt index 8af6a98661..0bb97a601b 100644 --- a/android/navi-cycs/src/main/kotlin/com/navi/cycs/common/ui/CycsCommonComposable.kt +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/common/ui/CycsCommonComposable.kt @@ -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( diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/LandingScreen.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/LandingScreen.kt index 30e5c84b25..e18dca25b3 100644 --- a/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/LandingScreen.kt +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/LandingScreen.kt @@ -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 -> {} diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/Loader.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/Loader.kt index 6aef1db8d4..bfaf012314 100644 --- a/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/Loader.kt +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/landing/Loader.kt @@ -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) + } } } diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/score/ScoreScreenComposable.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/score/ScoreScreenComposable.kt index 4d45306dfc..bfb59c60f3 100644 --- a/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/score/ScoreScreenComposable.kt +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/feature/score/ScoreScreenComposable.kt @@ -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(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, - 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, 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) + } } } diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ElementContainer.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ElementContainer.kt new file mode 100644 index 0000000000..4df9caf1e0 --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ElementContainer.kt @@ -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) } + } +} diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/KeyframeBasedMotion.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/KeyframeBasedMotion.kt new file mode 100644 index 0000000000..383aca2dcf --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/KeyframeBasedMotion.kt @@ -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? = null + + protected abstract fun getKeyframes(start: Offset, end: Offset): Pair + + 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) + } +} diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MaterialArcMotion.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MaterialArcMotion.kt new file mode 100644 index 0000000000..ec0d084c70 --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MaterialArcMotion.kt @@ -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 = + 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() } diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MathUtils.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MathUtils.kt new file mode 100644 index 0000000000..a6f52d09fb --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/MathUtils.kt @@ -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) diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/PathMotion.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/PathMotion.kt new file mode 100644 index 0000000000..5495c5d3cc --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/PathMotion.kt @@ -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 } diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ProgressThresholds.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ProgressThresholds.kt new file mode 100644 index 0000000000..042422f267 --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/ProgressThresholds.kt @@ -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 + } diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/QuadraticBezier.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/QuadraticBezier.kt new file mode 100644 index 0000000000..9399c8db8d --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/QuadraticBezier.kt @@ -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 { + 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 + } +} diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsRoot.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsRoot.kt new file mode 100644 index 0000000000..3cc285e0ec --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsRoot.kt @@ -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 { 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 { + 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()) + 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() + 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() diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsTransitionSpec.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsTransitionSpec.kt new file mode 100644 index 0000000000..7284ad5931 --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedElementsTransitionSpec.kt @@ -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() diff --git a/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedMaterialContainer.kt b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedMaterialContainer.kt new file mode 100644 index 0000000000..c5a3ae5025 --- /dev/null +++ b/android/navi-cycs/src/main/kotlin/com/navi/cycs/sharedelements/SharedMaterialContainer.kt @@ -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() + + 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 + ) +}