一、项目介绍
在社交、电商、教育等领域,卡片式交互(Card Stack)广泛应用于浏览推荐内容、问答系统、答题器、金融产品等场景。例如:
-
Tinder 风格滑卡:左右滑动“喜欢/不喜欢”
-
卡片翻转:查看卡片背面详情
-
卡片堆叠:顶部几张卡片叠加展示剩余数量
本项目将实现一个通用的卡片堆叠组件,支持:
-
卡片入栈/出栈:动态添加与移除
-
滑动擦除:拖拽卡片并滑出屏幕
-
点击翻转:点击卡片正反两面切换
-
多层预览:最多同时展示 N 张卡片,底层卡片带缩放和位移偏移
-
流畅动画:卡片滑出、翻转、底层卡片弹性上浮效果
-
可定制性:支持自定义卡片布局、动画时间、最大预览数
二、相关知识
-
自定义 ViewGroup
-
继承
FrameLayout
或ViewGroup
,管理子 View 布局 -
在
onLayout
中根据索引设置卡片缩放(scale)和偏移(translationY)
-
-
手势与动画
-
View.setOnTouchListener
监听触摸,计算dx, dy
-
ViewPropertyAnimator
或ObjectAnimator
实现平移动画、旋转动画、翻转动画
-
-
卡片翻转技术
-
使用
Camera
或View.setRotationY(…)
结合AnimatorSet
模拟 3D 翻转 -
正反面布局可用
ViewFlipper
或前后两个子 View 切换可见性
-
-
数据管理
-
使用
List<Card>
存储卡片数据,每次弹出时更新数据源并重新布局 -
回调接口
onCardSwiped(Card, Direction)
、onCardClicked(Card)
-
-
性能优化
-
缓存动画对象并复用,不在每次触摸时新建
-
限制同一时刻最多展现 3~4 张卡,避免过多 View 布局开销
-
三、实现思路
-
组件结构
-
CardStackView
:自定义ViewGroup
,承载所有卡片; -
CardAdapter
:将数据源与卡片布局绑定; -
CardTouchHelper
:在顶层卡片上注册触摸监听,处理拖拽与滑出; -
Card
:数据模型,包含正/反面内容;
-
-
生命周期
-
初始化时:
CardStackView.setAdapter(adapter)
,向内部添加前 N 张卡片视图并布局; -
滑出时:触摸释放后若超过阈值,则执行飞出动画并调用
adapter.onSwiped()
,随后移除旧视图并添加新视图; -
点击时:对顶层卡片执行翻转动画,切换前后布局;
-
-
子 View 排列
-
在
onLayout
中,依次为每个子 View(卡片)设置:
-
val level = childCount - index - 1
child.scaleX = 1f - level * 0.05f
child.scaleY = 1f - level * 0.05f
child.translationY = level * dpToPx(10)
child.rotation = 0f
child.alpha = 1f - level * 0.1f
-
动画细节
-
滑出:
view.animate().translationX(targetX).translationY(targetY).rotation(direction * 30f).setDuration(300)
-
复位:若未超过阈值,
view.animate().translationX(0f).translationY(0f).rotation(0f).setDuration(200)
-
翻转:
ObjectAnimator.ofFloat(view, "rotationY", 0f, 90f).withEndAction{ swapFace(); animate back 90→0 }
-
四、环境与依赖
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.cardstack"
minSdkVersion 21
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget = "1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
}
五、整合代码
// =======================================================
// 文件: AndroidManifest.xml
// 描述: 注册 MainActivity
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.cardstack">
<application>
<activity android:name=".MainActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 主界面布局,包含 CardStackView 和按钮示例
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.cardstack.CardStackView
android:id="@+id/cardStack"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<Button
android:id="@+id/btnUndo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="撤销"
android:layout_margin="16dp"
android:layout_gravity="bottom|start"/>
<Button
android:id="@+id/btnReset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重置"
android:layout_margin="16dp"
android:layout_gravity="bottom|end"/>
</FrameLayout>
// =======================================================
// 文件: res/layout/item_card.xml
// 描述: 单张卡片布局:正反面容器
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="280dp"
android:layout_height="380dp"
android:layout_gravity="center"
android:background="@drawable/card_bg"
android:clipToPadding="false">
<!-- 正面 -->
<LinearLayout
android:id="@+id/front"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="20sp"
android:textStyle="bold"/>
<ImageView
android:id="@+id/img"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scaleType="centerCrop"/>
</LinearLayout>
<!-- 背面 -->
<LinearLayout
android:id="@+id/back"
android:orientation="vertical"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:background="#FFF">
<TextView
android:id="@+id/tvDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"/>
</LinearLayout>
</FrameLayout>
// =======================================================
// 文件: Card.kt
// 描述: 数据模型
// =======================================================
package com.example.cardstack
data class Card(
val title: String,
val imageRes: Int,
val detail: String
)
// =======================================================
// 文件: CardAdapter.kt
// 描述: 负责创建和绑定单张卡片视图
// =======================================================
package com.example.cardstack
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.example.cardstack.databinding.ItemCardBinding
class CardAdapter(
private val data: MutableList<Card>
) {
/** 创建卡片 View */
fun createView(parent: ViewGroup): View {
val binding = ItemCardBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
// 初始状态:显示正面
binding.front.visibility = View.VISIBLE
binding.back.visibility = View.GONE
return binding.root
}
/** 绑定数据到卡片 */
fun bind(view: View, position: Int) {
val card = data[position]
val binding = ItemCardBinding.bind(view)
binding.tvTitle.text = card.title
binding.img.setImageResource(card.imageRes)
binding.tvDetail.text = card.detail
// 点击翻转
view.setOnClickListener {
flipCard(binding.front, binding.back)
}
}
/** 简单翻转动画 */
private fun flipCard(front: View, back: View) {
front.animate().rotationY(90f).setDuration(150).withEndAction {
front.visibility = View.GONE
back.visibility = View.VISIBLE
back.rotationY = -90f
back.animate().rotationY(0f).setDuration(150).start()
}.start()
}
/** 卡片数量 */
fun itemCount() = data.size
/** 弹出并返回顶层卡片 */
fun pop(): Card? =
if (data.isNotEmpty()) data.removeAt(0) else null
/** 撤销:将卡片重置到队列头 */
fun undo(card: Card) {
data.add(0, card)
}
/** 重置全部数据 */
fun reset(all: List<Card>) {
data.clear()
data.addAll(all)
}
}
// =======================================================
// 文件: CardStackView.kt
// 描述: 卡片堆叠容器,自定义 ViewGroup
// =======================================================
package com.example.cardstack
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import kotlin.math.abs
class CardStackView @JvmOverloads constructor(
ctx: Context, attrs: AttributeSet? = null
): FrameLayout(ctx, attrs) {
private var adapter: CardAdapter? = null
private var allData = listOf<Card>()
private var maxVisible = 3
private var touchSlop = 20
private var activeView: View? = null
private var downX = 0f; private var downY = 0f
private var lastRemoved: Card? = null
/** 绑定 Adapter 并展示初始卡片 */
fun setAdapter(adapter: CardAdapter, data: List<Card>) {
this.adapter = adapter
this.allData = data.toList()
adapter.reset(data)
removeAllViews()
// 添加前 maxVisible 张
val count = minOf(adapter.itemCount(), maxVisible)
for (i in 0 until count) addCardViewAt(i)
}
/** 撤销上次滑出 */
fun undo() {
lastRemoved?.let {
adapter?.undo(it)
// 在头部插入新卡
addCardViewAt(0)
lastRemoved = null
rearrangeCards()
}
}
/** 重置到初始状态 */
fun reset() {
adapter?.reset(allData)
removeAllViews()
setAdapter(adapter!!, allData)
}
private fun addCardViewAt(position: Int) {
adapter?.let { ad ->
val view = ad.createView(this)
val index = childCount
addView(view, 0) // 底部插入
ad.bind(view, position)
arrangeChild(view, index)
if (index == 0) attachTouchListener(view)
}
}
/** 布局时更新所有卡片位置 */
private fun rearrangeCards() {
for (i in 0 until childCount) {
arrangeChild(getChildAt(i), i)
}
}
/** 根据层级设置缩放与偏移 */
private fun arrangeChild(v: View, index: Int) {
val level = childCount - index - 1
v.scaleX = 1f - level * 0.05f
v.scaleY = 1f - level * 0.05f
v.translationY = level * dpToPx(10f)
v.rotation = 0f
}
/** 给顶层卡片添加滑动手势 */
private fun attachTouchListener(view: View) {
view.setOnTouchListener { v, ev ->
val x = ev.rawX; val y = ev.rawY
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
activeView = v; downX = x; downY = y
true
}
MotionEvent.ACTION_MOVE -> {
val dx = x - downX; val dy = y - downY
activeView?.apply {
translationX = dx; translationY = dy
rotation = dx / width * 20f
}
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
activeView?.let { handleRelease(it) }
true
}
else -> false
}
}
}
/** 抬起时根据滑出距离决定移除或复位 */
private fun handleRelease(view: View) {
val dx = view.translationX
if (abs(dx) > width/4) {
// 滑出
val dir = if (dx > 0) width.toFloat() else -width.toFloat()
view.animate()
.translationX(dir)
.translationY(view.translationY + dpToPx(50f))
.rotation(dir / width * 30f)
.setDuration(300)
.withEndAction {
// 移除视图
removeView(view)
// 更新数据
lastRemoved = adapter?.pop()
// 添加新卡
val nextPos = childCount + 0
if (adapter!!.itemCount() > maxVisible - 1) {
addCardViewAt(maxVisible - 1)
}
rearrangeCards()
}.start()
} else {
// 复位
view.animate()
.translationX(0f).translationY(0f).rotation(0f)
.setDuration(200).start()
}
}
private fun dpToPx(dp: Float): Float =
dp * resources.displayMetrics.density
}
// =======================================================
// 文件: MainActivity.kt
// 描述: 主界面逻辑,演示卡片堆叠动画
// =======================================================
package com.example.cardstack
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.cardstack.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var adapter: CardAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 构造示例数据
val cards = mutableListOf<Card>()
for (i in 1..10) {
cards += Card("Card #$i",
R.drawable.sample_image,
"Detail of card #$i"
)
}
adapter = CardAdapter(cards)
binding.cardStack.setAdapter(adapter, cards)
binding.btnUndo.setOnClickListener { binding.cardStack.undo() }
binding.btnReset.setOnClickListener { binding.cardStack.reset() }
}
}
六、代码解读
-
CardStackView
-
继承
FrameLayout
,通过setAdapter()
初始化前maxVisible
张卡片; -
arrangeChild()
根据层级设置scale
、translationY
、rotation
、alpha
(可选),形成堆叠效果; -
attachTouchListener()
仅对子 View(顶层卡)注册触摸监听,处理拖拽滑动; -
handleRelease()
判断拖拽距离是否超过阈值(宽度四分之一),若是执行飞出动画并弹出一张卡;否则复位; -
滑出后调用
adapter.pop()
获取被移除的Card
,并保存到lastRemoved
以便撤销;
-
-
CardAdapter
-
负责创建卡片 View、绑定数据,并实现正反两面翻转(点击时调用
flipCard()
); -
提供
pop()
、undo()
与reset()
方法管理数据队列;
-
-
MainActivity
-
构造示例
List<Card>
,并通过CardStackView.setAdapter()
绑定; -
btnUndo
调用cardStack.undo()
;btnReset
调用cardStack.reset()
;
-
-
布局与样式
-
item_card.xml
定义卡片前后布局,背景为自定义card_bg
(9-patch 或圆角矩形); -
activity_main.xml
将CardStackView
全屏展示,并在底部放置操作按钮。
-
七、性能与优化
-
复用 View
-
若数据量大,可考虑使用
RecyclerView
+ 自定义LayoutManager
实现类似效果,并复用 ViewHolder;
-
-
动画对象复用
-
不在每次滑动时调用
View.animate()
的新实例,可缓存AnimatorSet
并重用,减少 GC;
-
-
限制子 View 数量
-
仅加载
maxVisible+1
张卡片,避免同时布局过多,保证性能;
-
-
自定义属性
-
可将 缩放比例、Y 轴偏移、滑出阈值、动画时长 等封装成
@attr
,在布局中灵活配置;
-
八、项目总结与拓展
本文从基础到进阶,系统地讲解了如何在 Android 中实现流畅的卡片堆叠和卡片管理动画。通过自定义 ViewGroup
、手势拖拽、属性动画和数据适配,我们获得了一个易用且可扩展的组件。
拓展思路
-
RecyclerView 方案:结合
RecyclerView
和ItemTouchHelper
实现高性能卡片滑动; -
无限循环:在数据耗尽时自动从头循环,支持无限滑卡;
-
侧滑菜单:在卡片根布局中加入侧滑按钮,丰富操作;
-
网络加载:结合
Paging
和Glide
,动态加载卡片内容与图片; -
Compose 重构:使用 Jetpack Compose 中的
Box
与Modifier.draggable
实现同样效果。
九、FAQ
Q1:为何只给顶层卡片注册触摸监听?
A1:避免所有卡片都响应拖拽,降低交互冲突。底层卡片通过 arrangeChild()
更新动画,不参与触摸。
Q2:如何支持左右判断“喜欢/不喜欢”?
A2:在 handleRelease()
中可根据滑出方向(dx>0
或 <0
),回调不同接口,如 onSwipedRight()
。
Q3:卡片翻转时如何绘制 3D 效果?
A3:可使用 Camera
类和 Matrix
实现 3D 投影,或 View.setRotationY()
配合 cameraDistance
调整深度。
Q4:如何在滑卡时同时让底层卡片放大?
A4:在 ACTION_MOVE
中,监控 dx
的绝对值比例,并对底层卡片调用 scaleX/scaleY
动态调整。
Q5:如何在数据为空时显示占位布局?
A5:在 adapter.itemCount()==0
时,在 CardStackView
中添加一个 “Empty View”,并隐藏滑动逻辑。