如何使用 Android 导航组件实现从 RecyclerView 项目到 Fragment 的共享过渡元素?

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

我有一个非常简单的案例。我想在 recyclerViewfragment 中的项目之间实现共享元素转换。我在我的应用程序中使用 android 导航组件。

developer.android and topic on 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 上有一篇关于共享转换的文章,但我不想依赖第 3 方库并自己完成。

有什么解决办法吗?也许它应该工作,这只是一个错误?但是我还没有找到任何相关信息。

代码示例:

过渡开始

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

}

布局

<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" />

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

在 onItemClick 中我将 textView 表单项传递到 recyclerView 中进行转换

过渡结束

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)

    }
}

布局

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

有趣的 transitionWithTextViewInFragment() - 有转换。

有趣的 transitionWithTextViewInRecyclerViewItem(view: View) - 没有过渡。

这是我使用具有片段共享过渡的 RecyclerView 的示例。 在我的适配器中,我根据位置为每个项目设置不同的转换名称(在我的示例中是 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)
            }
        }
    })

}

点击项目时,我在源代码片段中实现的界面:

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

在我的目标片段中:

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

要解决 return 转换问题,您需要在初始化回收器视图的源片段(具有回收器视图的片段)上添加此行

// your recyclerView
recyclerView.apply {
                ...
                adapter = myAdapter
                postponeEnterTransition()
                viewTreeObserver
                    .addOnPreDrawListener {
                        startPostponedEnterTransition()
                        true
                    }
}

在 return 转换过程中遇到了与 SO 上许多人相同的问题,但对我来说,问题的根本原因是 Navigation 目前仅使用 replace 进行片段事务,它导致我在开始片段中的回收器在每次你回击时重新加载,这本身就是一个问题。

因此,通过解决第二个(根本)问题,return 过渡开始工作,没有延迟动画。对于那些希望在返回时保持初始状态的人,我就是这样做的:

只需在 onCreateView 中添加一个简单的检查

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

所以三赢在这里:回收器没有重绘,没有从服务器重新获取,而且 return 转换按预期工作。

我已经 return 过渡到工作。

实际上这不是 Android 中的错误,也不是 setReorderingAllowed = true 中的问题。这里发生的是原始片段(我们 return)试图在 其 views/data 完成之前开始转换

要解决这个问题,我们必须使用 postponeEnterTransition()startPostponedEnterTransition()

例如: 原始片段:

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

下一个片段:

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

}

有关更多详细信息和更深入的解释,请参阅: https://issuetracker.google.com/issues/118475573https://chris.banes.dev/2018/02/18/fragmented-transitions/

Android material design library contains MaterialContainerTransform class which allows to easily implement container transitions including transitions on recycler-view items. See container transform 部分了解更多详情。

下面是这种转换的示例:

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

并且不要忘记向视图添加唯一的转换名称:

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

完整样本为 available on GitHub

你也可以看看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