WWDC Quick Look 💓 By SwiftGGTeam
Code-along: Build powerful drag and drop in SwiftUI

Code-along: Build powerful drag and drop in SwiftUI

观看原视频

Highlight

SwiftUI 新增 reorderablereorderContainerdragContainerdragConfiguration 四个 API,让跨容器重排、批量拖拽和意图控制不再需要桥接 UIKit,纯 SwiftUI 即可实现纸牌游戏级别的复杂拖放交互。

核心内容

从 List.onMove 到跨容器重排

以前 SwiftUI 里做拖放,列表内移动用 List.onMove 就能搞定。一旦涉及多列看板、网格布局,或者像纸牌游戏这种跨容器的复杂重排,draggabledropDestination 就显得力不从心。开发者得自己算坐标、维护状态机,甚至桥接 UIKit。

Apple 这次推出了 reorderContainerreorderable,把跨容器重排的脏活累活全包了。

02:11)演讲者以纸牌游戏为例,展示了 reorderable 的基本用法。给 ForEach 里的子视图加上 .reorderable(),再给外层容器加上 .reorderContainer(for: CardValue.self),系统就会自动处理占位符、避让动画和位置计算。

03:54)当拖拽一个视图时,它从视图层级中被提起,原位置留下一个空占位符。拖动过程中,其他元素会自动让出空间,占位符实时更新以反映放置位置。松手后,卡片移动到新位置。

多牌堆场景:用 collectionID 区分容器

04:38)纸牌游戏有多个牌堆,需要让卡片在牌堆之间自由移动。reorderContainer 支持通过 collectionID 参数标识不同的子容器。

GameViewHStack 上声明 reorderContainer,指定 CardValue 为元素类型、Card.Group 为容器类型。然后在 PileView 内部,给可拖拽的卡片加上 reorderable(collectionID:),每个牌堆用唯一的 collectionID 标识。

05:58)这里有个实用技巧:背面朝上的牌不应该能被拖动。做法是把牌堆分成两个 ForEach——第一个遍历背面朝上的牌,不加 reorderable;第二个遍历正面朝上的牌,加上 .reorderable(collectionID:)。这样背面朝上的牌自然无法被拖拽,无需额外的状态判断。

批量拖拽:一次拖动多张牌

06:57)纸牌游戏的核心玩法之一是拖动一张牌时,它上面叠着的牌要一起跟着走。这需要用到 dragContainer API。

reorderContainer 隐式提供了 dragContainerdropDestination 能力,但你可以显式声明自己的 dragContainer 来定制行为。在闭包里,你拿到被拖拽元素的 ID,返回一组要一起拖动的元素。

07:50)演讲者在 GameView 上添加了 dragContainer(for: CardValue.self),在闭包里调用 game.cardStack(startingAt: cardID) 获取从指定牌开始的所有叠牌。拖动四张梅花时,它上面的方块三也会一起被提起。

自定义拖拽预览样式

08:45)多张牌一起拖动时,默认预览会把它们堆成一摞。你可以用 dragPreviewsFormation 自定义这个外观,可选值包括 .stack.pile.list 等。

演讲者选择了 .stack,因为纸牌堆叠的视觉效果最符合游戏直觉。同时,为了让拖拽过程中预览样式保持一致,还在根布局上加了 .dropPreviewsFormation(.stack),这样牌在悬停于目标区域上方时也不会改变外观。

控制数据传输意图:Move 还是 Copy

10:05)SwiftUI 的拖放默认按 Copy 处理。在纸牌游戏里,从牌库拖一张牌到牌堆,应该是移动(Move)而不是复制。这需要用到新的 Drag Configuration API。

11:40)在牌库的 RemainderView 中,给当前正面朝上的牌加上 .dragConfiguration(DragConfiguration(allowMove: true)),声明传输意图为移动。

12:05)然后在 GameViewreorderContainer 下方添加 dropDestination,接收 CardValue 类型的新卡片。在 dropConfiguration 闭包里做三件事:

  1. 计算拖拽位置对应的牌堆索引
  2. 检查当前操作是否包含 .move
  3. 用游戏逻辑验证这次移动是否合法

如果移动不合法(比如把方块 Q 放到方块 7 上面),返回 .forbidden,牌会自动弹回原位。

详细内容

基础重排:Preview 中的快速验证

03:40)在正式集成到游戏之前,可以先在 Preview 里验证重排效果:

#Preview {
    @Previewable @State var cards = [
        CardValue(rank: .ace, suit: .clubs),
        CardValue(rank: .ace, suit: .diamonds),
        CardValue(rank: .ace, suit: .hearts),
        CardValue(rank: .ace, suit: .spades)
    ]

    HStack {
        ForEach(cards) { card in
            CardFaceView(card: card)
        }
        .reorderable()
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .reorderContainer(for: CardValue.self) { difference in
        cards.apply(difference: difference)
    }
    .padding()
    .background(.green.gradient)
}

关键点:

  • .reorderable() 加在 ForEach 上,标记这个集合里的元素可以被重排
  • .reorderContainer(for:) 加在容器上,声明这个区域是一个重排沙盒
  • 闭包里的 differenceCollectionDifference<CardValue>,调用 apply(difference:) 直接更新数组
  • CardValue 必须遵循 Identifiable 且 ID 全局唯一

跨牌堆重排:GameView 中的完整配置

04:40)在游戏主视图中,把所有牌堆纳入同一个重排容器:

struct GameView: View {
    var game: Game

    var body: some View {
        GeometryReader { proxy in
            let spacing: CGFloat = 10
            let cardWidth = (proxy.size.width - 6 * spacing) / 7
            VStack {
                HStack(alignment: .top, spacing: spacing) {
                    Group {
                        RemainderView(game: game)
                        CardBackView()
                            .hidden()
                        ForEach(CardValue.Suit.allCases) { suit in
                            DestinationView(game: game, suit: suit)
                        }
                    }
                    .frame(width: cardWidth)
                }
                .padding(.bottom, 20)
                HStack(alignment: .top, spacing: spacing) {
                    ForEach(0..<7) { index in
                        PileView(game: game, index: index)
                            .frame(width: cardWidth)
                    }
                }
                .frame(maxHeight: .infinity, alignment: .top)
                .reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
                    game.moveCards(difference: difference)
                }
            }
        }
        .padding()
    }
}

关键点:

  • reorderContainer 有两个泛型参数:元素类型 CardValue 和容器类型 Card.Group
  • Card.Group 用来唯一标识每个牌堆,比如 .pile(0).pile(1)
  • 闭包里的 difference 包含了跨牌堆的移动信息,game.moveCards(difference:) 负责更新数据模型

牌堆视图:区分可拖拽和不可拖拽元素

05:58)在 PileView 中,背面朝上的牌不能拖,正面朝上的牌可以拖:

struct PileView: View {
    var game: Game
    var index: Int
    @Query var cards: [Card]

    var body: some View {
        ZStack(alignment: .topLeading) {
            CardPlaceholderView()
            PileLayout {
                let index = firstFaceUpIndex
                ForEach(cards[..<index]) { card in
                    CardView(card: card)
                }
                ForEach(cards[index...], id: \.value) { card in
                    CardView(card: card)
                }
                .reorderable(collectionID: Card.Group.pile(index))
            }
        }
    }

    var firstFaceUpIndex: Int {
        cards.firstIndex { !$0.isFaceDown } ?? cards.endIndex
    }
}

关键点:

  • 两个 ForEach 分别处理背面朝上和正面朝上的牌
  • 只有第二个 ForEach 加了 .reorderable(collectionID:)
  • collectionID 必须是 Card.Group 类型,与 reorderContainer 声明的容器类型一致
  • 背面朝上的牌因为没有 reorderable,自然无法被拖动

批量拖拽:dragContainer 实现叠牌跟随

07:50)让拖动一张牌时,它上面的牌一起跟着走:

.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
    game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}

关键点:

  • dragContainer 必须声明在 reorderContainer 之后
  • 两个 modifier 使用相同的元素类型 CardValue
  • 闭包接收被拖拽的牌 ID,返回一组要一起拖动的 CardValue
  • game.cardStack(startingAt:) 根据游戏规则计算叠牌列表

自定义预览样式

08:45)控制多张牌拖动时的视觉呈现:

.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)

09:14)在根布局上加 dropPreviewsFormation,保持拖拽全程视觉一致:

VStack {
    // ... 游戏布局 ...
}
.dropPreviewsFormation(.stack)

关键点:

  • .dragPreviewsFormation(.stack) 让被拖动的牌以堆叠方式呈现
  • .dropPreviewsFormation(.stack) 让牌在悬停于目标上方时保持堆叠样式
  • 可选值包括 .stack.pile.list

从牌库拖牌到牌堆:完整的 Move 配置

11:40)牌库中的当前牌声明移动意图:

struct RemainderView: View {
    @Query var cards: [Card]
    var game: Game

    var body: some View {
        // ...
        ZStack {
            CardPlaceholderView()
            if let currentCard {
                CardFaceView(card: currentCard.value)
                    .draggable(containerItemID: currentCard.value)
                    .opacity(currentCard.value == hiddenCard ? 0 : 1)
            }
        }
        .dragContainer(for: CardValue.self) { cardID in
            [cardID]
        }
        .dragConfiguration(DragConfiguration(allowMove: true))
    }
}

关键点:

  • .draggable(containerItemID:) 声明这个视图可以被拖拽,并指定传输的数据
  • .dragConfiguration(DragConfiguration(allowMove: true)) 声明传输意图为移动
  • 牌库的 dragContainer 只返回单张牌 [cardID],因为牌库一次只出一张

12:05)在 GameView 中配置 Drop 目标,接收牌库拖来的牌并校验规则:

.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
    game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)
.dragConfiguration(DragConfiguration(allowMove: true))
.dropDestination(for: CardValue.self) { newCards, session in
    if let destination = session.reorderDestination(
        for: CardValue.self, in: Card.Group.self) {
        game.insertCards(newCards, to: destination)
    }
}
.dropConfiguration { session in
    let alignedX = session.location.x - 0.5 * spacing
    let pile = Int(alignedX / (cardWidth + spacing))
    let destination = ReorderDifference<CardValue, Card.Group>
        .Destination(position: .end, collectionID: .pile(pile))
    let allowed = session.suggestedOperations.contains(.move)
        && game.validateMove(session: session, destination: destination)
    let operation: DropOperation = allowed ? .move : .forbidden
    return DropConfiguration(operation: operation, destination: destination)
}

关键点:

  • .dropDestination(for:) 声明这个区域可以接收 CardValue 类型的拖拽数据
  • session.reorderDestination(for:in:)reorderContainer 获取目标位置信息
  • .dropConfiguration 是最终决策点,返回 DropConfiguration 决定操作类型和目标位置
  • session.location 获取当前拖拽位置,用于计算落在哪个牌堆
  • session.suggestedOperations.contains(.move) 检查源端是否提议移动
  • game.validateMove(session:destination:) 用游戏规则验证这次移动是否合法
  • 如果不合法,返回 .forbidden,牌自动弹回原位

核心启发

做一个支持拖拽排序的看板应用

reorderContainercollectionID 实现 Trello 风格的多列看板。每列是一个 collectionID,卡片在不同列之间拖拽时,系统自动计算 CollectionDifference。入口 API 是 reorderContainer(for:in:)reorderable(collectionID:)

做一个批量整理照片的相册应用

dragContainer 实现多选照片后批量拖拽。用户先点击选中多张照片,然后拖动其中任意一张,所有选中的照片一起跟着走。用 dragPreviewsFormation(.stack) 让预览以堆叠方式呈现,视觉上清晰表达”多张照片”的概念。

做一个支持规则校验的卡牌或棋盘游戏

dropConfiguration 在闭包里实现游戏规则校验。比如象棋里,拖拽棋子到非法位置时返回 .forbidden,棋子自动弹回。这比手动写动画和状态回滚简单得多,SwiftUI 自动处理”弹回”动画。

做一个文件管理器风格的侧边栏

dragConfiguration(DragConfiguration(allowMove: true)) 实现文件夹之间的文件移动。源端声明移动意图,目标端用 dropConfiguration 做最终裁决。如果目标文件夹已满或权限不足,返回 .forbidden

做一个支持跨列表重排的设置面板

把多个设置分组做成独立的 ListVStack,用 reorderContainer 把它们纳入同一个重排沙盒。用户可以把设置项从一个分组拖到另一个分组,数据模型通过 CollectionDifference 自动同步。

关联 Session

  • 272 - SwiftUI + AppKit/UIKit — 学习如何在 SwiftUI 中桥接 AppKit/UIKit 的拖放能力,与本 Session 的纯 SwiftUI 方案形成互补
  • 269 - SwiftUI — SwiftUI 整体新特性概览,了解拖放 API 在整个框架中的位置
  • 321 - Lazy stacks — 拖放场景常与大数据列表配合,Lazy stacks 的性能优化对流畅拖拽很关键
  • 278 - UIKit modernization — 如果需要在 UIKit 中实现类似能力,了解 UIKit 的现代化拖放 API
  • 370 - TextKit — 文本编辑场景中的拖放处理,与 SwiftUI 拖放 API 可以结合使用

评论

GitHub Issues · utterances