Переходы между общими элементами — это удобный способ перехода между компонуемыми элементами с одинаковым содержимым. Они часто используются для навигации, позволяя визуально связывать разные экраны при переходе пользователя между ними.
Например, в следующем видео вы можете увидеть, как изображение и название закуски переносятся со страницы описания на страницу с подробным описанием.
В Compose есть несколько высокоуровневых API, которые помогут вам создавать общие элементы:
-
SharedTransitionLayout
: внешний макет, необходимый для реализации переходов между общими элементами. Он предоставляетSharedTransitionScope
. Для использования модификаторов общих элементов компонуемые элементы должны находиться вSharedTransitionScope
. -
Modifier.sharedElement()
: модификатор, который отмечает вSharedTransitionScope
составной элемент, который должен быть сопоставлен с другим составным элементом. -
Modifier.sharedBounds()
: модификатор, который указываетSharedTransitionScope
, что границы этого компонуемого объекта должны использоваться в качестве границ контейнера, в котором должен произойти переход. В отличие отsharedElement()
,sharedBounds()
предназначен для визуально разного содержимого.
Важная концепция при создании общих элементов в Compose — это то, как они работают с наложениями и обрезкой. Подробнее об этой важной теме см. в разделе «Обрезка и обрезка» .
Базовое использование
В этом разделе будет построен следующий переход от меньшего элемента «список» к большему подробному элементу:

Лучший способ использовать Modifier.sharedElement()
— в сочетании с AnimatedContent
, AnimatedVisibility
или NavHost
, так как это автоматически управляет переходом между компонуемыми элементами.
Отправной точкой является существующий базовый AnimatedContent
, имеющий MainContent
и DetailsContent
которые можно компоновать перед добавлением общих элементов: AnimatedContent
без каких-либо переходов общих элементов.
Чтобы анимировать общие элементы между двумя макетами, заключите компонуемый
AnimatedContent
вSharedTransitionLayout
. Области действия изSharedTransitionLayout
иAnimatedContent
передаются вMainContent
иDetailsContent
:var showDetails by remember { mutableStateOf(false) } SharedTransitionLayout { AnimatedContent( showDetails, label = "basic_transition" ) { targetState -> if (!targetState) { MainContent( onShowDetails = { showDetails = true }, animatedVisibilityScope = this@AnimatedContent, sharedTransitionScope = this@SharedTransitionLayout ) } else { DetailsContent( onBack = { showDetails = false }, animatedVisibilityScope = this@AnimatedContent, sharedTransitionScope = this@SharedTransitionLayout ) } } }
Добавьте
Modifier.sharedElement()
в цепочку модификаторов для двух соответствующих компонуемых элементов. Создайте объектSharedContentState
и запомните его с помощьюrememberSharedContentState()
. ОбъектSharedContentState
хранит уникальный ключ, определяющий общие элементы. Укажите уникальный ключ для идентификации содержимого и используйтеrememberSharedContentState()
для запоминаемого элемента.AnimatedContentScope
передаётся модификатору, который используется для координации анимации.@Composable private fun MainContent( onShowDetails: () -> Unit, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope ) { Row( // ... ) { with(sharedTransitionScope) { Image( painter = painterResource(id = R.drawable.cupcake), contentDescription = "Cupcake", modifier = Modifier .sharedElement( rememberSharedContentState(key = "image"), animatedVisibilityScope = animatedVisibilityScope ) .size(100.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) // ... } } } @Composable private fun DetailsContent( modifier: Modifier = Modifier, onBack: () -> Unit, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope ) { Column( // ... ) { with(sharedTransitionScope) { Image( painter = painterResource(id = R.drawable.cupcake), contentDescription = "Cupcake", modifier = Modifier .sharedElement( rememberSharedContentState(key = "image"), animatedVisibilityScope = animatedVisibilityScope ) .size(200.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) // ... } } }
Чтобы получить информацию о том, произошло ли совпадение общего элемента, извлеките rememberSharedContentState()
в переменную и выполните запрос isMatchFound
.
В результате получается следующая автоматическая анимация:

Вы можете заметить, что цвет фона и размер всего контейнера по-прежнему используют настройки AnimatedContent
по умолчанию.
Общие границы против общего элемента
Modifier.sharedBounds()
похож на Modifier.sharedElement()
. Однако модификаторы отличаются следующим:
-
sharedBounds()
предназначен для содержимого, которое визуально отличается, но должно занимать одну и ту же область между состояниями, тогда какsharedElement()
ожидает, что содержимое будет одинаковым. - При использовании
sharedBounds()
контент, поступающий на экран и покидающий его, виден во время перехода между двумя состояниями, тогда как при использованииsharedElement()
в преобразуемых границах отображается только целевой контент. УModifier.sharedBounds()
есть параметрыenter
иexit
, определяющие, как должен меняться контент, аналогично принципу работыAnimatedContent
. - Наиболее распространенным вариантом использования
sharedBounds()
является шаблон преобразования контейнера , тогда как дляsharedElement()
примером варианта использования является переход героя. - При использовании
Text
компоновочных элементов предпочтительнее использоватьsharedBounds()
для поддержки изменений шрифта, таких как переход между курсивом и полужирным начертанием или изменение цвета.
Из предыдущего примера, добавление Modifier.sharedBounds()
к Row
и Column
в двух разных сценариях позволит нам совместно использовать их границы и выполнять анимацию перехода, позволяя им расширяться относительно друг друга:
@Composable private fun MainContent( onShowDetails: () -> Unit, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope ) { with(sharedTransitionScope) { Row( modifier = Modifier .padding(8.dp) .sharedBounds( rememberSharedContentState(key = "bounds"), animatedVisibilityScope = animatedVisibilityScope, enter = fadeIn(), exit = fadeOut(), resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds() ) // ... ) { // ... } } } @Composable private fun DetailsContent( modifier: Modifier = Modifier, onBack: () -> Unit, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope ) { with(sharedTransitionScope) { Column( modifier = Modifier .padding(top = 200.dp, start = 16.dp, end = 16.dp) .sharedBounds( rememberSharedContentState(key = "bounds"), animatedVisibilityScope = animatedVisibilityScope, enter = fadeIn(), exit = fadeOut(), resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds() ) // ... ) { // ... } } }
Понимать области действия
Для использования Modifier.sharedElement()
компонуемый элемент должен находиться в области SharedTransitionScope
. Компонуемый элемент SharedTransitionLayout
предоставляет область SharedTransitionScope
. Убедитесь, что он находится в той же точке верхнего уровня иерархии пользовательского интерфейса, что и элементы, которые вы хотите сделать общими.
Как правило, компонуемые объекты также должны быть помещены в область AnimatedVisibilityScope
. Обычно это обеспечивается переключением между компонуемыми объектами с помощью AnimatedContent
, при прямом использовании AnimatedVisibility
или с помощью компонуемой функции NavHost
, если только вы не управляете видимостью вручную . Чтобы использовать несколько областей видимости, сохраните необходимые области видимости в CompositionLocal
, используйте приёмники контекста в Kotlin или передавайте области видимости в качестве параметров своим функциям.
Используйте CompositionLocals
в сценариях, где необходимо отслеживать несколько областей действия или существует глубокая вложенность иерархии. CompositionLocal
позволяет выбрать конкретные области действия для сохранения и использования. С другой стороны, при использовании приёмников контекста другие макеты в вашей иерархии могут случайно переопределить предоставленные области действия. Например, если у вас есть несколько вложенных AnimatedContent
, области действия могут быть переопределены.
val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null } val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null } @Composable private fun SharedElementScope_CompositionLocal() { // An example of how to use composition locals to pass around the shared transition scope, far down your UI tree. // ... SharedTransitionLayout { CompositionLocalProvider( LocalSharedTransitionScope provides this ) { // This could also be your top-level NavHost as this provides an AnimatedContentScope AnimatedContent(state, label = "Top level AnimatedContent") { targetState -> CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) { // Now we can access the scopes in any nested composables as follows: val sharedTransitionScope = LocalSharedTransitionScope.current ?: throw IllegalStateException("No SharedElementScope found") val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No AnimatedVisibility found") } // ... } } } }
В качестве альтернативы, если ваша иерархия не слишком вложенная, вы можете передавать области действия вниз как параметры:
@Composable fun MainContent( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { } @Composable fun Details( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { }
Общие элементы с AnimatedVisibility
В предыдущих примерах было показано, как использовать общие элементы с AnimatedContent
, но общие элементы работают и с AnimatedVisibility
.
Например, в этом примере ленивой сетки каждый элемент обёрнут в AnimatedVisibility
. При щелчке по элементу создаётся визуальный эффект, как будто содержимое вытягивается из пользовательского интерфейса в компонент, похожий на диалоговое окно.
var selectedSnack by remember { mutableStateOf<Snack?>(null) } SharedTransitionLayout(modifier = Modifier.fillMaxSize()) { LazyColumn( // ... ) { items(listSnacks) { snack -> AnimatedVisibility( visible = snack != selectedSnack, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut(), modifier = Modifier.animateItem() ) { Box( modifier = Modifier .sharedBounds( sharedContentState = rememberSharedContentState(key = "${snack.name}-bounds"), // Using the scope provided by AnimatedVisibility animatedVisibilityScope = this, clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement) ) .background(Color.White, shapeForSharedElement) .clip(shapeForSharedElement) ) { SnackContents( snack = snack, modifier = Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility ), onClick = { selectedSnack = snack } ) } } } } // Contains matching AnimatedContent with sharedBounds modifiers. SnackEditDetails( snack = selectedSnack, onConfirmClick = { selectedSnack = null } ) }
AnimatedVisibility
.Порядок модификаторов
При использовании Modifier.sharedElement()
и Modifier.sharedBounds()
порядок цепочки модификаторов имеет значение, как и в остальном Compose. Неправильное размещение модификаторов, влияющих на размер, может привести к неожиданным визуальным скачкам при сопоставлении общих элементов.
Например, если поместить модификатор отступа в разные позиции на двух общих элементах, в анимации возникнет визуальная разница.
var selectFirst by remember { mutableStateOf(true) } val key = remember { Any() } SharedTransitionLayout( Modifier .fillMaxSize() .padding(10.dp) .clickable { selectFirst = !selectFirst } ) { AnimatedContent(targetState = selectFirst, label = "AnimatedContent") { targetState -> if (targetState) { Box( Modifier .padding(12.dp) .sharedBounds( rememberSharedContentState(key = key), animatedVisibilityScope = this@AnimatedContent ) .border(2.dp, Color.Red) ) { Text( "Hello", fontSize = 20.sp ) } } else { Box( Modifier .offset(180.dp, 180.dp) .sharedBounds( rememberSharedContentState( key = key, ), animatedVisibilityScope = this@AnimatedContent ) .border(2.dp, Color.Red) // This padding is placed after sharedBounds, but it doesn't match the // other shared elements modifier order, resulting in visual jumps .padding(12.dp) ) { Text( "Hello", fontSize = 36.sp ) } } } }
Совпадающие границы | Несоответствующие границы: обратите внимание, что анимация общего элемента выглядит немного неправильно, поскольку ему необходимо изменить размер в соответствии с неправильными границами. |
---|---|
Модификаторы, используемые до модификаторов общих элементов, накладывают ограничения на модификаторы общих элементов, которые затем используются для вывода начальных и целевых границ, а затем и анимации границ.
Модификаторы, используемые после модификаторов общих элементов, используют ограничения, заданные до этого, для измерения и расчета целевого размера дочернего элемента. Модификаторы общих элементов создают серию анимированных ограничений для постепенного преобразования дочернего элемента от начального размера до целевого.
Исключением является случай, когда для анимации используется resizeMode = ScaleToBounds()
или Modifier.skipToLookaheadSize()
для компонуемого элемента. В этом случае Compose размещает дочерний элемент, используя целевые ограничения, и вместо изменения размера макета использует коэффициент масштабирования для выполнения анимации.
Уникальные ключи
При работе со сложными общими элементами рекомендуется создавать ключ, не являющийся строкой, поскольку строки могут быть подвержены ошибкам при сопоставлении. Для совпадений каждый ключ должен быть уникальным. Например, в Jetsnack есть следующие общие элементы:

Вы можете создать перечисление для представления типа общего элемента. В этом примере вся карточка закуски может отображаться в разных местах на главном экране, например, в разделах «Популярное» и «Рекомендуемое». Вы можете создать ключ, содержащий snackId
, origin
(«Популярное» / «Рекомендуемое») и type
общего элемента, который будет доступен для общего доступа:
data class SnackSharedElementKey( val snackId: Long, val origin: String, val type: SnackSharedElementType ) enum class SnackSharedElementType { Bounds, Image, Title, Tagline, Background } @Composable fun SharedElementUniqueKey() { // ... Box( modifier = Modifier .sharedElement( rememberSharedContentState( key = SnackSharedElementKey( snackId = 1, origin = "latest", type = SnackSharedElementType.Image ) ), animatedVisibilityScope = this@AnimatedVisibility ) ) // ... }
Классы данных рекомендуются для ключей, поскольку они реализуют hashCode()
и isEquals()
.
Управляйте видимостью общих элементов вручную
Если вы не используете AnimatedVisibility
или AnimatedContent
, вы можете самостоятельно управлять видимостью общего элемента. Используйте Modifier.sharedElementWithCallerManagedVisibility()
и укажите собственное условие, определяющее, должен ли элемент быть видимым или нет:
var selectFirst by remember { mutableStateOf(true) } val key = remember { Any() } SharedTransitionLayout( Modifier .fillMaxSize() .padding(10.dp) .clickable { selectFirst = !selectFirst } ) { Box( Modifier .sharedElementWithCallerManagedVisibility( rememberSharedContentState(key = key), !selectFirst ) .background(Color.Red) .size(100.dp) ) { Text(if (!selectFirst) "false" else "true", color = Color.White) } Box( Modifier .offset(180.dp, 180.dp) .sharedElementWithCallerManagedVisibility( rememberSharedContentState( key = key, ), selectFirst ) .alpha(0.5f) .background(Color.Blue) .size(180.dp) ) { Text(if (selectFirst) "false" else "true", color = Color.White) } }
Текущие ограничения
Эти API имеют ряд ограничений. Наиболее существенные из них:
- Взаимодействие между Views и Compose не поддерживается. Это касается любых компонуемых элементов, обёртывающих
AndroidView
, таких какDialog
илиModalBottomSheet
. - Автоматическая поддержка анимации отсутствует для следующих объектов:
- Композиции с общими изображениями :
-
ContentScale
по умолчанию не анимирован. Он привязывается к заданному конечному значениюContentScale
.
-
- Обрезка формы . Встроенной поддержки автоматической анимации между формами (например, анимации перехода от квадрата к кругу при переходе элемента) нет.
- Для неподдерживаемых случаев используйте
Modifier.sharedBounds()
вместоsharedElement()
и добавьтеModifier.animateEnterExit()
к элементам.
- Композиции с общими изображениями :