Переходы между общими элементами — это плавный способ перехода между составными элементами, имеющими согласованное содержимое. Они часто используются для навигации, позволяя визуально связывать различные экраны по мере перемещения пользователя между ними.
Например, в следующем видео вы можете увидеть, как изображение и название закуски переносятся со страницы со списком товаров на страницу с подробным описанием.
В 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. Неправильное размещение модификаторов, влияющих на размер, может вызвать неожиданные визуальные скачки во время сопоставления общих элементов.
Например, если вы разместите модификатор отступа (padding) в разных местах на двух общих элементах, это приведет к визуальной разнице в анимации.
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 у нас есть следующие общие элементы:

Вы можете создать перечисление (enum) для представления типа общего элемента. В этом примере вся карточка Snack может отображаться из нескольких разных мест на главном экране, например, в разделах «Популярное» и «Рекомендуемое». Вы можете создать ключ, содержащий 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 имеют ряд ограничений. Наиболее существенные из них:
- Взаимодействие между View и Compose не поддерживается. Это касается любых объектов Compose, которые являются обертками
AndroidView, таких какDialogилиModalBottomSheet. - Автоматическая поддержка анимации для следующих элементов отсутствует:
- Компоненты Shared Image :
-
ContentScaleпо умолчанию не анимируется. Оно привязывается к заданному значениюContentScale.
-
- Отсечение фигур — Встроенная поддержка автоматической анимации между фигурами отсутствует, например, анимация перехода от квадрата к кругу при изменении формы элемента.
- В случаях, когда поддержка данного метода не предусмотрена, используйте
Modifier.sharedBounds()вместоsharedElement()и добавьтеModifier.animateEnterExit()к элементам.
- Компоненты Shared Image :