Переходы между общими элементами – это удобный способ перехода между составными элементами, контент которых согласован между собой. Они часто используются для навигации, позволяя визуально соединять разные экраны, когда пользователь перемещается между ними.
Например, в видео ниже показано, как изображение и название закуски передаются со страницы со списком на страницу с подробными сведениями.
В Compose есть несколько высокоуровневых API, которые помогают создавать общие элементы:
SharedTransitionLayout– внешний макет, необходимый для реализации переходов между общими элементами. Она предоставляетSharedTransitionScope. Чтобы использовать модификаторы общих элементов, composable-функции должны находиться вSharedTransitionScope.Modifier.sharedElement()– модификатор, который сообщаетSharedTransitionScope, какую composable-функцию нужно сопоставить с другой.Modifier.sharedBounds()– модификатор, который сообщаетSharedTransitionScope, что границы этой composable-функции должны использоваться в качестве границ контейнера, в котором должен происходить переход. В отличие отsharedElement(),sharedBounds()предназначен для визуально отличающегося контента.
Важная концепция при создании общих элементов в Compose – это то, как они работают с наложениями и обрезкой. Подробнее об этом рассказывается в разделе Обрезка и наложение.
Основные функции
В этом разделе будет создан переход от меньшего элемента списка к большему элементу с подробными сведениями.
Лучше всего использовать Modifier.sharedElement() вместе с AnimatedContent, AnimatedVisibility или NavHost, поскольку это позволяет автоматически управлять переходом между composable-функциями.
В качестве отправной точки мы используем базовый компонент AnimatedContent, который содержит компоненты MainContent и DetailsContent, прежде чем добавлять общие элементы:
AnimatedContent без переходов между общими элементами.Чтобы анимировать общие элементы между двумя макетами, окружите composable-функцию
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()в цепочку модификаторов двух соответствующих composable-функций. Создайте объект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()примером использования является переход героя. - При использовании composable-функций
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(), composable-функция должна находиться в SharedTransitionScope. Компонуемый объект SharedTransitionLayout предоставляет SharedTransitionScope. Убедитесь, что вы разместили его в той же точке верхнего уровня в иерархии пользовательского интерфейса, которая содержит элементы, которыми вы хотите поделиться.
Как правило, composable-функции также должны быть помещены в AnimatedVisibilityScope. Обычно это делается с помощью AnimatedContent для переключения между composable-функциями, прямого использования AnimatedVisibility или composable-функции 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.Порядок модификаторов
Как и в остальной части Compose, при использовании Modifier.sharedElement() и Modifier.sharedBounds() важен порядок цепочки модификаторов. Неправильное размещение модификаторов, влияющих на размер, может привести к неожиданным визуальным скачкам во время сопоставления общих элементов.
Например, если вы поместите модификатор заполнения в другое положение на двух общих элементах, в анимации будет визуальная разница.
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() для composable-функции. В этом случае 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() и equals().
Как вручную управлять видимостью общих элементов
Если вы не используете 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 не поддерживается. Это относится к любой composable-функции, которая содержит
AndroidView, напримерDialogилиModalBottomSheet. - Автоматическая анимация не поддерживается для следующих элементов:
- Общие composable-функции Image:
- По умолчанию
ContentScaleне анимируется. Оно привязывается к заданному концуContentScale.
- По умолчанию
- Обрезка фигур. Встроенная поддержка автоматической анимации между фигурами отсутствует. Например, нельзя анимировать переход от квадрата к кругу.
- В неподдерживаемых случаях используйте
Modifier.sharedBounds()вместоsharedElement()и добавьтеModifier.animateEnterExit()к элементам.
- Общие composable-функции Image: