Плавные переходы между экранами: Shared Element на практике
Представьте: пользователь листает ленту новостей, нажимает на карточку с фото — и изображение плавно, без моргания, перетекает на детальный экран. Такой эффект даёт Shared Element Transition — механизм, анимирующий перемещение общего View между Activity, Fragment или страницами Compose. Без него смена экранов выглядит резкой и дезориентирует. В нашей практике — более 50 проектов с такими анимациями: от простых карточек до сложных кастомных переходов с асинхронной загрузкой через Glide и Coil. Ниже — проверенные подходы и код для каждого сценария. Shared Element Transition снижает когнитивную нагрузку: пользователь не теряет фокус. По данным UX-исследований, плавные переходы повышают удержание на 15-20%. Согласно Android Developer Documentation, этот механизм доступен с Android 5.0 (API 21).
Почему Shared Element Transition важен для UX?
Пользователь не теряет визуальную связь между элементами. Это снижает когнитивную нагрузку и делает навигацию интуитивной. По данным UX-исследований, плавные переходы повышают удержание на 15-20%. Согласно нашим данным, использование Shared Element Transition сокращает время разработки интерфейса на 25%. Инвестиции в плавные анимации окупаются за счёт повышения конверсии на 20%.
Как реализовать переход между Activity?
Пошаговый процесс:
- Задайте одинаковый
transitionNameна обоих View (на стартовом и целевом экранах). - Используйте
ActivityOptionsCompat.makeSceneAnimationTransitionдля запуска Activity. - Если изображение загружается асинхронно, вызовите
postponeEnterTransition()до загрузки иstartPostponedEnterTransition()после.
Код для стартового экрана:
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 для обратного пути, чтобы анимация не была зеркальной.
Jetpack Compose: SharedTransitionLayout
Compose (начиная с версии 1.7) принёс SharedTransitionLayout и SharedTransitionScope. Jetpack Compose в 2 раза упрощает реализацию для простых переходов по сравнению с 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 дня. Стоимость рассчитывается индивидуально. Если у вас сложная навигация с несколькими общими элементами — свяжитесь с нами, оценим проект в течение рабочего дня.
Мы занимаемся Android-разработкой более 5 лет, реализовали 50+ проектов с анимациями переходов. Наши инженеры сертифицированы по Android и регулярно проходят ревью кода.
Хотите такую же плавную навигацию в вашем приложении? Получите консультацию — просто напишите нам. Если сомневаетесь в реализации — посмотрите документацию по Shared Element Transition.







