Hey people! Welcome to my blog on shared element transition using fragments with navigation component.

Before we begin with the implementation it is important to understand the difference between element transition and other such as container transforms.
Container transforms opens a View from a ViewGroup into a fragment or activity as a whole. Whereas, element or shared element transition only moves the views from one place to other.

1. Add dependencies to project

Start with new android studio project choosing Empty Activity.
Give project and package name and select language Kotlin.
Minimum SDK as API 21: Lollipop and finish

gradle.build(Module: app)

// ConstraintLayout
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
// Material Design Components
implementation 'com.google.android.material:material:1.1.0'
// Navigation Component
implementation "androidx.navigation:navigation-fragment-ktx:2.3.0"
implementation "androidx.navigation:navigation-ui-ktx:2.3.0"
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.1.0'

2. Create destination fragments

We need two fragments for shared element transition to take place. First fragment has RecyclerView list data and Second fragment has detail for each item from the list.

So create two blank fragments with names as follows and leave them empty for now:
ListFragment.kt with fragment_list.xml
DetailFragment.kt with fragment_detail.xml
If you are unsure of this, take this as reference. BlankFragment and fragment_blank.xml

3. Make navigation graph and attach to activity

Let’s create a graph to connect all destinations in our app with actions. In our case we connect previously created empty fragments.

Right click on res --> New --> Android Resource File
In the dialog section:
File name --> nav_graph
Resource type --> Navigation and then click Ok.

Attach graph to Activity :
Let’s make our MainActivity class as default navigation host for all fragments by attaching our navigation graph.

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<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"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph"
        tools:ignore="FragmentTagUsage" />

</androidx.constraintlayout.widget.ConstraintLayout>

Let’s set NavController with ActionBar using NavigationUI to the activity class

MainActivity.kt:

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.ui.NavigationUI

class MainActivity : AppCompatActivity() {

    private lateinit var navController: NavController

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        navController = this.findNavController(R.id.nav_host_fragment)
        NavigationUI.setupActionBarWithNavController(this, navController)
    }

    override fun onSupportNavigateUp(): Boolean {
        return navController.navigateUp()
    }
}

4. Connect destinations with actions

Open nav_graph.xml file that you have created earlier, in that file from the toolbar go to Design section.
Click the New Destination icon to see available destinations to add.

Locate fragment_list and then click to insert, similarly add fragment_detail to the graph.
Now arrange the layouts properly. Select listFragment and drag the connection to detailFragment.

Here’s the xml layout for what we did.

nav_graph.xml:

<?xml version="1.0" encoding="utf-8"?>
<navigation
    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:id="@+id/nav_graph"
    app:startDestination="@id/listFragment">

    <fragment
        android:id="@+id/listFragment"
        android:name="com.developersbreach.myapplication.ListFragment"
        android:label="List"
        tools:layout="@layout/fragment_list">
        <action
            android:id="@+id/listToDetailFragment"
            app:destination="@id/detailFragment" />
    </fragment>

    <fragment
        android:id="@+id/detailFragment"
        android:name="com.developersbreach.myapplication.DetailFragment"
        android:label="Detail"
        tools:layout="@layout/fragment_detail" />

</navigation>

Now run the app and you will be able to see blank screen with no views to make sure everything is working.

5. Create classes to show RecyclerView and data

We need data such as text string resources to show in layout, let’s copy the strings for showing titles and other data, colors, drawables and styles for the app.
Get all the strings and paste into your strings.xml file.
Get all the colors and paste into your colors.xml file.
Get all the styles and paste into your styles.xml file.
Get all drawables and paste into your drawables folder.

Now let’s create a data model class Sports.kt which helps in making a list.

Sports.kt:

import android.content.Context
import android.os.Parcelable
import com.developersbreach.sharedelementtransitionexample.R
import kotlinx.android.parcel.Parcelize

@Parcelize
data class Sports(
    val id: Int,
    val banner: Int,
    val title: String,
    val about: String
) :
    Parcelable {

    companion object {
        fun sportsList(context: Context): List<Sports> {
            return listOf(
                Sports(0, R.drawable.ic_rugby,
                    context.getString(R.string.title_rugby),
                    context.getString(R.string.about_rugby)
                ),
                Sports(
                    1, R.drawable.ic_cricket,
                    context.getString(R.string.title_cricket),
                    context.getString(R.string.about_cricket)
                ),
                Sports(
                    2, R.drawable.ic_hockey,
                    context.getString(R.string.title_hockey),
                    context.getString(R.string.about_hockey)
                ),
                Sports(3, R.drawable.ic_basketball,
                    context.getString(R.string.title_basketball),
                    context.getString(R.string.about_basketball)
                ),
                Sports(4, R.drawable.ic_volleyball,
                    context.getString(R.string.title_volleyball),
                    context.getString(R.string.about_volleyball)
                ),
                Sports(5, R.drawable.ic_esports,
                    context.getString(R.string.title_esports),
                    context.getString(R.string.about_esports)
                ),
                Sports(6, R.drawable.ic_kabaddi,
                    context.getString(R.string.title_kabbadi),
                    context.getString(R.string.about_kabbadi)
                ),
                Sports(7, R.drawable.ic_baseball,
                    context.getString(R.string.title_baseball),
                    context.getString(R.string.about_baseball)
                ),
                Sports(8, R.drawable.ic_mma,
                    context.getString(R.string.title_mma),
                    context.getString(R.string.about_mma)
                ),
                Sports(9, R.drawable.ic_soccer,
                    context.getString(R.string.title_soccer),
                    context.getString(R.string.about_soccer)
                ),
                Sports(10, R.drawable.ic_handball,
                    context.getString(R.string.title_handball),
                    context.getString(R.string.about_handball)
                ),
                Sports(11, R.drawable.ic_tennis,
                    context.getString(R.string.title_tennis),
                    context.getString(R.string.about_tennis)
                )
            )
        }
    }
}

Create item_sports layout file to show each item in list.

item_sports.xml:

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
    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="wrap_content"
    app:cardCornerRadius="8dp"
    app:cardElevation="6dp"
    app:cardUseCompatPadding="true">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:id="@+id/sports_item_image_view"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:padding="16dp"
            android:scaleType="fitCenter"
            app:layout_constraintDimensionRatio="2:2"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:ignore="ContentDescription"
            tools:src="@drawable/ic_hockey" />

        <TextView
            android:id="@+id/title_item_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            android:gravity="center"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
            android:textColor="?attr/colorOnSurface"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/sports_item_image_view"
            tools:text="Title" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</com.google.android.material.card.MaterialCardView>

Finally create an RecyclerView adapter for fragment to show a list.

SportsAdapter.kt:

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class SportsAdapter(
    private val sportsList: List<Sports>,
    private val onClickListener: OnClickListener
) :
    RecyclerView.Adapter<SportsAdapter.SportsViewHolder>() {

    class SportsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        private val banner: ImageView = itemView.findViewById(R.id.sports_item_image_view)
        private val title: TextView = itemView.findViewById(R.id.title_item_text_view)

        fun bind(
            sports: Sports,
            onClickListener: OnClickListener
        ) {
            banner.setImageResource(sports.banner)
            title.text = sports.title
            itemView.setOnClickListener {
                onClickListener.onClick(sports)
            }
        }
    }

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

    override fun onBindViewHolder(holder: SportsViewHolder, position: Int) {
        val sports: Sports = sportsList[position]
        holder.bind(sports, onClickListener)
    }

    override fun getItemCount() = sportsList.size

    class OnClickListener(val clickListener: (Sports) -> Unit) {
        fun onClick(
            sports: Sports
        ) = clickListener(sports)
    }
}

6. With SafeArgs pass data class as argument

We have to pass our data class Sports.kt as argument from ListFragment to DetailFragment, and we will do this from nav_graph file.

In nav_graph.xml file go to design section from toolbar.
Select detailFragment, to the right side Attributes section, click plus(+) button to add new arguments to that fragment.

In Add Arguments section dialog :
Name --> sportsArgs
Type --> Custom Parcelable --> Sports.kt
Continue by clicking OK and then again select Add to finish.
Now Rebuild the project for android studio to auto generate the classes required for this.

Note: If build is not successful or android studio cannot build at this point, try cleaning the project and Rebuilding it from
Build--> Clean Project.

7. Show list and detail data in Fragments

Now we have data and arguments ready to be passed let’s populate the data inside fragments.
Add RecyclerView to ListFragment.kt and layout. Replace the code inside the layout with below one.

fragment_list.xml:

<?xml version="1.0" encoding="utf-8"?>
<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"
    tools:context=".ListFragment">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/sports_recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        app:spanCount="2"
        tools:itemCount="12"
        tools:listitem="@layout/item_sports" />

</androidx.constraintlayout.widget.ConstraintLayout>

ListFragment.kt:

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.developersbreach.sharedelementtransitionexample.R


class ListFragment : Fragment() {

    private lateinit var recyclerView: RecyclerView

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_list, container, false)
    }

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

        recyclerView = view.findViewById(R.id.sports_recycler_view)
        val list = Sports.sportsList(requireContext())
        val adapter = SportsAdapter(list, sportsItemListener)
        recyclerView.adapter = adapter
    }

    private val sportsItemListener = SportsAdapter.OnClickListener { sports ->
        val direction: NavDirections =
            ListFragmentDirections.listToDetailFragment(sports)
        findNavController().navigate(direction)
    }
}

Our ListFragment is ready to show the RecyclerView, let’s complete DetailFragment to show each list item which user is selected.

fragment_detail.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
    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="wrap_content"
    android:clipToPadding="false"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".DetailFragment">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/detail_image_view"
            android:layout_width="match_parent"
            android:layout_height="256dp"
            android:scaleType="fitCenter"
            app:layout_constraintTop_toTopOf="parent"
            tools:ignore="ContentDescription"
            tools:src="@drawable/ic_hockey" />

        <TextView
            android:id="@+id/title_detail_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:gravity="center"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Headline4"
            android:textColor="?attr/colorOnPrimary"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/detail_image_view"
            tools:text="Title" />

        <TextView
            android:id="@+id/about_detail_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="28dp"
            android:layout_marginEnd="16dp"
            android:justificationMode="inter_word"
            android:textColor="?attr/colorOnPrimary"
            android:textSize="20sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/title_detail_text_view"
            tools:targetApi="o"
            tools:text="@string/about_tennis" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</androidx.core.widget.NestedScrollView>

FragmentDetail.kt:

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView

class DetailFragment : Fragment() {

    private lateinit var sportsArgs: Sports

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val args = requireArguments()
        sportsArgs = DetailFragmentArgs.fromBundle(args).sportsArgs
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_detail, container, false)
    }

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

        val banner: ImageView = view.findViewById(R.id.detail_image_view)
        val title: TextView = view.findViewById(R.id.title_detail_text_view)
        val about: TextView = view.findViewById(R.id.about_detail_text_view)

        banner.setImageResource(sportsArgs.banner)
        title.text = sportsArgs.title
        about.text = sportsArgs.about
    }
}

Now run the app, ListFragment will be able to show list of Cards and when you click single item you will be navigated to DetailFragment with no errors.
Now let’s see how we can add shared element transitions between these fragments.

8. Make SharedElementTransition between fragments

To perform any transition between views first we need to identity those views from both layouts. So in our case we are performing transition between two layouts which are item_sports.xml to fragment_detail.xml. See the image below.

Preview image for shared element transition
Shared Element Transition Android

Since our item views are created by the adapter let’s make changes to those. Open SportsAdapter.kt file and scroll down below to class which implements OnClickListener, this takes single argument which is data class but now change that to take two more arguments which are ImageView(banner) and TextView(title).
These are the changes you need to make:

class OnClickListener(val clickListener: (Sports, ImageView, TextView) -> Unit) {
        fun onClick(
            sports: Sports,
            banner: ImageView,
            title: TextView
        ) = clickListener(sports, banner, title)
    }

Now for views which we are passing needs unique transition names while transition occurs, if all views have same transition name then transition won’t take place.
So the best way to do this is by giving each view transition name from it’s data class itself which must be of the type String.
We have access to both banner, title view with data in SportsViewHolder class.
These are the changes you need to make:

Inside SportsViewHolder

        fun bind(
            sports: Sports,
            onClickListener: OnClickListener
        ) {
            banner.setImageResource(sports.banner)
            title.text = sports.title
            
            banner.transitionName = sports.banner.toString()
            title.transitionName = sports.title
            
            itemView.setOnClickListener {
                onClickListener.onClick(sports, banner, title)
            }
        }

Since ListFragment.kt implements OnClickListener it takes extra parameters after changes which needs ImageView and TextView, so we will pass that which shown below.
Let’s use FragmentNavigatorExtras which builds pair of view with string value. We can pass as many as pairs we want for transition to take place. In our case we are going to pass two pairs a ImageView for banner and TextView for title.
After with an assigned value we pass that inside navigate along with directions and extras.
After all the required changes this is how your code should look like.

ListFragment.kt:

    private val sportsItemListener = SportsAdapter.OnClickListener { sports, imageView, textView ->
        val direction: NavDirections =
            ListFragmentDirections.listToDetailFragment(sports)

        val extras = FragmentNavigatorExtras(
            imageView to sports.banner.toString(),
            textView to sports.title
        )

        findNavController().navigate(direction, extras)
    }

We have to specify what kind of transition should take place from the leaving fragment and for that we have inflate a transition type using TransitionInflater in our fragment OnCreateView before returning.
So let’s inflate the transition with type move effect, see those changes below.

onCreateView ListFragment

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val view = inflater.inflate(R.layout.fragment_list, container, false)
        sharedElementReturnTransition =
            TransitionInflater.from(context).inflateTransition(android.R.transition.move)
        return view
    }

With this we will be able to perform moving transition but when user clicks back button we must be able to perform return transition, so we will this changes inside OnViewCreated after all views have been ready.
This gives fragment the ability to delay fragment animations until all data is being loaded.
With these changes you can get backward transition taking place.

onViewCreated at the end

postponeEnterTransition()

But enter transition on any view should happen while it’s been drawn. So kotlin has a way of doing this by calling doOnPreDraw for view for transition.
In our case we have to call this on RecyclerView while being drawn or laid out.

onViewCreated after enter transition

recyclerView.doOnPreDraw {
    startPostponedEnterTransition()
}

If you get an error at doOnPreDraw which means you have to specify proper jvm target version. So here’s the fix for it.

build.gradle(Module:app) after build types

compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8.toString()
}

Now Sync the project to fix this.
With all the changes being made to ListFragment. This is how it should look like.

ListFragment.kt:

class ListFragment : Fragment() {

    private lateinit var recyclerView: RecyclerView

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val view = inflater.inflate(R.layout.fragment_list, container, false)
        sharedElementReturnTransition =
            TransitionInflater.from(context).inflateTransition(android.R.transition.move)
        return view
    }

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

        recyclerView = view.findViewById(R.id.sports_recycler_view)
        val list = Sports.sportsList(requireContext())
        val adapter = SportsAdapter(list, sportsItemListener)
        recyclerView.adapter = adapter

        // When user hits back button transition takes backward
        postponeEnterTransition()
        recyclerView.doOnPreDraw {
            startPostponedEnterTransition()
        }
    }

    private val sportsItemListener = SportsAdapter.OnClickListener { sports, imageView, textView ->
        val direction: NavDirections =
            ListFragmentDirections.listToDetailFragment(sports)

        val extras = FragmentNavigatorExtras(
            imageView to sports.banner.toString(),
            textView to sports.title
        )

        findNavController().navigate(direction, extras)
    }
}

One last thing remaining to do is to apply these transition names in DetailFragment for transition to occur correctly.

Just like ListFragment one same thing we have to do is to inflate transition same way.
Optionally you can delay the transition from leaving fragment at certain duration and let the transition take place.

onCreateView

sharedElementEnterTransition =
    TransitionInflater.from(context).inflateTransition(android.R.transition.move)
postponeEnterTransition(250, TimeUnit.MILLISECONDS)

DetailsFragment.kt:

class DetailFragment : Fragment() {

    private lateinit var sportsArgs: Sports

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val args = requireArguments()
        sportsArgs = DetailFragmentArgs.fromBundle(args).modelArgs
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val view = inflater.inflate(R.layout.fragment_detail, container, false)
        sharedElementEnterTransition =
            TransitionInflater.from(context).inflateTransition(android.R.transition.move)
        postponeEnterTransition(250, TimeUnit.MILLISECONDS)
        return view
    }

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

        val banner: ImageView = view.findViewById(R.id.detail_image_view)
        val title: TextView = view.findViewById(R.id.title_detail_text_view)
        val about: TextView = view.findViewById(R.id.about_detail_text_view)

        banner.setImageResource(sportsArgs.banner)
        title.text = sportsArgs.title
        about.text = sportsArgs.about

        banner.transitionName = sportsArgs.banner.toString()
        title.transitionName = sportsArgs.title
    }
}

Now run the app check how transitions takes place between them. For your better understanding experiment by removing code where transition changes or doesn’t work at any point.

You will find this example wrote completely with BindingAdapters in my GitHub repository in different branch, so make sure to check that too.

9. External links, project Github and download

Here We Go Again : (

if (article == helpful) {
    println("Like and subscribe to blog newsletter.")
} else {
    println("Let me know what i should blog on.")
}

This site uses Akismet to reduce spam. Learn how your comment data is processed.