How to implement shared transition element from RecyclerView item to Fragment with Android Navigation Component?
B

5

17

I have a pretty straightforward case. I want to implement shared element transition between an item in recyclerView and fragment. I'm using android navigation component in my app.

There is an article about shared transition on developer.android and topic on stackoverflow but this solution works only for view that located in fragment layout that starts transition and doesn't work for items from RecyclerView. Also there is a lib on github but i don't want to rely on 3rd party libs and do it by myself.

Is there some solution for this? Maybe it should work and this is just a bug? But I haven't found any information about it.

code sample:

transition start

class TransitionStartFragment: Fragment() {

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    return inflater.inflate(R.layout.fragment_transition_start, container, false)
    }

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    val testData = listOf("one", "two", "three")
    val adapter = TestAdapter(testData, View.OnClickListener { transitionWithTextViewInRecyclerViewItem(it) })
    val recyclerView = view.findViewById<RecyclerView>(R.id.test_list)
    recyclerView.adapter = adapter
    val button = view.findViewById<Button>(R.id.open_transition_end_fragment)
    button.setOnClickListener { transitionWithTextViewInFragment() }
    }

private fun transitionWithTextViewInFragment(){
    val destination = TransitionStartFragmentDirections.openTransitionEndFragment()
    val extras = FragmentNavigatorExtras(transition_start_text to "transitionTextEnd")
    findNavController().navigate(destination, extras)
    }

private fun transitionWithTextViewInRecyclerViewItem(view: View){
    val destination = TransitionStartFragmentDirections.openTransitionEndFragment()
    val extras = FragmentNavigatorExtras(view to "transitionTextEnd")
    findNavController().navigate(destination, extras)
   }

}

layout

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
    android:id="@+id/transition_start_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="transition"
    android:transitionName="transitionTextStart"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

<Button
    android:id="@+id/open_transition_end_fragment"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintTop_toBottomOf="@id/transition_start_text"
    android:text="open transition end fragment" />

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/test_list"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toBottomOf="@id/open_transition_end_fragment"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

adapter for recyclerView

class TestAdapter(
    private val items: List<String>,
    private val onItemClickListener: View.OnClickListener
) : RecyclerView.Adapter<TestAdapter.ViewHodler>() {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHodler {
    return ViewHodler(LayoutInflater.from(parent.context).inflate(R.layout.item_test, parent, false))
    }

override fun getItemCount(): Int {
    return items.size
    }

override fun onBindViewHolder(holder: ViewHodler, position: Int) {
    val item = items[position]
    holder.transitionText.text = item
    holder.itemView.setOnClickListener { onItemClickListener.onClick(holder.transitionText) }

    }

class ViewHodler(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val transitionText = itemView.findViewById<TextView>(R.id.item_test_text)
    }
}

in onItemClick I pass the textView form item in recyclerView for transition

transition end

class TransitionEndFragment : Fragment() {

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    setUpTransition()
    return inflater.inflate(R.layout.fragment_transition_end, container, false)
    }

private fun setUpTransition(){
    sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)

    }
}

layout

<androidx.constraintlayout.widget.ConstraintLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
    android:id="@+id/transition_end_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="transition"
    android:transitionName="transitionTextEnd"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

fun transitionWithTextViewInFragment() - has transition.

fun transitionWithTextViewInRecyclerViewItem(view: View) - no transition.

Brianabriand answered 4/12, 2018 at 13:50 Comment(1)
You need to set different transition name for each recyclerview item textview, and in destination fragment set this transition name.Percolation
B
17

To solve the return transition problem you need to add this lines on the Source Fragment (the fragment with the recycler view) where you initialize your recycler view

// your recyclerView
recyclerView.apply {
                ...
                adapter = myAdapter
                postponeEnterTransition()
                viewTreeObserver
                    .addOnPreDrawListener {
                        startPostponedEnterTransition()
                        true
                    }
}
Bionics answered 30/5, 2019 at 12:17 Comment(4)
Thnks, I'll check it.Brianabriand
it somehow not working when I pressed back in detail fragment, my situation is the list page is inside the viewpager which also hosted inside a fragment, github.com/muhrahmatullah/televisi/blob/master/app/src/main/…Wornout
ohhh, finally got it working, since I used viewpager I should call the parent fragment instead of directly called postponeEnterTransition so the code should be like: parentFragment?.postponeEnterTransition() ...Wornout
Very nice bro. wasted two hours to figure out exit transition.Unroll
P
9

Here is my example with RecyclerView that have fragment shared transition. In my adapter i am setting different transition name for each item based on position(In my example it is ImageView).

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = items[position]
    holder.itemView.txtView.text=item
    ViewCompat.setTransitionName(holder.itemView.imgViewIcon, "Test_$position")
    holder.setClickListener(object : ViewHolder.ClickListener {
        override fun onClick(v: View, position: Int) {
            when (v.id) {
                R.id.linearLayout -> listener.onClick(item, holder.itemView.imgViewIcon, position)
            }
        }
    })

}

And when clicking on item, my interface that implemented in source fragment:

override fun onClick(text: String, img: ImageView, position: Int) {
    val action = MainFragmentDirections.actionMainFragmentToSecondFragment(text, position)
    val extras = FragmentNavigator.Extras.Builder()
            .addSharedElement(img, ViewCompat.getTransitionName(img)!!)
            .build()
    NavHostFragment.findNavController(this@MainFragment).navigate(action, extras)
}

And in my destination fragment:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    info("onCreate")
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)
    }
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    info("onCreateView")
    return inflater.inflate(R.layout.fragment_second, container, false)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    info("onViewCreated")
    val name=SecondFragmentArgs.fromBundle(arguments).name
    val position=SecondFragmentArgs.fromBundle(arguments).position
    txtViewName.text=name
    ViewCompat.setTransitionName(imgViewSecond, "Test_$position")
}
Percolation answered 6/12, 2018 at 7:21 Comment(6)
Thanks, it works. But now I have faced with another problem :) There is enter transition but on return transition.Brianabriand
@AlexandrSushkov there is problem with back shared transitions to RecyclerView when using setReorderingAllowed=true on FragmentTransaction, the problem that in Navigation component this flag is always set(you can look at source code of 'navigate' method). There is open issue for this: issuetracker.google.com/issues/118475573Percolation
Thanks a lot, again.Brianabriand
@AlexandrSushkov did you manage to make return transitions work?Jonell
@Jonell noBrianabriand
I also couldn't get the return animations to work. However setting a unique transition name in my recyclerview item did the trick. This has been bothering me for days!Crankshaft
O
1

Faced the same issue as many on SO with the return transition but for me the root cause of the problem was that Navigation currently only uses replace for fragment transactions and it caused my recycler in the start fragment to reload every time you hit back which was a problem by itself.

So by solving the second (root) problem the return transition started to work without delayed animations. For those of you who are looking to keep the initial state when hitting back here is what I did :

just adding a simple check in onCreateView as so

private lateinit var binding: FragmentSearchResultsBinding

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return if (::binding.isInitialized) {
            binding.root
        } else {
            binding = DataBindingUtil.inflate(inflater, R.layout.fragment_search_results, container, false)

            with(binding) {
                //doing some stuff here
                root
            }
        }

So triple win here: recycler is not redrawn, no refetching from server and also return transitions are working as expected.

Oxidase answered 9/7, 2019 at 15:43 Comment(4)
anyway to do it without databinding?Irina
save your View is a variable and return it instead of the binding instance @KitMakOxidase
binding is supposed to be set to null when view is destroyed otherwise this is a leak!Josh
@SamuelEminet if your fragment is not destroyed and retained in memory you can retain the view as well, otherwise there is no way to preserve the view state when navigating back on the stack.Oxidase
I
0

I have managed return transitions to work.

Actually this is not a bug in Android and not a problem with setReorderingAllowed = true. What happens here is the original fragment (to which we return) trying to start transition before its views/data are settled up.

To fix this we have to use postponeEnterTransition() and startPostponedEnterTransition().

For example: Original fragment:

class FragmentOne : Fragment(R.layout.f1) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        postponeEnterTransition()

        val items = listOf("one", "two", "three", "four", "five")
            .zip(listOf(Color.RED, Color.GRAY, Color.GREEN, Color.BLUE, Color.YELLOW))
            .map { Item(it.first, it.second) }

        val rv = view.findViewById<RecyclerView>(R.id.rvItems)
        rv.adapter = ItemsAdapter(items) { item, view -> navigateOn(item, view) }

        view.doOnPreDraw { startPostponedEnterTransition() }
    }

    private fun navigateOn(item: Item, view: View) {
        val extras = FragmentNavigatorExtras(view to "yura")
        findNavController().navigate(FragmentOneDirections.toTwo(item), extras)
    }
}

Next fragment:

class FragmentTwo : Fragment(R.layout.f2) {

    val item: Item by lazy { arguments?.getSerializable("item") as Item }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        sharedElementEnterTransition =
            TransitionInflater.from(context).inflateTransition(android.R.transition.move)

        val tv = view.findViewById<TextView>(R.id.tvItemId)
        with(tv) {
            text = item.id
            transitionName = "yura"
            setBackgroundColor(item.color)
        }
    }

}

enter image description here

For more details and deeper explanation see: https://issuetracker.google.com/issues/118475573 and https://chris.banes.dev/2018/02/18/fragmented-transitions/

Insensibility answered 16/11, 2020 at 13:39 Comment(0)
C
0

Android material design library contains MaterialContainerTransform class which allows to easily implement container transitions including transitions on recycler-view items. See container transform section for more details.

Here's an example of such a transition:

// FooListFragment.kt

class FooListFragment : Fragment() {
    ...

    private val itemListener = object : FooListener {
        override fun onClick(item: Foo, itemView: View) {
            ...

            val transitionName = getString(R.string.foo_details_transition_name)
            val extras = FragmentNavigatorExtras(itemView to transitionName)
            navController.navigate(directions, extras)
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Postpone enter transitions to allow shared element transitions to run.
        // https://github.com/googlesamples/android-architecture-components/issues/495
        postponeEnterTransition()
        view.doOnPreDraw { startPostponedEnterTransition() }

        ...
    }
// FooDetailsFragment.kt

class FooDetailsFragment : Fragment() {
    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        sharedElementEnterTransition = MaterialContainerTransform().apply {
            duration = 1000
        }
    }
}

And don't forget to add unique transition names to the views:

<!-- foo_list_item.xml -->

<LinearLayout ...
    android:transitionName="@{@string/foo_item_transition_name(foo.id)}">...</LinearLayout>
<!-- fragment_foo_details.xml -->

<LinearLayout ...
    android:transitionName="@string/foo_details_transition_name">...</LinearLayout>
<!-- strings.xml -->
<resources>
    ...
    <string name="foo_item_transition_name" translatable="false">foo_item_transition_%1$s</string>
    <string name="foo_details_transition_name" translatable="false">foo_details_transition</string>
</resources>

The full sample is available on GitHub.

You can also take a look at Reply - an official android material sample app where a similar transition is implemented, see HomeFragment.kt & EmailFragment.kt. There's a codelab describing the process of implementing transitions in the app, and a video tutorial.

Cortese answered 17/11, 2020 at 14:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.