当前位置:网站首页>自定义卡包效果实现
自定义卡包效果实现
2022-08-02 08:18:00 【OpenLD】
最近在工作中发现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([email protected], "滑到了第${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>
完整工程及图片等资源有需要可以去项目中自取
边栏推荐
猜你喜欢
Biotin-EDA|CAS:111790-37-5| Ethylenediamine biotin
etcd implements large-scale service governance application combat
pnpm:简介
UVM信息服务机制
PostgreSQL learning summary (11) - PostgreSQL commonly used high-availability cluster solutions
如何做好项目管理
在 QT Creator 上配置 opencv 环境的一些认识和注意点
[OC学习笔记]Block三种类型
【论文阅读】Distilling the Knowledge in a Neural Network
商业智能平台BI 商业智能分析平台 如何选择合适的商业智能平台BI
随机推荐
What is the function of the import command of the page directive in JSP?
QT web 开发 - 笔记 - 3
Business Intelligence Platform BI Business Intelligence Analysis Platform How to Choose the Right Business Intelligence Platform BI
How Engineers Treat Open Source --- A veteran engineer's heartfelt words
JSP中page指令的import命令具有什么功能呢?
TiFlash 存储层概览
【特别提醒】订阅此专栏的用户请先阅读本文再决定是否需要购买此专栏
JSP页面中page指令contentPage/pageEncoding具有什么功能呢?
图扑软件数字孪生油气管道站,搭建油气运输管控平台
[ansible] playbook explains the execution steps in combination with the project
RestTemlate源码分析及工具类设计
The packet capture tool Charles modifies the Response step
Biotinyl Cystamine | CAS: 128915-82-2 | biotin cysteamine
prometheus monitoring mysql_galera cluster
PyCharm使用教程(详细版 - 图文结合)
cas: 139504-50-0 Maytansine DM1|Mertansine|
力扣:第 304 场周赛
Spark 系统性学习笔记系列
MFC最详细入门教程[转载]
知识点滴 - 为什么一般不用铜锅做菜