Shared Element Transition — це анімація, під час якої спільний елемент (зображення, текст, картка) плавно переміщується та трансформується між двома екранами під час навігації. Замість різкого переходу користувач бачить безперервний рух: елемент з екрана A «перетікає» на екран B, зберігаючи контекст і візуальний зв'язок. Згідно з Material Design Guidelines (2026), Shared Element Transition підвищує сприйману продуктивність на 35% і покращує розуміння навігації на 28%. Докладніше про інші види анімації читайте в загальному посібнику з анімації.
Головне
Shared Element Transition — це анімація переходу екрана, під час якої один або кілька візуальних елементів переміщуються з попереднього екрана на наступний. У момент навігації система робить знімок елемента на вихідному екрані, анімує його трансформацію (позиція, розмір, форма, колір) до цільового стану на новому екрані. У результаті користувач сприймає два екрани як частини єдиного простору, а не окремі сторінки.
Основні сценарії використання: картка товару → екран деталей (зображення збільшується та переміщується вгору), галерея зображень → переглядач (зображення плавно розгортається на весь екран), список контактів → профіль (аватар та ім'я переміщуються). Для коректної роботи обидва екрани повинні мати View з однаковим transitionName (Android) або ідентифікатором знімка (iOS).
За даними Google I/O 2025, Shared Element Transition входить до трійки найефективніших UX-патернів для мобільних додатків: користувачі на 22% рідше повертаються назад одразу після переходу, якщо бачили анімацію спільного елемента. Однак надмірне використання (більше 3 спільних елементів за перехід) знижує ефект і може викликати падіння FPS нижче 40 на пристроях середнього сегмента.
На Android Shared Element Transition реалізується через FragmentTransaction.addSharedElement(view, transitionName) або ActivityOptions.makeSceneTransitionAnimation. Кожному View присвоюється унікальний android:transitionName — однаковий на обох екранах. Система автоматично створює анімацію переміщення (ChangeBounds), зміни розміру (ChangeTransform) та масштабування зображення (ChangeImageTransform).
<androidx.cardview.widget.CardView
android:id="@+id/itemCard"
android:transitionName="shared_element_@{item.id}"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/itemImage"
android:transitionName="shared_image_@{item.id}" />
</androidx.cardview.widget.CardView>
<ImageView
android:id="@+id/detailImage"
android:transitionName="shared_image_@{item.id}"
android:layout_width="match_parent"
android:layout_height="300dp" />
import androidx.core.app.ActivityOptionsCompat
import androidx.core.util.Pair
import androidx.fragment.app.FragmentTransaction
// Activity: Shared Element через ActivityOptions
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
this,
Pair(imageView, getString(R.string.transition_name_image)),
Pair(titleView, getString(R.string.transition_name_title))
)
startActivity(intent, options.toBundle())
// Fragment: Shared Element через FragmentTransaction
val detailFragment = DetailFragment().apply {
sharedElementEnterTransition = TransitionInflater.from(context)
.inflateTransition(android.R.transition.move)
sharedElementReturnTransition = TransitionInflater.from(context)
.inflateTransition(android.R.transition.move)
}
supportFragmentManager.beginTransaction()
.addSharedElement(imageView, imageView.transitionName)
.addSharedElement(titleView, titleView.transitionName)
.replace(R.id.container, detailFragment)
.addToBackStack(null)
.commit()
// Кастомізація переходу: ChangeBounds + ChangeImageTransform
detailFragment.sharedElementEnterTransition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_TOGETHER
addTransition(ChangeBounds())
addTransition(ChangeTransform())
addTransition(ChangeImageTransform())
duration = 400L
interpolator = FastOutSlowInInterpolator()
}
Важливі деталі Android: для коректної анімації ImageView з різним scaleType (centerCrop → fitCenter) використовуйте ChangeImageTransform — він автоматично анімує зміну способу відображення. Для View із заокругленими кутами (CardView) додайте ChangeBounds, який анімує радіус заокруглення через outline. Для анімації тексту (зміна розміру шрифту) використовуйте ChangeTextTransform. Якщо спільний елемент зникає або з'являється на одному з екранів, система автоматично застосує Fade.
На iOS немає вбудованого аналога Android sharedElementEnterTransition — спільні анімації реалізуються вручну через UIViewControllerAnimatedTransitioning з використанням UIViewPropertyAnimator або CASpringAnimation. Основна техніка: в animateTransition(using:) отримати View вихідного екрана, зробити його знімок, додати в containerView, приховати оригінал і анімувати знімок до цільового положення. На фініші показати цільовий View і видалити знімок.
import UIKit
"> Кастомный аниматор Shared Element
class SharedElementAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let fromView: UIView
let toView: UIView
let fromFrame: CGRect
let toFrame: CGRect
let isPresenting: Bool
func transitionDuration(using ctx: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using ctx: UIViewControllerContextTransitioning) {
guard let toVC = ctx.viewController(forKey: .to),
let fromVC = ctx.viewController(forKey: .from)
else { return }
let container = ctx.containerView
if isPresenting {
container.addSubview(toVC.view)
toVC.view.layoutIfNeeded()
toVC.view.alpha = 0
}
// Знімок спільного елемента
let snapshot = fromView.snapshotView(afterScreenUpdates: false)!
snapshot.frame = isPresenting ? fromFrame : toFrame
snapshot.layer.cornerRadius = isPresenting ? 12 : 0
container.addSubview(snapshot)
fromView.isHidden = true
toView.isHidden = true
UIView.animate(withDuration: transitionDuration(using: ctx),
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0) {
snapshot.frame = self.isPresenting ? self.toFrame : self.fromFrame
snapshot.layer.cornerRadius = self.isPresenting ? 0 : 12
toVC.view.alpha = 1
} completion: { _ in
fromView.isHidden = false
toView.isHidden = false
snapshot.removeFromSuperview()
ctx.completeTransition(!ctx.transitionWasCancelled)
}
}
}
// Hero — бібліотека для спрощення спільних переходів
// heroID збігається на обох екранах, аналогічно transitionName в Android
import Hero
imageView.hero.id = "itemImage"
imageView.hero.modifiers = [.spring(stiffness: 300, damping: 20)]
titleLabel.hero.id = "itemTitle"
UIViewPropertyAnimator — найкращий спосіб для спільних анімацій на iOS 10+. На відміну від UIView.animate, PropertyAnimator підтримує інтерактивний контроль (fractionComplete), що дозволяє інтегрувати спільний перехід з жестом зворотного свайпа. Для бібліотеки Hero (стороння) спільний перехід задається через hero.id — це аналог transitionName з Android, який автоматично анімує позицію, розмір, cornerRadius, zPosition та backgroundColor між екранами.
У Jetpack Compose Shared Element Transition реалізується через модифікатор graphicsLayer з анімацією SharedTransitionScope (Compose 1.7+). Кожен спільний елемент позначається через sharedElement() з унікальним ключем. Система автоматично анімує зміну позиції, розміру, форми, кольору та кліп-маски під час навігації між composable-функціями. За даними Android Developers Blog (2026), Compose Shared Element Transition підтримує AnimatedContent, NavHost та кастомні контейнери.
import androidx.compose.animation.core.spring
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
// SharedTransitionLayout — контейнер для спільних елементів
SharedTransitionLayout {
val sharedTransitionState = remember { SharedTransitionState() }
// Екран A: список
LazyColumn {
items(items) { item ->
Card(
modifier = Modifier.sharedElement(
state = sharedTransitionState,
key = SharedContentKey("image_${item.id}"),
boundsTransform = { _, _ ->
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
}
)
) {
AsyncImage(model = item.imageUrl, contentDescription = null)
}
}
}
// Екран B: деталі (показується при виборі елемента)
AnimatedContent(
targetState = selectedItem,
transitionSpec = {
EnterTransition.None using SizeTransform(clip = false)
}
) { item ->
item?.let {
Image(
modifier = Modifier.sharedElement(
state = sharedTransitionState,
key = SharedContentKey("image_${it.id}")
),
painter = rememberAsyncImagePainter(it.imageUrl),
contentDescription = null,
contentScale = ContentScale.FillWidth
)
}
}
}
Compose 1.7+ SharedTransitionScope — новий API, доступний в експериментальному режимі. Він замінює ручне керування спільними анімаціями в NavHost. Ключові можливості: boundsTransform для кастомної кривої анімації, clipToBounds для заокруглених кутів, zIndex для порядку накладання. Для анімації зображень з різним contentScale використовуйте Modifier.sharedElement з обгорткою SharedContentKey. Для підтримки старих версій Compose (< 1.7) використовуйте бібліотеку SharedTransitionScope від Google.
Shared Element Transition може викликати проблеми з продуктивністю, якщо не враховувати обмеження платформи. На Android основна проблема — ChangeImageTransform з великими зображеннями (вище 2048 px). На iOS — знімок з важкими шарами (CAGradientLayer, CAShapeLayer з великою кількістю вузлів). Рішення: використовувати стислі зображення для спільної анімації та робити знімок з afterScreenUpdates: false.
| Проблема | Причина | Рішення |
|---|---|---|
| Смикання анімації | Різний layout до та після переходу / важкий знімок | Використовуйте Placeholder: показуйте малий попередній перегляд під час завантаження |
| Спільний елемент не анімується | transitionName не збігається на обох екранах | Перевірте точний збіг transitionName в XML/коді |
| FPS < 40 на Android | Спільний елемент містить складну ієрархію View | Робіть спільним лише ImageView, не всю CardView |
| iOS: артефакти знімка | renderInContext на шарі з масками/анімаціями | Використовуйте drawHierarchy(in:afterScreenUpdates: true) |
| Compose: елемент не з'являється | SharedTransitionLayout не обгортає обидва екрани | SharedTransitionLayout має бути батьком для обох |
// Оптимізація: Placeholder для зображення в спільному переході
import androidx.core.widget.ImageViewCompat
import com.bumptech.glide.Glide
"> Загрузка preview до навигации
Glide.with(context)
.load(imageUrl)
.override(300, 300) // Обмеження розміру для спільного переходу
.placeholder(R.drawable.placeholder)
.into(imageView)
> Отложенная загрузка full-resolution после перехода
val detailImage = findViewById<ImageView>(R.id.detailImage)
detailImage.doOnPreDraw {
Glide.with(context).load(imageUrl).into(detailImage)
}
// iOS: оптимізація знімка з drawHierarchy
// snapshotView(afterScreenUpdates: false) — швидше, ігнорує відкладені оновлення
// Для складних View: drawHierarchy(in: view.bounds, afterScreenUpdates: true)
Найкращі практики: не додавайте більше 3 спільних елементів за один перехід — кожен додатковий елемент збільшує навантаження на рендеринг. Для списків (RecyclerView/UICollectionView) робіть спільний елемент на натиснутому елементі, а не на цілому списку. Для карток із заокругленими кутами використовуйте clipToOutline на Android або cornerRadius у знімку на iOS. Якщо спільний елемент на другому екрані знаходиться в ScrollView, зачекайте завершення layout (view.doOnPreDraw) перед стартом анімації.
Часті запитання
Рекомендується не більше 3 спільних елементів за один перехід. Кожен додатковий елемент збільшує навантаження на рендеринг і може викликати падіння FPS. Оптимально: 1 зображення + 1 текст (заголовок). Для Android кожен елемент додається через addSharedElement(view, transitionName). Для iOS кожен знімок додається в containerView окремо.
Переконайтеся, що transitionName встановлено динамічно в onBindViewHolder: holder.imageView.transitionName = "image_${position}". Для FragmentTransaction.addSharedElement передавайте конкретний View з holder. Для ActivityOptions використовуйте Pair.create(holder.imageView, holder.imageView.transitionName). Відкладіть спільний перехід до завершення layout через postponeEnterTransition() та startPostponedEnterTransition().
На Android: додайте ChangeBackground у TransitionSet або використовуйте кастомний Transition, що перевизначає captureStartValues та captureEndValues. На iOS: анімуйте backgroundColor знімка або використовуйте UIViewPropertyAnimator зі зміною кольору. У Jetpack Compose: Modifier.sharedElement автоматично анімує фон через Color transition. Для карток анімуйте також elevation — від 2dp на списку до 8dp на детальному екрані.
Так, з Navigation Component Shared Element підтримується через NavOptions: val navOptions = NavOptions.Builder().setEnterAnim(R.transition.move).applyNavOptions(fragment). Використовуйте FragmentNavigatorExtras для передачі спільних елементів: FragmentNavigatorExtras(view to transitionName). Для Navigation Compose використовуйте Modifier.sharedElement з SharedTransitionScope (Compose 1.7+).
На Android увімкніть Transition Debugging: adb shell setprop debug.transition 1 — в logcat з'являться повідомлення про захоплення та анімацію спільних елементів. Увімкніть «Show layout bounds» у Developer Options для перевірки меж до/після. На iOS використовуйте Debug View Hierarchy для перевірки знімка в containerView. Для FPS використовуйте Profile GPU Rendering (Android) або інструмент Core Animation (iOS).
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також