概覽
自從 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 的簡單性體現在如下幾個方面:
- 由于 SwiftUI 描述性的特質,我們可以徹底丟棄故事板用簡潔的代碼去構建 Grid 自身布局和 Cell 的界面;
- 現成的 SwiftUI 原生拖放視圖修改器,讓簡潔更進一步;
- 由狀態驅動的數據源在改變時能夠“變化自如”,各種動畫效果應用起來更是得心應手;
- 可以非常方便的更改拖動中 Cell 的外觀;
看到這里,小伙伴們是否有些怦然心動了呢??
心動不如行動,下面就且看我們如何用 SwiftUI 來簡化 UIKit 中“笨拙”的實現!
3. 設置導出類型標識符
首先,新建一個 SwiftUI 項目,進入 Xcode 項目中 TARGET 的 info 選項窗口,展開底部的 Exported Type Identifiers 面板,在其中新建一個 Identifier 為 com.hopy.panda.com.ITEM 的導出類型:
大家可以自由選擇上面 Identifier 對應的字符串標識,并沒有特定要求。
在這里,新建一個導出類型的目的是防止 App 在運行時出現所需類型未導出的警告。
實際上,如果只是要實現單個 App 中的拖放,也可以對此“不聞不問”。
4. 創建數據模型
接著,我們創建 Item 數據模型:
import UniformTypeIdentifiersextension UTType {static var item: UTType = .init(exportedAs: "com.hopy.panda.com.ITEM")
}struct Item: Identifiable, Hashable, Transferable, Codable{var id = UUID()var title: Stringstatic 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: "🥸"),]}()
}
在上面的代碼中,我們做了這樣幾件事:
- 導入 UniformTypeIdentifiers 框架;
- 為我們的 Item 擴展 UTType 類型;
- 讓 Item 類型遵守 Transferable 和 Codable 協議;
5. 強大的 Drag&Drop 視圖修改器
在數據模型就緒之后,我們可以來打造 App 界面了。
首先是最簡單的 Item 導航目標視圖 DetailView:
struct DetailView: View {let item: Itemvar body: some View {Text(item.title).font(.system(size: 55, weight: .bold, design: .rounded))}
}
然后,是我們期待已久的主視圖 ContentView:
struct ContentView: View {@State var items = Item.previewprivate 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 inNavigationLink(value: item) {itemView(item: item)}}}}.padding(.horizontal).edgesIgnoringSafeArea(.bottom)}}
}
運行效果如下圖所示:
接著,我們來實現核心拖放功能。所幸的是,SwiftUI 早已為我們打點好了一切!
在 SwiftUI 中,對于拖動功能我們有 draggable(_:preview:) 修改器方法:
而對于放置功能,同樣有 dropDestination(for:action:isTargeted:) 修改器為我們排憂解難:
有了上述兩者的合璧,我們即可“利劍出鞘,無堅不摧”!
下面,我們先為 ContentView 中添加拖放交互所需的狀態:
@State var draggingItem: Item?
@State var draggingOverItem: Item?
現在,在 Grid 中的每個 Cell 上附著我們的拖放視圖修改器:
itemView(item: item).draggable(item) {itemView(item: item).onAppear {draggingItem = item}}.dropDestination(for: Item.self, action: { _, _ inguard 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 = nildraggingOverItem = nilreturn true}, isTargeted: { entered inguard entered, item != draggingItem else { return }draggingOverItem = item})
上面代碼的功能很簡單:我們在拖動那一剎那獲取源 Item,在拖動中即時更新目標 Item,最后在拖動結束時交換它們。
My God!怎能如此簡單,竟引無數禿頭碼農門競折腰、齊掉發!
注意,目前拖放功能在 Xcode (15.2)預覽中執行起來有 Bug,大家可以在模擬器或真機中測試上述代碼。
6. 調整拖放的視覺效果
雖然我們已經實現了博文開頭的預定目標,不過我們還可以百尺竿頭更進一步。
利用 SwiftUI 的簡潔性,我們希望當用戶拖動 Item 時應該體現出有所不同的視覺效果:Grid 中對應的 Cell 能夠略微縮小、變淡;
我們照例還是先在 ContentView 中增加一個用來表示當前拖動是否包含對應目標 Item 的 wasEntered 狀態,并新建一個 needApplyDragingEffect() 方法來檢查是否要添加額外的視覺效果:
@State var wasEntered = falseprivate func needApplyDragingEffect(_ item: Item) -> Bool {draggingOverItem == item && wasEntered
}
接著,我們將 ContentView 中的 body 代碼修改為如下形式:
var body: some View {NavigationStack {ScrollView(showsIndicators: false) {LazyVGrid(columns: cols) {ForEach(items) { item inNavigationLink(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: { _, _ inguard 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 = nildraggingOverItem = nilwasEntered = falsereturn true}, isTargeted: { entered inwithAnimation(.bouncy) {wasEntered = entereddraggingOverItem = item}})}}}}}
}
在上面代碼中,當拖動著的視圖凌駕于任意 Cell 的上空時,我們為對應的 Cell 添加了視覺特效:
至此,我們用 SwiftUI 簡潔的代碼邏輯完成了 UIKit 中相同的功能,我們還更進一步為拖放添加了些許取悅用戶的視覺效果,棒棒噠!💯
總結
在本篇博文中,我們討論了在 SwiftUI 中如何為集合視圖(Grid)添加拖放交換其 Cell 的功能,小伙伴們可以從代碼中真正體會到 SwiftUI 的簡潔之美!
感謝觀賞,再會!😎