SwiftUI 手势处理:我如何实现可拖拽排序的列表
SwiftUI 手势处理:我如何实现可拖拽排序的列表
App 需要一个支持拖拽排序的任务列表。最早想用系统的 .onDrag + .onDrop,但发现 iOS 的拖放 API 对列表排序的支持有限,最终自己实现了一套基于 DragGesture 的方案。
基础:DragGesture 的工作原理
DragGesture 有三个阶段:
struct DragGestureView: View {
@State private var offset: CGSize = .zero
@State private var isDragging = false
var body: some View {
Rectangle()
.fill(isDragging ? Color.blue : Color.gray)
.frame(width: 100, height: 100)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
offset = .zero
isDragging = false
}
)
}
}
value.translation 是从拖拽开始到当前的位移,不是绝对位置。
拖拽排序的核心问题
拖拽排序需要解决:
- 哪个 item 被拖起来了
- 拖到哪个位置了
- 其他 item 如何让位
- 松手时如何插入到正确位置
关键数据模型:
struct SortableItem: Identifiable, Equatable {
let id: String
var title: String
var order: Int
static func == (lhs: SortableItem, rhs: SortableItem) -> Bool {
lhs.id == rhs.id
}
}
完整实现:带占位动画的拖拽排序
struct DraggableListView: View {
@State private var items: [SortableItem] = []
@State private var draggingItem: SortableItem?
@State private var dragOffset: CGSize = .zero
@State private var currentIndex: Int?
var body: some View {
List {
ForEach(items) { item in
ItemRow(
item: item,
isDragging: draggingItem?.id == item.id
)
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ItemPositionKey.self,
value: [item.id: geometry.frame(in: .named("list"))]
)
}
)
}
.onMove(perform: move)
}
.coordinateSpace(name: "list")
.onPreferenceChange(ItemPositionKey.self) { positions in
updateDropPosition(for: positions)
}
.gesture(dragGesture)
}
private var dragGesture: some Gesture {
LongPressGesture(minimumDuration: 0.3)
.sequenced(before: DragGesture())
.onChanged { value in
switch value {
case .first(true):
// 长按激活
break
case .second(true, let drag):
if let drag {
dragOffset = drag.translation
// 通知其他 item 移动
}
default:
break
}
}
.onEnded { value in
// 放置 item
if let targetIndex = currentIndex,
let dragging = draggingItem {
move(from: items.firstIndex(where: { $0.id == dragging.id }) ?? 0,
to: targetIndex)
}
draggingItem = nil
dragOffset = .zero
currentIndex = nil
}
}
private func updateDropPosition(for positions: [String: CGRect]) {
guard let dragging = draggingItem else { return }
let dragFrame = positions[dragging.id] ?? return
// 找到当前拖拽位置对应的 index
let centerY = dragFrame.midY + dragOffset.height
currentIndex = positions.first { _, frame in
centerY >= frame.minY && centerY <= frame.maxY
}.map { items.firstIndex(where: { $0.id == $0.key }) }
}
}
struct ItemRow: View {
let item: SortableItem
let isDragging: Bool
var body: some View {
HStack {
Image(systemName: "line.3.horizontal")
.foregroundStyle(.secondary)
Text(item.title)
}
.padding(.vertical, 8)
.opacity(isDragging ? 0.5 : 1)
.scaleEffect(isDragging ? 1.05 : 1)
.animation(.spring(response: 0.3), value: isDragging)
}
}
struct ItemPositionKey: PreferenceKey {
static var defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
一个简化版本:基于 List 的 onMove
如果不需要自定义动画,用系统的 onMove 更简洁:
struct SimpleSortableList: View {
@State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove(perform: move)
}
.environment(\.editMode, .constant(.active))
}
private func move(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)
}
}
但这个版本的问题是:它依赖系统的 edit mode,用户需要先点击编辑按钮。
实战:自定义长按激活的拖拽排序
实际项目里通常需要"长按激活拖拽"而不是"编辑模式下的拖拽":
struct TouchDraggableList: View {
@State private var items: [TaskItem] = []
@GestureState private var dragState = DragState.inactive
var body: some View {
List {
ForEach(items) { item in
TaskRow(item: item, isDragging: dragState.isDragging && dragState.item?.id == item.id)
.opacity(dragState.isDragging && dragState.item?.id == item.id ? 0.5 : 1)
.offset(dragState.isDragging && dragState.item?.id == item.id ? dragState.translation : .zero)
}
}
.gesture(longPressGesture)
}
private var longPressGesture: some Gesture {
LongPressGesture(minimumDuration: 0.5)
.updating($dragState) { value, state, _ in
if value {
state = DragState.dragging(item: items.first)
}
}
.simultaneously(with: dragGesture)
}
private var dragGesture: some Gesture {
DragGesture()
.updating($dragState) { value, state, _ in
if case .dragging = state {
state = DragState.dragging(item: state.item)
}
}
.onEnded { value in
// 处理放置
}
}
}
enum DragState {
case inactive
case pressing
case dragging(item: TaskItem?)
var isDragging: Bool {
if case .dragging = self { return true }
return false
}
var translation: CGSize {
if case .dragging(_, let t) = self { return t }
return .zero
}
}
手势冲突的处理
同一个视图上可能有多个手势,需要决定优先级:
struct ScrollViewWithTap: View {
@State private var showDetail = false
var body: some View {
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemCard(item: item)
.onTapGesture {
showDetail = true
}
}
}
}
.fullScreenCover(isPresented: $showDetail) {
DetailView()
}
}
}
但如果 ItemCard 里有 onLongPressGesture,滚动和长按可能冲突。用 .highPriorityGesture 处理:
struct ItemCard: View {
let item: Item
@State private var isPressed = false
var body: some View {
VStack {
Text(item.title)
}
.frame(height: 100)
.background(isPressed ? Color.blue.opacity(0.2) : Color.clear)
.highPriorityGesture(
LongPressGesture(minimumDuration: 0.5)
.onChanged { _ in isPressed = true }
.onEnded { _ in isPressed = false }
)
}
}
highPriorityGesture 会在 ScrollView 的滑动手势之前优先响应。
手势与动画的配合
拖拽时会有"跟随手指"的即时响应,放手时需要动画过渡:
struct AnimatedDragView: View {
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
@GestureState private var isDragging = false
var body: some View {
Rectangle()
.frame(width: 100, height: 100)
.offset(x: offset.width, y: offset.height)
.animation(isDragging ? nil : .spring(response: 0.3), value: offset)
.gesture(
DragGesture()
.updating($isDragging) { _, state, _ in
state = true
}
.onChanged { value in
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { value in
lastOffset = offset
isDragging = false
}
)
}
}
关键点:isDragging 时关闭动画(用 nil),拖拽结束后用 spring 动画回到目标位置。
总结
SwiftUI 手势处理的核心要点:
- DragGesture 的 translation 是累加的:每次 onChanged 时需要加上上次的 offset
- 用 @GestureState 管理临时状态:拖拽激活状态等,不需要持久化
- 同时响应多个手势用
.simultaneously:但要注意优先级 - 滚动视图里的拖拽用
.highPriorityGesture:防止手势冲突 - 放手动画用
.animation(_:value:):drag 状态解除时触发动画
拖拽排序的实现本身不复杂,关键是处理好:激活时机、位置跟踪、动画过渡这三个环节。
