На мероприятии I/O 2024 компания Google представила функцию перехода между общими элементами (Shared Element Transitions) для Jetpack Compose!
API Shared Element Transition предоставляет возможность создавать плавные и визуально привлекательные переходы между композитами, которые используют один и тот же элемент пользовательского интерфейса. Это помогает поддерживать визуальную непрерывность и улучшает пользовательский опыт, особенно при навигации по разным экранам.
Например, представьте, что пользователь нажимает на фотографию в списке, и вместо перехода на новый экран фотография плавно расширяется, заполняя весь экран, что создает отличное впечатление для пользователя. Раньше для создания подобных эффектов в Jetpack Compose требовался сложный код. Теперь Google упростила этот процесс, сделав его гораздо более доступным.
В этой статье мы расскажем о Shared Element Transitions и его ключевых элементах, а также покажем на наглядных примерах, как их настраивать. Во второй части мы расскажем, как настроить их для вашего приложения, чтобы создать интерактивный пользовательский опыт.
Экспериментально: поддержка общих элементов доступна с версии Compose 1.7.0-beta01 и является экспериментальной, API может измениться в будущем.
Ключевые элементы
В Jetpack Compose есть несколько высокоуровневых API, которые облегчают создание переходов между общими элементами.
1. SharedTransitionLayout
- Это специальный макет, необходимый при создании общих переходов элементов в Jetpack Compose.
- Он предоставляет
SharedTransitionScope
, который необходим для элементов, участвующих в переходе.
2. Modifier.sharedElement()
- Помечает composable элемент (UI-элемент ) как тот, который должен быть связан с другим составным элементом для перехода.
- Он указывает
SharedTransitionScope
сопоставить этот элемент с соответствующим элементом в переходе.
3. Modifier.sharedBounds()
- Используется, когда нужно, чтобы границы композита (размер и положение) определяли область перехода.
- В отличие от
sharedElement()
, этот модификатор предназначен для случаев, когда содержимое внутри переходящих элементов может отличаться, но они все равно должны переходить друг в друга.
SharedBounds и SharedElement
Modifier.sharedBounds()
похож на Modifier.sharedElement()
. Однако модификаторы различаются следующим образом:
sharedBounds()
предназначен для содержимого, которое визуально отличается, но должно иметь одну и ту же область между состояниями, в то время какsharedElement()
предполагает, что содержимое должно быть одинаковым.- При использовании
sharedBounds()
содержимое, входящее и выходящее из экрана, видно во время перехода между двумя состояниями, в то время как при использованииsharedElement()
в трансформирующихся границах отображается только целевое содержимое.
Необходимые зависимости
Чтобы использовать новый Shared Element Transition API, убедитесь, что вы используете последнюю версию Jetpack Compose UI и библиотек анимации. Ниже приведен пример добавления необходимых зависимостей:
dependencies { implementation("androidx.compose.ui:ui:latest_version") implementation("androidx.compose.animation:animation:latest_version") }
Всегда обращайтесь к официальной документации для проверки и использования последних версий.
Создание примера реализации Shared Element Transitions
Начнем нашу примерную реализацию с настройки навигации.
Определяем экраны
sealed class Screen(val route: String) { data object AlbumsScreen : Screen("list") data object AlbumDetailsScreen : Screen("details/{albumId}") { fun createRoute(albumId: Int) = "details/$albumId" } }
Этот код определяет sealed класс Screen
для управления навигацией. Он включает в себя два экрана: AlbumsScreen
, со статическим маршрутом list
, и AlbumDetailsScreen
, с динамическим маршрутом details/{albumId}
. Функция createRoute
в экране AlbumDetailsScreen
генерирует конкретный маршрут на основе идентификатора альбома.
Определение модели альбома
data class Album( val id: Int, val title: String, val author: String, val year: Int, @DrawableRes val cover: Int )
Он определяет класс данных под названием Album
, который моделирует альбом с такими свойствами, как ID, название, автор, год выпуска и идентификатор ресурса для рисования обложки альбома.
Настройка AnimationSpec
private const val ANIMATION_DURATION_IN_MILLIS = 500 /** * Transformation for the shared element bounds. * Defines the tween animation for the shared element transitions. */ val albumBoundsTransform = { _: Rect, _: Rect -> tween<Rect>(durationMillis = ANIMATION_DURATION_IN_MILLIS) }
Этот код заставляет анимацию длиться 500 миллисекунд (или полсекунды). Функция albumBoundsTransform
описывает, как за это время плавно переместить общий элемент из начальной позиции в конечную.
Настройка MainContent
Самым внешним слоем ваших композитов должен быть SharedTransitionLayout
. Этот макет используется для соединения двух экранов, AlbumsScreen
и AlbumDetailScreen
, с помощью навигационного фреймворка Jetpack Compose.
@Composable private fun MainContent( modifier: Modifier = Modifier, navController: NavHostController, albums: List<Album> ) { SharedTransitionLayout( modifier = modifier ) { NavHost( navController = navController, startDestination = Screen.AlbumsScreen.route ) { composable(route = Screen.AlbumsScreen.route) { AlbumsScreen( albums = albums, animatedVisibilityScope = this, onAlbumClick = { album -> navController.navigate(Screen.AlbumDetailsScreen.createRoute(album.id)) { popUpTo(Screen.AlbumsScreen.route) { inclusive = true } } } ) } composable( route = Screen.AlbumDetailsScreen.route, arguments = listOf(navArgument("albumId") { type = NavType.IntType }) ) { navBackStackEntry -> val albumId = navBackStackEntry.arguments?.getInt("albumId") ?: -1 val album = albums[albumId] AlbumDetailScreen( modifier = Modifier .padding(10.dp) .clip(MaterialTheme.shapes.small.copy(all = CornerSize(25.dp))) .background(Color.LightGray.copy(alpha = 0.5f)) .sharedElement( state = rememberSharedContentState(key = album.id), animatedVisibilityScope = this, boundsTransform = albumBoundsTransform ), album = album, onBackClick = { navController.navigate(Screen.AlbumsScreen.route) { popUpTo(Screen.AlbumDetailsScreen.createRoute(albumId)) { inclusive = true } } } ) } } } }
При такой настройке наша навигация готова к работе с переходами между двумя экранами. В этих экранах доступен SharedTransitionScope
, позволяющий осуществлять переходы между общими элементами.
Обратите внимание, что модификатор sharedElement
доступен только в области SharedTransitionScope
, предоставляемой SharedTransitionLayout
, поэтому нам нужно убедиться, что эта область доступна для композита AlbumsScreen
. Мы можем либо передать область в качестве аргумента и затем обернуть композит с помощью with(scope)
, либо, в качестве альтернативы, сделать композит AlbumsScreen
функцией расширения для SharedTransitionScope
.
Реализация AlbumsScreen
Здесь мы рассмотрим реализацию AlbumsScreen
и общего перехода элементов, для детальной реализации UI.
@Composable fun SharedTransitionScope.AlbumsScreen( modifier: Modifier = Modifier, albums: List<Album>, animatedVisibilityScope: AnimatedVisibilityScope, onAlbumClick: (Album) -> Unit ) { LazyVerticalGrid( modifier = modifier, columns = GridCells.Fixed(2), contentPadding = PaddingValues(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { itemsIndexed(albums) { _, album -> AlbumItem( modifier = Modifier .sharedElement( state = rememberSharedContentState(key = album.id), animatedVisibilityScope = animatedVisibilityScope, boundsTransform = albumBoundsTransform ), album = album, onClick = { onAlbumClick(album) } ) } } }
В приведенном выше коде мы взяли LazyVerticalGrid
с двумя колонками, где все альбомы выровнены в сетке. Мы применили модификатор sharedElement
при каждом клике на AlbumItem
.
Чтобы включить переход между элементами, мы использовали API Modifier.sharedElement()
, который принимает три параметра: AnimatedVisibilityScope
, rememberSharedContentState()
и boundsTransform
.
AnimatedVisibilityScope
— это область видимости для содержимого AnimatedVisibility
. В этой области прямые и косвенные дочерние элементы AnimatedVisibility
смогут определять свои собственные переходы входа/выхода, используя встроенные опции через пользовательскую функцию Modifier.animateEnterExit
. Подробнее об AnimatedVisibility
вы можете узнать из ее официальной документации.
RememberSharedContentState()
— важный параметр, который помогает в переходе между общими элементами, обеспечивая последовательное применение перехода на всех экранах. Эта функция запоминает объект SharedContentState
, который хранит уникальный ключ, определяющий, какие элементы являются общими.
Параметр boundsTransformation
определяет спецификацию анимации, используемую для перехода от начальных к целевым границам.
Реализация AlbumItem
Здесь мы рассмотрим реализацию композитного элемента AlbumItem
, который содержит изображение альбома и добавляет эффект перехода между общими элементами.
@Composable private fun AlbumItem( modifier: Modifier = Modifier, album: Album, onClick: () -> Unit ) { Box( modifier = modifier .clickable(onClick = onClick) ) { Image( modifier = Modifier .aspectRatio(1f) .clip(MaterialTheme.shapes.small.copy(all = CornerSize(20.dp))), painter = painterResource(id = album.cover), contentDescription = "", contentScale = ContentScale.Crop, ) } }
Это все, что нам нужно сделать для экрана AlbumsScreen
, чтобы добиться перехода к общему элементу. Далее мы рассмотрим реализацию AlbumDetailScreen
.
Реализация экрана AlbumDetailScreen
В этом экране мы будем применять тот же ключ sharedElement() в нашем композите, чтобы переход применялся для конкретного элемента.
Экран AlbumDetailScreen
в основе своей разделен на три составные части: AlbumDetailHeader
, AlbumDetailInfo
и AlbumDetailDescription
.
@Composable fun SharedTransitionScope.AlbumDetailScreen( modifier: Modifier = Modifier, album: Album, onBackClick: () -> Unit ) { Column( modifier = modifier ) { AlbumDetailHeader( cover = painterResource(id = album.cover), onBackClick = onBackClick ) AlbumDetailInfo( modifier = Modifier.padding(20.dp), title = album.title, subtitle = "${album.author}, ${album.year}" ) AlbumDetailDescription( modifier = Modifier.padding(10.dp) ) } }
1. AlbumDetailHeader
Композит AlbumDetailHeader
содержит изображение обложки альбома с кнопкой «Назад».
@Composable private fun AlbumDetailHeader( modifier: Modifier = Modifier, cover: Painter, onBackClick: () -> Unit ) { Box( modifier = modifier ) { Image( modifier = Modifier .fillMaxWidth() .height(400.dp) .clip(MaterialTheme.shapes.small.copy(all = CornerSize(25.dp))), painter = cover, contentDescription = null, contentScale = ContentScale.Crop, ) Box( modifier = Modifier .padding(start = 10.dp, top = 10.dp) .clip(CircleShape) .background(Color.White) .clickable(onClick = onBackClick) ) { Icon( modifier = Modifier .size(50.dp) .padding(10.dp), imageVector = Icons.AutoMirrored.Outlined.ArrowBack, tint = Color.Black, contentDescription = null ) } } }
2. AlbumDetailInfo
Композит AlbumDetailInfo
содержит такие сведения об альбоме, как название, автор, год, кнопка воспроизведения и т.д.
@Composable private fun AlbumDetailInfo( modifier: Modifier = Modifier, title: String, subtitle: String ) { Column( modifier = modifier ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = title, fontFamily = FontFamily.Serif, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp, color = MaterialTheme.colors.onSurface ) Text( text = subtitle, fontSize = 20.sp, color = MaterialTheme.colors.onSurface ) } IconButton( modifier = Modifier .clip(CircleShape) .background(Color.Magenta.copy(alpha = 0.3f)), onClick = { /* Handle play action */ } ) { Icon( modifier = Modifier .size(50.dp), imageVector = Icons.Filled.PlayArrow, tint = MaterialTheme.colors.onSurface, contentDescription = null, ) } } } }
3. AlbumDetailDescription
Композит AlbumDetailDescription
содержит подробное описание альбома.
@Composable private fun SharedTransitionScope.AlbumDetailDescription(modifier: Modifier = Modifier) { Column( modifier = modifier ) { InformationPanel( modifier = Modifier.padding(horizontal = 10.dp), description = stringResource(R.string.album_description) ) } }
4. InformationPanel
Здесь композитная панель InformationPanel
содержит описание альбома.
@Composable fun SharedTransitionScope.InformationPanel( modifier: Modifier = Modifier, description: String ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( text = stringResource(R.string.about), fontFamily = FontFamily.Monospace, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp, color = MaterialTheme.colors.onSurface, ) Text( modifier = Modifier.skipToLookaheadSize(), text = description, color = MaterialTheme.colors.onSurface, ) } }
Для перехода к тексту мы использовали функцию skipToLookaheadSize()
. По умолчанию при переходе между двумя макетами размер макета анимируется между начальным и конечным состоянием. Это может быть нежелательным поведением при анимировании такого содержимого, как текст.
Поэтому, по сути, функция skipToLookaheadSize()
предотвращает перетекание текста и сохраняет его конечное состояние в начале анимации.
С этой установкой мы наконец-то закончили с реализацией перехода общих элементов.
Полный код приведенного выше демонстрационного примера вы можете найти на GitHub.
Заключение
Переходы между общими элементами в Jetpack Compose упрощают создание плавных, визуально связанных анимаций между экранами. Благодаря простой настройке и кастомизации эти переходы помогают создать отполированный и привлекательный дизайн приложения, делая навигацию плавной и естественной.
Для получения дополнительной информации вы можете посетить официальный документ по Shared Element Transition API.