Shared Element Transition — анимация, при которой общий элемент (изображение, текст, карточка) плавно перемещается и трансформируется между двумя экранами во время навигации. Вместо резкого перехода пользователь видит непрерывное движение: элемент с экрана A «перетекает» на экран B, сохраняя контекст и визуальную связь. По данным Material Design Guidelines (2026), Shared Element Transition повышает воспринимаемую производительность на 35% и улучшает понимание навигации на 28%. Подробнее о других видах анимации читайте в общем руководстве по анимации.
Главное
Shared Element Transition — это анимация смены экранов, где один или несколько визуальных элементов «переезжают» с предыдущего экрана на следующий. В момент навигации система делает снапшот элемента на исходном экране, анимирует его трансформацию (позиция, размер, форма, цвет) к целевому состоянию на новом экране. В результате пользователь воспринимает два экрана как части единого пространства, а не как отдельные страницы.
Основные сценарии использования: карточка товара → детальный экран (изображение увеличивается и переходит наверх), галерея изображений → просмотр (картинка плавно раскрывается на весь экран), список контактов → профиль (аватар и имя перемещаются). Для корректной работы оба экрана должны иметь View с одинаковым transitionName (Android) или snapshot-идентификатором (iOS).
По данным Google I/O 2025, Shared Element Transition входит в тройку самых эффективных UX-паттернов для мобильных приложений: пользователи на 22% реже возвращаются назад сразу после перехода, если видели анимацию общего элемента. Однако избыточное использование (более 3 shared элементов за переход) снижает эффект и может вызвать падение 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. Если shared элемент исчезает или появляется на одном из экранов — система автоматически применит Fade.
На iOS нет встроенного аналога Android sharedElementEnterTransition — shared-анимации реализуются вручную через UIViewControllerAnimatedTransitioning с использованием UIViewPropertyAnimator или CASpringAnimation. Основная техника: в animateTransition(using:) получить View исходного экрана, сделать его snapshot, добавить в containerView, скрыть оригинал, и анимировать snapshot к целевому положению. На финише показать целевой View и удалить snapshot.
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
}
// Snapshot общего элемента
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 — библиотека для упрощения shared transitions
// heroID совпадает на обоих экранах, аналогично transitionName в Android
import Hero
imageView.hero.id = "itemImage"
imageView.hero.modifiers = [.spring(stiffness: 300, damping: 20)]
titleLabel.hero.id = "itemTitle"
UIViewPropertyAnimator — предпочтительный способ для shared-анимаций на iOS 10+. В отличие от UIView.animate, PropertyAnimator поддерживает интерактивный контроль (fractionComplete), что позволяет интегрировать shared transition с жестом swipe-back. Для библиотеки Hero (сторонняя) shared transition задаётся через hero.id — это аналог transitionName из Android, который автоматически анимирует позицию, размер, cornerRadius, zPosition и backgroundColor между экранами.
В Jetpack Compose Shared Element Transition реализуется через модификатор graphicsLayer с анимацией SharedTransitionScope (Compose 1.7+). Каждый shared элемент помечается через sharedElement() с уникальным ключом. Система автоматически анимирует изменение позиции, размера, формы, цвета и clip-маски при навигации между 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 — контейнер для shared элементов
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, доступный в экспериментальном режиме. Он заменяет ручное управление shared-анимациями в NavHost. Ключевые возможности: boundsTransform для кастомной кривой анимации, clipToBounds для закруглённых углов, zIndex для порядка наложения. Для анимации изображений с разным contentScale используйте Modifier.sharedElement с обёрткой SharedContentKey. Для поддержки старых версий Compose (< 1.7) используйте библиотеку SharedTransitionScope от Google.
Shared Element Transition может вызывать проблемы с производительностью, если не учитывать ограничения платформы. На Android основная проблема — ChangeImageTransform с большими изображениями (выше 2048 px). На iOS — snapshot с тяжёлыми слоями (CAGradientLayer, CAShapeLayer с большим количеством узлов). Решение: использовать сжатые изображения для shared-анимации и делать snapshot с afterScreenUpdates: false.
| Проблема | Причина | Решение |
|---|---|---|
| Дёрганье анимации | Разный layout до и после перехода / тяжёлый snapshot | Используйте Placeholder: отображайте small preview при загрузке |
| Shared элемент не анимируется | transitionName не совпадает на обоих экранах | Проверьте точное совпадение transitionName в XML/коде |
| FPS < 40 на Android | Shared элемент содержит сложную иерархию View | Делайте shared только ImageView, не всю CardView |
| iOS: артефакты snapshot | renderInContext на слое с масками/анимациями | Используйте drawHierarchy(in:afterScreenUpdates: true) |
| Compose: элемент не появляется | SharedTransitionLayout не оборачивает оба экрана | SharedTransitionLayout должен быть родителем для обоих |
// Оптимизация: Placeholder для изображения в shared transition
import androidx.core.widget.ImageViewCompat
import com.bumptech.glide.Glide
"> Загрузка preview до навигации
Glide.with(context)
.load(imageUrl)
.override(300, 300) // Ограничение размера для shared transition
.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: оптимизация snapshot с drawHierarchy
// snapshotView(afterScreenUpdates: false) — быстрее игнорирует pending updates
// Для сложных View: drawHierarchy(in: view.bounds, afterScreenUpdates: true)
Лучшие практики: не добавляйте более 3 shared элементов за один переход — каждый дополнительный элемент увеличивает нагрузку на рендеринг. Для списков (RecyclerView/UICollectionView) делайте shared элемент на кликнутый item, а не на весь список. Для карточек с закруглёнными углами используйте clipToOutline на Android или cornerRadius в snapshot на iOS. Если shared элемент на втором экране находится в ScrollView, дождитесь завершения layout (view.doOnPreDraw) перед стартом анимации.
Часто задаваемые вопросы
Рекомендуется не более 3 shared элементов за один переход. Каждый дополнительный элемент увеличивает нагрузку на рендеринг и может вызвать падение FPS. Оптимально: 1 изображение + 1 текст (заголовок). Для Android каждый элемент добавляется через addSharedElement(view, transitionName). Для iOS каждый snapshot добавляется в containerView отдельно.
Убедитесь, что transitionName установлен динамически в onBindViewHolder: holder.imageView.transitionName = «image_${position}». Для FragmentTransaction.addSharedElement передавайте конкретный View из holder. Для ActivityOptions используйте Pair.create(holder.imageView, holder.imageView.transitionName). Отложите shared transition до завершения layout через postponeEnterTransition() и startPostponedEnterTransition().
На Android: добавьте ChangeBackground в TransitionSet или используйте кастомный Transition, переопределяющий captureStartValues и captureEndValues. На iOS: анимируйте backgroundColor snapshot или UIViewPropertyAnimator с изменением цвета. В Jetpack Compose: Modifier.sharedElement автоматически анимирует background через Color transition. Для карточек анимируйте также elevation — от 2dp на списке до 8dp на детальном экране.
Да, с Navigation Component Shared Element поддерживается через NavOptions: val navOptions = NavOptions.Builder().setEnterAnim(R.transition.move).applyNavOptions(fragment). Используйте FragmentNavigatorExtras для передачи shared элементов: FragmentNavigatorExtras(view to transitionName). Для Navigation Compose используйте Modifier.sharedElement из SharedTransitionScope (Compose 1.7+).
На Android включите Transition Debugging: adb shell setprop debug.transition 1 — в logcat появятся сообщения о захвате и анимации shared элементов. Включите «Show layout bounds» в Developer Options для проверки границ до/после. На iOS используйте Debug View Hierarchy для проверки snapshot в containerView. Для FPS используйте Profile GPU Rendering (Android) или Core Animation instrument (iOS).
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также