自定义卡包效果实现

        最近在工作中发现UI的脑洞越来越大了,需要实现一个卡包的效果,虽然不是我的工作,但是工作之余也探索着实现了一下类似的效果。

        卡包其实就是多张卡片的集合,围绕着它要实现以下一些炫酷的效果:

1.用户可能有多张卡片,未选中卡片的时候有卡片画廊效果,可以左右自由滑动,其实就是类似ViewPager的效果。这里的滑动不是随便滑动,小幅度的滑动会回弹,大幅度的滑动达到阈值的时候会滑动到左边或者右边的一张卡片,且滑动结束后新的卡片始终在中间位置。

2.最开始卡包是展开态,此时可滑动。当点击中间卡的时候,两边的卡片逐个收起在选中卡的下方(有动画效果),实现一个层叠展示的折叠态,当然你点击的卡片可能本来就在首或尾,那其实就是它边上的其他所有卡片收起堆叠。

3.卡包处于折叠态时,只有中间的卡可点击,其他边上层叠收起的卡不能点击。此时点击中间卡片,整个卡包以点击的卡片为中心展开(有动画效果),同时又恢复到了展开态可左右滑动。相关的滑动切换以及点击事件暴露给外部方便做逻辑。

        听起来是不是很炫酷,我们直接看效果。

cards

        下面分别是最左边、中间、最右边卡片收起时候的效果,收起时就不能再左右滑动了。

         下面是正常展开时的效果,如果左右还有卡片那大幅度滑动就会切换,小幅度滑动会回弹到当前卡片。

         简单说下我的实现思路和踩过的坑,大家可以参考,达到抛砖引玉的效果。

        最开始因为有卡片画廊效果,首先想到的是用ViewPager做,但是写了发现点击卡片后的卡包展开、收起效果不好实现,即便是动态改变ViewPager中Page之间的Margin或者设置新的PageTransformer都没法做到丝滑连贯的展开收起效果。因此含泪放弃,那没有捷径可走只好自己想办法了。

        这个时候想到可以在外层使用自定义的一个CardsHorizontalScrollView(继承自HorizontalScrollView),这个最外层的布局负责数据绑定,滑动处理,以便最终达到画廊的效果。即卡包展开态时可以像ViewPager一样滑动,大于滑动距离阈值就切换当前卡片,小于就回弹当前卡片。

        在CardsHorizontalScrollView中嵌套一个自定义的CardContainerLayout(继承自LinearLayout),在该布局中动态添加卡片布局,然后处理具体卡片的点击,这个时候点击可能是展开卡包也可能是折叠卡包。根据点击前的状态决定。

        单个卡片布局这里加上左、上、右、下的边距,水平方向让卡片恰好占满屏幕宽度,那在滑动切换卡片的时候每次滚动布局只需要滚动屏幕宽度的倍数即可。折叠效果中卡片一个压着一个的效果需要动态设置具体卡片的elevation属性来达到,另外要设置点横向偏移才能使一张卡相对另一张卡露出一点。这里用到了许多属性动画的操作。

        下面贴下核心代码。

        横向可滚动自定义布局

package com.openld.seniorui.testcards

import android.content.Context
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.HorizontalScrollView

/**
 * author: lllddd
 * created on: 2022/7/30 21:45
 * description:卡片横向可滚动布局
 */
class CardsHorizontalScrollView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : HorizontalScrollView(context, attrs) {
    private var mScreenWidth = 0

    private var mWidth = 0

    var mIsFold = false

    var mOnCardScrollListener: OnCardScrollListener? = null

    private var mCardCounts = 0

    private lateinit var mCardList: List<CardBean>

    init {
        mScreenWidth = context.resources.displayMetrics.widthPixels
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        mWidth = measuredWidth
    }


    private var downX: Float = 0F
    private var downScrollX = 0

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        if (ev!!.action == MotionEvent.ACTION_DOWN) {
            downX = ev.x
            downScrollX = scrollX
        } else if (ev.action == MotionEvent.ACTION_UP) {
            if (mIsFold) {
                return super.dispatchTouchEvent(ev)
            }

            val offsetX = ev.x - downX

            if (offsetX > 0F) {// 右滑
                val index = downScrollX / mScreenWidth

                if (offsetX > mScreenWidth / 5) {
                    smoothScrollTo((index - 1) * mScreenWidth, 0)
                    changeBackground(index - 1)
                    if (index - 1 in 0 until mCardCounts) {
                        mOnCardScrollListener?.onCardScrolled(index - 1)
                    }

                } else {
                    smoothScrollTo(index * mScreenWidth, 0)
                    changeBackground(index)
                    mOnCardScrollListener?.onCardScrolled(index)
                }
                return true
            } else if (offsetX < 0F) {// 左滑
                val index = downScrollX / mScreenWidth

                if (offsetX < -mScreenWidth / 5) {
                    smoothScrollTo((index + 1) * mScreenWidth, 0)
                    changeBackground(index + 1)
                    if (index + 1 in 0 until mCardCounts) {
                        mOnCardScrollListener?.onCardScrolled(index + 1)
                    }
                } else {
                    smoothScrollTo(index * mScreenWidth, 0)
                    changeBackground(index)
                    mOnCardScrollListener?.onCardScrolled(index)
                }
                return true
            } else {// 滑动距离过小

            }
        }
        return super.dispatchTouchEvent(ev)
    }

    private fun changeBackground(index: Int) {
        if (index in 0 until mCardCounts) {
            setBackgroundResource(mCardList[index].image)
            background.mutate().colorFilter =
                ColorMatrixColorFilter(ColorMatrix().apply {
                    setScale(0.3F, 0.3F, 0.3F, 1F)
                })
        }
    }

    fun setCards(cardList: List<CardBean>) {
        if (cardList.isEmpty()) {
            return
        }

        this.mCardList = cardList
        this.mCardCounts = cardList.size

        if (childCount == 1 && getChildAt(0) is CardsContainerLayout) {
            (getChildAt(0) as CardsContainerLayout).setCards(mCardList)
        }

        changeBackground(0)
    }
}

         卡片容器布局

package com.openld.seniorui.testcards

import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AccelerateInterpolator
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.NonNull
import com.openld.seniorui.R
import kotlin.math.abs

/**
 * author: lllddd
 * created on: 2022/7/29 22:51
 * description:卡片容器布局
 */
class CardsContainerLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    private var mDensity = 0F
    private var mScreenWidth = 0

    private var mWidth = 0
    private var mHeight = 0

    private var mCardsCount = 0
    private var mCurrentIndex = 0

    private var mIsFold = false;

    private val DURATION = 600L
    private val DELAY = 60L

    var mOnCardClickListener: OnCardClickListener? = null

    init {
        orientation = LinearLayout.HORIZONTAL
        mDensity = context.resources.displayMetrics.density
        mScreenWidth = context.resources.displayMetrics.widthPixels
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        mWidth = MeasureSpec.getSize(widthMeasureSpec)
        mHeight = MeasureSpec.getSize(heightMeasureSpec)
    }

    @SuppressLint("UseCompatLoadingForDrawables")
    fun setCards(@NonNull cards: List<CardBean>) {
        removeAllViews()

        for (index in cards.indices) {
            val childView = LayoutInflater.from(context).inflate(R.layout.item_card, this, false)
            val params =
                LinearLayout.LayoutParams((mWidth * 0.9F).toInt(), (mHeight * 0.9F).toInt())
            params.setMargins(
                (mWidth * 0.05F).toInt(),
                (mHeight * 0.05F).toInt(),
                (mWidth * 0.05F).toInt(),
                (mHeight * 0.05F).toInt()
            )

            childView.layoutParams = params

            val imageCard = childView.findViewById<ImageView>(R.id.img_card)
            imageCard.setImageResource(cards[index].image)

            val txtCard = childView.findViewById<TextView>(R.id.txt_card)
            txtCard.text = cards[index].title

            childView.setOnClickListener {
                Toast.makeText(context, "点击了第${index}个卡片", Toast.LENGTH_SHORT).show()
                childView.elevation = 10F
                childView.isClickable = false
                mCurrentIndex = index

                if (mIsFold) {// 当前是折叠态
                    // 点击展开
                    clickToUnFold(index)
                } else {// 当前是展开态
                    // 点击折叠
                    clickToFold(index)
                }

                mIsFold = !mIsFold
                mOnCardClickListener?.onCardClicked(index, mIsFold)
            }

            addView(childView)
        }
    }

    /**
     * 折叠,当前点击了第index个卡片
     */
    @SuppressLint("Recycle")
    private fun clickToFold(index: Int) {
        val totalDelay =
            abs(index - 0).coerceAtLeast(abs(index - (mCardsCount - 1))) * DELAY + DURATION

        for (i in 0 until childCount) {
            getChildAt(i).isClickable = false

            var left = index - 1
            var right = index + 1
            while (left >= 0 || right < childCount) {
                if (left >= 0 && right < childCount) {
                    val leftChild = getChildAt(left)
                    val rightChild = getChildAt(right)

                    leftChild.elevation = 10F - abs(index - left) * 0.1F
                    rightChild.elevation = 10F - abs(index - right) * 0.1F

                    val leftTranslationX = abs(index - left) * (mWidth - 100F)
                    val animLeft =
                        ObjectAnimator.ofFloat(leftChild, "translationX", 0F, leftTranslationX)
                    val animLeftScaleX =
                        ObjectAnimator.ofFloat(
                            leftChild,
                            "scaleX",
                            1F,
                            1F - abs(index - left) * 0.1F
                        )
                    val animLeftScaleY =
                        ObjectAnimator.ofFloat(
                            leftChild,
                            "scaleY",
                            1F,
                            1F - abs(index - left) * 0.1F
                        )

                    val rightTranslationX = abs(index - right) * (-mWidth + 100F)
                    val animRight =
                        ObjectAnimator.ofFloat(rightChild, "translationX", 0F, rightTranslationX)
                    val animRightScaleX = ObjectAnimator.ofFloat(
                        rightChild,
                        "scaleX",
                        1F,
                        1F - abs(index - left) * 0.1F
                    )
                    val animRightScaleY = ObjectAnimator.ofFloat(
                        rightChild,
                        "scaleY",
                        1F,
                        1F - abs(index - left) * 0.1F
                    )

                    val animSet = AnimatorSet().apply {
                        duration = DURATION
                        interpolator = AccelerateDecelerateInterpolator()
                        playTogether(
                            animLeft,
                            animLeftScaleX,
                            animLeftScaleY,
                            animRight,
                            animRightScaleX,
                            animRightScaleY
                        )
                        startDelay = (abs(index - left) * DELAY).toLong()
                        start()
                    }

                    left--;
                    right++;
                } else if (left >= 0) {
                    val leftChild = getChildAt(left)
                    leftChild.elevation = 10F - abs(index - left) * 0.1F

                    val leftTranslationX = abs(index - left) * (mWidth - 100F)
                    val animLeft =
                        ObjectAnimator.ofFloat(leftChild, "translationX", 0F, leftTranslationX)
                    val animLeftScaleX =
                        ObjectAnimator.ofFloat(
                            leftChild,
                            "scaleX",
                            1F,
                            1F - abs(index - left) * 0.1F
                        )
                    val animLeftScaleY =
                        ObjectAnimator.ofFloat(
                            leftChild,
                            "scaleY",
                            1F,
                            1F - abs(index - left) * 0.1F
                        )

                    val animSet = AnimatorSet().apply {
                        duration = DURATION
                        interpolator = AccelerateDecelerateInterpolator()
                        playTogether(animLeft, animLeftScaleX, animLeftScaleY)
                        startDelay = (abs(index - left) * DELAY).toLong()
                        start()
                    }

                    left--
                } else if (right < childCount) {
                    val rightChild = getChildAt(right)
                    rightChild.elevation = 10F - abs(index - right) * 0.1F

                    val rightTranslationX = abs(index - right) * (-mWidth + 100F)
                    val animRight =
                        ObjectAnimator.ofFloat(rightChild, "translationX", 0F, rightTranslationX)
                    val animRightScaleX = ObjectAnimator.ofFloat(
                        rightChild,
                        "scaleX",
                        1F,
                        1F - abs(index - right) * 0.1F
                    )
                    val animRightScaleY = ObjectAnimator.ofFloat(
                        rightChild,
                        "scaleY",
                        1F,
                        1F - abs(index - right) * 0.1F
                    )

                    val animSet = AnimatorSet().apply {
                        duration = DURATION
                        interpolator = AccelerateDecelerateInterpolator()
                        playTogether(animRight, animRightScaleX, animRightScaleY)
                        startDelay = (abs(index - left) * DELAY).toLong()
                        start()
                    }

                    right++;
                } else {
                    break
                }
            }

            postDelayed({
                getChildAt(index).isClickable = true
            }, totalDelay.toLong())
        }
    }

    /**
     * 展开,当前点击了第index个卡片
     */
    @SuppressLint("Recycle")
    private fun clickToUnFold(index: Int) {
        var left = index - 1
        var right = index + 1

        val totalDelay =
            abs(index - 0).coerceAtLeast(abs(1 + index - mCardsCount)) * DELAY + DURATION

        while (left >= 0 || right < childCount) {
            if (left >= 0 && right < childCount) {
                val leftChild = getChildAt(left)
                val rightChild = getChildAt(right)

                val animLeft =
                    ObjectAnimator.ofFloat(leftChild, "translationX", 0F)
                val animLeftScaleX = ObjectAnimator.ofFloat(leftChild, "scaleX", 1F)
                val animLeftScaleY = ObjectAnimator.ofFloat(leftChild, "scaleY", 1F)

                val animRight =
                    ObjectAnimator.ofFloat(rightChild, "translationX", 0F)
                val animRightScaleX = ObjectAnimator.ofFloat(rightChild, "scaleX", 1F)
                val animRightScaleY = ObjectAnimator.ofFloat(rightChild, "scaleY", 1F)

                val animSet = AnimatorSet().apply {
                    duration = DURATION
                    interpolator = AccelerateInterpolator()
                    playTogether(
                        animLeft,
                        animLeftScaleX,
                        animLeftScaleY,
                        animRight,
                        animRightScaleX,
                        animRightScaleY
                    )
                    startDelay = (abs(index - left) * DELAY).toLong()
                    start()
                }

                left--
                right++
            } else if (left >= 0) {
                val leftChild = getChildAt(left)

                val animLeft =
                    ObjectAnimator.ofFloat(leftChild, "translationX", 0F)
                val animLeftScaleX = ObjectAnimator.ofFloat(leftChild, "scaleX", 1F)
                val animLeftScaleY = ObjectAnimator.ofFloat(leftChild, "scaleY", 1F)

                val animSet = AnimatorSet().apply {
                    duration = DURATION
                    interpolator = AccelerateInterpolator()
                    playTogether(animLeft, animLeftScaleX, animLeftScaleY)
                    startDelay = (abs(index - left) * DELAY).toLong()
                    start()
                }

                left--
            } else if (right < childCount) {
                val rightChild = getChildAt(right)

                val animRight =
                    ObjectAnimator.ofFloat(rightChild, "translationX", 0F)
                val animRightScaleX = ObjectAnimator.ofFloat(rightChild, "scaleX", 1F)
                val animRightScaleY = ObjectAnimator.ofFloat(rightChild, "scaleY", 1F)

                val animSet = AnimatorSet().apply {
                    duration = DURATION
                    interpolator = AccelerateInterpolator()
                    playTogether(animRight, animRightScaleX, animRightScaleY)
                    startDelay = (abs(index - left) * DELAY).toLong()
                    start()
                }

                right++
            } else {
                break
            }
        }

        postDelayed({
            for (o in 0 until childCount) {
                getChildAt(o).isClickable = true
                getChildAt(o).elevation = 0F
            }
        }, totalDelay.toLong())
    }

}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.cardview.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="@drawable/bg_card_title"
        app:cardCornerRadius="16dp"
        app:cardElevation="5dp"
        app:cardUseCompatPadding="true"
        app:layout_constraintDimensionRatio="1920:1200"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ImageView
            android:id="@+id/img_card"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="centerCrop"
            tools:ignore="ContentDescription"
            tools:src="@drawable/scene1" />

        <TextView
            android:id="@+id/txt_card"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:background="@drawable/bg_card_title"
            android:paddingHorizontal="8dp"
            android:paddingVertical="2dp"
            android:textColor="@color/black"
            android:textSize="14sp"
            tools:text="这是卡片的描述" />
    </androidx.cardview.widget.CardView>

</androidx.constraintlayout.widget.ConstraintLayout>

         卡片点击监听器

package com.openld.seniorui.testcards

/**
 * author: lllddd
 * created on: 2022/7/30 13:23
 * description:卡片点击监听
 */
interface OnCardClickListener {
    /**
     * 卡片点击的监听
     * 
     * @param position 点击的卡片的位置
     * @param isFold 当前卡包是否折叠
     */
    fun onCardClicked(position: Int, isFold: Boolean)
}

         卡片滑动监听器

package com.openld.seniorui.testcards

/**
 * author: lllddd
 * created on: 2022/7/31 9:38
 * description:卡片滚动监听
 */
interface OnCardScrollListener {
    /**
     * 当前滚动到的卡片游标
     *
     * @param index 卡片游标
     */
    fun onCardScrolled(index: Int)
}

         卡片Bean约定

package com.openld.seniorui.testcards

data class CardBean(val image: Int, val title: String) {

}

         调用页面相关

package com.openld.seniorui.testcards

import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import com.openld.seniorui.R

class TestCardsActivity : AppCompatActivity() {
    private lateinit var mScrollView: CardsHorizontalScrollView

    private lateinit var mCardsContainerLayout: CardsContainerLayout

    private lateinit var mCardList: MutableList<CardBean>

    private var mWidth = 0

    @SuppressLint("ClickableViewAccessibility", "UseCompatLoadingForDrawables")
    @RequiresApi(Build.VERSION_CODES.M)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_test_cards)

        mWidth = resources.displayMetrics.widthPixels

        mCardList = ArrayList<CardBean>()
        mCardList.add(CardBean(R.drawable.scene1, "阴阳师卡片 0"))
        mCardList.add(CardBean(R.drawable.scene2, "阴阳师卡片 1"))
        mCardList.add(CardBean(R.drawable.scene3, "阴阳师卡片 2"))
        mCardList.add(CardBean(R.drawable.scene4, "阴阳师卡片 3"))
        mCardList.add(CardBean(R.drawable.scene5, "阴阳师卡片 4"))
        mCardList.add(CardBean(R.drawable.scene6, "阴阳师卡片 5"))
        mCardList.add(CardBean(R.drawable.scene7, "阴阳师卡片 6"))
        mCardList.add(CardBean(R.drawable.scene8, "阴阳师卡片 7"))
        mCardList.add(CardBean(R.drawable.scene9, "阴阳师卡片 8"))
        mCardList.add(CardBean(R.drawable.scene10, "阴阳师卡片 9"))
        mCardList.add(CardBean(R.drawable.scene11, "阴阳师卡片 10"))
        mCardList.add(CardBean(R.drawable.scene12, "阴阳师卡片 11"))
        mCardList.add(CardBean(R.drawable.scene13, "阴阳师卡片 12"))
        mCardList.add(CardBean(R.drawable.scene14, "阴阳师卡片 13"))

        mScrollView = findViewById(R.id.scroll_container)

        mScrollView.mOnCardScrollListener = object : OnCardScrollListener {
            @SuppressLint("UseCompatLoadingForDrawables")
            override fun onCardScrolled(index: Int) {
                Toast.makeText(this@TestCardsActivity, "滑到了第${index}个卡片", Toast.LENGTH_SHORT).show()
            }
        }
        mScrollView.post {
            mScrollView.setCards(mCardList)
        }

        mCardsContainerLayout = findViewById(R.id.cards_container_layout)
        mCardsContainerLayout.mOnCardClickListener = object : OnCardClickListener {
            @SuppressLint("ClickableViewAccessibility")
            override fun onCardClicked(position: Int, isFold: Boolean) {
                mScrollView.mIsFold = isFold
                if (isFold) {
                    mScrollView.setOnTouchListener { v, event -> true }
                } else {
                    mScrollView.setOnTouchListener { v, event -> false }
                }
            }
        }

    }
}

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context=".testcards.TestCardsActivity"
    tools:ignore="MissingDefaultResource">

    <com.openld.seniorui.testcards.CardsHorizontalScrollView
        android:id="@+id/scroll_container"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:fillViewport="true"
        android:orientation="horizontal"
        android:scrollbars="none"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintDimensionRatio="33:20"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.openld.seniorui.testcards.CardsContainerLayout
            android:id="@+id/cards_container_layout"
            android:layout_width="wrap_content"
            android:layout_height="match_parent" />

    </com.openld.seniorui.testcards.CardsHorizontalScrollView>


</androidx.constraintlayout.widget.ConstraintLayout>

 完整工程及图片等资源有需要可以去项目中自取

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值