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 slidingRecyclerViewLooking 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:

  1. Custom oneItemDecorationPass in a collection of background images
  2. inItemDecorationtheonDrawMethod to calculate the currentRecyclerViewSlip distance of
  3. According to theRecyclerViewThe sliding distance andparallaxParallax coefficient calculates the sliding distance of the current background
  4. According to the sliding distance of the background, it is converted into coordinates and drawn toRecyclerViewtheCanvason
  5. 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:

  1. In the one above, the number of background images fully visible on the screen is zero3whenbg3The right margin of thescreenThe right margin of the1pxWhen,bg4There are1pxIs displayed on the screen, so the maximum number of visible pictures on the screen is4.
  2. Let’s take a look at the next picture, let’s say the top picturebg3The right margin of thescreenThe right margin of the2px, and the scene of the following picture appears in the sliding process, which isbg2The left margin sum ofscrrenThe left margin of,bg4The sum of the right margin ofscreenThe 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.
  3. 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
  1. So we know that during the slide, we need to be inonDrawHow many images are drawn in the method.
  • Next, we need to find the starting point of the drawing becauseRecyclerViewIs 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:

  1. Let’s ignore the parallax coefficient for the moment and get to the presentRecyclerViewSliding 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
  1. 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
  1. In the loop drawing process above, we optimized the valuebestDrawCount, 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.szieThis condition is triggered only once, but inRecyclerViewFrequently triggered here during continuous slidingonDrawCallbacks, dropping a loop is a significant performance improvement, and we’re calculatingfirstVisibleThe time is wrong firstbitmapCountI’m going to do the mod becausedrawWe 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
  1. At this point our background image is ready to followRecyclerViewSliding and looping shows that for parallax effects, only calculations are neededscrollOffset, add a parallax coefficientparallaxYou 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…