SwiftUI 集合视图(Grid)拖放交换 Cell 的极简实现

概览

自从 SwiftUI 横空出世那天起,小伙伴们都感受到了它惊人的简单与便捷。而在本课中,我们将会用一个小"栗子"更直观的让大家体验到它无与伦比简洁的描述性特质:

如上图所示,我们在 SwiftUI 中实现了 Grid 中拖放交换 Cell 的功能,它是如何做到又快又好的呢?

在本篇博文中,您将学到如下内容:

  1. UIKit 中类似实现的思路
  2. SwiftUI 的世界:超乎寻常的简单!
  3. 设置导出类型标识符
  4. 创建数据模型
  5. 强大的 Drag&Drop 视图修改器
  6. 调整拖放的视觉效果

相信学完本课后,小伙伴们对 SwiftUI 中 Grid 视图以及拖放行为的内功修为都能够愈发精进!

那还等什么呢?Let's go!!!;)


1. UIKit 中类似实现的思路

在探索 SwiftUI 的解决方案之前,我们先来看看 UIKit 中完成类似实现要做些神马。

首先,SwiftUI 集合视图 Grid 在 UIKit 中的"对应物"是 UICollectionView。为了使 UICollectionView 履行拖放的责任和义务,我们需要让其视图控制器遵守 UICollectionViewDragDelegate 和 UICollectionViewDropDelegate 协议。

接着,选择实现上面两个协议中的若干方法。一般的,我们需要:

  • 在拖动开始时获取源 Item:通过 collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) 方法来实现;
  • 在拖动进行中实时更新目标 Item:通过 collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) 方法来实现;
  • 在拖动完成后交换源和目标 Item:通过 collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) 方法来实现;

最后,为了完成交换操作我们需要更改 UICollectionView 的数据源,并且亲自动手处理界面的更新。

UIKit 在 UICollectionView 中拖放交换 Cell 视图的极简实现 这篇博文中,我们详细讨论了如何在 UIKit 的 UICollectionView 视图中实现 Cell 拖放交换,感兴趣的小伙伴们可以猛戳进一步观赏。

2. SwiftUI 的世界:超乎寻常的简单!

看到 UIKit 中这么"一大坨"实现概要,小伙伴们是否有点欲哭无泪的赶脚。

现在,欢迎大家来到 SwiftUI 的世界!

在 SwiftUI 中实现与 UIKit 中同样的功能,不能说轻而易举,也只能是毫不费力!

总的来说 SwiftUI 的简单性体现在如下几个方面:

  1. 由于 SwiftUI 描述性的特质,我们可以彻底丢弃故事板用简洁的代码去构建 Grid 自身布局和 Cell 的界面;
  2. 现成的 SwiftUI 原生拖放视图修改器,让简洁更进一步;
  3. 由状态驱动的数据源在改变时能够"变化自如",各种动画效果应用起来更是得心应手;
  4. 可以非常方便的更改拖动中 Cell 的外观;

看到这里,小伙伴们是否有些怦然心动了呢?❤

心动不如行动,下面就且看我们如何用 SwiftUI 来简化 UIKit 中"笨拙"的实现!

3. 设置导出类型标识符

首先,新建一个 SwiftUI 项目,进入 Xcode 项目中 TARGET 的 info 选项窗口,展开底部的 Exported Type Identifiers 面板,在其中新建一个 Identifier 为 com.hopy.panda.com.ITEM 的导出类型:

大家可以自由选择上面 Identifier 对应的字符串标识,并没有特定要求。

在这里,新建一个导出类型的目的是防止 App 在运行时出现所需类型未导出的警告。

实际上,如果只是要实现单个 App 中的拖放,也可以对此"不闻不问"。

4. 创建数据模型

接着,我们创建 Item 数据模型:

swift 复制代码
import UniformTypeIdentifiers

extension UTType {
    static var item: UTType = .init(exportedAs: "com.hopy.panda.com.ITEM")
}

struct Item: Identifiable, Hashable, Transferable, Codable{
    var id = UUID()
    var title: String
    
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .item)
    }
    
    static var preview: [Item] = {
        [
            Item(title: "Apple"), Item(title: "Banana"),
            Item(title: "Cherry"), Item(title: "Date"),
            Item(title: "Dragon"), Item(title: "Sheep"),
            Item(title: "V-Malicious"), Item(title: "X-Code"),
            Item(title: "GreatWall"), Item(title: "TaiTan"),
            Item(title: "Milk"), Item(title: "🥸"),
        ]
    }()
}

在上面的代码中,我们做了这样几件事:

  1. 导入 UniformTypeIdentifiers 框架;
  2. 为我们的 Item 扩展 UTType 类型;
  3. 让 Item 类型遵守 Transferable 和 Codable 协议;

5. 强大的 Drag&Drop 视图修改器

在数据模型就绪之后,我们可以来打造 App 界面了。

首先是最简单的 Item 导航目标视图 DetailView:

swift 复制代码
struct DetailView: View {
    
    let item: Item
    
    var body: some View {
        Text(item.title)
            .font(.system(size: 55, weight: .bold, design: .rounded))
    }
}

然后,是我们期待已久的主视图 ContentView:

swift 复制代码
struct ContentView: View {
    
    @State var items = Item.preview
    private let cols = [GridItem(.flexible()), GridItem(.flexible())]
    
    @ViewBuilder func itemView(item: Item) -> some View {
        ZStack {
            Rectangle()
                .frame(width: 170, height: 170)
                .foregroundStyle(.pink.gradient)
            
            Text(item.title)
                .font(.title2.weight(.bold))
                .foregroundStyle(.white)
        }
        .clipShape(RoundedRectangle(cornerRadius: 11))
    }
    
    var body: some View {
        NavigationStack {
            ScrollView(showsIndicators: false) {
                LazyVGrid(columns: cols) {
                    ForEach(items) { item in
                        NavigationLink(value: item) {
                            itemView(item: item)
                        }
                    }
                }
            }
            .padding(.horizontal)
            .edgesIgnoringSafeArea(.bottom)
        }
    }
}

运行效果如下图所示:

接着,我们来实现核心拖放功能。所幸的是,SwiftUI 早已为我们打点好了一切!

在 SwiftUI 中,对于拖动功能我们有 draggable(_:preview:) 修改器方法:

而对于放置功能,同样有 dropDestination(for:action:isTargeted:) 修改器为我们排忧解难:

有了上述两者的合璧,我们即可"利剑出鞘,无坚不摧"!

下面,我们先为 ContentView 中添加拖放交互所需的状态:

swift 复制代码
@State var draggingItem: Item?
@State var draggingOverItem: Item?

现在,在 Grid 中的每个 Cell 上附着我们的拖放视图修改器:

swift 复制代码
itemView(item: item)
    .draggable(item) {
        itemView(item: item)
            .onAppear {
                draggingItem = item
            }
    }
    .dropDestination(for: Item.self, action: { _, _ in
        guard let srcItem = draggingItem, let destItem = draggingOverItem else { return false }
        
        let srcIdx = items.firstIndex(of: srcItem)!
        let destIdx = items.firstIndex(of: destItem)!
        
        withAnimation(.snappy) {
            items.swapAt(srcIdx, destIdx)
        }
        
        draggingItem = nil
        draggingOverItem = nil
        return true
    }, isTargeted: { entered in
        guard entered, item != draggingItem else { return }
        draggingOverItem = item
    })

上面代码的功能很简单:我们在拖动那一刹那获取源 Item,在拖动中即时更新目标 Item,最后在拖动结束时交换它们。

My God!怎能如此简单,竟引无数秃头码农门竞折腰、齐掉发!


注意,目前拖放功能在 Xcode (15.2)预览中执行起来有 Bug,大家可以在模拟器或真机中测试上述代码。


6. 调整拖放的视觉效果

虽然我们已经实现了博文开头的预定目标,不过我们还可以百尺竿头更进一步。

利用 SwiftUI 的简洁性,我们希望当用户拖动 Item 时应该体现出有所不同的视觉效果:Grid 中对应的 Cell 能够略微缩小、变淡;

我们照例还是先在 ContentView 中增加一个用来表示当前拖动是否包含对应目标 Item 的 wasEntered 状态,并新建一个 needApplyDragingEffect() 方法来检查是否要添加额外的视觉效果:

swift 复制代码
@State var wasEntered = false

private func needApplyDragingEffect(_ item: Item) -> Bool {
    draggingOverItem == item && wasEntered
}

接着,我们将 ContentView 中的 body 代码修改为如下形式:

swift 复制代码
var body: some View {
    NavigationStack {
        ScrollView(showsIndicators: false) {
            LazyVGrid(columns: cols) {
                ForEach(items) { item in
                    NavigationLink(value: item) {
                        itemView(item: item)
                            .opacity(needApplyDragingEffect(item) ? 0.5 : 1.0)
                            .scaleEffect(x: needApplyDragingEffect(item) ? 0.9 : 1.0, y: needApplyDragingEffect(item) ? 0.9 : 1.0)
                            .draggable(item) {...}
                            .dropDestination(for: Item.self, action: { _, _ in
                                
                                guard let srcItem = draggingItem, let destItem = draggingOverItem else { return false }
                                
                                let srcIdx = items.firstIndex(of: srcItem)!
                                let destIdx = items.firstIndex(of: destItem)!
                                
                                withAnimation(.snappy) {
                                    items.swapAt(srcIdx, destIdx)
                                }
                                
                                draggingItem = nil
                                draggingOverItem = nil
                                wasEntered = false
                                return true
                            }, isTargeted: { entered in
                                withAnimation(.bouncy) {
                                    wasEntered = entered
                                    draggingOverItem = item
                                }
                            })
                    }
                }
            }
        }
    }
}

在上面代码中,当拖动着的视图凌驾于任意 Cell 的上空时,我们为对应的 Cell 添加了视觉特效:

至此,我们用 SwiftUI 简洁的代码逻辑完成了 UIKit 中相同的功能,我们还更进一步为拖放添加了些许取悦用户的视觉效果,棒棒哒!💯

总结

在本篇博文中,我们讨论了在 SwiftUI 中如何为集合视图(Grid)添加拖放交换其 Cell 的功能,小伙伴们可以从代码中真正体会到 SwiftUI 的简洁之美!

感谢观赏,再会!8-)

相关推荐
HarderCoder18 小时前
Swift 中的不透明类型与装箱协议类型:概念、区别与实践
swift
HarderCoder18 小时前
Swift 泛型深度指南 ——从“交换两个值”到“通用容器”的代码复用之路
swift
东坡肘子19 小时前
惊险但幸运,两次!| 肘子的 Swift 周报 #0109
人工智能·swiftui·swift
胖虎120 小时前
Swift项目生成Framework流程以及与OC的区别
framework·swift·1024程序员节·swift framework
iOS阿玮1 天前
别问了,我自己的产品也卡审了44个小时!
uni-app·app·apple
songgeb1 天前
What Auto Layout Doesn’t Allow
swift
汉秋2 天前
SwiftUI动画之使用 navigationTransition(.zoom) 实现 Hero 动画
ios·swiftui
YGGP2 天前
【Swift】LeetCode 240.搜索二维矩阵 II
swift
YGGP2 天前
【Swift】LeetCode 73. 矩阵置零
swift
非专业程序员Ping3 天前
HarfBuzz 实战:五大核心API 实例详解【附iOS/Swift实战示例】
android·ios·swift