This content originally appeared on Level Up Coding - Medium and was authored by Itsuki
Honestly speaking, SwiftUI really lacks of document on how Drag and Drop for Table works. And the super buggy Xcode16 makes it even harder!
(I love Apple, Swift, anything Apple related and this is ONE OF those times I want to scream at Apple!)
But once you get it, everything starts looking super easy and you will (at least I did) start wondering why we spent hours looking for such a simple solution!
Also, for some reason, the same logic will not work when running on iPad!
Anyway, let’s get started!
Set Up
To make sure we are on the same page, let’s set up our project with a simple sortable, selectable, filterable pokemon table!
import SwiftUI
struct ContentView: View {
@State private var selections = Set<Pokemon.ID>()
@State private var sortOrder = [KeyPathComparator(\Pokemon.id, order: .reverse)]
@State private var searchText: String = ""
@State private var pokemons: [Pokemon] = Pokemon.pokemonList
var body: some View {
NavigationStack {
Table(of: Pokemon.self , selection: $selections, sortOrder: $sortOrder, columns: {
TableColumn("Index", value: \.id) { pokemon in
Text("No.\(pokemon.id)")
}
TableColumn("Name", value: \.name)
TableColumn("Height (dm)", value: \.height) { pokemon in
Text("\(pokemon.height)")
}
TableColumn("Weight (hg)", value: \.weight) { pokemon in
Text("\(pokemon.weight)")
}
TableColumn("Type", value: \.types, comparator: PokemonTypeComparator()) { pokemon in
Text("\(pokemon.types.map({ $0.rawValue}).joined(separator: ", "))")
}
}, rows: {
ForEach(pokemons) {pokemon in
TableRow(pokemon)
.draggable(pokemon)
}
})
.searchable(text: $searchText)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.3))
.onChange(of: sortOrder, initial: true, {
pokemons = pokemons.sorted(using: sortOrder)
})
.onChange(of: searchText, {
pokemons = Pokemon.pokemonList.filter({searchText.isEmpty ? true: $0.name.localizedCaseInsensitiveContains(searchText)})
})
}
}
}
private struct PokemonTypeComparator: SortComparator {
typealias Compared = [PokemonType]
func compare(_ lhs: [PokemonType], _ rhs: [PokemonType]) -> ComparisonResult {
if lhs.count == rhs.count {
return .orderedSame
} else {
if order == .forward {
return lhs.count > rhs.count ? .orderedDescending : .orderedAscending
} else {
return lhs.count > rhs.count ? .orderedAscending : .orderedDescending
}
}
}
var order: SortOrder = .forward
}
struct Pokemon: Identifiable {
var id: Int
var name: String
var height: Int
var weight: Int
var types: [PokemonType]
}
enum PokemonType: String {
case normal
case fighting
case flying
case poison
case ground
case rock
case bug
case ghost
case steel
case fire
case water
case grass
case electric
case psychic
case ice
case dragon
case dark
case fairy
case stellar
case unknown
}
extension Pokemon {
static let pokemonList = [bulbasaur, ivysaur, venusaur, charmander, charmeleon, charizard, squirtle, wartortle, blastoise, pikachu]
static let bulbasaur = Pokemon(id: 1, name: "bulbasaur", height: 7, weight: 69, types: [.grass, .poison])
static let ivysaur = Pokemon(id: 2, name: "ivysaur", height: 10, weight: 130, types: [.grass, .poison])
static let venusaur = Pokemon(id: 3, name: "venusaur", height: 20, weight: 1000, types: [.grass, .poison])
static let charmander = Pokemon(id: 4, name: "charmander", height: 6, weight: 85, types: [.fire])
static let charmeleon = Pokemon(id: 5, name: "charmeleon", height: 11, weight: 190, types: [.fire])
static let charizard = Pokemon(id: 6, name: "charizard", height: 17, weight: 905, types: [.fire])
static let squirtle = Pokemon(id: 7, name: "squirtle", height: 5, weight: 90, types: [.water])
static let wartortle = Pokemon(id: 8, name: "wartortle", height: 10, weight: 225, types: [.water])
static let blastoise = Pokemon(id: 9, name: "blastoise", height: 16, weight: 855, types: [.water])
static let pikachu = Pokemon(id: 25, name: "pikachu", height: 4, weight: 60, types: [.electric])
}
Custom Transferable
If you have created a Custom Transferable while trying to support drag and drop for Lists, it is the same! (By the way, if you are interested in Drag and Drop List Across Pages With Custom Transferable, please check out my previous article here!)
Let’s do it here again!
Define Custom UTType
Two steps.
- Declare the type in Code
- Add the type to the project’s Info.plist
import UniformTypeIdentifiers
extension Pokemon {
// replace com.example with the Bundle ID of your app!
static var draggableType = UTType(exportedAs: "com.example.pokemon")
}
When initializing a UTType, we have init(importedAs:conformingTo:) and init(exportedAs:conformingTo:). importedAs is for declaring a type that the app uses but does NOT own, whereas exportedAs creates a type that our app owns based on an identifier.
Now, let’s head to our project info.plist to add the corresponding entry.
Click on the + under Exported Type identifiers.
And fill the information like following.
- Identifier: what you have for exportedAs above.
- Conforms To: defines our custom type’s conformance to system-declared types. We will have public.data here.
Conform to Transferable
To make our life easier, let’s first have our Pokemon and PokemonType conform to Codable. By doing so, Transferable automatically handles conversion to and from Data through CodableRepresentation.
struct Pokemon: Identifiable, Codable {...}
enum PokemonType: String, Codable {...}
We can then have our Pokemon conform the Transferable by implementing the transferRepresentation property.
extension Pokemon: Transferable {
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: Pokemon.draggableType)
}
}
Enable Drag and Drop
To add drag and drop support to Table, we will be using the draggable and the dropDestination modifier.
Wait! That sounds exactly the same as that for List!
Actually, a little different in 2 aspects.
- Parameters of the modifier
- Location that we attach the modifier
Let me first share with you the code so that we can better visualize the difference and key points!
I have extract the rows part of the Table initializer out as this will be the only changes we have to make.
rows: {
ForEach(pokemons) {pokemon in
TableRow(pokemon)
.draggable(pokemon)
}
.dropDestination(for: Pokemon.self, action: { index, pokemons in
guard let firstPokemon = pokemons.first, let firstRemoveIndex = self.pokemons.firstIndex(where: {$0.id == firstPokemon.id}) else { return }
self.pokemons.removeAll(where: { pokemon in
pokemons.contains(where: { insertPokemon in insertPokemon.id == pokemon.id})
})
self.pokemons.insert(contentsOf: pokemons, at: (index > firstRemoveIndex ? (index - 1) : index))
})
})
First of all, the parameter of the modifiers.
draggable is the same as that for a List where we pass in a payload that conforms to Transferable. However, the dropDestination is a little different.
The action closure takes two arguments, first one being the offset relative to the dynamic view’s underlying collection of data, ie: the data index, and the second one being an array of Transferable items that representing the data that is dragged on.
Secondly, The draggable modifier is attached directly to the TableRow where as the dropDestination is attached to the ForEach.
Yes, we CANNOT attach the dropDestination directly to the TableRow!
This is the part that took me forever to actually get it right! Partly due to the buggy Xcode16 not prompting the available modifiers for the view and the unintelligent apple intelligence keep giving the wrong auto-completion!
Anyway! I bet you are enough with my complaint today so let’s give our app a run!
Wrap Up
Full code for our future reference!
import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@State private var selections = Set<Pokemon.ID>()
@State private var sortOrder = [KeyPathComparator(\Pokemon.id, order: .reverse)]
@State private var searchText: String = ""
@State private var pokemons: [Pokemon] = Pokemon.pokemonList
var body: some View {
NavigationStack {
Table(of: Pokemon.self , selection: $selections, sortOrder: $sortOrder, columns: {
TableColumn("Index", value: \.id) { pokemon in
Text("No.\(pokemon.id)")
}
TableColumn("Name", value: \.name)
TableColumn("Height (dm)", value: \.height) { pokemon in
Text("\(pokemon.height)")
}
TableColumn("Weight (hg)", value: \.weight) { pokemon in
Text("\(pokemon.weight)")
}
TableColumn("Type", value: \.types, comparator: PokemonTypeComparator()) { pokemon in
Text("\(pokemon.types.map({ $0.rawValue}).joined(separator: ", "))")
}
}, rows: {
ForEach(pokemons) {pokemon in
TableRow(pokemon)
.draggable(pokemon)
}
.dropDestination(for: Pokemon.self, action: { index, pokemons in
guard let firstPokemon = pokemons.first, let firstRemoveIndex = self.pokemons.firstIndex(where: {$0.id == firstPokemon.id}) else { return }
self.pokemons.removeAll(where: { pokemon in
pokemons.contains(where: { insertPokemon in insertPokemon.id == pokemon.id})
})
self.pokemons.insert(contentsOf: pokemons, at: (index > firstRemoveIndex ? (index - 1) : index))
})
})
.searchable(text: $searchText)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.3))
.onChange(of: sortOrder, initial: true, {
pokemons = pokemons.sorted(using: sortOrder)
})
.onChange(of: searchText, {
pokemons = Pokemon.pokemonList.filter({searchText.isEmpty ? true: $0.name.localizedCaseInsensitiveContains(searchText)})
})
}
}
}
private struct PokemonTypeComparator: SortComparator {
typealias Compared = [PokemonType]
func compare(_ lhs: [PokemonType], _ rhs: [PokemonType]) -> ComparisonResult {
if lhs.count == rhs.count {
return .orderedSame
} else {
if order == .forward {
return lhs.count > rhs.count ? .orderedDescending : .orderedAscending
} else {
return lhs.count > rhs.count ? .orderedAscending : .orderedDescending
}
}
}
var order: SortOrder = .forward
}
struct Pokemon: Identifiable {
var id: Int
var name: String
var height: Int
var weight: Int
var types: [PokemonType]
}
enum PokemonType: String {
case normal
case fighting
case flying
case poison
case ground
case rock
case bug
case ghost
case steel
case fire
case water
case grass
case electric
case psychic
case ice
case dragon
case dark
case fairy
case stellar
case unknown
}
extension Pokemon {
static let pokemonList = [bulbasaur, ivysaur, venusaur, charmander, charmeleon, charizard, squirtle, wartortle, blastoise, pikachu]
static let bulbasaur = Pokemon(id: 1, name: "bulbasaur", height: 7, weight: 69, types: [.grass, .poison])
static let ivysaur = Pokemon(id: 2, name: "ivysaur", height: 10, weight: 130, types: [.grass, .poison])
static let venusaur = Pokemon(id: 3, name: "venusaur", height: 20, weight: 1000, types: [.grass, .poison])
static let charmander = Pokemon(id: 4, name: "charmander", height: 6, weight: 85, types: [.fire])
static let charmeleon = Pokemon(id: 5, name: "charmeleon", height: 11, weight: 190, types: [.fire])
static let charizard = Pokemon(id: 6, name: "charizard", height: 17, weight: 905, types: [.fire])
static let squirtle = Pokemon(id: 7, name: "squirtle", height: 5, weight: 90, types: [.water])
static let wartortle = Pokemon(id: 8, name: "wartortle", height: 10, weight: 225, types: [.water])
static let blastoise = Pokemon(id: 9, name: "blastoise", height: 16, weight: 855, types: [.water])
static let pikachu = Pokemon(id: 25, name: "pikachu", height: 4, weight: 60, types: [.electric])
}
extension Pokemon: Transferable {
static var draggableType = UTType(exportedAs: "itsuki.enjoy.TableDemo.pokemon")
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: Pokemon.draggableType)
}
}
Thank you for reading!
That’s all I have for today!
Happy dragging and dropping!
SwiftUI: Enable Drag and Drop for Table Rows With Custom Transferable was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Itsuki
Itsuki | Sciencx (2024-10-21T20:23:33+00:00) SwiftUI: Enable Drag and Drop for Table Rows With Custom Transferable. Retrieved from https://www.scien.cx/2024/10/21/swiftui-enable-drag-and-drop-for-table-rows-with-custom-transferable/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.