iOS 卡片式布局 —— 基于 UICollectionView 和 Transform 动画

什么是卡片式布局?

卡片式布局(Card-based Layout) 是一种以“卡片”为单元的 UI 布局方式,每张卡片作为一个独立的内容模块,包含图片、文字、按钮等元素。卡片通常具有圆角、阴影和立体感,通过重叠或堆叠效果营造视觉层次,常用于滑动、拖拽等交互场景。类似于现实中的纸牌,卡片式布局在移动应用中广泛应用,因其美观、模块化和交互性强而备受欢迎。

特点:

  • 模块化:每张卡片独立,便于动态管理。

  • 视觉层次:通过重叠、堆叠、阴影等效果增强深度感。

  • 动态交互:支持滑动、点击、拖拽,结合动画提升体验。

  • 响应式:适配不同屏幕尺寸,保持一致性。

典型应用:

  • 社交媒体(如 Tinder 的用户卡片滑动)。

  • 新闻应用(如 Apple News 的头条卡片)。

  • 任务管理(如 Trello 的任务卡片)。

  • 电商(如商品推荐卡片)。

iOS 中的卡片式布局实现方式

在 iOS 开发中,卡片式布局通常通过 UICollectionView 实现,结合 transform 动画(如缩放、位移、旋转)打造重叠或堆叠效果。以下是几种常见实现方式:

1. UICollectionView + 自定义 Card Layout

  • 描述:通过继承 UICollectionViewLayout 或 UICollectionViewFlowLayout,自定义卡片的位置、缩放和重叠效果。核心是动态调整每张卡片的 transform 和 zIndex。

  • 特点:

    • 卡片以堆叠或重叠形式排列,靠前的卡片更大且位于顶层。

    • 支持滑动交互,滑动时卡片动态调整位置和缩放。

    • 可限制显示的卡片数量,隐藏靠后的卡片。

  • 实现步骤:

    1. 创建自定义 UICollectionViewLayout,定义卡片尺寸和间距。

    2. 在 layoutAttributesForElements 中为每张卡片设置 transform(缩放、位移)和 zIndex。

    3. 通过 UIScrollViewDelegate 监听滑动,动态更新卡片动画。

  • 代码示例(简化的 Card Layout):

    class CardStackLayout: UICollectionViewFlowLayout {
        private let cardSpacing: CGFloat = 20
        private let maxVisibleCards = 3
        
        override func prepare() {
            super.prepare()
            scrollDirection = .vertical
            minimumLineSpacing = cardSpacing
            itemSize = CGSize(width: collectionView!.bounds.width - 40, height: 300)
        }
        
        override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
            guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
            
            for (index, attribute) in attributes.enumerated() {
                let offset = min(index, maxVisibleCards - 1)
                let scale = 1 - CGFloat(offset) * 0.05
                let translation = CGFloat(offset) * cardSpacing
                
                attribute.zIndex = maxVisibleCards - offset
                attribute.transform = CGAffineTransform(scaleX: scale, y: scale)
                    .translatedBy(x: 0, y: translation)
                
                if index >= maxVisibleCards {
                    attribute.alpha = 0
                }
            }
            
            return attributes
        }
        
        override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
            return true
        }
    }

  • 使用方式:

    let layout = CardStackLayout()
    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    collectionView.dataSource = self
    collectionView.delegate = self
    collectionView.register(CardCell.self, forCellWithReuseIdentifier: "CardCell")

  • 适用场景:需要堆叠效果的场景,如 Tinder 风格的卡片滑动、新闻头条展示。

2. UICollectionViewCompositional Layout (iOS 13+)

  • 描述:使用 UICollectionViewCompositionalLayout 实现现代化的卡片布局,通过 NSCollectionLayoutGroup 配置卡片排列,结合 transform 动画实现重叠效果。

  • 特点:

    • 声明式布局,代码简洁。

    • 支持动态尺寸,但需要配合 UICollectionViewDelegate 调整卡片样式。

    • 适合现代 iOS 应用。

  • 实现步骤:

    1. 定义 compositional layout,设置单列或多列 group。

    2. 使用 transform 或 zIndex 实现卡片重叠。

    3. 在 delegate 中动态调整卡片样式。

  • 代码示例:

    func createCardLayout() -> UICollectionViewCompositionalLayout {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9), heightDimension: .absolute(300))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(320))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 20
        section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
        
        section.visibleItemsInvalidationHandler = { items, offset, environment in
            items.forEach { item in
                let index = item.indexPath.item
                let scale = 1 - CGFloat(index) * 0.05
                let translation = CGFloat(index) * 20
                item.transform = CGAffineTransform(scaleX: scale, y: scale)
                    .translatedBy(x: 0, y: translation)
                item.zIndex = items.count - index
            }
        }
        
        return UICollectionViewCompositionalLayout(section: section)
    }

  • 适用场景:现代应用,追求简洁代码和动态交互。

3. SwiftUI+ZStack

  • 描述:在 SwiftUI 中,使用 ZStack 和 ForEach 实现卡片堆叠布局,通过 scaleEffect 和 offset 添加重叠效果。

  • 特点:

    • 声明式语法,开发效率高。

    • 适合快速原型或跨平台开发。

    • 对于复杂交互可能需要额外逻辑。

  • 代码示例:

    struct ContentView: View {
        let cards = ["Card 1", "Card 2", "Card 3", "Card 4"]
        
        var body: some View {
            ZStack {
                ForEach(cards.indices, id: \.self) { index in
                    CardView(title: cards[index])
                        .scaleEffect(1 - CGFloat(index) * 0.05)
                        .offset(y: CGFloat(index) * 20)
                        .zIndex(Double(cards.count - index))
                }
            }
            .padding()
        }
    }
    
    struct CardView: View {
        let title: String
        
        var body: some View {
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.white)
                .frame(width: 300, height: 200)
                .shadow(radius: 8)
                .overlay(Text(title).font(.headline))
        }
    }

  • 适用场景:SwiftUI 项目,快速实现简单卡片堆叠。

4. 第三方库

  • 描述:使用第三方库快速实现卡片式布局,减少开发时间。

  • 推荐库:

    • Koloda:专为 Tinder 风格的卡片滑动设计,支持拖拽和动画。

    • iCarousel:支持多种 3D 卡片效果(如旋转、堆叠)。

  • 使用方式:通过 CocoaPods 或 Swift Package Manager 引入,按文档配置。

  • 注意:第三方库可能限制灵活性,需评估维护成本。

  • 适用场景:快速原型开发或特定风格的卡片布局。

代码实现

以下是一个完整的实现示例,包含卡片式布局、堆叠效果和滑动动画。

1. 设置项目结构

创建一个新的 iOS 项目,选择 UIKit,使用 Storyboard 或纯代码均可。我们将使用纯代码实现以便更清晰地展示逻辑。

2. 创建卡片 Cell

首先定义卡片的 UICollectionViewCell,包含图片、标题和阴影效果。

import UIKit

class CardCell: UICollectionViewCell {
    static let reuseIdentifier = "CardCell"
    
    private let imageView = UIImageView()
    private let titleLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {
        // 卡片样式
        contentView.layer.cornerRadius = 10
        contentView.layer.masksToBounds = true
        contentView.backgroundColor = .white
        contentView.layer.shadowColor = UIColor.black.cgColor
        contentView.layer.shadowOpacity = 0.3
        contentView.layer.shadowOffset = CGSize(width: 0, height: 4)
        contentView.layer.shadowRadius = 8
        
        // 图片
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        contentView.addSubview(imageView)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            imageView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 0.6)
        ])
        
        // 标题
        titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
        titleLabel.textAlignment = .center
        contentView.addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 8),
            titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
            titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -8)
        ])
    }
    
    func configure(with title: String, image: UIImage?) {
        titleLabel.text = title
        imageView.image = image
    }
}

3. 自定义卡片布局

创建一个自定义的 UICollectionViewFlowLayout,实现卡片的重叠和堆叠效果。

import UIKit

class CardStackLayout: UICollectionViewFlowLayout {
    private let cardSpacing: CGFloat = 20 // 卡片间的垂直偏移
    private let maxVisibleCards = 3 // 最多显示的卡片数
    
    override func prepare() {
        super.prepare()
        scrollDirection = .vertical
        minimumLineSpacing = cardSpacing
        itemSize = CGSize(width: collectionView!.bounds.width - 40, height: 300) // 卡片尺寸
    }
    
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
        
        for (index, attribute) in attributes.enumerated() {
            let offset = min(index, maxVisibleCards - 1) // 限制最多显示几张卡片
            let scale = 1 - CGFloat(offset) * 0.05 // 靠后的卡片略微缩小
            let translation = CGFloat(offset) * cardSpacing // 靠后的卡片向下偏移
            
            attribute.zIndex = maxVisibleCards - offset // 前面的卡片 zIndex 更高
            attribute.transform = CGAffineTransform(scaleX: scale, y: scale)
                .translatedBy(x: 0, y: translation)
            
            // 隐藏超出最大显示数量的卡片
            if index >= maxVisibleCards {
                attribute.alpha = 0
            }
        }
        
        return attributes
    }
    
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true // 确保滑动时重新计算布局
    }
}

4. 设置 ViewController

在 ViewController 中配置 UICollectionView,并实现滑动时的动画效果。

import UIKit

class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
    private var collectionView: UICollectionView!
    private let cardTitles = ["Card 1", "Card 2", "Card 3", "Card 4", "Card 5"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupCollectionView()
    }
    
    private func setupCollectionView() {
        let layout = CardStackLayout()
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.backgroundColor = .systemGray6
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(CardCell.self, forCellWithReuseIdentifier: CardCell.reuseIdentifier)
        view.addSubview(collectionView)
        
        // Auto Layout
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
    
    // MARK: - UICollectionViewDataSource
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return cardTitles.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CardCell.reuseIdentifier, for: indexPath) as! CardCell
        cell.configure(with: cardTitles[indexPath.item], image: UIImage(named: "placeholder"))
        return cell
    }
    
    // MARK: - UIScrollViewDelegate
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // 动态调整卡片动画
        guard let collectionView = scrollView as? UICollectionView else { return }
        let visibleCells = collectionView.visibleCells
        for case let cell in visibleCells {
            let indexPath = collectionView.indexPath(for: cell)
            let attributes = collectionView.layoutAttributesForItem(at: indexPath!)
            
            // 根据滑动偏移调整 transform
            let offsetY = scrollView.contentOffset.y
            let translation = max(0, offsetY - (attributes?.frame.origin.y ?? 0))
            let scale = max(0.8, 1 - translation / 1000) // 动态缩放
            cell.transform = CGAffineTransform(scaleX: scale, y: scale)
                .translatedBy(x: 0, y: translation / 2)
        }
    }
    
    // MARK: - UICollectionViewDelegate
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        // 点击卡片触发动画
        let cell = collectionView.cellForItem(at: indexPath)
        UIView.animate(withDuration: 0.3, animations: {
            cell?.transform = .init(scaleX: 1.1, y: 1.1)
        }, completion: { _ in
            UIView.animate(withDuration: 0.3) {
                cell?.transform = .identity
            }
        })
    }
}

代码解析

  1. 卡片 Cell(CardCell):

    • 使用 cornerRadius 和 shadow 创建卡片的外观。

    • imageView 和 titleLabel 分别展示图片和标题,布局使用 Auto Layout。

  2. 自定义布局(CardStackLayout):

    • 重写 layoutAttributesForElements 方法,为每张卡片设置 zIndex、缩放和位移。

    • 使用 scale 和 translation 实现堆叠效果,靠后的卡片略小且向下偏移。

    • 设置 alpha 隐藏超出最大显示数量的卡片。

  3. ViewController:

    • 配置 UICollectionView,使用自定义的 CardStackLayout。

    • 在 scrollViewDidScroll 中根据滑动偏移动态调整卡片的 transform,实现平滑动画。

    • 添加点击交互,点击卡片时触发放大动画。

核心组件

  1. UICollectionView:

    • 用于管理卡片的展示和滚动。

    • 自定义 UICollectionViewLayout 或使用 UICollectionViewFlowLayout 控制卡片位置。

  2. Transform 动画:

    • 使用 CGAffineTransform 实现卡片的缩放(scale)、位移(translate)和旋转(rotate)。

    • 通过 UIView.animate 或 UIViewPropertyAnimator 添加平滑动画。

  3. 卡片 Cell:

    • 自定义 UICollectionViewCell,包含卡片的 UI 元素(如图片、标题、阴影)。

  4. 交互逻辑:

    • 通过 UIScrollViewDelegate 或 UICollectionViewDelegate 监听滑动,动态调整卡片的位置和 transform。

    • 使用 UIGestureRecognizer 支持点击或拖拽交互。

布局思路

  • 重叠效果:通过设置卡片的 zIndex 和 transform,让靠前的卡片覆盖靠后的卡片,同时施加缩放和位移。

  • 堆叠效果:卡片按顺序排列,靠后的卡片略微偏移,模拟一叠纸牌。

  • 动画触发:当用户滑动 UICollectionView 时,根据滑动偏移量动态调整卡片的 transform,实现平滑过渡。

适用场景

  • 社交媒体:Tinder 的用户卡片滑动、Instagram 故事预览。

  • 新闻应用:头条文章或推荐内容展示。

  • 任务管理:Trello 风格的任务卡片。

  • 电商:商品卡片快速浏览。

总结

卡片式布局通过 UICollectionView 和 transform 动画,能轻松实现重叠或堆叠效果,兼具美观和交互性。UICollectionViewLayout 提供最大灵活性,CompositionalLayout 适合现代应用,SwiftUI 则简化开发流程。根据项目需求选择合适的实现方式,能显著提升用户体验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值