Hanbit the Developer

[Kotlin] Recycler View Animation Using Custom Layout Manager 본문

Mobile/Android

[Kotlin] Recycler View Animation Using Custom Layout Manager

hanbikan 2022. 3. 24. 23:53

Before get started, be aware of that this post contains only about the 'custom layout manager' which means overrided LinearLayoutManager by programmers, not about a recycler view moving like a carousel. If you want to implement the recycler view, I refer to my other post explaining it: https://rccode.tistory.com/entry/Kotlin-Profile-Card-RecyclerView-With-PagerSnapHelper

 

[Kotlin] Carousel RecyclerView With PagerSnapHelper

I will skip some basic contents like setting recycler view origentation to horizontal and just say important things.  > Layouts  - Add padding to recycler view so that you can see a part of the a..

rccode.tistory.com

 

 

  > Results

 

 

Let's see what we'll build. First there are 2 modes, 'Scale mode' and 'Y mode'. A scale mode means a scale of items changes when scrolling, and the last one means a y position of that does.

 

 > Create CustomLayoutManager which implements LinearLayoutManager

class CustomLayoutManager constructor(
    context: Context,
    snapHelper: SnapHelper,
    mode: Mode,
    minScale: Float,
    yOffset: Float
): LinearLayoutManager(context) {
    private val snapHelper = snapHelper
    
    /** Mode of an animation */
    private val mode = mode

    /** A limit of scale getting smaller(ex. 0.5f then items will be smaller by up to half) */
    private val minScale = minScale

    /** A degree to which y position changes */
    private val yOffset = yOffset

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT
        )
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        super.onLayoutChildren(recycler, state)

        if(state != null){
            layoutItems(state)

            if(mode == Mode.SCALE_MODE){
                for(i in 0 until itemCount) findViewByPosition(i)?.pivotY = (height/2).toFloat()
            }
        }
    }

    override fun scrollHorizontallyBy(
        dx: Int,
        recycler: RecyclerView.Recycler?,
        state: RecyclerView.State?
    ): Int {
        if(state != null){
            layoutItems(state)
        }

        return super.scrollHorizontallyBy(dx, recycler, state)
    }

    private fun layoutItems(state: RecyclerView.State) {
        if(state.isPreLayout) return

        val centerPos = getCenterPosition() ?: return
        for(pos in centerPos-1..centerPos+1){
            val item = findViewByPosition(pos) ?: continue
            if(item.tag == "DRAGGED") continue // A item longclicked for moving

            layoutItem(item)
        }
    }

    private fun getCenterPosition(): Int? {
        val centerView = snapHelper.findSnapView(this)
        return centerView?.let { getPosition(it) }
    }

    private fun layoutItem(item: View) {
        val itemCenterX = item.x + item.width/2

        when(mode){
            Mode.Y_MODE -> {
                item.y = computeY(itemCenterX)
            }
            Mode.SCALE_MODE -> {
                val scale = computeScale(itemCenterX)
                item.scaleX = scale
                item.scaleY = scale
                item.pivotX = computePivot(itemCenterX, item.width)
            }
        }
    }

    private fun computeY(itemCenterX: Float): Float {
        return if(itemCenterX < width/2) {
            min(1f, itemCenterX / (width/2)) * yOffset - yOffset
        }else{
            min(1f, (width - itemCenterX) / (width/2)) * yOffset - yOffset
        }
    }

    private fun computeScale(itemCenterX: Float): Float {
        return if(itemCenterX < width/2) {
            min(1f, itemCenterX / (width/2)) * (1 - minScale) + minScale
        }else{
            min(1f, (width - itemCenterX) / (width/2)) * (1 - minScale) + minScale
        }
    }
    
    private fun computePivot(itemCenterX: Float, itemWidth: Int): Float {
        return if(itemCenterX < width/2) itemWidth.toFloat() else 0f
    }


    enum class Mode {
        SCALE_MODE, Y_MODE
    }

    class Builder constructor(context: Context, snapHelper: SnapHelper){
        private val context = context
        private val snapHelper = snapHelper
        private var mode = Mode.Y_MODE
        private var minScale = 0.85f
        private var yOffset = -75f

        fun setMode(mode: Mode): Builder {
            this.mode = mode
            return this
        }

        fun setMinScale(minScale: Float): Builder {
            this.minScale = minScale
            return this
        }

        fun setYOffset(yOffset: Float): Builder {
            this.yOffset = yOffset
            return this
        }

        fun build(): CustomLayoutManager {
            return CustomLayoutManager(context, snapHelper, mode, minScale, yOffset)
        }
    }
}

Only one purpose of CustomLayoutManager is just to make items animated when scrolling, because I implemented a recycler view moving like a carousel by SnapHelper simply and 'longclick to move an item' before.

So, the point is simple. Just put a function 'layoutItems()' to onLayoutChildren() and scrollHorizontallyBy() which are override functions of LinearLayoutManager. First, onLayoutChildren() means literally a function called when items begin to be placed therefore we put layoutItems()──I'll descript it later──on it. And scrollHorizontallyBy() also does.

 

The last thing we have to do is to write the function layoutItems() which get the items moved. And, I told there are 2 modes but I'll talk about only 'Y mode' because implementations of those things are very similar. Let's define how items move on. Here is a graph describing it.

 

 

It's easy if you think of it as a trajectory left by an item moving from left to right. To compute y position by an item's central x position, I writed a function named computeY().

Now, I can get y position for x position of an item and make a function layoutItem() which layouts an item using computeY().

And finally I'll create layoutItems() function. There are only 3 items to consider because our recycler view shows 3 items in a screen. So, just call layoutItem() 3 times for items of n-1, n, n+1 index where n is a center item position that I can get using findSnapView() of SnapHelper. Implementation is now complete.

 

Use CustomLayoutManager like below:

// Initialize Adapter
binding.recyclerView.adapter = adapter

// Initialize PagerSnapHelper
val snapHelper = PagerSnapHelper()
snapHelper.attachToRecyclerView(binding.recyclerView)

// Initialize LayoutManager
layoutManager = CustomLayoutManager.Builder(requireContext(), snapHelper)
    .setMode(CustomLayoutManager.Mode.Y_MODE)
    // Or .setMode(CustomLayoutManager.Mode.SCALE_MODE)
    .setYOffset(0.8f)
    // Or .setMinScale(0.8f)
    .build()
layoutManager.orientation = LinearLayoutManager.HORIZONTAL
binding.myPetListRecyclerView.layoutManager = layoutManager