Реалізація паралакс-ефекту прокручування у мобільних додатках
Паралакс-ефект прокручування виникає, коли фонові зображення рухаються повільніше за контент, створюючи ілюзію глибини. Цей прийом широко використовується для карточок товарів, hero-секцій і профілів користувачів. Різниця між поганою та відмінною реалізацією не в самій візуальній ідеї, а в тому, виконується ця операція на головному потоці чи ні.
iOS: правильний паралакс без блокування головного потоку
Наївна реалізація через UIScrollViewDelegate.scrollViewDidScroll з оновленням frame або transform працює, але виглядає стрибкувато. Кожен кадр активує делегат на головному потоці, розраховує зміщення і оновлює макет. На iPhone SE 2nd gen при швидкому прокручуванні це може впасти до 45 FPS.
Правильний підхід у UIKit використовує CAScrollLayer або окремі трансформації CALayer. Найелегантніший варіант — UICollectionViewCompositionalLayout з orthogonalScrollingBehavior та supplementaryContentInsetsReference. Для простого паралаксу в ячейках:
override func layoutSubviews() {
super.layoutSubviews()
// Викликається при зміні bounds, включаючи прокручування collection view
guard let superview = superview else { return }
let cellFrameInSuperview = convert(bounds, to: superview)
let parallaxOffset = cellFrameInSuperview.minY * 0.3
heroImageView.transform = CGAffineTransform(translationX: 0, y: -parallaxOffset)
}
layoutSubviews виконується під час кожного pass макета, включаючи прокручування — це працює на головному потоці, але без зайвих делегатів чи DispatchQueue викликів. Коефіцієнт 0.3 означає 30% від зміщення прокручування. Зображення повинно розширюватися над ячейкою приблизно на cellHeight * parallaxRatio з кожної сторони.
У SwiftUI паралакс досягається через ScrollView та GeometryReader:
ScrollView {
LazyVStack {
ForEach(items) { item in
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
Image(item.imageName)
.resizable()
.scaledToFill()
.frame(height: 250)
.offset(y: offset * 0.3)
.clipped()
}
.frame(height: 200) // видима область менша за зображення
}
}
}
GeometryReader читає позицію в глобальних координатах — це перераховується при кожному прокручуванні. На iOS 17+ використовуйте .scrollTargetBehavior та ScrollView з onScrollGeometryChange для кращої продуктивності.
Android: паралакс без затримок
Наївна реалізація через RecyclerView.OnScrollListener з view.translationY = offset * factor викликає аналогічні проблеми: слухач прокручування на головному потоці та непотрібні pass-операції виміру/макета.
Найкращий підхід — MotionLayout з OnSwipe скролл-триггером через NestedScrollView:
<MotionScene>
<Transition motion:constraintSetStart="@id/start" motion:constraintSetEnd="@id/end"
motion:duration="1000">
<OnSwipe
motion:touchAnchorId="@id/nestedScrollView"
motion:touchAnchorSide="top"
motion:dragDirection="dragUp"
motion:moveWhenScrollAtTop="true" />
</Transition>
<ConstraintSet android:id="@+id/start">
<Constraint android:id="@+id/heroImage"
android:translationY="0dp" ... />
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<Constraint android:id="@+id/heroImage"
android:translationY="-60dp" ... />
</ConstraintSet>
</MotionScene>
MotionLayout керує анімацією на основі прогресу прокручування — зображення змінює позицію в міру прокручування контенту без викликів на головному потоці.
Для RecyclerView з паралаксом для кожної ячейки використовуйте RecyclerView.ItemDecoration з onDrawOver:
class ParallaxDecoration(private val factor: Float = 0.3f) : RecyclerView.ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
val imageView = child.findViewById<ImageView>(R.id.heroImage) ?: continue
val centerOffset = (parent.height / 2f) - (child.top + child.height / 2f)
imageView.translationY = centerOffset * factor
}
}
}
onDrawOver виконується під час кожного draw pass — це ефективно, оскільки привʼязане до рендерингу, а не до скролл-подій.
Jetpack Compose
@Composable
fun ParallaxCard(item: Item) {
val density = LocalDensity.current
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
) {
var offsetY by remember { mutableStateOf(0f) }
Box(modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
// Викликається при позиціюванні — використовуйте обережно
}
)
// Використовуйте ScrollState через LazyListState
// Реалізація через rememberLazyListState() + derivedStateOf
}
}
Правильний паралакс у Compose використовує LazyListState та derivedStateOf:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
itemsIndexed(items) { index, item ->
val itemOffset by remember {
derivedStateOf {
val itemInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == index }
itemInfo?.let { (listState.layoutInfo.viewportEndOffset / 2f) - (it.offset + it.size / 2f) } ?: 0f
}
}
Box(modifier = Modifier.height(200.dp).fillMaxWidth()) {
Image(
painter = painterResource(item.imageRes),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { translationY = itemOffset * 0.3f },
contentScale = ContentScale.Crop
)
}
}
}
graphicsLayer застосовує трансформацію на GPU без інвалідації макета — найефективніший спосіб у Compose.
Терміни
Паралакс для hero-зображення на одному екрані: половина дня. Паралакс у списку з багатьма елементами (RecyclerView / LazyColumn / LazyVStack): 1 день. Вартість розраховується індивідуально.







