跳到主要内容

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

鱼雪

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

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

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

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

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

  • 如何使用SwiftUI的手势动画来实现卡片的拖拽与滑动
  • 如何在拖拽距离超过一定阈值时,实现卡片“抛出”效果,并切换至下一张卡片;
  • 如何在卡片上显示“喜欢”或“不喜欢”等提示icon
  • 如何使用ZStackForEach以及多种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()
}
}

顶部菜单栏中有三个图标,通过 HStackSpacer() 分布在水平方向的三端。 底部菜单栏 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预览中分别查看三种视图:ContentViewTopBarMenuBottomBarMenu


组合主界面与卡片堆叠

现在来看看 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!