You know that animation where you tap a photo in a list and it smoothly expands into a full-screen detail view — like Google Photos or the Play Store? That's a Shared Element Transition. It creates a visual continuity between two screens that tells the user "this is the same thing, just bigger" without a single word of explanation.
It's one of those details that separates apps that feel polished from apps that feel functional. And it's surprisingly achievable — once you know the three gotchas that cause it to break silently. This guide covers Activity transitions, Fragment transitions, RecyclerView integration, and the critical postponeEnterTransition() trick you need when images load asynchronously.
How It Works
The system needs to know which view in Screen A corresponds to which view in Screen B. You tell it by giving both views the same transitionName. When you navigate, Android animates the view from its position in Screen A to its position in Screen B. On back press, it reverses.
Three things must be true for it to work:
- Both views must have the same
transitionName - The transition name must be unique on screen — if you have a list, each item needs a unique name
- If images load asynchronously, you must postpone the transition until they're ready
Activity to Activity — Basic Setup
Step 1: Set transitionName in both layouts
<!-- activity_list.xml — source view -->
<ImageView
android:id="@+id/imgUser"
android:layout_width="80dp"
android:layout_height="80dp"
android:transitionName="user_image" />
<!-- activity_detail.xml — destination view -->
<ImageView
android:id="@+id/imgUserDetail"
android:layout_width="match_parent"
android:layout_height="300dp"
android:transitionName="user_image" />
Step 2: Start Activity with the transition
// In your source Activity — Kotlin
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("userId", user.id)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
this,
binding.imgUser, // the view to animate
"user_image" // must match transitionName in both layouts
)
startActivity(intent, options.toBundle())
Step 3: Enable return transition in the detail Activity
// In DetailActivity — enable the return animation on back press
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Must be called BEFORE setContentView
window.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS)
setContentView(R.layout.activity_detail)
}
Theme.MaterialComponents or Theme.Material3, you don't need to call requestFeature or add windowContentTransitions to styles.xml. It's enabled by default.
The Most Important Part — Postponing for Async Image Loading
Here's the gotcha that nobody explains clearly. If you load images with Glide or Coil, the image isn't ready when the transition starts. The animation plays against an empty ImageView and looks broken — the element just appears instead of flying in.
The fix is postponeEnterTransition() — tell Android to wait before starting the animation, then call startPostponedEnterTransition() once the image is loaded:
// In DetailActivity
class DetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_detail)
// Postpone until image is ready
postponeEnterTransition()
val userId = intent.getStringExtra("userId")
Glide.with(this)
.load(getUserImageUrl(userId))
.listener(object : RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable, model: Any, target: Target<Drawable>?,
dataSource: DataSource, isFirstResource: Boolean
): Boolean {
// Image loaded — start the transition now
startPostponedEnterTransition()
return false
}
override fun onLoadFailed(
e: GlideException?, model: Any?,
target: Target<Drawable>, isFirstResource: Boolean
): Boolean {
// Image failed — start anyway to avoid freezing the screen
startPostponedEnterTransition()
return false
}
})
.into(binding.imgUserDetail)
}
}
window.sharedElementEnterTransition.duration = 400L
RecyclerView to Detail — The Real World Use Case
This is where most developers actually need it. A list of users, articles, or products — tap one, it expands into a detail screen. The key is giving each item a unique transition name using the item's ID:
In your Adapter
class UserAdapter(
private val users: List<User>,
private val onItemClick: (User, View) -> Unit
) : RecyclerView.Adapter<UserAdapter.ViewHolder>() {
inner class ViewHolder(private val binding: ItemUserBinding)
: RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
// CRITICAL: unique transition name per item using item ID
ViewCompat.setTransitionName(binding.imgUser, "user_image_${user.id}")
Glide.with(binding.root)
.load(user.imageUrl)
.into(binding.imgUser)
binding.root.setOnClickListener {
onItemClick(user, binding.imgUser)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
holder.bind(users[position])
override fun getItemCount() = users.size
}
In your Fragment or Activity launching the detail
// Pass the unique transition name to the detail screen
userAdapter = UserAdapter(users) { user, imageView ->
val intent = Intent(this, DetailActivity::class.java).apply {
putExtra("userId", user.id)
// Pass the transition name so detail screen can set it on its ImageView
putExtra("transitionName", ViewCompat.getTransitionName(imageView))
}
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
this, imageView, ViewCompat.getTransitionName(imageView) ?: ""
)
startActivity(intent, options.toBundle())
}
In DetailActivity — apply the received transition name
class DetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_detail)
postponeEnterTransition()
// Apply the transition name received from the list
val transitionName = intent.getStringExtra("transitionName")
ViewCompat.setTransitionName(binding.imgUserDetail, transitionName)
// Load image and start transition when ready
Glide.with(this)
.load(getUserImageUrl(intent.getStringExtra("userId")))
.listener(object : RequestListener<Drawable> {
override fun onResourceReady(resource: Drawable, model: Any,
target: Target<Drawable>?, dataSource: DataSource,
isFirstResource: Boolean): Boolean {
startPostponedEnterTransition()
return false
}
override fun onLoadFailed(e: GlideException?, model: Any?,
target: Target<Drawable>, isFirstResource: Boolean): Boolean {
startPostponedEnterTransition()
return false
}
})
.into(binding.imgUserDetail)
}
}
Fragment to Fragment Transitions
Fragment transitions use the Navigation Component and work slightly differently — you set the transition on the Fragment itself rather than the Intent:
// In your list Fragment — navigate to detail with shared element
class UserListFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
userAdapter = UserAdapter(users) { user, imageView ->
// Set the transition name to match the detail fragment
val extras = FragmentNavigatorExtras(
imageView to "user_image_${user.id}"
)
val action = UserListFragmentDirections
.actionListToDetail(userId = user.id)
findNavController().navigate(action, extras)
}
}
}
// In DetailFragment — receive the transition
class UserDetailFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Set the enter transition
sharedElementEnterTransition = TransitionInflater.from(requireContext())
.inflateTransition(android.R.transition.move)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postponeEnterTransition()
val userId = args.userId
ViewCompat.setTransitionName(binding.imgUserDetail, "user_image_$userId")
Glide.with(this)
.load(getUserImageUrl(userId))
.listener(object : RequestListener<Drawable> {
override fun onResourceReady(resource: Drawable, model: Any,
target: Target<Drawable>?, dataSource: DataSource,
isFirstResource: Boolean): Boolean {
startPostponedEnterTransition()
return false
}
override fun onLoadFailed(e: GlideException?, model: Any?,
target: Target<Drawable>, isFirstResource: Boolean): Boolean {
startPostponedEnterTransition()
return false
}
})
.into(binding.imgUserDetail)
}
}
Multiple Shared Elements
You can animate more than one element simultaneously — for example an image and a title text together:
// Animate both image and title text
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
this,
Pair(binding.imgUser as View, "user_image"),
Pair(binding.tvUserName as View, "user_name")
)
startActivity(intent, options.toBundle())
<!-- In detail layout — same transition names -->
<ImageView
android:transitionName="user_image" ... />
<TextView
android:transitionName="user_name" ... />
Best Practices
- Always use unique transition names in lists — use the item ID in the name:
"user_image_${user.id}". If two visible items share the same transition name, Android picks one randomly and the wrong element animates. - Always postpone when loading images async —
postponeEnterTransition()inonCreate(),startPostponedEnterTransition()inonResourceReady()ANDonLoadFailed(). Missing the failure case freezes the screen. - Keep transitions short — 300–400ms is the sweet spot. Longer than 500ms starts to feel sluggish. Set it with
window.sharedElementEnterTransition.duration = 350L. - Set
transitionNamein code for dynamic content — in RecyclerView, always set it inonBindViewHolderusingViewCompat.setTransitionName()rather than in XML, because the same ViewHolder is reused for different items. - Test the return transition too — press back after the enter transition and verify the return animation works correctly. A common issue is the image returning to the wrong position if the RecyclerView has scrolled.
Frequently Asked Questions
How do I implement Shared Element Transition with Kotlin?
Set the same android:transitionName on both views, then use ActivityOptionsCompat.makeSceneTransitionAnimation() with the source view and name. For async images, call postponeEnterTransition() and startPostponedEnterTransition() after the image loads.
Why is my transition not working with Glide?
The transition fires before Glide finishes loading. Call postponeEnterTransition() before setContentView(), then startPostponedEnterTransition() inside onResourceReady() AND onLoadFailed() — both are required.
How do I use this with RecyclerView?
Set a unique transition name per item in onBindViewHolder using ViewCompat.setTransitionName(view, "prefix_${item.id}"). Pass the name to the detail screen via Intent. Apply the same name to the destination view there.
How do I do Fragment-to-Fragment shared element transitions?
Use FragmentNavigatorExtras mapping the source view to its transition name, pass extras to NavController.navigate(). In the destination Fragment, set sharedElementEnterTransition in onCreate() and postpone until async content loads.
- Both views must have the same
transitionName— that's all Android needs to connect them - Use unique names in RecyclerView —
"prefix_${item.id}"set inonBindViewHolder - Always postpone for async image loading —
postponeEnterTransition()+startPostponedEnterTransition() - Call
startPostponedEnterTransition()in bothonResourceReady()andonLoadFailed() - For Fragment transitions use
FragmentNavigatorExtraswith Navigation Component - Multiple elements — use
Pair(view, name)overload ofmakeSceneTransitionAnimation() - Keep duration 300–400ms — longer feels sluggish
supb work ... :)
ReplyDelete