最近在工作中发现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
-
- 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
(R.id.img_card) - imageCard.setImageResource(cards[index].image)
-
- val txtCard = childView.findViewById
(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())
- }
-
- }
- <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
-
- 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
() - 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 }
- }
- }
- }
-
- }
- }
- <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>