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 是从拖拽开始到当前的位移,不是绝对位置。

拖拽排序的核心问题

拖拽排序需要解决:

  1. 哪个 item 被拖起来了
  2. 拖到哪个位置了
  3. 其他 item 如何让位
  4. 松手时如何插入到正确位置

关键数据模型:

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 手势处理的核心要点:

  1. DragGesture 的 translation 是累加的:每次 onChanged 时需要加上上次的 offset
  2. 用 @GestureState 管理临时状态:拖拽激活状态等,不需要持久化
  3. 同时响应多个手势用 .simultaneously:但要注意优先级
  4. 滚动视图里的拖拽用 .highPriorityGesture:防止手势冲突
  5. 放手动画用 .animation(_:value:):drag 状态解除时触发动画

拖拽排序的实现本身不复杂,关键是处理好:激活时机、位置跟踪、动画过渡这三个环节。

最后更新 4/20/2026, 6:02:32 AM