Recently, the project has started to revise and iterate, and PM has also started its crazy borrowing method again. But it’s something we’ve gotten used to. Ha ha, a bit of a digression, back to the topic, let’s first look at the interaction effect to be achieved this time (reference goal) :
Simply described, the interface is a horizontal list. When sliding, the background image slides along with the parallax effect. As the sliding distance increases, the background image is displayed in a loop.
See this effect, list scheme must be the first choice RecyclerView, then look at the background parallax effect, the first thing that comes to mind is through the way of drawing background. As we all know, RecyclerView has such an internal class ItemDecoration, which can provide the ability to draw foreground, background and Item separation lines, so we can build an Item decoration to draw our background.
By slidingRecyclerView
Looking closely at the content of the background, it is shown in a continuous loop, so I guess the background should be a series of pictures pieced together into a long picture side by side. To verify our conjecture, extract the apK of the other party and find the corresponding resource file. The long background image is a series of images of the same size.
At this point, we can basically determine the target scheme:
- Custom one
ItemDecoration
Pass in a collection of background images - in
ItemDecoration
theonDraw
Method to calculate the currentRecyclerView
Slip distance of - According to the
RecyclerView
The sliding distance andparallax
Parallax coefficient calculates the sliding distance of the current background - According to the sliding distance of the background, it is converted into coordinates and drawn to
RecyclerView
theCanvas
on - You need to deal specifically with looping logic and only drawing as many images as are currently visible on the screen
- Take a look at these two images:
- In the one above, the number of background images fully visible on the screen is zero
3
whenbg3
The right margin of thescreen
The right margin of the1px
When,bg4
There are1px
Is displayed on the screen, so the maximum number of visible pictures on the screen is4
. - Let’s take a look at the next picture, let’s say the top picture
bg3
The right margin of thescreen
The right margin of the2px
, and the scene of the following picture appears in the sliding process, which isbg2
The left margin sum ofscrren
The left margin of,bg4
The sum of the right margin ofscreen
The distance to the right of the1px
, indicates that the number of fully visible pictures on the current screen is3
, but the maximum visible quantity is5
. - Therefore, we can draw the following conclusions:
<ParallaxDecoration.kt>
...
// Number of fully visible images = screen width/single image width
val allInScreen = screenWidth / bitmapWidth
// The remaining pixel space from the edge of the screen after the number of fully visible images is currently displayed
val outOfScreenOffset = screenWidth % bitmapWidth
// If the remaining pixels are > 1px, the scene shown in Figure 2 above will appear
val outOfScreen = outOfScreenOffset > 1
// Hence the maximum number of visible pixels = remaining pixels on the screen >1px? Fully visible +2: Fully visible +1
val maxVisibleCount = if (outOfScreen) allInScreen + 2 else allInScreen + 1
Copy the code
- So we know that during the slide, we need to be in
onDraw
How many images are drawn in the method.
- Next, we need to find the starting point of the drawing because
RecyclerView
Is slidable, so the first visible picture on the screen is certainly not fixed, as long as we find the index of the first visible picture in our initialization background set, we can draw it in order according to the number of pictures calculated above. Again, here’s a picture:
- Let’s ignore the parallax coefficient for the moment and get to the present
RecyclerView
Sliding distance:
<ParallaxDecoration.kt>
...
// The current recyclerView sliding distance
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// Slide distance/single image width = current number of images
// We can obtain the first visible index of the set of images
val firstVisible = (scrollOffset / bitmapWidth).toInt() % bitmapPool.size
// Gets the offset of the left edge of the current first image from the left edge of the screen
val firstVisibleOffset = scrollOffset % bitmapWidth
Copy the code
- We have determined the index of the first visible image on the current screen, and the offset of the first image from the left edge of the screen. Now we can start the actual drawing:
<ParallaxDecoration.kt>
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
...
c.save()
// Move the canvas to the left edge of the first image
c.translate(-firstVisibleOffset, 0f)
// Loop the number of images visible on the current screen
for ((i, currentIndex) in (firstVisible until firstVisible + bestDrawCount).withIndex()) {
c.drawBitmap(
bitmapPool[currentIndex % bitmapCount],
i * bitmapWidth.toFloat(),
0f.null)}// Restore the canvas
c.restore()
}
Copy the code
- In the loop drawing process above, we optimized the value
bestDrawCount
, the specific calculation logic is, whenfirstVisibleOffset = 0
“Indicates that the current first visible graph is aligned with the left edge of the screen, which is equivalent to the initial state, so the maximum visible number ismaxVisibleCount - 1
. Although it takes every cyclebitmapPool.szie
This condition is triggered only once, but inRecyclerView
Frequently triggered here during continuous slidingonDraw
Callbacks, dropping a loop is a significant performance improvement, and we’re calculatingfirstVisible
The time is wrong firstbitmapCount
I’m going to do the mod becausedraw
We still need to make sure the index is accurate:
<ParallaxDecoration.kt>
// maxVisibleCount
val maxVisibleCount = if (outOfScreen) allInScreen + 2 else allInScreen + 1
val bestDrawCount = if (firstVisibleOffset.toInt() == 0) maxVisibleCount - 1 else maxVisibleCount
FirstVisible = n * bitmapCount + firstIndex
val firstVisible = (scrollOffset / bitmapWidth).toInt()
Copy the code
- At this point our background image is ready to follow
RecyclerView
Sliding and looping shows that for parallax effects, only calculations are neededscrollOffset
, add a parallax coefficientparallax
You can:
<ParallaxDecoration.kt>
// The current recyclerView sliding distance
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// Add parallax coefficient, convert into background sliding distance, and RecyclerView to produce parallax effect
val parallaxOffset = scrollOffset * parallax
Copy the code
-
An ItemDecoration that supports the background parallax effect is complete. Finally, there is a problem, that is, when our background map cannot pave the RecyclerView height, how do we need to deal with it? For those who are familiar with drawing, it should be very simple. You just need to scale canvas. Scale when drawing, and you can draw the auto-filled background. Note that when we calculate the sliding distance offset and firstVisible, we need to use bitmapWidth*scale as the actual bitmapWidth. The logic is relatively simple, so WE won’t expand it here. At the same time also need to RecyclerView LayoutManager direction to distinguish processing, interested can read the source code.
-
Finally, here is ParallaxDecoration ontouch core logic, complete projects and the way of using see links at the bottom:
<ParallaxDecoration.kt>
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
if (bitmapPool.isNotEmpty()) {
// if layoutManager is null, just throw exception
val lm = parent.layoutManager!!
// step1. check orientation
isHorizontal = lm.canScrollHorizontally()
// step2. check maxVisible count
// step3. if autoFill, calculate the scale bitmap size
if (screenWidth == 0 || screenHeight == 0) {
screenWidth = c.width
screenHeight = c.height
val allInScreen: Int
val doubleOutOfScreen: Boolean
if (isHorizontal) {
if (autoFill) {
scale = screenHeight * 1f / bitmapHeight
scaleBitmapWidth = (bitmapWidth * scale).toInt()
}
allInScreen = screenWidth / scaleBitmapWidth
doubleOutOfScreen = screenWidth % scaleBitmapWidth > 1
} else {
if (autoFill) {
scale = screenWidth * 1f / bitmapWidth
scaleBitmapHeight = (bitmapHeight * scale).toInt()
}
allInScreen = screenHeight / scaleBitmapHeight
doubleOutOfScreen = screenHeight % scaleBitmapHeight > 1
}
minVisibleCount = allInScreen + 1
maxVisibleCount = if (doubleOutOfScreen) allInScreen + 2 else minVisibleCount
}
// step4. find the firstVisible index
// step5. calculate the firstVisible offset
val parallaxOffset: Float
val firstVisible: Int
val firstVisibleOffset: Float
if (isHorizontal) {
parallaxOffset = lm.computeHorizontalScrollOffset(state) * parallax
firstVisible = (parallaxOffset / scaleBitmapWidth).toInt()
firstVisibleOffset = parallaxOffset % scaleBitmapWidth
} else {
parallaxOffset = lm.computeVerticalScrollOffset(state) * parallax
firstVisible = (parallaxOffset / scaleBitmapHeight).toInt()
firstVisibleOffset = parallaxOffset % scaleBitmapHeight
}
// step6. calculate the best draw count
val bestDrawCount =
if (firstVisibleOffset.toInt() == 0) minVisibleCount else maxVisibleCount
// step7. translate to firstVisible offset
c.save()
if (isHorizontal) {
c.translate(-firstVisibleOffset, 0f)}else {
c.translate(0f, -firstVisibleOffset)
}
// step8. if autoFill, scale the canvas to draw
if (autoFill) {
c.scale(scale, scale)
}
// step9. draw from current first visible bitmap, the max looper count is the best draw count by step6
for ((i, currentIndex) in (firstVisible until firstVisible + bestDrawCount).withIndex()) {
if (isHorizontal) {
c.drawBitmap(
bitmapPool[currentIndex % bitmapCount],
i * bitmapWidth.toFloat(),
0f.null)}else {
c.drawBitmap(
bitmapPool[currentIndex % bitmapCount],
0f,
i * bitmapHeight.toFloat(),
null
)
}
}
c.restore()
}
}
Copy the code
- To show the effect:
- Project address: github.com/seagazer/pa…