Android实现卡片堆叠,卡片管理动画效果(附带源码)

一、项目介绍

在社交、电商、教育等领域,卡片式交互(Card Stack)广泛应用于浏览推荐内容、问答系统、答题器、金融产品等场景。例如:

  • Tinder 风格滑卡:左右滑动“喜欢/不喜欢”

  • 卡片翻转:查看卡片背面详情

  • 卡片堆叠:顶部几张卡片叠加展示剩余数量

本项目将实现一个通用的卡片堆叠组件,支持:

  1. 卡片入栈/出栈:动态添加与移除

  2. 滑动擦除:拖拽卡片并滑出屏幕

  3. 点击翻转:点击卡片正反两面切换

  4. 多层预览:最多同时展示 N 张卡片,底层卡片带缩放和位移偏移

  5. 流畅动画:卡片滑出、翻转、底层卡片弹性上浮效果

  6. 可定制性:支持自定义卡片布局、动画时间、最大预览数


二、相关知识

  1. 自定义 ViewGroup

    • 继承 FrameLayoutViewGroup,管理子 View 布局

    • onLayout 中根据索引设置卡片缩放(scale)和偏移(translationY)

  2. 手势与动画

    • View.setOnTouchListener 监听触摸,计算 dx, dy

    • ViewPropertyAnimatorObjectAnimator 实现平移动画、旋转动画、翻转动画

  3. 卡片翻转技术

    • 使用 CameraView.setRotationY(…) 结合 AnimatorSet 模拟 3D 翻转

    • 正反面布局可用 ViewFlipper 或前后两个子 View 切换可见性

  4. 数据管理

    • 使用 List<Card> 存储卡片数据,每次弹出时更新数据源并重新布局

    • 回调接口 onCardSwiped(Card, Direction)onCardClicked(Card)

  5. 性能优化

    • 缓存动画对象并复用,不在每次触摸时新建

    • 限制同一时刻最多展现 3~4 张卡,避免过多 View 布局开销


三、实现思路

  1. 组件结构

    • CardStackView:自定义 ViewGroup,承载所有卡片;

    • CardAdapter:将数据源与卡片布局绑定;

    • CardTouchHelper:在顶层卡片上注册触摸监听,处理拖拽与滑出;

    • Card:数据模型,包含正/反面内容;

  2. 生命周期

    • 初始化时:CardStackView.setAdapter(adapter),向内部添加前 N 张卡片视图并布局;

    • 滑出时:触摸释放后若超过阈值,则执行飞出动画并调用 adapter.onSwiped(),随后移除旧视图并添加新视图;

    • 点击时:对顶层卡片执行翻转动画,切换前后布局;

  3. 子 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
  1. 动画细节

    • 滑出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() }
  }
}

六、代码解读

  1. CardStackView

    • 继承 FrameLayout,通过 setAdapter() 初始化前 maxVisible 张卡片;

    • arrangeChild() 根据层级设置 scaletranslationYrotationalpha(可选),形成堆叠效果;

    • attachTouchListener() 仅对子 View(顶层卡)注册触摸监听,处理拖拽滑动;

    • handleRelease() 判断拖拽距离是否超过阈值(宽度四分之一),若是执行飞出动画并弹出一张卡;否则复位;

    • 滑出后调用 adapter.pop() 获取被移除的 Card,并保存到 lastRemoved 以便撤销;

  2. CardAdapter

    • 负责创建卡片 View、绑定数据,并实现正反两面翻转(点击时调用 flipCard());

    • 提供 pop()undo()reset() 方法管理数据队列;

  3. MainActivity

    • 构造示例 List<Card>,并通过 CardStackView.setAdapter() 绑定;

    • btnUndo 调用 cardStack.undo()btnReset 调用 cardStack.reset()

  4. 布局与样式

    • item_card.xml 定义卡片前后布局,背景为自定义 card_bg(9-patch 或圆角矩形);

    • activity_main.xmlCardStackView 全屏展示,并在底部放置操作按钮。


七、性能与优化

  1. 复用 View

    • 若数据量大,可考虑使用 RecyclerView + 自定义 LayoutManager 实现类似效果,并复用 ViewHolder;

  2. 动画对象复用

    • 不在每次滑动时调用 View.animate() 的新实例,可缓存 AnimatorSet 并重用,减少 GC;

  3. 限制子 View 数量

    • 仅加载 maxVisible+1 张卡片,避免同时布局过多,保证性能;

  4. 自定义属性

    • 可将 缩放比例Y 轴偏移滑出阈值动画时长 等封装成 @attr,在布局中灵活配置;


八、项目总结与拓展

本文从基础到进阶,系统地讲解了如何在 Android 中实现流畅的卡片堆叠卡片管理动画。通过自定义 ViewGroup、手势拖拽、属性动画和数据适配,我们获得了一个易用且可扩展的组件。

拓展思路

  1. RecyclerView 方案:结合 RecyclerViewItemTouchHelper 实现高性能卡片滑动;

  2. 无限循环:在数据耗尽时自动从头循环,支持无限滑卡;

  3. 侧滑菜单:在卡片根布局中加入侧滑按钮,丰富操作;

  4. 网络加载:结合 PagingGlide,动态加载卡片内容与图片;

  5. Compose 重构:使用 Jetpack Compose 中的 BoxModifier.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”,并隐藏滑动逻辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值