From b061682e01f4a808428cf194c79032552a45cc76 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 21 Aug 2024 08:32:43 +1200 Subject: [PATCH] Added shared element support for Fragments and Composables which are hosted in Fragment NavigationContainers --- .../fragment/FragmentSharedElements.kt | 219 ++++++++++++++ .../container/FragmentNavigationContainer.kt | 7 + enro-core/src/main/res/values/id.xml | 1 + .../application/src/main/AndroidManifest.xml | 1 + .../enro/tests/application/TestApplication.kt | 8 + .../fragment/FragmentWithSharedElements.kt | 281 ++++++++++++++++++ .../fragment_shared_element_activity.xml | 12 + 7 files changed, 529 insertions(+) create mode 100644 enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt create mode 100644 tests/application/src/main/res/layout/fragment_shared_element_activity.xml diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt new file mode 100644 index 00000000..298f1847 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt @@ -0,0 +1,219 @@ +package dev.enro.destination.fragment + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.transition.TransitionInflater +import android.util.Log +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.core.view.doOnAttach +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.findFragment +import dev.enro.core.NavigationHost +import dev.enro.core.R +import dev.enro.core.controller.NavigationController +import dev.enro.core.controller.application +import dev.enro.core.plugins.EnroPlugin +import dev.enro.destination.fragment.FragmentSharedElements.DelayedTransitionController +import java.util.WeakHashMap + +/** + * This object provides hooks for supporting shared element transitions in Fragments. + */ +public object FragmentSharedElements { + internal class SharedElement(val view: View, val name: String) + internal class SharedElementContainer(val map: WeakHashMap = WeakHashMap()) + + internal fun getSharedElements(fragment: Fragment): List { + val container = fragment.view?.getTag(R.id.enro_internal_shared_element_container_id) as? SharedElementContainer + ?: return emptyList() + + return container.map.map { (view, name) -> SharedElement(view, name) } + } + + /** + * This method configures a shared element transition for the View/name combination that is provided. When the Fragment + * associated with the View is part of a Fragment transaction, the View provided will be added to the transaction + * using [FragmentTransaction.addSharedElement]. + * + * If you add a shared element with a name that has already been used, it will cause the View associated with that name to + * be removed as a shared element. + * + * If you've previously configured a shared element transition for a View, but you want to remove it, use [clearSharedElement] + */ + public fun addSharedElement(view: View, name: String) { + view.doOnAttach { + val rootFragmentView = runCatching { view.findFragment() } + .getOrNull() + ?.view + + if (rootFragmentView == null) { + throw IllegalStateException("Cannot add shared element to a View that is not attached to a Fragment") + } + + val sharedElementContainer = + rootFragmentView.getTag(R.id.enro_internal_shared_element_container_id) as? SharedElementContainer + ?: SharedElementContainer().apply { + rootFragmentView.setTag( + R.id.enro_internal_shared_element_container_id, + this + ) + } + + // ensure we don't have duplicate names + sharedElementContainer.map.toList().forEach { (otherView, otherName) -> + if (otherName == name) { sharedElementContainer.map.remove(otherView) } + } + sharedElementContainer.map[view] = name + } + } + + /** + * Removes a shared element from the shared element transition for the Fragment that contains the provided View. + */ + public fun clearSharedElement(view: View) { + val rootFragmentView = runCatching { view.findFragment() } + .getOrNull() + ?.view + + if (rootFragmentView == null) { + throw IllegalStateException("Cannot clear shared element from a View that is not attached to a Fragment") + } + + val sharedElementContainer = + rootFragmentView.getTag(R.id.enro_internal_shared_element_container_id) as? SharedElementContainer + ?: SharedElementContainer().apply { + rootFragmentView.setTag( + R.id.enro_internal_shared_element_container_id, + this + ) + } + + sharedElementContainer.map.remove(view) + } + + private val delayedTransitionFragments = WeakHashMap() + + /** + * This plugin is used to provide interoperability support for Compose and Fragment shared element transitions. You should + * install this plugin in your NavigationController if you want to enable shared element transitions for Composables that + * are hosted in FragmentNavigationContainers. + */ + public val composeCompatibilityPlugin: EnroPlugin = object : EnroPlugin() { + private val fragmentCallbacks = object : FragmentLifecycleCallbacks() { + override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) { + if (f !is NavigationHost) return + if (v !is ComposeView) return + f.postponeEnterTransition() + v.post { + if (delayedTransitionFragments.containsKey(f)) return@post + f.startPostponedEnterTransition() + } + } + } + + private val activityCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is FragmentActivity) { + activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true) + } + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + + override fun onAttached(navigationController: NavigationController) { + super.onAttached(navigationController) + navigationController.application.registerActivityLifecycleCallbacks(activityCallbacks) + } + + override fun onDetached(navigationController: NavigationController) { + super.onDetached(navigationController) + navigationController.application.unregisterActivityLifecycleCallbacks(activityCallbacks) + } + } + + /** + * This method is used to configure the shared element transitions for a Composable destination that is hosted in a + * FragmentNavigationContainer. + * + * By default, this method will use android.R.transition.move for shared element transitions, but by providing a value + * to [sharedElementEnter] or [sharedElementReturn], you can customize the shared element transitions for the Composable. + * These lambdas expect an [Any?] because that's the same type used by a Fragment's sharedElementEnterTransition and + * sharedElementReturnTransition. + * + * If you need to delay the start of the shared element transition, you can use [rememberDelayedTransitionController] to + * create a [DelayedTransitionController] that can be used to control the start of the shared element transition. + */ + @Composable + public fun ConfigureComposable( + sharedElementEnter: (Context) -> Any? = { TransitionInflater.from(it).inflateTransition(android.R.transition.move) }, + sharedElementReturn: (Context) -> Any? = { TransitionInflater.from(it).inflateTransition(android.R.transition.move) }, + ) { + val view = LocalView.current + LaunchedEffect(view) { + val fragment = runCatching { + view.findFragment() + }.getOrNull() + + if (fragment == null) { + Log.e("Enro", "Attempted to use FragmentSharedElements.ConfigureComposable in a Composable that is not hosted in a Fragment") + return@LaunchedEffect + } + fragment.sharedElementEnterTransition = sharedElementEnter(fragment.requireContext()) + fragment.sharedElementReturnTransition = sharedElementEnter(fragment.requireContext()) + } + } + + /** + * This interface is used to control the start of a delayed shared element transition. + * + * When using the FragmentSharedElement interoperability support for Compose, if you need to delay the start of the + * shared element transition, you can call [FragmentSharedElements.rememberDelayedTransitionController], to get an instance + * of [DelayedTransitionController]. This will cause the shared element transition to be delayed until you call [start] on + * the [DelayedTransitionController] instance. + */ + public fun interface DelayedTransitionController { public fun start() } + + /** + * [rememberDelayedTransitionController] is used to create a [DelayedTransitionController] that can be used to control the + * start of a delayed shared element transition when using the FragmentSharedElement interoperability support for Compose. + * This method should only be called from a Composable that has already called [FragmentSharedElements.ConfigureComposable]. + * + * @return A [DelayedTransitionController] instance that can be used to control the start of a delayed shared element transition. + */ + @Composable + public fun rememberDelayedTransitionController(): DelayedTransitionController { + val view = LocalView.current + return remember(view) { + val fragment = runCatching { + view.findFragment() + }.getOrNull() + + if (fragment == null) { + Log.e("Enro", "Attempted to use FragmentSharedElements.rememberDelayedTransitionController in a Composable that is not hosted in a Fragment") + return@remember DelayedTransitionController {} + } + delayedTransitionFragments[fragment] = Unit + DelayedTransitionController { + delayedTransitionFragments.remove(fragment) + fragment.startPostponedEnterTransition() + } + } + } +} diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt index da4648eb..ccb74a5e 100644 --- a/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt @@ -34,6 +34,7 @@ import dev.enro.core.controller.get import dev.enro.core.controller.interceptor.builder.NavigationInterceptorBuilder import dev.enro.core.controller.usecase.HostInstructionAs import dev.enro.core.navigationContext +import dev.enro.destination.fragment.FragmentSharedElements import dev.enro.extensions.animate import dev.enro.extensions.getParcelableCompat @@ -172,6 +173,9 @@ public class FragmentNavigationContainer internal constructor( ) toRemoveDirect.forEach { remove(it) + FragmentSharedElements.getSharedElements(it).forEach { sharedElement -> + addSharedElement(sharedElement.view, sharedElement.name) + } ownedFragments.remove(it.tag) } runOnCommit { @@ -181,6 +185,9 @@ public class FragmentNavigationContainer internal constructor( } } toDetach.forEach { + FragmentSharedElements.getSharedElements(it.fragment).forEach { sharedElement -> + addSharedElement(sharedElement.view, sharedElement.name) + } detach(it.fragment) } if (activePushed != null) { diff --git a/enro-core/src/main/res/values/id.xml b/enro-core/src/main/res/values/id.xml index 6c55075c..5ee6a69a 100644 --- a/enro-core/src/main/res/values/id.xml +++ b/enro-core/src/main/res/values/id.xml @@ -3,4 +3,5 @@ + \ No newline at end of file diff --git a/tests/application/src/main/AndroidManifest.xml b/tests/application/src/main/AndroidManifest.xml index f3a59cd8..383a1c72 100644 --- a/tests/application/src/main/AndroidManifest.xml +++ b/tests/application/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ + \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt b/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt index a63a510b..9c979321 100644 --- a/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt +++ b/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt @@ -5,6 +5,7 @@ import androidx.compose.material.MaterialTheme import dev.enro.annotations.NavigationComponent import dev.enro.core.controller.NavigationApplication import dev.enro.core.controller.createNavigationController +import dev.enro.destination.fragment.FragmentSharedElements @NavigationComponent class TestApplication : Application(), NavigationApplication { @@ -13,5 +14,12 @@ class TestApplication : Application(), NavigationApplication { composeEnvironment { content -> MaterialTheme { content() } } + + /** + * The following plugin is installed specifically to support the example in + * [dev.enro.tests.application.fragment.FragmentSharedElementDestination], which has an example of + * shared element transitions between a Fragment and Composable NavigationDestination + */ + plugin(FragmentSharedElements.composeCompatibilityPlugin) } } \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt b/tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt new file mode 100644 index 00000000..d3da18e8 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt @@ -0,0 +1,281 @@ +package dev.enro.tests.application.fragment + +import android.content.res.ColorStateList +import android.os.Bundle +import android.os.Parcelable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.TransitionInflater +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.compose.OverrideNavigationAnimations +import dev.enro.core.compose.navigationHandle +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.fragment.container.navigationContainer +import dev.enro.core.navigationHandle +import dev.enro.core.push +import dev.enro.core.requestClose +import dev.enro.destination.fragment.FragmentSharedElements +import dev.enro.test.application.R +import kotlinx.parcelize.Parcelize + +/** + * FragmentSharedElementDestination is a destination that provides an example of using FragmentSharedElements to create shared + * element transitions between Fragments and Composables. This example uses a RecyclerView to display a list of images, and + * allows the user to open a Fragment or Composable that displays the image in a larger format, with a shared element transition + * going from the RecyclerView to the Fragment/Composable (and back again). + * + * Note: The NavigationController must be configured with [FragmentSharedElements.composeCompatibilityPlugin] to allow shared + * element transitions between Fragments and Composables (it's not required for Fragment-to-Fragment shared element transitions). + * See [dev.enro.tests.application.TestApplication] where [FragmentSharedElements.composeCompatibilityPlugin] is installed. + */ +@Parcelize +object FragmentSharedElementDestination : NavigationKey.SupportsPresent { + @Parcelize + internal object RecyclerViewFragment : Parcelable, NavigationKey.SupportsPush + + @Parcelize + internal class DetailViewFragment( + val imageId: Int + ) : Parcelable, NavigationKey.SupportsPush + + @Parcelize + internal class DetailViewComposable( + val imageId: Int + ) : Parcelable, NavigationKey.SupportsPush +} + +/** + * This Activity is not interesting, it's just a container to hold the Fragments/Composables for this example. + */ +@NavigationDestination(FragmentSharedElementDestination::class) +class FragmentSharedElementActivity : AppCompatActivity() { + val container by navigationContainer( + containerId = R.id.fragment_container, + root = { FragmentSharedElementDestination.RecyclerViewFragment }, + emptyBehavior = EmptyBehavior.CloseParent, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.fragment_shared_element_activity) + } +} + +/** + * This imageTints list is used as a way to tint the images in the RecyclerView for this example, the position in the list + * is considered to be an "id" for the examples below. In reality, you would likely load the data from a remote source. + */ +private val imageTints = listOf( + 0xFFFF0000, + 0xFF00FF00, + 0xFF0000FF, + 0xFFFF00FF, + 0xFFFFFF00, + 0xFF00FFFF, +) + +/** + * The FragmentSharedElementRecyclerView is a simple RecyclerView that displays a list of images, each one tinted to a different + * color based on [imageTints]. Each row in the RecyclerView can open a Fragment or Composable that displays the image in a + * larger "detail" format. There is a shared element transition between FragmentSharedElementRecyclerView and + * FragmentSharedElementDetailFragment/FragmentSharedElementDetailComposable. + */ +@NavigationDestination(FragmentSharedElementDestination.RecyclerViewFragment::class) +class FragmentSharedElementRecyclerView : Fragment() { + + private val navigation by navigationHandle() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedElementEnterTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + sharedElementReturnTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return RecyclerView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setBackgroundColor(0xFFFFFFFF.toInt()) + layoutManager = LinearLayoutManager(requireContext()) + adapter = object : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SharedElementViewHolder { + val view = LinearLayout(requireContext()) + view.layoutParams = + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + view.setVerticalGravity(Gravity.CENTER_VERTICAL) + view.orientation = LinearLayout.HORIZONTAL + view.addView(ImageView(requireContext()).apply { + setImageResource(R.drawable.ic_launcher_foreground) + }) + view.addView(Button(requireContext()).apply { + text = "Open Fragment" + }) + view.addView(Button(requireContext()).apply { + text = "Open Compose" + }) + return SharedElementViewHolder(view) + } + + override fun onBindViewHolder(holder: SharedElementViewHolder, position: Int) { + holder.bind(position) + } + + override fun getItemCount(): Int = imageTints.size + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // It's required to slightly delay enter transitions when using RecyclerViews, according to the following documentation: + // https://developer.android.com/guide/fragments/animate#recyclerview + // The following code will delay the enter transition until the RecyclerView is ready to draw: + postponeEnterTransition() + view.doOnPreDraw { startPostponedEnterTransition() } + } + + inner class SharedElementViewHolder(view: View) : RecyclerView.ViewHolder(view) { + fun bind(imageId: Int) { + val view = itemView as LinearLayout + view.getChildAt(0).apply { + this as ImageView + transitionName = "image_$imageId" + imageTintList = ColorStateList.valueOf(imageTints[imageId].toInt()) + } + view.getChildAt(1).setOnClickListener { + FragmentSharedElements.addSharedElement(view.getChildAt(0), "sharedElementImage") + navigation.push(FragmentSharedElementDestination.DetailViewFragment(imageId)) + } + view.getChildAt(2).setOnClickListener { + FragmentSharedElements.addSharedElement(view.getChildAt(0), "sharedElementImage") + navigation.push(FragmentSharedElementDestination.DetailViewComposable(imageId)) + } + } + } +} + +@NavigationDestination(FragmentSharedElementDestination.DetailViewFragment::class) +class FragmentSharedElementDetailFragment : Fragment() { + + private val navigation by navigationHandle() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedElementEnterTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + sharedElementReturnTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + requireNotNull(container) + + val imageSize = maxOf(container.measuredWidth, container.measuredHeight) / 2 + return LinearLayout(requireContext()).apply { + setBackgroundColor(0xFFFFFFFF.toInt()) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setHorizontalGravity(Gravity.CENTER_HORIZONTAL) + orientation = LinearLayout.VERTICAL + val image = ImageView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams(imageSize, imageSize) + transitionName = "sharedElementImage" + setImageResource(R.drawable.ic_launcher_foreground) + imageTintList = ColorStateList.valueOf(imageTints[navigation.key.imageId].toInt()) + } + addView(image) + addView(Button(requireContext()).apply { + text = "Close" + FragmentSharedElements.addSharedElement(image, "image_${navigation.key.imageId}") + setOnClickListener { + navigation.requestClose() + } + }) + } + } +} + +@OptIn(AdvancedEnroApi::class) +@NavigationDestination(FragmentSharedElementDestination.DetailViewComposable::class) +@Composable +fun FragmentSharedElementDetailComposable() { + // It's important to call FragmentSharedElements.ConfigureComposable() when you want to configure an + // @Composable @NavigationDestination to support shared elements that are shared with Fragments. + // In this case, we're also calling OverrideNavigationAnimations() to override the default navigation animations, so + // that the other elements on the screen fade in and out, which puts a focus on the shared element animation. + FragmentSharedElements.ConfigureComposable() + OverrideNavigationAnimations(fadeIn(), fadeOut()) + + // We're also going to configure a delayed transition, so that the shared element transition can be delayed until we're + // ready to draw the View associated with the shared element. This is important for shared elements that are not + // immediately visible when the view is drawn. It's not strictly necessary in this case, but it's a useful example. + val delayedTransition = FragmentSharedElements.rememberDelayedTransitionController() + + val navigation = navigationHandle() + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + val constraints = constraints + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val imageSize = maxOf(constraints.maxWidth, constraints.maxHeight) / 2 + val imageSizeDp = LocalDensity.current.run { imageSize.toDp() } + + AndroidView( + modifier = Modifier.size(imageSizeDp), + factory = { context -> + ImageView(context).apply { + setImageResource(R.drawable.ic_launcher_foreground) + imageTintList = ColorStateList.valueOf(imageTints[navigation.key.imageId].toInt()) + transitionName = "sharedElementImage" + FragmentSharedElements.addSharedElement(this, "image_${navigation.key.imageId}") + + // Wait until the view is ready to draw before starting the transition + doOnPreDraw { delayedTransition.start() } + } + }, + ) + + Button( + onClick = { + navigation.requestClose() + } + ) { + Text("Close") + } + } + } +} + + diff --git a/tests/application/src/main/res/layout/fragment_shared_element_activity.xml b/tests/application/src/main/res/layout/fragment_shared_element_activity.xml new file mode 100644 index 00000000..f592a160 --- /dev/null +++ b/tests/application/src/main/res/layout/fragment_shared_element_activity.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file