Rust in Everything
#SwiftUI#iOS#Swift

使用SwiftUI手势和动画构建Tinder风格的卡片交互界面

上一章节中, 我们介绍了SwiftUI中的手势识别器,并使用 LongPressGesture 和 DragGesture 实现了简单的手势交互。

本章节中,我们将继续探索SwiftUI中的动画与手势, 并使用 LongPressGesture 和 DragGesture 来实现一个Tinder风格的卡片滑动UI。

在移动应用中,左右滑动卡片已成为一种流行且直观的交互形式。 Tinder最早将这种卡片滑动模式引入大众视野,用户可以向右滑表示喜爱(Like), 向左滑表示不喜欢(Dislike)。

在本篇教程中,我们将通过SwiftUI来实现一个类似Tinder的滑动卡片UI, 重点聚焦手势与动画的使用,而非构建完整的App逻辑。

通过学习本示例,你将掌握以下内容:

  • 如何使用SwiftUI的手势与动画来实现卡片的拖拽与滑动;
  • 如何在拖拽距离超过一定阈值时,实现卡片“抛出”效果,并切换至下一张卡片;
  • 如何在卡片上显示“喜欢”或“不喜欢”等提示icon;
  • 如何使用ZStack、ForEach以及多种SwiftUI修饰器完善Tinder风格的交互体验。

目录

  1. 项目准备
  2. 构建卡片视图(CardView)与菜单栏
  3. 组合主界面与卡片堆叠
  4. 实现滑动手势与动画效果
  5. 显示图标与移除插入卡片逻辑
  6. 总结

项目准备

在开始实现Tinder风格的滑动卡片前,我们先进行一些数据与模型的准备。 本示例中,我们会创建一个 Trip 模型用来表示旅行目的地及对应照片。 文件 Trip.swift 内容如下所示:

struct Trip {
    var destination: String
    var image: String
}

#if DEBUG
var trips = [
    Trip(destination: "Yosemite, USA", image: "yosemite-usa"),
    Trip(destination: "Venice, Italy", image: "venice-italy"),
    Trip(destination: "Hong Kong", image: "hong-kong"),
    Trip(destination: "Barcelona, Spain", image: "barcelona-spain"),
    Trip(destination: "Braies, Italy", image: "braies-italy"),
    Trip(destination: "Kanangra, Australia", image: "kanangra-australia"),
    Trip(destination: "Mount Currie, Canada", image: "mount-currie-canada"),
    Trip(destination: "Ohrid, Macedonia", image: "palawan-philippines"),
    Trip(destination: "Oia, Greece", image: "oia-greece"),
    Trip(destination: "Palawan, Philippines", image: "ohrid-macedonia"),
    Trip(destination: "Salerno, Italy", image: "salerno-italy"),
    Trip(destination: "Tokyo, Japan", image: "tokyo-japan"),
    Trip(destination: "West Vancouver, Canada", image: "west-vancouver-canada"),
    Trip(destination: "Singapore", image: "garden-by-bay-singapore"),
    Trip(destination: "Perhentian Islands, Malaysia", image: "perhentian-islands-malaysia")
]
#endif

在这里,我们为演示准备了若干景点图片及目的地名称,如果你想使用自己的素材, 只需替换资产目录中的图片并更新 Trip.swift 文件即可。


构建卡片视图和菜单栏

接下来,我们将界面拆分为三个部分:

  1. 顶部菜单栏(TopBarMenu)
  2. 卡片视图(CardView)
  3. 底部菜单栏(BottomBarMenu)

1. 创建卡片视图(CardView)

卡片视图将展示不同的旅游目的地照片和标题。 为使代码结构更清晰,我们将该视图放在一个单独的文件 CardView.swift 中, 并在其中声明相应的变量:

let image: String
let title: String

另外,为了能让SwiftUI使用 ForEach 来识别每张卡片(并区分它们的唯一性), 我们会让 CardView 遵守 Identifiable 协议,引入一个 id 属性:

struct CardView: View, Identifiable {
    let id = UUID()
    let image: String
    let title: String
    
    var body: some View {
        Image(image)
            .resizable()
            .scaledToFill()
            .frame(minWidth: 0, maxWidth: .infinity)
            .cornerRadius(10)
            .padding(.horizontal, 15)
            .overlay(alignment: .bottom) {
                VStack {
                    Text(title)
                        .font(.system(.headline, design: .rounded))
                        .fontWeight(.bold)
                        .padding(.horizontal, 30)
                        .padding(.vertical, 10)
                        .background(.white)
                        .cornerRadius(5)
                }
                .padding(.bottom, 20)
            }
    }
}

该卡片视图包括:

  • 图片:使用 .resizable() 和 .scaledToFill() 调整填充模式。
  • 文本遮罩:使用 .overlay 并借助 alignment: .bottom 将文字显示在图片底部,并附加了一个白色背景与圆角。

在预览时,需要在 #Preview 中为卡片视图提供示例数据:

#Preview {
    CardView(image: "yosemite-usa", title: "Yosemite, USA")
}

2. 构建顶部与底部菜单栏

在 ContentView.swift 中,我们先创建顶部菜单栏 TopBarMenu:

struct TopBarMenu: View {
    var body: some View {
        HStack {
            Image(systemName: "line.horizontal.3")
                .font(.system(size: 30))

            Spacer()

            Image(systemName: "mappin.and.ellipse")
                .font(.system(size: 35))

            Spacer()

            Image(systemName: "heart.circle.fill")
                .font(.system(size: 30))
        }
        .padding()
    }
}

顶部菜单栏中有三个图标,通过 HStack 和 Spacer() 分布在水平方向的三端。 底部菜单栏 BottomBarMenu 同样在 ContentView.swift 中实现:

struct BottomBarMenu: View {
    var body: some View {
        HStack {
            Image(systemName: "xmark")
                .font(.system(size: 30))
                .foregroundStyle(.black)
            
            Button {
                // Book the trip
            } label: {
                Text("BOOK IT NOW")
                    .font(.system(.subheadline, design: .rounded))
                    .bold()
                    .foregroundStyle(.white)
                    .padding(.horizontal, 35)
                    .padding(.vertical, 15)
                    .background(.black)
                    .cornerRadius(10)
            }
            .padding(.horizontal, 20)
            
            Image(systemName: "heart")
                .font(.system(size: 30))
                .foregroundStyle(.black)
        }
    }
}

在这里,“立即预订(BOOK IT NOW)” 按钮尚未实现具体功能,预留了对应的闭包。

如果想单独预览这两个菜单栏的布局,我们可以在 ContentView.swift 中添加以下 #Preview:

#Preview("TopBarMenu") {
    TopBarMenu()
}

#Preview("BottomBarMenu") {
    BottomBarMenu()
}

这样就能在Xcode预览中分别查看三种视图:ContentView、TopBarMenu 和 BottomBarMenu。


组合主界面与卡片堆叠

现在来看看 ContentView 的主体结构。最初的布局可如下:

struct ContentView: View {
    var body: some View {
        VStack {
            TopBarMenu()
            CardView(image: "yosemite-usa", title: "Yosemite, USA")
            Spacer(minLength: 20)
            BottomBarMenu()
        }
    }
}

使用ZStack实现卡片堆叠

Tinder风格的UI可以被看作是一组卡片重叠在一起。 当我们在最上方(topmost)的卡片滑动并移除后,才会看到下面的卡片。 要实现卡片堆叠,我们可以使用 ZStack 并在其内部使用 ForEach 迭代多个 CardView:

struct ContentView: View {
    var cardViews: [CardView] = {
        var views = [CardView]()
        for trip in trips {
            views.append(CardView(image: trip.image, title: trip.destination))
        }
        return views
    }()
    
    var body: some View {
        VStack {
            TopBarMenu()
            ZStack {
                ForEach(cardViews) { cardView in
                    cardView
                }
            }
            Spacer(minLength: 20)
            BottomBarMenu()
        }
    }
}

这样,一次性创建了所有卡片并将其叠放在ZStack中。但是我们发现两个问题:

  1. 顺序反了:列表第一个卡片(Yosemite, USA)应当在最顶层,却被放在最底层。
  2. 如果有成千上万张图片,这种一次性创建所有卡片的方式会浪费资源。

问题一的解决:我们可以用 zIndex 来指定堆叠次序,令顶层卡片的 zIndex 更大。

先写一个辅助函数,用于判断某个卡片是否为顶部卡片:

private func isTopCard(cardView: CardView) -> Bool {
    guard let index = cardViews.firstIndex(where: { $0.id == cardView.id }) else {
        return false
    }
    return index == 0
}

然后在 ForEach 中对每个卡片应用 .zIndex 修饰器:

ForEach(cardViews) { cardView in
    cardView
        .zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
}

这样就能保证索引为0的卡片(数组第一张)总是在最顶层。

问题二的解决:在实际开发中,我们不必一次性加载所有卡片。只要始终维持2张卡片:顶部卡片与底部卡片。每当顶部卡片被移除时,就动态添加一张新的卡片到堆叠中,以保证用户始终看到两张卡片堆叠的效果。

因此,我们将 cardViews 的初始化改为只加载前两张:

@State var cardViews: [CardView] = {
    var views = [CardView]()
    for index in 0..<2 {
        views.append(CardView(image: trips[index].image, title: trips[index].destination))
    }
    return views
}()

这样就可以节省性能开销,后续再按需求插入新的卡片以形成轮播效果。


实现滑动手势与动画效果

定义DragState

首先,我们需要能够追踪用户手势的各种状态(例如:未拖拽、按下、正在拖拽等)。 为此,可以定义一个枚举 DragState:

enum DragState {
    case inactive
    case pressing
    case dragging(translation: CGSize)
    
    var translation: CGSize {
        switch self {
        case .inactive, .pressing:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }
    
    var isDragging: Bool {
        switch self {
        case .dragging:
            return true
        case .pressing, .inactive:
            return false
        }
    }
    
    var isPressing: Bool {
        switch self {
        case .pressing, .dragging:
            return true
        case .inactive:
            return false
        }
    }
}

并在 ContentView 中声明一个 @GestureState 用于追踪当前的拖拽状态:

@GestureState private var dragState = DragState.inactive

应用于ZStack中的顶部卡片

在ZStack里的 ForEach 中,将手势应用到卡片视图上:

ZStack {
    ForEach(cardViews) { cardView in
        cardView
            .zIndex(self.isTopCard(cardView: cardView) ? 1 : 0)
            .offset(x: self.dragState.translation.width, 
                    y: self.dragState.translation.height)
            .scaleEffect(self.dragState.isDragging ? 0.95 : 1.0)
            .rotationEffect(Angle(degrees: Double(self.dragState.translation.width / 10)))
            .animation(.interpolatingSpring(stiffness: 180, damping: 100),
                       value: self.dragState.translation)
            .gesture(
                LongPressGesture(minimumDuration: 0.01)
                    .sequenced(before: DragGesture())
                    .updating(self.$dragState) { (value, state, transaction) in
                        switch value {
                        case .first(true):
                            state = .pressing
                        case .second(true, let drag):
                            state = .dragging(translation: drag?.translation ?? .zero)
                        default:
                            break
                        }
                    }
            )
    }
}

上述代码在拖拽时会:

  • 使用 offset 来移动卡片位置;
  • 使用 scaleEffect 来在拖拽状态下略微缩小卡片;
  • 使用 rotationEffect 来根据水平位移决定旋转角度;
  • 使用 .interpolatingSpring 动画来产生弹簧效果。

然而此时,你会发现下面的卡片也会一起移动或缩放。为只让顶部卡片生效,需要在相关修饰器中添加判断 isTopCard(cardView:):

.offset(x: self.isTopCard(cardView: cardView) ? self.dragState.translation.width : 0,
        y: self.isTopCard(cardView: cardView) ? self.dragState.translation.height : 0)
.scaleEffect(self.dragState.isDragging && self.isTopCard(cardView: cardView) ? 0.95 : 1.0)
.rotationEffect(
    Angle(degrees: self.isTopCard(cardView: cardView)
           ? Double(self.dragState.translation.width / 10) : 0)
)

这样就实现了只拖动顶层卡片的效果。

隐藏底部菜单栏

在用户开始拖拽卡片时,可以同时隐藏底部菜单栏以优化视觉体验。我们可以在底部菜单栏外增加一个 opacity 修饰器:

BottomBarMenu()
    .opacity(dragState.isDragging ? 0.0 : 1.0)
    .animation(.default, value: dragState.isDragging)

显示图标与移除插入卡片逻辑

接下来,当用户左右拖拽超过一定阈值后,可显示爱心(表示“喜欢”)或x号(表示“不喜欢”)标识, 然后移除顶层卡片并插入新卡片。

1. 显示心形或 xmark 图标

先定义一个拖拽阈值 dragThreshold,当水平位移超过该值,就显示相应的图标:

private let dragThreshold: CGFloat = 80.0

对 cardView 再使用 .overlay 来叠加图标:

.overlay {
    ZStack {
        Image(systemName: "x.circle")
            .foregroundColor(.white)
            .font(.system(size: 100))
            .opacity(
                self.dragState.translation.width < -self.dragThreshold 
                && self.isTopCard(cardView: cardView) ? 1.0 : 0
            )
        
        Image(systemName: "heart.circle")
            .foregroundColor(.white)
            .font(.system(size: 100))
            .opacity(
                self.dragState.translation.width > self.dragThreshold 
                && self.isTopCard(cardView: cardView) ? 1.0 : 0
            )
    }
}

当用户将卡片拖到左侧超过80点,就显示 x.circle,拖到右侧超过80点, 就显示 heart.circle。如果未达到阈值,则图标保持隐藏。

2. 移除顶层卡片并插入新卡片

为使界面在移除/插入卡片后自动刷新,我们将 cardViews 改为 @State 修饰, 并额外定义一个 lastIndex 来记录上一次加载的下标:

@State var cardViews: [CardView] = {
    var views = [CardView]()
    for index in 0..<2 {
        views.append(CardView(image: trips[index].image, title: trips[index].destination))
    }
    return views
}()

@State private var lastIndex = 1

实现一个名为 moveCard() 的函数,用于将顶层卡片移除并插入新的卡片:

private func moveCard() {
    cardViews.removeFirst()
    self.lastIndex += 1

    let trip = trips[lastIndex % trips.count]
    let newCardView = CardView(image: trip.image, title: trip.destination)
    cardViews.append(newCardView)
}

当卡片被丢弃时,我们会移除第一个卡片,并用下一个下标的图片创建新的卡片,插入到数组中。 若 lastIndex 超过 trips 的范围,就利用 取模运算 % 实现循环。

3. 在手势结束时判断是否丢弃卡片

在 .gesture 的 .onEnded 中加入对拖拽结果的判断。 当水平位移超过阈值时就调用 moveCard() 移除顶部卡片:

.gesture(
    LongPressGesture(minimumDuration: 0.01)
        .sequenced(before: DragGesture())
        .updating(self.$dragState) { (value, state, transaction) in
            switch value {
            case .first(true):
                state = .pressing
            case .second(true, let drag):
                state = .dragging(translation: drag?.translation ?? .zero)
            default:
                break
            }
        }
        .onEnded { (value) in
            guard case .second(true, let drag?) = value else {
                return
            }
            if drag.translation.width < -self.dragThreshold 
                || drag.translation.width > self.dragThreshold {
                // 超过阈值则移除并插入卡片
                self.moveCard()
            }
        }
)

4. 精细化动画:卡片消失的方向

我们希望卡片在被丢弃时能带有一个流畅的飞出动画,而非立刻消失。 可以通过 asymmetric transition 来区分插入和移除时的动画。

在文件末尾添加一个 AnyTransition 扩展,定义两种移除动画:

extension AnyTransition {
    static var trailingBottom: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .identity,
            removal: AnyTransition.move(edge: .trailing)
                     .combined(with: .move(edge: .bottom))
        )
    }
    static var leadingBottom: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .identity,
            removal: AnyTransition.move(edge: .leading)
                     .combined(with: .move(edge: .bottom))
        )
    }
}

并用一个状态属性 removalTransition 来记录当前移除卡片的动画类型:

@State private var removalTransition = AnyTransition.trailingBottom

在卡片视图上追加 .transition(self.removalTransition):

.transition(self.removalTransition)

最后,在拖拽进行过程中设置移除方向(左侧还是右侧):

.onChanged { (value) in
    guard case .second(true, let drag?) = value else {
        return
    }
    if drag.translation.width < -self.dragThreshold {
        self.removalTransition = .leadingBottom
    }
    if drag.translation.width > self.dragThreshold {
        self.removalTransition = .trailingBottom
    }
}

这样在用户拖拽超出阈值并释放时,卡片会依照拖拽方向并带有底部飞出的动画效果。 新卡片则直接插入而不带有淡入动画,从而营造Tinder式的快速切换体验。


总结

通过本教程,我们将SwiftUI中的手势与动画灵活组合,完成了一个Tinder风格的卡片滑动UI示例。

主要包含以下要点:

  • 使用 ZStack 叠加卡片,并借助 zIndex 控制顶层卡片。
  • 利用 @GestureState 和手势识别器(LongPressGesture + DragGesture)准确追踪手势状态。
  • 通过阈值判断(dragThreshold)来决定是否出现爱心或x图标,并实现卡片移除。
  • 应用对称或非对称的 transition 动画,让卡片移除时拥有更流畅的飞出过渡效果。
  • 借助只保持两张卡片的方式,提高资源使用效率,并通过循环策略 (lastIndex % trips.count) 不断给用户呈现新的内容。

你可以根据需要对动画、手势灵敏度甚至外观样式进行自定义,从而将这一示例扩展到更多真实的项目场景。 希望这篇教程能帮助你更好地理解和运用SwiftUI中的高级特性,为你的iOS项目带来更精彩的交互效果!

如果你对手势与动画的细节感兴趣,或想更深入地了解SwiftUI的架构与组件,可以参考本篇教程中提到的相关章节或官方文档。 祝你编码顺利,玩转SwiftUI!

评论区

登录后即可发表评论

加载评论中…

💬 关联讨论

发起讨论
加载中...