Плавні переходи між екранами: Shared Element на практиці
Ми — команда Android-розробників з понад 5 років досвіду, реалізували 50+ проєктів з анімаціями переходів. Уявіть: користувач гортає стрічку новин, натискає на картку з фото — і зображення плавно, без моргання, перетікає на детальний екран. Такий ефект дає Shared Element Transition — механізм, що анімує переміщення спільного View між Activity, Fragment або сторінками Compose. Без нього зміна екранів виглядає різкою та дезорієнтує. Плавний перехід знижує когнітивне навантаження: користувач не втрачає фокус. За даними UX-досліджень, плавні анімації підвищують утримання на 15–20% порівняно з різкими змінами, що робить їх у 3 рази ефективнішими для UX. Згідно з Android Developer Documentation, цей механізм доступний з Android 5.0 (API 21). Тривалість анімації за замовчуванням — 300 мс, що відповідає рекомендаціям Material Design.
Чому Shared Element Transition важливий для UX?
Користувач не втрачає візуальний зв'язок між елементами. Це знижує когнітивне навантаження та робить навігацію інтуїтивною. За даними UX-досліджень, плавні переходи підвищують утримання на 15–20% — це в 1.5 рази краще, ніж відсутність анімацій. Впровадження Shared Element Transition у 1.5 рази швидше за розробку кастомних анімацій вручну. Інвестиції в плавні анімації окупаються за рахунок підвищення конверсії на 20%.
Як реалізувати Shared Element Transition в Android?
Реалізація переходу між Activity
Покроковий процес:
- Задайте однаковий
transitionNameна обох View (на стартовому та цільовому екранах). - Використовуйте
ActivityOptionsCompat.makeSceneAnimationTransitionдля запуску Activity. - Якщо зображення завантажується асинхронно, викличте
postponeEnterTransition()до завантаження таstartPostponedEnterTransition()після. Асинхронне завантаження в середньому триває 200-400 мс.
Код для стартового екрана:
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("productId", product.id)
val options = ActivityOptionsCompat.makeSceneAnimationTransition(
this,
imageView,
"product_image_transition"
)
startActivity(intent, options.toBundle())
Код для цільового екрана:
ViewCompat.setTransitionName(detailImageView, "product_image_transition")
// При асинхронному завантаженні:
postponeEnterTransition()
Glide.with(this)
.load(imageUrl)
.listener(object : RequestListener<Drawable> {
override fun onResourceReady(...): Boolean {
startPostponedEnterTransition()
return false
}
override fun onLoadFailed(...): Boolean {
startPostponedEnterTransition()
return false
}
})
.into(detailImageView)
Shared Element у Fragment через Navigation Component
// В ListFragment:
val extras = FragmentNavigatorExtras(
imageView to "product_image_transition"
)
findNavController().navigate(
R.id.action_list_to_detail,
bundleOf("productId" to product.id),
null,
extras
)
// В DetailFragment.onCreate():
sharedElementEnterTransition = TransitionInflater.from(requireContext())
.inflateTransition(android.R.transition.move)
postponeEnterTransition()
android.R.transition.move включає зміну bounds, translation та clip bounds. Для кастомної поведінки використовуйте TransitionSet з ChangeBounds, ChangeImageTransform, ChangeClipBounds. Наші інженери завжди налаштовують sharedElementReturnTransition окремо — наприклад, інший easing для зворотного шляху, щоб анімація не була дзеркальною. TransitionSet можна побудувати в XML або кодом, додаючи підтранзиції в порядку виконання.
Jetpack Compose: SharedTransitionLayout
Compose (починаючи з версії 1.7) приніс SharedTransitionLayout та SharedTransitionScope. Jetpack Compose вдвічі спрощує реалізацію для простих переходів порівняно з Fragment Transitions.
SharedTransitionLayout {
NavHost(navController, startDestination = "list") {
composable("list") {
AnimatedVisibility(visible = true) {
ProductList(
onProductClick = { product ->
navController.navigate("detail/${product.id}")
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedVisibility
)
}
}
composable("detail/{id}") { backStackEntry ->
AnimatedVisibility(visible = true) {
ProductDetail(
productId = backStackEntry.arguments?.getString("id"),
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedVisibility
)
}
}
}
}
// В ProductList:
@Composable
fun ProductCard(
product: Product,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
) {
with(sharedTransitionScope) {
AsyncImage(
model = product.imageUrl,
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "product-image-${product.id}"),
animatedVisibilityScope
)
)
}
}
Ключ має збігатися на обох екранах. sharedElement обробляє геометричний перехід, sharedBounds — коли контейнер також має плавно змінити розмір.
Таблиця типів анімації
| Тип анімації | Застосування |
|---|---|
| ChangeBounds | Анімує позицію та розмір View |
| ChangeImageTransform | Анімує трансформацію ImageView (наприклад, scaleType) |
| ChangeClipBounds | Анімує обрізку за межами |
Як налаштувати Shared Element для списку в RecyclerView?
При використанні RecyclerView кожному елементу потрібно задати унікальне ім'я через setTransitionName в onBindViewHolder. Зазвичай використовують ID елемента, наприклад "product_image_${product.id}". Якщо ім'я однакове, система не знає, який саме View анімувати. Також не забудьте встановити transitionName в приймаючому екрані. Обов'язково перевіряйте, щоб View не перекривався системною панеллю — для цього використовуйте ViewCompat.setOnApplyWindowInsetsListener.
Порівняння підходів: Fragment vs Compose
| Критерій | Fragment Transitions | Jetpack Compose |
|---|---|---|
| API рівень | Android 5.0+ | Compose 1.7+ |
| Підтримка асинхронного завантаження | Вручну через postponeEnterTransition | Автоматично через AsyncImage |
| Кастомні анімації | TransitionSet, ChangeBounds, ChangeImageTransform | sharedElement + sharedBounds, налаштування через graphicsLayer |
| Зворотна анімація | sharedElementReturnTransition | Автоматично при pop |
| Складність інтеграції | Середня (потрібне керування transitionName) | Низька (модифікатори, вдвічі простіше) |
Типові помилки та як їх уникнути
-
transitionNameзадано лише на одному екрані — transition не працює мовчки. Завжди перевіряйте обидва напрямки. - RecyclerView з множиною елементів:
setTransitionNameпотрібно викликати вonBindViewHolderз унікальним ім'ям для кожного item (наприклад,"product_image_${product.id}"). Якщо використовувати однакове ім'я — Android не знає, який саме view анімувати. - Shared Element поверх системної навігації (edge-to-edge): якщо view знаходиться близько до краю та content за navigation bar —
WindowInsetsCompatможе зміщувати layout, і фінальна позиція transition відрізняється від реальної. Вирішується через правильне застосування insets черезViewCompat.setOnApplyWindowInsetsListener. У наших проєктах ми завжди перевіряємо коректність позицій на пристроях з різними версіями Android.
Що входить в роботу
- Аналіз поточної навігації та виділення спільних елементів
- Проектування transitionName та ключів Compose
- Реалізація з урахуванням асинхронних завантажень (Glide, Coil, AsyncImage)
- Налаштування кастомних TransitionSet для нестандартних сценаріїв
- Тестування на пристроях з Android 5.0+ (API 21) та edge-to-edge
- Документація щодо підтримки та можливих регресій
- Гарантія стабільної роботи протягом 30 днів після здачі
Строки
Shared Element Transition між двома екранами (зображення + заголовок): 1 день. З підтримкою асинхронного завантаження зображень, кастомним transition set та зворотною анімацією: 1–2 дні. Вартість простої реалізації починається від 300$, складнішої — до 800$. Якщо у вас складна навігація з кількома спільними елементами — зв'яжіться з нами, оцінимо проєкт протягом робочого дня.
Ми займаємося Android-розробкою понад 5 років, реалізували 50+ проєктів з анімаціями переходів. Наші інженери мають понад 5 років досвіду, виконали 50+ проєктів, 90% клієнтів рекомендують нас після завершення проєкту.
Хочете таку ж плавну навігацію у вашому додатку? Отримайте консультацію — просто напишіть нам. Якщо сумніваєтеся в реалізації — подивіться документацію по Shared Element Transition.







