Site icon AppTractor

Shared Element Transition в Jetpack Compose

На мероприятии 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

2. Modifier.sharedElement()

3. Modifier.sharedBounds()

SharedBounds и SharedElement

Modifier.sharedBounds() похож на Modifier.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.

Источник

Exit mobile version