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