יישום אפקט גלילה פרלקסה באפליקציות מובייל
הלקוח שלך מבקש גלילת פרלקסה? היישום הראשון לרוב גורם לגמגום – ה-FPS יורד ל-45 במכשירים כמו iPhone SE דור 2. הבנו איך לעשות את זה נכון ב-iOS וב-Android ללא ירידות ביצועים. המהנדסים שלנו, עם ניסיון של 5+ שנים, יישמו פרלקסה ביותר מ-30 פרויקטים, ואנחנו חולקים את הגישות העובדות. השירותים שלנו מתחילים ב-$500 לפרלקסת hero פשוטה, עם עלויות טיפוסיות בין $2,000 ל-$5,000 ליישום מלא של רשימה.
פרלקסה יוצרת עומק ויזואלי: הרקע זז לאט יותר מהתוכן. אבל אם קושרים את האנימציה ל-thread הראשי, מקבלים 40 FPS במקום 60. במכשירי Android ישנים יותר, זה אפילו גרוע יותר – עד 35 FPS. אנחנו מבטיחים חלקות ב-iOS 15+ וב-Android 11+.
למה פרלקסה ב-Main Thread היא רעה
טיפול בגלילה דרך delegates (scrollViewDidScroll, OnScrollListener) חוסם את ה-UI thread: כל פריים גורם לחישוב מחדש של layout. גישות נכונות מתחברות ל-draw pass: layoutSubviews ב-UIKit, graphicsLayer ב-Compose, MotionLayout ב-Android. הן נמנעות מקריאות measure/layout נוספות, וחוסכות עד 30% ממשאבי החישוב.
בחירת ה-API הנכון לגלילת פרלקסה
הבחירה תלויה בפלטפורמה ובמורכבות. להלן טבלת גישות וביצועים:
| פלטפורמה | גישה | ביצועים | מורכבות |
|---|---|---|---|
| iOS UIKit | layoutSubviews | ~60 FPS | נמוכה |
| iOS SwiftUI | GeometryReader / onScrollGeometryChange | ~55–60 FPS | בינונית |
| Android MotionLayout | OnSwipe + ConstraintSet | ~60 FPS | בינונית |
| Android Compose | graphicsLayer + derivedStateOf | ~60 FPS | נמוכה |
בנוסף, בשימוש ב-MotionLayout, ה-FPS ב-Pixel 4 עלה מ-40 ל-60 (המלצות האנימציה של Google). היישום שלנו עם MotionLayout מהיר פי 1.5 מגישת מאזין גלילה נאיבית ב-Android.
איך ליישם פרלקסה ב-3 שלבים
-
הכן את התמונה: הפוך אותה לגבוהה יותר מהמכולה ב-
cellHeight * коэффициент (0.3)בכל צד כדי למנוע קצוות ריקים. -
קשר ל-rendering: ב-iOS, השתמש ב-
layoutSubviewsבתא; ב-Android, השתמש ב-onDrawOverב-ItemDecoration או ב-graphicsLayerב-Compose. - בדוק במכשירים חלשים: בדוק FPS ב-iPhone SE דור 2 (iOS) וב-Pixel 4 (Android). אם הירידות עולות על 5 FPS, הקטן את המקדם ל-0.2.
דוגמאות יישום
iOS: UIKit
override func layoutSubviews() {
super.layoutSubviews()
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)
} iOS: SwiftUI
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)
}
}
} Android: MotionLayout
<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> Android: Compose
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
)
}
}
} תוצאות מדידת FPS
| מכשיר | לפני אופטימיזציה | אחרי אופטימיזציה |
|---|---|---|
| iPhone SE דור 2 | 45 FPS | 58 FPS |
| Pixel 4 | 40 FPS | 60 FPS |
| Xiaomi Redmi 9T | 35 FPS | 57 FPS |
מה כלול בעבודה שלנו
- ניתוח המסך הנוכחי ואב-טיפוס פרלקסה.
- עיצוב ארכיטקטורת אנימציה (בחירת גישה ספציפית לפלטפורמה).
- אינטגרציה ב-iOS (UIKit/SwiftUI) וב-Android (MotionLayout/Compose).
- אופטימיזציה: בדיקות FPS במכשירים אמיתיים (iPhone SE, Pixel 4).
- מסירת קוד מקור, תיעוד והמלצות תחזוקה.
- גישה ל-repository של הפרויקט וסשן הדרכה של שעה לצוות שלך.
לוחות זמנים
פרלקסה לתמונת hero במסך בודד: חצי יום. פרלקסה ברשימה עם אלמנטים רבים (RecyclerView / LazyColumn / LazyVStack): 1–2 ימים. העלות מחושבת באופן אישי.
קבל ייעוץ לפרויקט שלך — נמצא את היישום האופטימלי. צור קשר להערכה מפורטת.
דוגמאות פרויקטים
בפרויקט e-commerce אחד, החלפנו פרלקסה נאיבית ב-MotionLayout — ה-FPS עלה מ-40 ל-60 ב-Android. בפרויקט iOS אחר, השתמשנו ב-UICollectionViewCompositionalLayout עם פרלקסה בתאים — ה-UI נשאר חלק גם בגלילה של אלפי פריטים.טעויות נפוצות ביישום פרלקסה
הטעות הראשונה היא אנימציה ב-main thread. שימוש ב-override func layoutSubviews() { super.layoutSubviews() 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) } לחישוב והחלת offset חוסם ישירות את ה-UI thread. ב-iPhone SE דור 2, זה מוריד את ה-FPS מ-60 ל-40–45. הגישה הנכונה היא להעביר טרנספורמציות ל-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) } } } (UIKit) או ל-<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> (Compose), שם הן מתבצעות בשלב ה-rendering, לא ב-layout.
הטעות השנייה היא גודל תמונה לא מספק. אם גובה התמונה שווה לגובה המכולה, מופיעים קצוות כהים במהלך הגלילה. הוסף 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 ) } } } בחלק העליון והתחתון במהלך הכנת המשאבים. אנחנו מקצים מרווח של 30–40% מגובה התא.
הטעות השלישית היא חוסר caching. פרלקסה ב-LazyVStack או LazyColumn ללא caching תמונות (Kingfisher, Coil, Glide) גורמת לירידות בגלילה חזרה למעלה – הטעינה מחדש אורכת 200–500 אלפיות השנייה. אנו ממליצים להגדיר cache זיכרון של 100 MB ו-cache דיסק של 500 MB.
הטעות הרביעית היא בדיקה רק במכשירי דגל. iPhone 15 Pro ו-Pixel 9 מתמודדים עם כל יישום בקלות. בעיות מופיעות ב-iPhone SE דור 2 וב-Xiaomi Redmi 9T. אנחנו תמיד בודקים על שלושה דורות של מכשירים לפני מסירה סופית.
הימנעות מטעויות אלו תיתן לך פרלקסה חלקה ללא פשרות באיכות.







