From ffb4859754015c8cf0f6382b2e29b3c122cf4e0d Mon Sep 17 00:00:00 2001 From: Arkadii Ivanov Date: Wed, 10 Jul 2024 10:13:25 +0100 Subject: [PATCH] Added new stack animation API with shared transitions support --- extensions-compose-experimental/.gitignore | 1 + .../extensions-compose-experimental.api | 58 +++ .../extensions-compose-experimental.klib.api | 49 +++ .../jvm/extensions-compose-experimental.api | 58 +++ .../build.gradle.kts | 56 +++ .../compose/experimental/stack/ChildStack.kt | 106 ++++++ .../stack/SimpleAnimatedVisibilityScope.kt | 20 ++ .../compose/experimental/stack/Utils.kt | 8 + .../stack/animation/DefaultStackAnimation.kt | 335 ++++++++++++++++++ .../stack/animation/DefaultStackAnimator.kt | 34 ++ .../stack/animation/EmptyStackAnimation.kt | 43 +++ .../experimental/stack/animation/Fade.kt | 25 ++ .../stack/animation/PredictiveBackParams.kt | 30 ++ .../experimental/stack/animation/Scale.kt | 27 ++ .../experimental/stack/animation/Slide.kt | 41 +++ .../stack/animation/StackAnimation.kt | 61 ++++ .../stack/animation/StackAnimationProvider.kt | 17 + .../stack/animation/StackAnimator.kt | 72 ++++ .../experimental/stack/ChildStackTest.kt | 251 +++++++++++++ .../stack/animation/GetFadeAlphaTest.kt | 81 +++++ .../animation/StackAnimationDirectionsTest.kt | 105 ++++++ extensions-compose/build.gradle.kts | 3 - .../animation/StackAnimationDirectionsTest.kt | 72 ++-- sample/shared/compose/build.gradle.kts | 4 +- .../SharedTransitionsContent.kt | 35 +- .../gallery/GalleryContent.kt | 22 +- .../sharedtransitions/photo/PhotoContent.kt | 22 +- .../DefaultSharedTransitionsComponent.kt | 4 + .../SharedTransitionsComponent.kt | 5 +- settings.gradle.kts | 1 + 30 files changed, 1567 insertions(+), 79 deletions(-) create mode 100644 extensions-compose-experimental/.gitignore create mode 100644 extensions-compose-experimental/api/android/extensions-compose-experimental.api create mode 100644 extensions-compose-experimental/api/extensions-compose-experimental.klib.api create mode 100644 extensions-compose-experimental/api/jvm/extensions-compose-experimental.api create mode 100644 extensions-compose-experimental/build.gradle.kts create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStack.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/SimpleAnimatedVisibilityScope.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimator.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/EmptyStackAnimation.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Fade.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Scale.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Slide.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProvider.kt create mode 100644 extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator.kt create mode 100644 extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt create mode 100644 extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/GetFadeAlphaTest.kt create mode 100644 extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationDirectionsTest.kt diff --git a/extensions-compose-experimental/.gitignore b/extensions-compose-experimental/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/extensions-compose-experimental/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/extensions-compose-experimental/api/android/extensions-compose-experimental.api b/extensions-compose-experimental/api/android/extensions-compose-experimental.api new file mode 100644 index 000000000..c9d9eaa04 --- /dev/null +++ b/extensions-compose-experimental/api/android/extensions-compose-experimental.api @@ -0,0 +1,58 @@ +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackKt { + public static final fun ChildStack (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V + public static final fun ChildStack (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/FadeKt { + public static final fun fade (Landroidx/compose/animation/core/FiniteAnimationSpec;F)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun fade$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams { + public static final field $stable I + public fun (Lcom/arkivanov/essenty/backhandler/BackHandler;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;)V + public synthetic fun (Lcom/arkivanov/essenty/backhandler/BackHandler;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAnimatableSelector ()Lkotlin/jvm/functions/Function3; + public final fun getBackHandler ()Lcom/arkivanov/essenty/backhandler/BackHandler; + public final fun getOnBack ()Lkotlin/jvm/functions/Function0; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/ScaleKt { + public static final fun scale (Landroidx/compose/animation/core/FiniteAnimationSpec;FF)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun scale$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FFILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/SlideKt { + public static final fun slide (Landroidx/compose/animation/core/FiniteAnimationSpec;Landroidx/compose/foundation/gestures/Orientation;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun slide$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Landroidx/compose/foundation/gestures/Orientation;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + +public abstract interface class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation { + public abstract fun invoke (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationKt { + public static final fun stackAnimation (Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; + public static final fun stackAnimation (ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;Lkotlin/jvm/functions/Function3;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; + public static synthetic fun stackAnimation$default (Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; + public static synthetic fun stackAnimation$default (ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; +} + +public abstract interface class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProvider { + public abstract fun provide ()Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProviderKt { + public static final fun getLocalStackAnimationProvider ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public abstract interface class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator { + public abstract fun animate (Landroidx/compose/animation/AnimatedVisibilityScope;Lcom/arkivanov/decompose/extensions/compose/stack/animation/Direction;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimatorKt { + public static final fun plus (Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static final fun stackAnimator (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function4;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun stackAnimator$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + diff --git a/extensions-compose-experimental/api/extensions-compose-experimental.klib.api b/extensions-compose-experimental/api/extensions-compose-experimental.klib.api new file mode 100644 index 000000000..1d636f34c --- /dev/null +++ b/extensions-compose-experimental/api/extensions-compose-experimental.klib.api @@ -0,0 +1,49 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, macosArm64, macosX64, wasmJs] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract fun interface <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation { // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation|null[0] + abstract fun invoke(com.arkivanov.decompose.router.stack/ChildStack<#A, #B>, androidx.compose.ui/Modifier, kotlin/Function4, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation.invoke|invoke(com.arkivanov.decompose.router.stack.ChildStack<1:0,1:1>;androidx.compose.ui.Modifier;kotlin.Function4,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +} + +abstract fun interface com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator { // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator|null[0] + abstract fun (androidx.compose.animation/AnimatedVisibilityScope).animate(com.arkivanov.decompose.extensions.compose.stack.animation/Direction, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui/Modifier // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator.animate|animate@androidx.compose.animation.AnimatedVisibilityScope(com.arkivanov.decompose.extensions.compose.stack.animation.Direction;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +} + +abstract interface com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimationProvider { // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimationProvider|null[0] + abstract fun <#A1: kotlin/Any, #B1: kotlin/Any> provide(): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation<#A1, #B1>? // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimationProvider.provide|provide(){0§;1§}[0] +} + +final class <#A: in kotlin/Any, #B: in kotlin/Any> com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams { // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams|null[0] + constructor (com.arkivanov.essenty.backhandler/BackHandler, kotlin/Function0, kotlin/Function3, com.arkivanov.decompose/Child.Created<#A, #B>, com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback/PredictiveBackAnimatable?> = ...) // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.|(com.arkivanov.essenty.backhandler.BackHandler;kotlin.Function0;kotlin.Function3,com.arkivanov.decompose.Child.Created<1:0,1:1>,com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.PredictiveBackAnimatable?>){}[0] + + final val animatableSelector // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.animatableSelector|{}animatableSelector[0] + final fun (): kotlin/Function3, com.arkivanov.decompose/Child.Created<#A, #B>, com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback/PredictiveBackAnimatable?> // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.animatableSelector.|(){}[0] + final val backHandler // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.backHandler|{}backHandler[0] + final fun (): com.arkivanov.essenty.backhandler/BackHandler // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.backHandler.|(){}[0] + final val onBack // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.onBack|{}onBack[0] + final fun (): kotlin/Function0 // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams.onBack.|(){}[0] +} + +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/LocalStackAnimationProvider // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/LocalStackAnimationProvider|{}LocalStackAnimationProvider[0] + final fun (): androidx.compose.runtime/ProvidableCompositionLocal // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/LocalStackAnimationProvider.|(){}[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop[0] +final val com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop|#static{}com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop[0] + +final fun (com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator).com.arkivanov.decompose.extensions.compose.experimental.stack.animation/plus(com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/plus|plus@com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimator(com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimator){}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.extensions.compose.experimental.stack.animation/stackAnimation(com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator = ..., kotlin/Boolean = ..., com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams<#A, #B>? = ...): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation<#A, #B> // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/stackAnimation|stackAnimation(com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimator;kotlin.Boolean;com.arkivanov.decompose.extensions.compose.experimental.stack.animation.PredictiveBackParams<0:0,0:1>?){0§;1§}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.extensions.compose.experimental.stack.animation/stackAnimation(kotlin/Boolean = ..., com.arkivanov.decompose.extensions.compose.experimental.stack.animation/PredictiveBackParams<#A, #B>? = ..., kotlin/Function3, com.arkivanov.decompose/Child.Created<#A, #B>, com.arkivanov.decompose.extensions.compose.stack.animation/Direction, com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator?>): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation<#A, #B> // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/stackAnimation|stackAnimation(kotlin.Boolean;com.arkivanov.decompose.extensions.compose.experimental.stack.animation.PredictiveBackParams<0:0,0:1>?;kotlin.Function3,com.arkivanov.decompose.Child.Created<0:0,0:1>,com.arkivanov.decompose.extensions.compose.stack.animation.Direction,com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimator?>){0§;1§}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.extensions.compose.experimental.stack/ChildStack(com.arkivanov.decompose.router.stack/ChildStack<#A, #B>, androidx.compose.ui/Modifier?, com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation<#A, #B>?, kotlin/Function4, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.arkivanov.decompose.extensions.compose.experimental.stack/ChildStack|ChildStack(com.arkivanov.decompose.router.stack.ChildStack<0:0,0:1>;androidx.compose.ui.Modifier?;com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimation<0:0,0:1>?;kotlin.Function4,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§;1§}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.extensions.compose.experimental.stack/ChildStack(com.arkivanov.decompose.value/Value>, androidx.compose.ui/Modifier?, com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimation<#A, #B>?, kotlin/Function4, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.arkivanov.decompose.extensions.compose.experimental.stack/ChildStack|ChildStack(com.arkivanov.decompose.value.Value>;androidx.compose.ui.Modifier?;com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimation<0:0,0:1>?;kotlin.Function4,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§;1§}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimation$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_DefaultStackAnimator$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop_getter(): kotlin/Int // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop_getter|com_arkivanov_decompose_extensions_compose_experimental_stack_animation_PredictiveBackParams$stableprop_getter(){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/fade(androidx.compose.animation.core/FiniteAnimationSpec = ..., kotlin/Float = ...): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/fade|fade(androidx.compose.animation.core.FiniteAnimationSpec;kotlin.Float){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/scale(androidx.compose.animation.core/FiniteAnimationSpec = ..., kotlin/Float = ..., kotlin/Float = ...): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/scale|scale(androidx.compose.animation.core.FiniteAnimationSpec;kotlin.Float;kotlin.Float){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/slide(androidx.compose.animation.core/FiniteAnimationSpec = ..., androidx.compose.foundation.gestures/Orientation = ...): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/slide|slide(androidx.compose.animation.core.FiniteAnimationSpec;androidx.compose.foundation.gestures.Orientation){}[0] +final fun com.arkivanov.decompose.extensions.compose.experimental.stack.animation/stackAnimator(androidx.compose.animation.core/FiniteAnimationSpec = ..., kotlin/Function4): com.arkivanov.decompose.extensions.compose.experimental.stack.animation/StackAnimator // com.arkivanov.decompose.extensions.compose.experimental.stack.animation/stackAnimator|stackAnimator(androidx.compose.animation.core.FiniteAnimationSpec;kotlin.Function4){}[0] diff --git a/extensions-compose-experimental/api/jvm/extensions-compose-experimental.api b/extensions-compose-experimental/api/jvm/extensions-compose-experimental.api new file mode 100644 index 000000000..c9d9eaa04 --- /dev/null +++ b/extensions-compose-experimental/api/jvm/extensions-compose-experimental.api @@ -0,0 +1,58 @@ +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackKt { + public static final fun ChildStack (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V + public static final fun ChildStack (Lcom/arkivanov/decompose/value/Value;Landroidx/compose/ui/Modifier;Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/FadeKt { + public static final fun fade (Landroidx/compose/animation/core/FiniteAnimationSpec;F)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun fade$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams { + public static final field $stable I + public fun (Lcom/arkivanov/essenty/backhandler/BackHandler;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;)V + public synthetic fun (Lcom/arkivanov/essenty/backhandler/BackHandler;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAnimatableSelector ()Lkotlin/jvm/functions/Function3; + public final fun getBackHandler ()Lcom/arkivanov/essenty/backhandler/BackHandler; + public final fun getOnBack ()Lkotlin/jvm/functions/Function0; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/ScaleKt { + public static final fun scale (Landroidx/compose/animation/core/FiniteAnimationSpec;FF)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun scale$default (Landroidx/compose/animation/core/FiniteAnimationSpec;FFILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/SlideKt { + public static final fun slide (Landroidx/compose/animation/core/FiniteAnimationSpec;Landroidx/compose/foundation/gestures/Orientation;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun slide$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Landroidx/compose/foundation/gestures/Orientation;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + +public abstract interface class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation { + public abstract fun invoke (Lcom/arkivanov/decompose/router/stack/ChildStack;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationKt { + public static final fun stackAnimation (Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; + public static final fun stackAnimation (ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;Lkotlin/jvm/functions/Function3;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; + public static synthetic fun stackAnimation$default (Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; + public static synthetic fun stackAnimation$default (ZLcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; +} + +public abstract interface class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProvider { + public abstract fun provide ()Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProviderKt { + public static final fun getLocalStackAnimationProvider ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public abstract interface class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator { + public abstract fun animate (Landroidx/compose/animation/AnimatedVisibilityScope;Lcom/arkivanov/decompose/extensions/compose/stack/animation/Direction;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; +} + +public final class com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimatorKt { + public static final fun plus (Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static final fun stackAnimator (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function4;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; + public static synthetic fun stackAnimator$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator; +} + diff --git a/extensions-compose-experimental/build.gradle.kts b/extensions-compose-experimental/build.gradle.kts new file mode 100644 index 000000000..4828d6919 --- /dev/null +++ b/extensions-compose-experimental/build.gradle.kts @@ -0,0 +1,56 @@ +import com.arkivanov.gradle.bundle +import com.arkivanov.gradle.iosCompat +import com.arkivanov.gradle.macosCompat +import com.arkivanov.gradle.setupBinaryCompatibilityValidator +import com.arkivanov.gradle.setupMultiplatform +import com.arkivanov.gradle.setupPublication +import com.arkivanov.gradle.setupSourceSets + +plugins { + id("kotlin-multiplatform") + id("com.android.library") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") + id("com.arkivanov.gradle.setup") +} + +setupMultiplatform { + androidTarget() + jvm() + macosCompat() + iosCompat() + js { browser() } + wasmJs { browser() } +} + +setupPublication() +setupBinaryCompatibilityValidator() + +android { + namespace = "com.arkivanov.decompose.extensions.compose.experimental" +} + +kotlin { + setupSourceSets { + val jvm by bundle() + + all { + languageSettings { + optIn("com.arkivanov.decompose.InternalDecomposeApi") + } + } + + common.main.dependencies { + implementation(project(":decompose")) + api(project(":extensions-compose")) + implementation(compose.foundation) + } + + jvm.test.dependencies { + implementation(deps.jetbrains.compose.ui.uiTestJunit4) + implementation(deps.jetbrains.kotlinx.kotlinxCoroutinesSwing) + implementation(deps.junit.junit) + implementation(compose.desktop.currentOs) + } + } +} diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStack.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStack.kt new file mode 100644 index 000000000..7761a7782 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStack.kt @@ -0,0 +1,106 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.LocalStackAnimationProvider +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimation +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.emptyStackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.keyHashString +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.value.Value + +/** + * Displays the provided [stack] of child components, taking care of saving and restoring the UI state. + * + * @param stack a [ChildStack] to be displayed. + * @param modifier a [Modifier] to applied to a wrapping container. + * @param animation an optional [StackAnimation] for animating stack changes. If not provided (or `null`), + * then a default [StackAnimation] is obtained from [LocalStackAnimationProvider]. + * If that is also `null`, then there is no animation. + * @param content a `Composable` function that displays the provided [Child][Child.Created] component. + * The receiver [AnimatedVisibilityScope] can be used for additional animations, such as + * [Shared Element Transitions](https://developer.android.com/develop/ui/compose/animation/shared-elements). + */ +@ExperimentalDecomposeApi +@Composable +fun ChildStack( + stack: ChildStack, + modifier: Modifier = Modifier, + animation: StackAnimation? = null, + content: @Composable AnimatedVisibilityScope.(child: Child.Created) -> Unit, +) { + val holder = rememberSaveableStateHolder() + + holder.retainStates(stack.getKeys()) + + val animationProvider = LocalStackAnimationProvider.current + val anim = animation ?: remember(animationProvider, animationProvider::provide) ?: emptyStackAnimation() + + anim(stack = stack, modifier = modifier) { child -> + holder.SaveableStateProvider(child.keyHashString()) { + content(child) + } + } +} + +/** + * Displays the provided [stack] of child components, taking care of saving and restoring the UI state. + * + * @param stack an observable [ChildStack] to be displayed. + * @param modifier a [Modifier] to applied to a wrapping container. + * @param animation an optional [StackAnimation] for animating stack changes. If not provided (or `null`), + * then a default [StackAnimation] is obtained from [LocalStackAnimationProvider]. + * If that is also `null`, then there is no animation. + * @param content a `Composable` function that displays the provided [Child][Child.Created] component. + * The receiver [AnimatedVisibilityScope] can be used for additional animations, such as + * [Shared Element Transitions](https://developer.android.com/develop/ui/compose/animation/shared-elements). + */ +@ExperimentalDecomposeApi +@Composable +fun ChildStack( + stack: Value>, + modifier: Modifier = Modifier, + animation: StackAnimation? = null, + content: @Composable AnimatedVisibilityScope.(child: Child.Created) -> Unit, +) { + val state = stack.subscribeAsState() + + ChildStack( + stack = state.value, + modifier = modifier, + animation = animation, + content = content + ) +} + +private fun ChildStack<*, *>.getKeys(): Set = + items.mapTo(HashSet(), Child<*, *>::keyHashString) + +@Composable +private fun SaveableStateHolder.retainStates(currentKeys: Set) { + val keys = remember(this) { Keys(currentKeys) } + + DisposableEffect(this, currentKeys) { + keys.set.forEach { + if (it !in currentKeys) { + removeState(it) + } + } + + keys.set = currentKeys + + onDispose {} + } +} + +private class Keys( + var set: Set +) diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/SimpleAnimatedVisibilityScope.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/SimpleAnimatedVisibilityScope.kt new file mode 100644 index 000000000..48ae04d6c --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/SimpleAnimatedVisibilityScope.kt @@ -0,0 +1,20 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +internal fun WithAnimatedVisibilityScope( + transition: Transition, + block: @Composable AnimatedVisibilityScope.() -> Unit, +) { + val scope = remember(transition) { SimpleAnimatedVisibilityScope(transition) } + scope.block() +} + +private class SimpleAnimatedVisibilityScope( + override val transition: Transition, +) : AnimatedVisibilityScope diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt new file mode 100644 index 000000000..434d4fa9f --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/Utils.kt @@ -0,0 +1,8 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack + +import com.arkivanov.decompose.router.stack.ChildStack + +internal fun ChildStack.dropLast(): ChildStack = + ChildStack(active = backStack.last(), backStack = backStack.dropLast(1)) + +internal val ChildStack<*, *>.size: Int get() = items.size diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt new file mode 100644 index 000000000..b8a18f399 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimation.kt @@ -0,0 +1,335 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.TransitionState +import androidx.compose.animation.core.rememberTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.experimental.stack.WithAnimatedVisibilityScope +import com.arkivanov.decompose.extensions.compose.experimental.stack.dropLast +import com.arkivanov.decompose.extensions.compose.experimental.stack.size +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction +import com.arkivanov.decompose.extensions.compose.stack.animation.isExit +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.PredictiveBackAnimatable +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.essenty.backhandler.BackCallback +import com.arkivanov.essenty.backhandler.BackEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch + +@ExperimentalDecomposeApi +internal class DefaultStackAnimation( + private val disableInputDuringAnimation: Boolean, + private val predictiveBackParams: PredictiveBackParams?, + private val selector: (child: Child.Created, otherChild: Child.Created, direction: Direction) -> StackAnimator?, +) : StackAnimation { + + @Composable + override operator fun invoke( + stack: ChildStack, + modifier: Modifier, + content: @Composable AnimatedVisibilityScope.(child: Child.Created) -> Unit, + ) { + var currentStack by remember { mutableStateOf(stack) } + var items by remember { mutableStateOf(getAnimationItems(newStack = currentStack)) } + + if (stack.active.key != currentStack.active.key) { + val oldStack = currentStack + currentStack = stack + + if ((items.size == 1) && (items.keys.single() != currentStack.active.key)) { + items = getAnimationItems(newStack = currentStack, oldStack = oldStack) + } + } + + Box(modifier = modifier) { + items.forEach { (key, item) -> + key(key) { + Child( + item = item, + onFinished = { + if (item.direction.isExit) { + items -= key + } else { + items += (key to item.copy(animator = null)) + } + }, + content = content, + ) + } + } + + // A workaround until https://issuetracker.google.com/issues/214231672. + // Normally only the exiting child should be disabled. + if (disableInputDuringAnimation && (items.size > 1)) { + Overlay(modifier = Modifier.matchParentSize()) + } + } + + if ((predictiveBackParams != null) && currentStack.backStack.isNotEmpty()) { + key(items.keys) { + PredictiveBackController( + stack = currentStack, + predictiveBackParams = predictiveBackParams, + setItems = { items = it }, + ) + } + } + } + + @Composable + private fun Child( + item: AnimationItem, + onFinished: () -> Unit, + content: @Composable AnimatedVisibilityScope.(child: Child.Created) -> Unit + ) { + val transition = rememberTransition(item.transitionState) + + if (item.transitionState.isIdle()) { + LaunchedEffect(Unit) { + onFinished() + } + } + + WithAnimatedVisibilityScope(transition) { + Box(modifier = item.animator?.run { animate(item.direction) } ?: Modifier) { + content(item.child) + } + } + } + + private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack? = null): Map> = + when { + oldStack == null -> + keyedItemsOf( + AnimationItem( + child = newStack.active, + direction = Direction.ENTER_FRONT, + transitionState = MutableTransitionState(EnterExitState.Visible), + ) + ) + + (newStack.size < oldStack.size) && oldStack.backStack.any { it.key == newStack.active.key } -> + keyedItemsOf( + AnimationItem( + child = newStack.active, + direction = Direction.ENTER_BACK, + transitionState = EnterExitState.PreEnter transitionTo EnterExitState.Visible, + otherChild = oldStack.active, + ), + AnimationItem( + child = oldStack.active, + direction = Direction.EXIT_FRONT, + transitionState = EnterExitState.Visible transitionTo EnterExitState.PostExit, + otherChild = newStack.active, + ), + ) + + else -> + keyedItemsOf( + AnimationItem( + child = oldStack.active, + direction = Direction.EXIT_BACK, + transitionState = EnterExitState.Visible transitionTo EnterExitState.PostExit, + otherChild = newStack.active, + ), + AnimationItem( + child = newStack.active, + direction = Direction.ENTER_FRONT, + transitionState = EnterExitState.PreEnter transitionTo EnterExitState.Visible, + otherChild = oldStack.active, + ), + ) + } + + @ExperimentalDecomposeApi + @Composable + private fun PredictiveBackController( + stack: ChildStack, + predictiveBackParams: PredictiveBackParams, + setItems: (Map>) -> Unit, + ) { + val scope = rememberCoroutineScope() + + val callback = + remember { + PredictiveBackCallback( + stack = stack, + scope = scope, + predictiveBackParams = predictiveBackParams, + setItems = setItems, + ) + } + + DisposableEffect(predictiveBackParams.backHandler, callback) { + predictiveBackParams.backHandler.register(callback) + onDispose { predictiveBackParams.backHandler.unregister(callback) } + } + } + + private fun AnimationItem( + child: Child.Created, + direction: Direction, + transitionState: TransitionState, + otherChild: Child.Created, + predictiveBackAnimator: StackAnimator? = null, + ): AnimationItem = + AnimationItem( + child = child, + direction = direction, + transitionState = transitionState, + animator = predictiveBackAnimator ?: selector(child, otherChild, direction), + ) + + private inner class PredictiveBackCallback( + private val stack: ChildStack, + private val scope: CoroutineScope, + private val predictiveBackParams: PredictiveBackParams, + private val setItems: (Map>) -> Unit, + ) : BackCallback() { + private val exitChild = stack.active + private val exitTransitionState = SeekableTransitionState(initialState = EnterExitState.Visible) + private val enterChild = stack.backStack.last() + private val enterTransitionState = SeekableTransitionState(initialState = EnterExitState.PreEnter) + private var animatable: PredictiveBackAnimatable? = null + + override fun onBackStarted(backEvent: BackEvent) { + animatable = predictiveBackParams.animatableSelector(backEvent, exitChild, enterChild) + + setItems( + keyedItemsOf( + AnimationItem( + child = enterChild, + direction = Direction.ENTER_BACK, + transitionState = enterTransitionState, + otherChild = exitChild, + predictiveBackAnimator = animatable?.let { anim -> SimpleStackAnimator { anim.enterModifier } }, + ), + AnimationItem( + child = exitChild, + direction = Direction.EXIT_FRONT, + transitionState = exitTransitionState, + otherChild = enterChild, + predictiveBackAnimator = animatable?.let { anim -> SimpleStackAnimator { anim.exitModifier } }, + ), + ) + ) + + scope.launch { + joinAll( + launch { exitTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.PostExit) }, + launch { enterTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.Visible) }, + launch { animatable?.animate(backEvent) }, + ) + } + } + + override fun onBackProgressed(backEvent: BackEvent) { + scope.launch { + animatable?.run { + animate(backEvent) + return@launch // Don't animate transition states on back progress if there is PredictiveBackAnimatable + } + + joinAll( + launch { exitTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.PostExit) }, + launch { enterTransitionState.seekTo(fraction = backEvent.progress, targetState = EnterExitState.Visible) }, + ) + } + } + + override fun onBackCancelled() { + scope.launch { + joinAll( + launch { exitTransitionState.snapTo(EnterExitState.Visible) }, + launch { enterTransitionState.snapTo(EnterExitState.PreEnter) }, + launch { animatable?.cancel() }, + ) + + setItems(getAnimationItems(newStack = stack)) + } + } + + override fun onBack() { + scope.launch { + joinAll( + launch { exitTransitionState.animateTo(EnterExitState.PostExit) }, + launch { enterTransitionState.animateTo(EnterExitState.Visible) }, + launch { animatable?.finish() } + ) + + setItems(getAnimationItems(newStack = stack.dropLast())) + predictiveBackParams.onBack() + } + } + } +} + +@Composable +private fun Overlay(modifier: Modifier) { + Box( + modifier = modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + event.changes.forEach { it.consume() } + } + } + } + ) +} + +@ExperimentalDecomposeApi +private data class AnimationItem( + val child: Child.Created, + val direction: Direction, + val transitionState: TransitionState, + val animator: StackAnimator? = null, +) + +@ExperimentalDecomposeApi +private fun keyedItemsOf(vararg items: AnimationItem): Map> = + items.associateBy { it.child.key } + +/* + * Can't be anonymous. See: + * https://github.com/JetBrains/compose-jb/issues/2688 + * https://github.com/JetBrains/compose-jb/issues/2612 + */ +@ExperimentalDecomposeApi +private class SimpleStackAnimator( + private val modifier: () -> Modifier, +) : StackAnimator { + @Composable + override fun AnimatedVisibilityScope.animate(direction: Direction): Modifier = + modifier() +} + +private infix fun S.transitionTo(targetState: S): MutableTransitionState = + MutableTransitionState(this).apply { + this.targetState = targetState + } + + +private fun TransitionState<*>.isIdle(): Boolean = + when (this) { + is MutableTransitionState -> isIdle + is SeekableTransitionState -> false + else -> false + } diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimator.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimator.kt new file mode 100644 index 000000000..7fc611a79 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/DefaultStackAnimator.kt @@ -0,0 +1,34 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction +import com.arkivanov.decompose.extensions.compose.stack.animation.isFront + +@ExperimentalDecomposeApi +internal class DefaultStackAnimator( + private val animationSpec: FiniteAnimationSpec = tween(), + private val frame: @Composable (factor: Float, direction: Direction) -> Modifier +) : StackAnimator { + + @Composable + override fun AnimatedVisibilityScope.animate(direction: Direction): Modifier { + val factor by transition.animateFloat(transitionSpec = { animationSpec }) { state -> + when (state) { + EnterExitState.Visible -> 0F + + EnterExitState.PreEnter, + EnterExitState.PostExit -> if (direction.isFront) 1F else -1F + } + } + + return frame(factor, direction) + } +} diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/EmptyStackAnimation.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/EmptyStackAnimation.kt new file mode 100644 index 000000000..be87601a6 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/EmptyStackAnimation.kt @@ -0,0 +1,43 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.rememberTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.experimental.stack.WithAnimatedVisibilityScope +import com.arkivanov.decompose.router.stack.ChildStack + +@ExperimentalDecomposeApi +internal fun emptyStackAnimation(): StackAnimation = + EmptyStackAnimation() + +/* + * Can't be anonymous. See: + * https://github.com/JetBrains/compose-jb/issues/2688 + * https://github.com/JetBrains/compose-jb/issues/2612 + */ +@ExperimentalDecomposeApi +private class EmptyStackAnimation : StackAnimation { + + @Composable + override fun invoke( + stack: ChildStack, + modifier: Modifier, + content: @Composable AnimatedVisibilityScope.(child: Child.Created) -> Unit, + ) { + val transitionState = remember { MutableTransitionState(EnterExitState.Visible) } + val transition = rememberTransition(transitionState) + + WithAnimatedVisibilityScope(transition) { + Box(modifier = modifier) { + content(stack.active) + } + } + } +} diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Fade.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Fade.kt new file mode 100644 index 000000000..20ad0fd8e --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Fade.kt @@ -0,0 +1,25 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import com.arkivanov.decompose.ExperimentalDecomposeApi +import kotlin.math.abs + +/** + * A simple fading animation. Appearing children's `alpha` is animated from [minAlpha] to 1.0. + * Disappearing children's `alpha` is animated from 1.0 to [minAlpha]. + */ +@ExperimentalDecomposeApi +fun fade( + animationSpec: FiniteAnimationSpec = tween(), + minAlpha: Float = 0F, +): StackAnimator = + stackAnimator(animationSpec = animationSpec) { factor, _ -> + Modifier.alpha(getFadeAlpha(factor = factor, minAlpha = minAlpha)) + } + +internal fun getFadeAlpha(factor: Float, minAlpha: Float): Float = + (1F - abs(factor) * (1F - minAlpha)).coerceIn(minimumValue = 0F, maximumValue = 1F) diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams.kt new file mode 100644 index 000000000..e55c148aa --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/PredictiveBackParams.kt @@ -0,0 +1,30 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.PredictiveBackAnimatable +import com.arkivanov.essenty.backhandler.BackEvent +import com.arkivanov.essenty.backhandler.BackHandler + +/** + * Contains configuration parameters for the predictive back gesture. + * + * @param backHandler A [BackHandler] for observing back events, usually taken from the + * corresponding child [ComponentContext][com.arkivanov.decompose.ComponentContext]. + * @param onBack a callback to be called when the back gesture is confirmed (finished), + * it should usually call [StackNavigator#pop][com.arkivanov.decompose.router.stack.pop]. + * @param animatableSelector a selector function that returns a [PredictiveBackAnimatable] + * for the given initial [BackEvent] and child components. If not provided, then a default + * animation will be used for back gestures + * (see [ChildStack][com.arkivanov.decompose.extensions.compose.experimental.stack.ChildStack]). + */ +@ExperimentalDecomposeApi +class PredictiveBackParams( + val backHandler: BackHandler, + val onBack: () -> Unit, + val animatableSelector: ( + initialBackEvent: BackEvent, + exitChild: Child.Created, + enterChild: Child.Created, + ) -> PredictiveBackAnimatable? = { _, _, _ -> null }, +) diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Scale.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Scale.kt new file mode 100644 index 000000000..7427e1138 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Scale.kt @@ -0,0 +1,27 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import com.arkivanov.decompose.ExperimentalDecomposeApi + +/** + * A simple scaling animation. Front (above) children are scaling from [frontFactor] to 1.0. + * Back (below) children are scaling from 1.0 to [backFactor]. + */ +@ExperimentalDecomposeApi +fun scale( + animationSpec: FiniteAnimationSpec = tween(), + frontFactor: Float = 1.15F, + backFactor: Float = 0.95F, +): StackAnimator = + stackAnimator(animationSpec = animationSpec) { factor, _ -> + Modifier.scale( + if (factor >= 0F) { + factor * (frontFactor - 1F) + 1F + } else { + factor * (1F - backFactor) + 1F + } + ) + } diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Slide.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Slide.kt new file mode 100644 index 000000000..7681252a5 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/Slide.kt @@ -0,0 +1,41 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import com.arkivanov.decompose.ExperimentalDecomposeApi + +/** + * A simple sliding animation. Children enter from one side and exit to another side. + */ +@ExperimentalDecomposeApi +fun slide( + animationSpec: FiniteAnimationSpec = tween(), + orientation: Orientation = Orientation.Horizontal, +): StackAnimator = + stackAnimator(animationSpec = animationSpec) { factor, _ -> + when (orientation) { + Orientation.Horizontal -> Modifier.offsetXFactor(factor) + Orientation.Vertical -> Modifier.offsetYFactor(factor) + } + } + +private fun Modifier.offsetXFactor(factor: Float): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(x = (placeable.width.toFloat() * factor).toInt(), y = 0) + } + } + +private fun Modifier.offsetYFactor(factor: Float): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(x = 0, y = (placeable.height.toFloat() * factor).toInt()) + } + } diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation.kt new file mode 100644 index 000000000..081fe651b --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimation.kt @@ -0,0 +1,61 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction +import com.arkivanov.decompose.router.stack.ChildStack + +/** + * Tracks the [ChildStack] changes and animates between child widget. + */ +@ExperimentalDecomposeApi +fun interface StackAnimation { + + @Composable + operator fun invoke( + stack: ChildStack, + modifier: Modifier, + content: @Composable AnimatedVisibilityScope.(child: Child.Created) -> Unit, + ) +} + +/** + * Creates an implementation of [StackAnimation] that allows different [StackAnimator]s. + * + * @param disableInputDuringAnimation disables input and touch events while animating, default value is `true`. + * @param predictiveBackParams enables the predictive back gesture with the specified parameters. + * @param selector provides a [StackAnimator] for the current [Child], other [Child] and [Direction]. + */ +@ExperimentalDecomposeApi +fun stackAnimation( + disableInputDuringAnimation: Boolean = true, + predictiveBackParams: PredictiveBackParams? = null, + selector: (child: Child.Created, otherChild: Child.Created, direction: Direction) -> StackAnimator?, +): StackAnimation = + DefaultStackAnimation( + disableInputDuringAnimation = disableInputDuringAnimation, + predictiveBackParams = predictiveBackParams, + selector = selector, + ) + +/** + * Creates an implementation of [StackAnimation] with the provided [StackAnimator]. + * + * @param animator a [StackAnimator] to be used for animation, default is [fade]. + * @param disableInputDuringAnimation disables input and touch events while animating, default value is `true`. + * @param predictiveBackParams enables the predictive back gesture with the specified parameters. + */ +@ExperimentalDecomposeApi +fun stackAnimation( + animator: StackAnimator = fade(), + disableInputDuringAnimation: Boolean = true, + predictiveBackParams: PredictiveBackParams? = null, +): StackAnimation = + DefaultStackAnimation( + disableInputDuringAnimation = disableInputDuringAnimation, + predictiveBackParams = predictiveBackParams, + selector = { _, _, _ -> animator }, + ) diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProvider.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProvider.kt new file mode 100644 index 000000000..5ffc2a7a8 --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationProvider.kt @@ -0,0 +1,17 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.runtime.compositionLocalOf +import com.arkivanov.decompose.ExperimentalDecomposeApi + +@ExperimentalDecomposeApi +interface StackAnimationProvider { + fun provide(): StackAnimation? +} + +@ExperimentalDecomposeApi +val LocalStackAnimationProvider = + compositionLocalOf { + object : StackAnimationProvider { + override fun provide(): StackAnimation? = null + } + } diff --git a/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator.kt b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator.kt new file mode 100644 index 000000000..b0386682b --- /dev/null +++ b/extensions-compose-experimental/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimator.kt @@ -0,0 +1,72 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction + +/** + * Animates a child widget in the given [Direction]. + */ +@ExperimentalDecomposeApi +fun interface StackAnimator { + + /** + * Animates the returned [Modifier] in the given [Direction]. + * + * @param direction the [Direction] in which the animation should run. + */ + @Composable + fun AnimatedVisibilityScope.animate(direction: Direction): Modifier +} + +/** + * Creates an implementation of [StackAnimator] with a convenient frame-by-frame rendering. + * + * @param animationSpec a [FiniteAnimationSpec] to configure the animation. + * @param frame returns a [Modifier] for the given `factor` and [Direction]. Called for every animation frame. + * The `factor` argument changes as follows: + * - From 1F to 0F for [Direction.ENTER_FRONT] + * - From 0F to 1F for [Direction.EXIT_FRONT] + * - From -1F to 0F for [Direction.ENTER_BACK] + * - From 0F to -1F for [Direction.EXIT_BACK] + */ +@ExperimentalDecomposeApi +fun stackAnimator( + animationSpec: FiniteAnimationSpec = tween(), + frame: @Composable (factor: Float, direction: Direction) -> Modifier, +): StackAnimator = + DefaultStackAnimator( + animationSpec = animationSpec, + frame = frame + ) + +/** + * Combines (merges) the receiver [StackAnimator] with the [other] [StackAnimator]. + */ +@ExperimentalDecomposeApi +operator fun StackAnimator.plus(other: StackAnimator): StackAnimator = + PlusStackAnimator(first = this, second = other) + +/* + * Can't be anonymous. See: + * https://github.com/JetBrains/compose-jb/issues/2688 + * https://github.com/JetBrains/compose-jb/issues/2612 + */ +@ExperimentalDecomposeApi +private class PlusStackAnimator( + private val first: StackAnimator, + private val second: StackAnimator, +) : StackAnimator { + + @Composable + override fun AnimatedVisibilityScope.animate(direction: Direction): Modifier { + val firstModifier = with(first) { animate(direction) } + val secondModifier = with(second) { animate(direction) } + + return firstModifier.then(secondModifier) + } +} diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt new file mode 100644 index 000000000..1434cd170 --- /dev/null +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/ChildStackTest.kt @@ -0,0 +1,251 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.StackAnimation +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.plus +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.scale +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@OptIn(ExperimentalDecomposeApi::class) +@Suppress("TestFunctionName") +@RunWith(Parameterized::class) +class ChildStackTest( + private val animation: StackAnimation?, +) { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun WHEN_active_child_and_no_back_stack_THEN_active_child_displayed() { + val state = mutableStateOf(routerState(Config.A)) + + setContent(state) + + composeRule.onNodeWithText(text = "ChildA", substring = true).assertExists() + } + + @Test + fun GIVEN_child_A_displayed_WHEN_push_child_B_THEN_child_B_displayed() { + val state = mutableStateOf(routerState(Config.A)) + setContent(state) + + state.setValueOnIdle(routerState(Config.A, Config.B)) + + composeRule.onNodeWithText(text = "ChildB", substring = true).assertExists() + } + + @Test + fun GIVEN_child_A1_displayed_WHEN_push_child_A2_THEN_child_A2_displayed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA2", substring = true).assertExists() + } + + @Test + fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_child_A_displayed() { + val state = mutableStateOf(routerState(Config.A)) + setContent(state) + state.setValueOnIdle(routerState(Config.A, Config.B)) + + state.setValueOnIdle(routerState(Config.A)) + + composeRule.onNodeWithText(text = "ChildA", substring = true).assertExists() + } + + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_THEN_child_A1_displayed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + + state.setValueOnIdle(routerState("A1" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA1", substring = true).assertExists() + } + + @Test + fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_state_restored_for_child_A() { + val state = mutableStateOf(routerState(Config.A)) + setContent(state) + composeRule.onNodeWithText(text = "ChildA=0").performClick() + state.setValueOnIdle(routerState(Config.A, Config.B)) + + state.setValueOnIdle(routerState(Config.A)) + + composeRule.onNodeWithText(text = "ChildA=1").assertExists() + } + + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_THEN_state_restored_for_child_A1() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + composeRule.onNodeWithText(text = "ChildA1=0").performClick() + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + state.setValueOnIdle(routerState("A1" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA1=1").assertExists() + } + + @Test + fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_and_push_child_B_THEN_state_not_restored_for_child_B() { + val state = mutableStateOf(routerState(Config.A)) + setContent(state) + + state.setValueOnIdle(routerState(Config.A, Config.B)) + composeRule.onNodeWithText(text = "ChildB=0").performClick() + + state.setValueOnIdle(routerState(Config.A)) + state.setValueOnIdle(routerState(Config.A, Config.B)) + + composeRule.onNodeWithText(text = "ChildB=0").assertExists() + } + + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_and_push_child_A2_THEN_state_not_restored_for_child_A2() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + composeRule.onNodeWithText(text = "ChildA2=0").performClick() + + state.setValueOnIdle(routerState("A1" to Config.A)) + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA2=0").assertExists() + } + + @Test + fun GIVEN_child_A_displayed_WHEN_push_child_B_THEN_child_A_disposed() { + val state = mutableStateOf(routerState(Config.A)) + setContent(state) + + state.setValueOnIdle(routerState(Config.A, Config.B)) + + composeRule.onNodeWithText(text = "ChildA", substring = true).assertDoesNotExist() + } + + @Test + fun GIVEN_child_A1_displayed_WHEN_push_child_A2_THEN_child_A1_disposed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA1", substring = true).assertDoesNotExist() + } + + @Test + fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_child_B_disposed() { + val state = mutableStateOf(routerState(Config.A)) + setContent(state) + state.setValueOnIdle(routerState(Config.A, Config.B)) + + state.setValueOnIdle(routerState(Config.A)) + + composeRule.onNodeWithText(text = "ChildB", substring = true).assertDoesNotExist() + } + + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_THEN_child_A2_disposed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + state.setValueOnIdle(routerState("A1" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA2", substring = true).assertDoesNotExist() + } + + private fun setContent(state: State>) { + composeRule.setContent { + ChildStack(stack = state.value, animation = animation) { child -> + Child(name = child.key.toString()) + } + } + + composeRule.runOnIdle {} + } + + private fun routerState(vararg stack: Config): ChildStack = + ChildStack( + active = stack.last().toChild(), + backStack = stack.dropLast(1).map { it.toChild() }, + ) + + private fun Config.toChild(): Child.Created = + Child.Created(configuration = this, instance = this) + + private fun routerState(vararg stack: Pair): ChildStack = + ChildStack( + active = stack.last().toChild(), + backStack = stack.dropLast(1).map { it.toChild() }, + ) + + private fun Pair.toChild(): Child.Created = + Child.Created(configuration = second, instance = second, key = first) + + @Composable + private fun Child(name: String) { + var count by rememberSaveable { mutableStateOf(0) } + + BasicText( + text = "Child$name=$count", + modifier = Modifier.clickable { count++ } + ) + } + + private fun MutableState.setValueOnIdle(value: T) { + composeRule.runOnIdle { this.value = value } + composeRule.runOnIdle {} + } + + companion object { + @Parameterized.Parameters + @JvmStatic + fun parameters(): List> = + getParameters().map { arrayOf(it) } + + private fun getParameters(): List?> = + listOf( + null, + stackAnimation { _, _, _ -> null }, + stackAnimation { _, _, _ -> scale() }, + stackAnimation { _, _, _ -> fade() }, + stackAnimation { _, _, _ -> slide() }, + stackAnimation { _, _, _ -> scale() + fade() + slide() }, + ) + } + + // Can be enum, workaround https://issuetracker.google.com/issues/195185633 + sealed class Config { + data object A : Config() + data object B : Config() + } +} diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/GetFadeAlphaTest.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/GetFadeAlphaTest.kt new file mode 100644 index 000000000..0e8bafe8c --- /dev/null +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/GetFadeAlphaTest.kt @@ -0,0 +1,81 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetFadeAlphaTest { + + private val cases = + listOf( + Case(factor = 1F, minAlpha = 0F, expected = 0F), + Case(factor = 0.75F, minAlpha = 0F, expected = 0.25F), + Case(factor = 0.5F, minAlpha = 0F, expected = 0.5F), + Case(factor = 0.25F, minAlpha = 0F, expected = 0.75F), + Case(factor = 0F, minAlpha = 0F, expected = 1F), + Case(factor = -0.25F, minAlpha = 0F, expected = 0.75F), + Case(factor = -0.5F, minAlpha = 0F, expected = 0.5F), + Case(factor = -0.75F, minAlpha = 0F, expected = 0.25F), + Case(factor = -1F, minAlpha = 0F, expected = 0F), + + Case(factor = 1F, minAlpha = 0.25F, expected = 0.25F), + Case(factor = 0.75F, minAlpha = 0.25F, expected = 0.4375F), + Case(factor = 0.5F, minAlpha = 0.25F, expected = 0.625F), + Case(factor = 0.25F, minAlpha = 0.25F, expected = 0.8125F), + Case(factor = 0F, minAlpha = 0.25F, expected = 1F), + Case(factor = -0.25F, minAlpha = 0.25F, expected = 0.8125F), + Case(factor = -0.5F, minAlpha = 0.25F, expected = 0.625F), + Case(factor = -0.75F, minAlpha = 0.25F, expected = 0.4375F), + Case(factor = -1F, minAlpha = 0.25F, expected = 0.25F), + + Case(factor = 1F, minAlpha = 0.5F, expected = 0.5F), + Case(factor = 0.75F, minAlpha = 0.5F, expected = 0.625F), + Case(factor = 0.5F, minAlpha = 0.5F, expected = 0.75F), + Case(factor = 0.25F, minAlpha = 0.5F, expected = 0.875F), + Case(factor = 0F, minAlpha = 0.5F, expected = 1F), + Case(factor = -0.25F, minAlpha = 0.5F, expected = 0.875F), + Case(factor = -0.5F, minAlpha = 0.5F, expected = 0.75F), + Case(factor = -0.75F, minAlpha = 0.5F, expected = 0.625F), + Case(factor = -1F, minAlpha = 0.5F, expected = 0.5F), + + Case(factor = 1F, minAlpha = 0.75F, expected = 0.75F), + Case(factor = 0.75F, minAlpha = 0.75F, expected = 0.8125F), + Case(factor = 0.5F, minAlpha = 0.75F, expected = 0.875F), + Case(factor = 0.25F, minAlpha = 0.75F, expected = 0.9375F), + Case(factor = 0F, minAlpha = 0.75F, expected = 1F), + Case(factor = -0.25F, minAlpha = 0.75F, expected = 0.9375F), + Case(factor = -0.5F, minAlpha = 0.75F, expected = 0.875F), + Case(factor = -0.75F, minAlpha = 0.75F, expected = 0.8125F), + Case(factor = -1F, minAlpha = 0.75F, expected = 0.75F), + + Case(factor = 1F, minAlpha = 1F, expected = 1F), + Case(factor = 0.75F, minAlpha = 1F, expected = 1F), + Case(factor = 0.5F, minAlpha = 1F, expected = 1F), + Case(factor = 0.25F, minAlpha = 1F, expected = 1F), + Case(factor = 0F, minAlpha = 1F, expected = 1F), + Case(factor = -0.25F, minAlpha = 1F, expected = 1F), + Case(factor = -0.5F, minAlpha = 1F, expected = 1F), + Case(factor = -0.75F, minAlpha = 1F, expected = 1F), + Case(factor = -1F, minAlpha = 1F, expected = 1F), + ) + + @Test + fun getFadeAlpha_allCases() { + cases.forEach { case -> + assertEquals( + expected = case.expected, + actual = com.arkivanov.decompose.extensions.compose.experimental.stack.animation.getFadeAlpha( + factor = case.factor, + minAlpha = case.minAlpha + ), + absoluteTolerance = 0.0000001F, + message = case.toString(), + ) + } + } + + private data class Case( + val factor: Float, + val minAlpha: Float, + val expected: Float, + ) +} diff --git a/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationDirectionsTest.kt b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationDirectionsTest.kt new file mode 100644 index 000000000..abb1c20a6 --- /dev/null +++ b/extensions-compose-experimental/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/experimental/stack/animation/StackAnimationDirectionsTest.kt @@ -0,0 +1,105 @@ +package com.arkivanov.decompose.extensions.compose.experimental.stack.animation + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction.ENTER_BACK +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction.ENTER_FRONT +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction.EXIT_BACK +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction.EXIT_FRONT +import com.arkivanov.decompose.router.stack.ChildStack +import org.junit.Rule +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalDecomposeApi::class) +@RunWith(Parameterized::class) +class StackAnimationDirectionsTest( + private val params: Params, +) { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun test() { + val results = HashMap() + + val animation = + DefaultStackAnimation( + disableInputDuringAnimation = false, + predictiveBackParams = null, + selector = { child, _, _ -> + StackAnimator { direction -> + results[child.configuration] = direction + Modifier + } + }, + ) + + var stack by mutableStateOf(stack(params.from)) + + composeRule.setContent { + animation(stack, Modifier) {} + } + + composeRule.runOnIdle {} + results.clear() + stack = stack(params.to) + composeRule.runOnIdle {} + + assertEquals(params.expected, results) + } + + private fun stack(configs: List): ChildStack = + ChildStack( + active = child(configs.last()), + backStack = configs.dropLast(1).map(::child), + ) + + private fun child(config: String): Child.Created = + Child.Created(configuration = config, instance = config) + + companion object { + @Parameterized.Parameters + @JvmStatic + fun parameters(): List> = + getParameters().map { arrayOf(it) } + + private fun getParameters(): List = + listOf( + Params(from = listOf("a", "b", "c"), to = listOf("a", "b"), expected = mapOf("c" to EXIT_FRONT, "b" to ENTER_BACK)), + Params(from = listOf("a", "b", "c"), to = listOf("a"), expected = mapOf("c" to EXIT_FRONT, "a" to ENTER_BACK)), + Params(from = listOf("a", "b", "c", "d"), to = listOf("a", "b"), expected = mapOf("d" to EXIT_FRONT, "b" to ENTER_BACK)), + Params(from = listOf("a", "b", "c", "d"), to = listOf("e", "b"), expected = mapOf("d" to EXIT_FRONT, "b" to ENTER_BACK)), + Params(from = listOf("a", "b", "c"), to = listOf("d", "a"), expected = mapOf("c" to EXIT_FRONT, "a" to ENTER_BACK)), + Params(from = listOf("a"), to = listOf("a", "b"), expected = mapOf("b" to ENTER_FRONT, "a" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("a", "b", "c"), expected = mapOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("a", "c"), expected = mapOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("c", "d"), expected = mapOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a"), to = listOf("b"), expected = mapOf("b" to ENTER_FRONT, "a" to EXIT_BACK)), + Params(from = listOf("a"), to = listOf("b", "c"), expected = mapOf("c" to ENTER_FRONT, "a" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("a", "c", "d"), expected = mapOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("c"), expected = mapOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b", "c"), to = listOf("a", "d"), expected = mapOf("d" to ENTER_FRONT, "c" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("c", "d"), expected = mapOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("b", "a"), expected = mapOf("a" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("b"), expected = emptyMap()), + Params(from = listOf("a", "b"), to = listOf("c", "b"), expected = emptyMap()), + Params(from = listOf("b", "c"), to = listOf("a", "b", "c"), expected = emptyMap()), + ) + } + + class Params( + val from: List, + val to: List, + val expected: Map, + ) +} diff --git a/extensions-compose/build.gradle.kts b/extensions-compose/build.gradle.kts index 8db11f22c..a0ab7f411 100644 --- a/extensions-compose/build.gradle.kts +++ b/extensions-compose/build.gradle.kts @@ -49,9 +49,6 @@ kotlin { common.main.dependencies { implementation(project(":decompose")) implementation(compose.foundation) - - // Required due to https://github.com/JetBrains/compose-multiplatform/issues/4326 - implementation(deps.jetbrains.kotlinx.kotlinxCoroutinesCore) } jvm.test.dependencies { diff --git a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/StackAnimationDirectionsTest.kt b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/StackAnimationDirectionsTest.kt index 0f8290965..c3766cd1d 100644 --- a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/StackAnimationDirectionsTest.kt +++ b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/StackAnimationDirectionsTest.kt @@ -28,26 +28,21 @@ class StackAnimationDirectionsTest( @Test fun test() { - val configs = HashSet() - val directions = HashSet() - - val animator = - StackAnimator { direction, isInitial, onFinished, content -> - directions += direction - content(Modifier) - - DisposableEffect(direction, isInitial) { - onFinished() - onDispose {} - } - } + val results = HashMap() val animation = SimpleStackAnimation( disableInputDuringAnimation = false, - selector = { - configs += it.configuration - animator + selector = { child -> + StackAnimator { direction, isInitial, onFinished, content -> + results[child.configuration] = direction + content(Modifier) + + DisposableEffect(direction, isInitial) { + onFinished() + onDispose {} + } + } }, ) @@ -58,12 +53,11 @@ class StackAnimationDirectionsTest( } composeRule.runOnIdle {} - directions.clear() + results.clear() stack = stack(params.to) composeRule.runOnIdle {} - assertEquals(params.expected.map { it.first }.toSet(), configs) - assertEquals(params.expected.map { it.second }.toSet(), directions) + assertEquals(params.expected, results) } private fun stack(configs: List): ChildStack = @@ -83,31 +77,31 @@ class StackAnimationDirectionsTest( private fun getParameters(): List = listOf( - Params(from = listOf("a", "b", "c"), to = listOf("a", "b"), expected = setOf("c" to EXIT_FRONT, "b" to ENTER_BACK)), - Params(from = listOf("a", "b", "c"), to = listOf("a"), expected = setOf("c" to EXIT_FRONT, "a" to ENTER_BACK)), - Params(from = listOf("a", "b", "c", "d"), to = listOf("a", "b"), expected = setOf("d" to EXIT_FRONT, "b" to ENTER_BACK)), - Params(from = listOf("a", "b", "c", "d"), to = listOf("e", "b"), expected = setOf("d" to EXIT_FRONT, "b" to ENTER_BACK)), - Params(from = listOf("a", "b", "c"), to = listOf("d", "a"), expected = setOf("c" to EXIT_FRONT, "a" to ENTER_BACK)), - Params(from = listOf("a"), to = listOf("a", "b"), expected = setOf("b" to ENTER_FRONT, "a" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("a", "b", "c"), expected = setOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("a", "c"), expected = setOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("c", "d"), expected = setOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a"), to = listOf("b"), expected = setOf("b" to ENTER_FRONT, "a" to EXIT_BACK)), - Params(from = listOf("a"), to = listOf("b", "c"), expected = setOf("c" to ENTER_FRONT, "a" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("a", "c", "d"), expected = setOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("c"), expected = setOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a", "b", "c"), to = listOf("a", "d"), expected = setOf("d" to ENTER_FRONT, "c" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("c", "d"), expected = setOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("b", "a"), expected = setOf("a" to ENTER_FRONT, "b" to EXIT_BACK)), - Params(from = listOf("a", "b"), to = listOf("b"), expected = setOf("b" to ENTER_FRONT)), - Params(from = listOf("a", "b"), to = listOf("c", "b"), expected = setOf("b" to ENTER_FRONT)), - Params(from = listOf("b", "c"), to = listOf("a", "b", "c"), expected = setOf("c" to ENTER_FRONT)), + Params(from = listOf("a", "b", "c"), to = listOf("a", "b"), expected = mapOf("c" to EXIT_FRONT, "b" to ENTER_BACK)), + Params(from = listOf("a", "b", "c"), to = listOf("a"), expected = mapOf("c" to EXIT_FRONT, "a" to ENTER_BACK)), + Params(from = listOf("a", "b", "c", "d"), to = listOf("a", "b"), expected = mapOf("d" to EXIT_FRONT, "b" to ENTER_BACK)), + Params(from = listOf("a", "b", "c", "d"), to = listOf("e", "b"), expected = mapOf("d" to EXIT_FRONT, "b" to ENTER_BACK)), + Params(from = listOf("a", "b", "c"), to = listOf("d", "a"), expected = mapOf("c" to EXIT_FRONT, "a" to ENTER_BACK)), + Params(from = listOf("a"), to = listOf("a", "b"), expected = mapOf("b" to ENTER_FRONT, "a" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("a", "b", "c"), expected = mapOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("a", "c"), expected = mapOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("c", "d"), expected = mapOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a"), to = listOf("b"), expected = mapOf("b" to ENTER_FRONT, "a" to EXIT_BACK)), + Params(from = listOf("a"), to = listOf("b", "c"), expected = mapOf("c" to ENTER_FRONT, "a" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("a", "c", "d"), expected = mapOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("c"), expected = mapOf("c" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b", "c"), to = listOf("a", "d"), expected = mapOf("d" to ENTER_FRONT, "c" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("c", "d"), expected = mapOf("d" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("b", "a"), expected = mapOf("a" to ENTER_FRONT, "b" to EXIT_BACK)), + Params(from = listOf("a", "b"), to = listOf("b"), expected = mapOf("b" to ENTER_FRONT)), + Params(from = listOf("a", "b"), to = listOf("c", "b"), expected = mapOf("b" to ENTER_FRONT)), + Params(from = listOf("b", "c"), to = listOf("a", "b", "c"), expected = mapOf("c" to ENTER_FRONT)), ) } class Params( val from: List, val to: List, - val expected: Set>, + val expected: Map, ) } diff --git a/sample/shared/compose/build.gradle.kts b/sample/shared/compose/build.gradle.kts index 39c696c37..c6e2b930b 100644 --- a/sample/shared/compose/build.gradle.kts +++ b/sample/shared/compose/build.gradle.kts @@ -58,6 +58,7 @@ kotlin { common.main.dependencies { api(project(":decompose")) implementation(project(":extensions-compose")) + implementation(project(":extensions-compose-experimental")) // Only for the experimental shared transitions api(project(":sample:shared:shared")) implementation(project(":sample:shared:dynamic-features:api")) implementation(project(":sample:shared:dynamic-features:compose-api")) @@ -66,9 +67,6 @@ kotlin { implementation(compose.material) implementation(compose.ui) implementation(compose.components.resources) - - // Required due to https://github.com/JetBrains/compose-multiplatform/issues/4326 - api(deps.jetbrains.kotlinx.kotlinxCoroutinesCore) } jvm.main.dependencies { diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsContent.kt index 2a1a6c2f9..3dd012495 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsContent.kt @@ -5,48 +5,53 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.stack.animation.fade -import com.arkivanov.decompose.extensions.compose.stack.animation.plus -import com.arkivanov.decompose.extensions.compose.stack.animation.scale -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation -import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.experimental.stack.ChildStack +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.PredictiveBackParams +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.plus +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.scale +import com.arkivanov.decompose.extensions.compose.experimental.stack.animation.stackAnimation import com.arkivanov.sample.shared.sharedtransitions.SharedTransitionsComponent.Child.GalleryChild import com.arkivanov.sample.shared.sharedtransitions.SharedTransitionsComponent.Child.PhotoChild import com.arkivanov.sample.shared.sharedtransitions.gallery.GalleryContent import com.arkivanov.sample.shared.sharedtransitions.photo.PhotoContent -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalDecomposeApi::class) @Composable internal fun SharedTransitionsContent( component: SharedTransitionsComponent, modifier: Modifier = Modifier, ) { - val stack by component.stack.subscribeAsState() - SharedTransitionLayout(modifier = modifier) { - Children( + ChildStack( stack = component.stack, modifier = Modifier.fillMaxSize().background(Color.Black), - animation = stackAnimation(fade() + scale()), + animation = stackAnimation( + animator = fade() + scale(), + predictiveBackParams = PredictiveBackParams( + backHandler = component.backHandler, + onBack = component::onBack, + ), + ), ) { when (val child = it.instance) { is GalleryChild -> GalleryContent( component = child.component, - isVisible = stack.active.instance is GalleryChild, + animatedVisibilityScope = this, modifier = Modifier.fillMaxSize(), ) - is PhotoChild -> + is PhotoChild -> { PhotoContent( component = child.component, - isVisible = stack.active.instance is PhotoChild, + animatedVisibilityScope = this, modifier = Modifier.fillMaxSize(), ) + } } } } diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt index 6ac70130b..3f353bd40 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt @@ -1,5 +1,7 @@ package com.arkivanov.sample.shared.sharedtransitions.gallery +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope @@ -24,7 +26,7 @@ import com.arkivanov.sample.shared.utils.TopAppBar @Composable internal fun SharedTransitionScope.GalleryContent( component: GalleryComponent, - isVisible: Boolean, + animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -40,9 +42,9 @@ internal fun SharedTransitionScope.GalleryContent( contentDescription = null, modifier = Modifier .aspectRatio(1F) - .sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key = image.id), - visible = isVisible, + .sharedElement( + state = rememberSharedContentState(key = image.id), + animatedVisibilityScope = animatedVisibilityScope, ) .clickable { component.onImageClicked(index = index) }, contentScale = ContentScale.Crop, @@ -57,10 +59,12 @@ internal fun SharedTransitionScope.GalleryContent( @Composable internal fun GalleryContentPreview() { SharedTransitionLayout { - GalleryContent( - component = PreviewGalleryComponent(), - isVisible = true, - modifier = Modifier.fillMaxSize(), - ) + AnimatedVisibility(visible = true) { + GalleryContent( + component = PreviewGalleryComponent(), + animatedVisibilityScope = this, + modifier = Modifier.fillMaxSize(), + ) + } } } diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt index f498ee93c..73ec6889b 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt @@ -1,5 +1,7 @@ package com.arkivanov.sample.shared.sharedtransitions.photo +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope @@ -20,7 +22,7 @@ import com.arkivanov.sample.shared.utils.TopAppBar @Composable internal fun SharedTransitionScope.PhotoContent( component: PhotoComponent, - isVisible: Boolean, + animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -30,9 +32,9 @@ internal fun SharedTransitionScope.PhotoContent( painter = painterResource(component.image.resourceId), contentDescription = null, modifier = Modifier - .sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key = component.image.id), - visible = isVisible, + .sharedElement( + state = rememberSharedContentState(key = component.image.id), + animatedVisibilityScope = animatedVisibilityScope, ) .fillMaxWidth() .weight(1F) @@ -47,10 +49,12 @@ internal fun SharedTransitionScope.PhotoContent( @Composable internal fun PhotoContentPreview() { SharedTransitionLayout { - PhotoContent( - component = PreviewPhotoComponent(), - isVisible = true, - modifier = Modifier.fillMaxSize(), - ) + AnimatedVisibility(visible = true) { + PhotoContent( + component = PreviewPhotoComponent(), + animatedVisibilityScope = this, + modifier = Modifier.fillMaxSize(), + ) + } } } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt index 609787bb9..c712dfd6c 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt @@ -50,6 +50,10 @@ class DefaultSharedTransitionsComponent( ) } + override fun onBack() { + nav.pop() + } + @Serializable private sealed interface Config { @Serializable diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt index 0b5725030..34e806817 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt @@ -2,13 +2,16 @@ package com.arkivanov.sample.shared.sharedtransitions import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.backhandler.BackHandlerOwner import com.arkivanov.sample.shared.sharedtransitions.gallery.GalleryComponent import com.arkivanov.sample.shared.sharedtransitions.photo.PhotoComponent -interface SharedTransitionsComponent { +interface SharedTransitionsComponent : BackHandlerOwner { val stack: Value> + fun onBack() + sealed class Child { class GalleryChild(val component: GalleryComponent) : Child() class PhotoChild(val component: PhotoComponent) : Child() diff --git a/settings.gradle.kts b/settings.gradle.kts index b957cc366..481630628 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,7 @@ pluginManagement { if (!startParameter.projectProperties.containsKey("check_publication")) { include(":decompose") include(":extensions-compose") + include(":extensions-compose-experimental") include(":extensions-android") include(":sample:shared:shared") include(":sample:shared:compose")