Реализация параллакс-карусели из SwiftUI в Jetpack Compose

В рамках повседневной деятельности я часто изучаю последние разработки на таких платформах, как X и Medium. Однажды утром, пролистывая X, наткнулся на GitHub-репозиторий, созданный KavSoft и сразу же привлекший мое внимание. Этот проект, размещенный на KavSoft-Tutorials-iOS, представлял собой потрясающую параллакс-карусель. Ее визуальная привлекательность и удобство использования просто завораживали.

Слайдер параллакс-карусели, созданный kenfai с помощью TabView в SwiftUI 2.0

В тот момент я решил, что должен воссоздать эту магию в Jetpack Compose для Android. Данная статья служит своеобразным мостом между этими двумя платформами, показывая, как преобразовать параллакс-карусель SwiftUI в ее эквивалент в Jetpack Compose.

Пример проекта

Наше путешествие начнется с освоения основных компонентов. Те из вас, кто заинтересован в изучении подробных фрагментов кода и полного исходного кода, найдет их здесь.

Итак, приступим к рассмотрению ключевых компонентов, необходимых для реализации данного проекта.

TabView → HorizontalPager

Чтобы перекинуть мост от TabView в SwiftUI к Jetpack Compose, обратимся к HorizontalPager. TabView в SwiftUI предлагает восхитительный способ плавного пролистывания коллекции представлений. В Android-разработке переход от TabView в SwiftUI к Jetpack Compose начинается с HorizontalPager.

Будучи экспериментальным, HorizontalPager оказывается подходящим вариантом для воспроизведения основной функциональности TabView в SwiftUI. HorizontalPager позволяет изящно и плавно перемещаться по ряду изображений, сохраняя желаемый пользовательский опыт.

// Внутренние отступы значений
private val cardPadding = 25.dp
private val imagePadding = 10.dp

// Значения тени и формы для Card
private val shadowElevation = 15.dp
private val borderRadius = 15.dp
private val shape = RoundedCornerShape(borderRadius)

// Смещение для эффекта параллакса
private val xOffset = cardPadding.value * 2

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ParallaxCarousel() {
// Получение размеров и плотности экрана
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val density = LocalDensity.current.density

// Список ресурсов изображений
val images = listOf(
R.drawable.p1,
...
R.drawable.p7,
)

// Создание состояния Pager
val pagerState = rememberPagerState {
images.size
}

// Расчет высоты для Pager
val pagerHeight = screenHeight / 1.5f

// Composable-функция HorizontalPager: пролистывание изображений
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.height(pagerHeight),
) { page ->
// Расчет смещения параллакса для текущей страницы
val parallaxOffset = pagerState.getOffsetFractionForPage(page) * screenWidth.value

// Вызов ParallaxCarouselItem с ресурсом изображения и параметрами
ParallaxCarouselItem(
images[page],
parallaxOffset,
pagerHeight,
screenWidth,
density
)
}
}

Вычисление parallaxOffset используется для определения смещения позиции текущей страницы в карусели. Вот почему:

  • pagerState.getOffsetFractionForPage(page): эта функция извлекает дробное значение, отражающее расстояние текущей страницы от ее места фиксации. Оно изменяется от -0,5 (если страница смещена к началу макета) до 0,5 (если страница смещена к концу макета). Когда текущая страница находится в фиксированном положении, вышеуказанное значение равно 0,0. Это полезный индикатор положения страницы в карусели.
  • screenWidth: ширина экрана или области отображения. Умножение дробного смещения на ширину экрана позволяет масштабировать значение смещения в соответствии с размерами экрана. Такой шаг обеспечивает пропорциональное перемещение изображения по экрану благодаря эффекту параллакса.

GeometryReader → Canvas

В коде SwiftUI я обратил внимание на использование GeometryReader  —  важнейшего компонента, позволяющего достичь эффекта параллакса. В Jetpack Compose для реализации этого эффекта используется Canvas.

Однако здесь есть тонкое различие. Если в SwiftUI для достижения правильного соотношения сторон изображения можно поместить изображение внутрь GeometryReader с помощью .aspectRatio(contentMode: .fill), то в Jetpack Compose применяется несколько иной подход. В Canvas нельзя напрямую использовать Compose Image. Вместо этого в Canvas для рендеринга изображений используется функция drawBitmap.

Чтобы воспроизвести поведение, наблюдаемое на iOS,  —  изображение занимает всю ширину (эквивалентную размеру экрана) и сохраняет при этом правильную высоту,  —  необходимо прибегнуть к некоторым вычислениям. При этом важно убедиться, что отрисованное изображение сохраняет правильное соотношение сторон.

Для тех, кому интересно, приведу пример функции calculateDrawSize, которая выполняет эти вычисления:

// Функция, рассчитывающая размер рисунка для изображения
private fun ImageBitmap.calculateDrawSize(density: Float, screenWidth: Dp, pagerHeight: Dp): IntSize {
val originalImageWidth = width / density
val originalImageHeight = height / density

val frameAspectRatio = screenWidth / pagerHeight
val imageAspectRatio = originalImageWidth / originalImageHeight

val drawWidth = xOffset + if (frameAspectRatio > imageAspectRatio) {
screenWidth.value
} else {
pagerHeight.value * imageAspectRatio
}

val drawHeight = if (frameAspectRatio > imageAspectRatio) {
screenWidth.value / imageAspectRatio
} else {
pagerHeight.value
}

return IntSize(drawWidth.toIntPx(density), drawHeight.toIntPx(density))
}

// Расширение функции для преобразования Float в Int в пикселях
private fun Float.toIntPx(density: Float) = (this * density).roundToInt()

Теперь уделим внимание еще одному важному моменту  —  использованию элементов Canvas translate, parallaxOffset и xOffset. Эти элементы играют ключевую роль в создании эффекта параллакса, к которому мы стремимся в Jetpack Compose.

@Composable
fun ParallaxCarouselItem(
imageResId: Int,
parallaxOffset: Float,
pagerHeight: Dp,
screenWidth: Dp,
density: Float,
) {
// Загрузка растрового изображения
val imageBitmap = ImageBitmap.imageResource(id = imageResId)

// Расчет размера рисунка для изображения
val drawSize = imageBitmap.calculateDrawSize(density, screenWidth, pagerHeight)

// Composable-функция Card для элемента
Card(
modifier = Modifier
.fillMaxSize()
.padding(cardPadding)
.background(Color.White, shape)
.shadow(elevation = shadowElevation, shape = shape)
) {
// Холст (Canvas) для отрисовки изображения с эффектом параллакса
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(imagePadding)
.clip(shape)
) {
// Перемещение Canvas для получения эффекта параллакса
translate(left = parallaxOffset * density) {
// Отрисовка изображения
drawImage(
image = imageBitmap,
srcSize = IntSize(imageBitmap.width, imageBitmap.height),
dstOffset = IntOffset(-xOffset.toIntPx(density), 0),
dstSize = drawSize,
)
}
}
}
}

xOffset определяется как значение смещения для достижения эффекта параллакса. Оно вычисляется как удвоенное значение cardPadding. Это смещение определяет, насколько изображение будет сдвинуто по горизонтали в пределах Canvas.

Функция translate внутри Canvas используется для сдвига Canvas по горизонтали на величину, заданную параметром parallaxOffset. Этот горизонтальный сдвиг создает эффект параллакса, благодаря которому изображение кажется перемещающимся по горизонтали при взаимодействии пользователя с каруселью.

Результат: параллакс-карусель в Jetpack Compose

Таким образом, мы успешно перекинули мост между SwiftUI и Jetpack Compose, переведя захватывающую параллакс-карусель с одной платформы на другую. Как видите, несмотря на различия в инструментах и методах, конечный результат получается одинаково потрясающим. Jetpack Compose позволяет воплощать творческие идеи на платформе Android, и это путешествие  —  еще одно подтверждение его гибкости и функциональных возможностей.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Imre Kaszab: Implementing Parallax Carousel Jetpack Compose

Предыдущая статьяКак работает Supabase  —  альтернатива облачной платформе Firebase
Следующая статьяСложные вопросы на собеседовании для тех, кто 7 лет работал с Java. Часть 1