Drag and Drop Editable Lists: Tutorial for SwiftUI
Grow your SwiftUI List skills beyond the basics. Implement editing, moving, deletion and drag-and-drop support. Then, learn how custom views can support draggable content.
Version
- Swift 5, iOS 14, Xcode 12

Lists show up everywhere in mobile apps and users expect to interact with their content in standard ways. SwiftUI’s lists offer a simple, ordered display of data with each item on a separate row. The List
view handles much of the organization and structure for list data display on each platform, although as you’ll see throughout this tutorial, there’s still some work for you to do.
In this article, you’ll:
- Use a SwiftUI
List
view to add the ability to edit a list to an app called Drag Todo. - Give the app drag-and-drop support by adding a custom class.
- See the trade-offs and decisions you’ll need to make when you use both functionalities.
List
and ForEach
views. New to SwiftUI? Start with SwiftUI: Getting Started, read the developer documentation for List
and ForEach
, then come back here.Now, it’s time to jump in.
Getting Started
Use the Download Materials button at the top or bottom of this tutorial to download the starter project.
Build and run the Xcode project in the starter directory.
Open TodoItem.swift in the Models group. For this tutorial, the app starts with several pre-loaded TODO items, each consisting of a unique ID (not displayed), name, due date and completion status.
You’ll add new items with the Plus button at the top trailing edge of the app. Each item shows a circle you can tap to toggle the completion status. The app stores the TODO items in an array and doesn’t persist data.
ContentView
is the main screen of Drag Todo. Open ContentView.swift in the Views group to examine its structure. You’ll see a NavigationView
containing a VStack
at the top level of the app. The app displays the TODO items within a List
view.
The first section of the List
is ActiveTodoView
. Open ActiveTodoView.swift and you’ll see it contains header and footer views surrounding a ForEach
loop.
Why use a ForEach
inside a List
? Not only does it give you more flexibility, but the editing features you’ll use in this tutorial require it.
You’ll also use two modifiers, onDelete(perform:)
and onMove(perform:)
, that only work with a ForEach
view and not the List
view.
Understanding Data Flow in Drag Todo
Look through Drag Todo’s code and you’ll notice a class called TodoList
. This is the object that manages the collection of TodoItem
s the user sees and interacts with.
Open TodoList.swift in the Models group and look at what’s happening inside.
The class itself conforms to ObservableObject
, causing it to expose published events when @Published
properties change. SwiftUI then uses this publisher to reflect the state in the UI.
AppMain.swift injects the TodoList.sampleData()
instance into the view hierarchy via environmentObject(_:)
. Each view accesses the object by declaring a property with the same type using the @EnvironmentObject
property wrapper.
TodoList
has three main properties:
-
items: A dictionary holding a reference to each
TodoItem
. You can access each reference easily via its identifier. -
activeItemIds: An array of identifiers representing active (incomplete)
TodoItem
s. You’ll use the ordered array to reflect the contents of the Active section you’ll display inContentView
. -
completedItemIds: The same as
activeItemIds
, except it handles the items in the Completed section.
The rest of the class provides helper methods for accessing items, adding new items and updating an item’s completion state.
Editing Lists
Since the dawn of iOS, lists have supported easy-to-implement editing functionality. Things are no different in SwiftUI with the List
view.
Simple lists are straightforward to implement, but there are some interesting ways to build upon them. Over the coming sections, you’ll learn everything you need to know by adding edit support to Drag Todo.
In Drag Todo, users need to be able to work with their TODO items. For example, they need to be able to delete items they’ve completed and to move items from one list to another. You’ll start by working on removing items from lists.
Deleting Items
When deleting items within a List
, you need to do two things:
- Tell SwiftUI which content to delete.
- Update your data model to reflect any deletions the user performs.
To make these things happen, you’ll add onDelete(perform:)
to the ForEach
view containing the items you want to delete.
This modifier has a single parameter, which is a closure. SwiftUI invokes the closure when the user attempts to delete an item from the list. The closure then receives an IndexSet
containing the indices of the items the user deleted.
Before adding the modifier, you need to update TodoList
to support deleting items. Open TodoList.swift and add the following method under updateTodo(withId:isCompleted:)
:
func deleteActiveTodos(atOffsets offsets: IndexSet) {
// 1
for index in offsets {
items.removeValue(forKey: activeItemIds[index])
}
// 2
activeItemIds.remove(atOffsets: offsets)
}
In this method, you do the following:
- Iterate over each index in
IndexSet
and remove the associatedTodoItem
from the dictionary. - Remove the IDs corresponding to those items from
activeItemIds
.
To use this new method, open ActiveTodoView.swift and add the following code immediately below the ForEach
view:
.onDelete(perform: todoList.deleteActiveTodos(atOffsets:))
This implements onDelete(perform:)
to define the delete action for the view. When you implement this modifier, SwiftUI knows you support deleting items and calls the new deleteActiveTodos(atOffsets:)
to perform the deletion.
That’s all. It’s that simple to allow the user to delete items from the lists in your app! :]
Build and run to try it out:
Swipe from right to left on one of the items in the active list and a Delete button appears. Tap it to remove the TODO item from TodoList
.
Using EditButton
While swiping each item is nice, users aren’t likely to discover this function if they aren’t already familiar with it. Fortunately, there’s a well-established design pattern that provides a dedicated mode for editing.
SwiftUI makes it simple to adopt by providing the EditButton
view.
EditButton
view, visit the official documentation.Open ContentView.swift and look for toolbar
. Add the following code immediately before the Button
view:
EditButton()
This adds the familiar Edit button to the left of the Plus.
Build and run. You’ll see a new button in the navigation bar. Tap Edit and the list will transition into edit mode.
Since you’ve told the List
your Active section supports deletion, each item in the list shows a red circle. Tapping that circle then shows the Delete button you saw earlier. Tapping Done exits edit mode.
At this point, it might not be clear how EditButton
and List
work together. Don’t worry; you’ll learn more about this later.
Now, users can delete items from their TODO lists. Next, you’ll see how to let users rearrange items by moving them from one list to another.
Moving Items
Editing mode makes it easy to allow the user to rearrange items within a list. You only need to implement onMove(perform:)
and update your data source appropriately, similar to the process for deletions.
Return to TodoList.swift and add the following method after deleteActiveTodos(atOffsets:)
:
func moveActiveTodos(fromOffsets source: IndexSet, toOffset destination: Int) {
activeItemIds.move(fromOffsets: source, toOffset: destination)
}
To move elements, you need to know two things:
- Which elements to move.
- The new destination for those elements.
As with the delete method, you get an IndexSet
containing the indices in the list to move. The new location is a single Int
, which tells you where in the collection to place those elements.
For convenience, Swift collections include a method that supports these parameters. Call move(fromOffsets:toOffset:)
on activeItemIds
so the data source will update to reflect the move.
Return to ActiveTodoView.swift and add the following code beneath .onDelete(perform:)
:
.onMove(perform: todoList.moveActiveTodos(fromOffsets:toOffset:))
Adding this modifier tells SwiftUI your list supports reordering. You provide your method to handle the action.
Build and run. Tap Edit and you’ll now see a new move indicator to the right of each item in the list. Tap and hold the move indicator. You can now drag the item to a new position. When you release the hold, the item will snap into its new location.
Now that you’ve seen how to make the most out of List
in edit mode, you’ll take advantage of it in your own views next.
Getting the Current EditMode
You’ve seen List
and EditButton
respond to edit mode, but what if you want to react to state changes in your own views? Well, you’re in luck! SwiftUI leans on the environment to support this functionality, and as a result, you can too!
Open ContentView.swift and add the following code at the top of the file, right after the declaration of isShowingAddTodoView
:
@State private var editMode: EditMode = .inactive
Here, you define a new state property to manage the EditMode
enum
. You can now use this property to update your UI.
Add the following modifier to both CompletedTodoView
and Button
within ContentView
:
.disabled(editMode.isEditing)
This code uses disabled(_:)
to disable each of those views during editing.
Build and run. Tap the Edit button, and… nothing happens to either the Add button or the Completed section. You can still add new items and toggle completion status.
What’s going on?
The problem is, while you’ve defined some states for EditMode
, List
and EditButton
are still using their own states to manage editing. To fix this, you need to inject your state into the environment so List
and EditButton
can use it.
Add the following line of code after toolbar(content:)
and before sheet(isPresented:onDismiss:content:)
:
.environment(\.editMode, $editMode)
This injects the editMode state property you created earlier into the environment so child views can access it.
Build and run. As the other views update, you’ll see the expected behavior when you start or stop editing.
In the next section, you’ll explore another method of managing list content — drag and drop.
Introducing Drag and Drop
Drag and drop lets the user drag items from one location to another in a continuous gesture. SwiftUI provides an excellent framework for supporting drag and drop in your apps.
Desktop platforms like macOS have long supported these operations using the mouse. Although the touch interface and single-app focus on iOS and iPadOS make the process a bit more complicated, recent versions of these platforms added drag-and-drop support.
In the rest of this tutorial, you’ll add drag-and-drop functionality to your app and explore some of the trade-offs.
Each platform handles drag and drop in different ways. Mac and iPad support drag and drop between apps, enabling a rich set of new experiences when sharing content. On the iPhone, however, the user can only drag and drop within a single app.
You’ll only add support for drag-and-drop operations within the app in this tutorial. However, the concepts and techniques are similar for multi-app drag and drop.
You’ll begin by implementing a focus section in Drag Todo that allows the user to drag and drop TODO items onto that area.
Building a Drop Target
Create a new file to represent the drop view by highlighting the Views group, then right clicking and choosing New File… ▸ SwiftUI View and calling it FocusTodoView.swift. Replace the contents of the file with the following:
import SwiftUI
struct FocusTodoView: View {
// 1
@EnvironmentObject private var todoList: TodoList
var focusId: Int?
var body: some View {
VStack {
if let id = focusId, let item = todoList.items[id] {
// 2
Text("Current Focus")
TodoItemView(item: item)
} else {
// 3
Text("Drag Current Focus Here")
}
}
// 4
.frame(maxWidth: .infinity)
.padding()
// 5
.background(
RoundedRectangle(cornerRadius: 15)
.strokeBorder(Color.gray, style: StrokeStyle(dash: [10]))
.background(Color.white))
}
}
Here’s what your new view does:
- Defines the
todoList
environment property and holds an optional reference to focusId, anInt
representing the identifier of the TODO on which the user chooses to focus. - Uses the existing
TodoItemView
to display the item if thefocusId
is set and theTodoList
contains an item with that identifier. - Otherwise, displays a placeholder label to prompt the user to drag and drop a TODO item into this view.
- Sets the frame to fill the available width for the view, regardless of the width of the contents, along with some padding.
- Sets a background using rounded edges and a dashed border.
To see your custom view in action, paste the following code into the bottom of the file:
struct FocusTodoView_Previews: PreviewProvider {
static let list = TodoList.sampleData()
static var previews: some View {
Group {
FocusTodoView(focusId: nil)
FocusTodoView(focusId: 0)
FocusTodoView(focusId: 4)
}
.environmentObject(list)
.padding()
.frame(width: 375)
.previewLayout(.sizeThatFits)
}
}
Verify your custom view using Xcode Previews:
Implementing Your View
Now that you’ve created and verified the view, add it to the app.
Open ContentView.swift again and add the following property at the top of the file with the other properties:
@State private var focusId: Int?
This will hold the identifier of the focused TODO item, focusId, as state.
Next, add the following code to the VStack
above List
:
FocusTodoView(focusId: focusId)
.padding()
Build and run. You’ll see your FocusTodoView
sitting nicely above the content of the List
:
In the next section, you’ll implement the functionality to drag and drop from the active section to the focus view.
Preparing for Drag Support
SwiftUI — and UIKit — use NSItemProvider
to represent objects in a drag-and-drop session. While some built-in types, such as NSString
, NSURL
, UIImage
and UIColor
, support this out of the box, you’ll need to do a little bit of extra work to offer TodoItem
to the drag-and-drop gods.
To start, you’ll use NSItemProviderWriting
to convert the TODO item into a data representation upon request.
Open TodoItem.swift and add the following code to the bottom of the file:
extension TodoItem: NSItemProviderWriting {
// 1
static let typeIdentifier = "com.raywenderlich.DragTodo.todoItem"
// 2
static var writableTypeIdentifiersForItemProvider: [String] {
[typeIdentifier]
}
// 3
func loadData(
withTypeIdentifier typeIdentifier: String,
forItemProviderCompletionHandler completionHandler:
@escaping (Data?, Error?) -> Void
) -> Progress? {
// 4
do {
// 5
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
completionHandler(try encoder.encode(self), nil)
} catch {
// 6
completionHandler(nil, error)
}
// 7
return nil
}
}
In the code above, you:
- Define a new constant called
typeIdentifier
containing a string unique to your project. This becomes the item type used to distinguish your TODO item from other objects thatNSItemProvider
represents. - Implement
writableTypeIdentifiersForItemProvider
forNSItemProviderWriting
and return your unique type identifier. - Implement
loadData(withTypeIdentifier:forItemProviderCompletionHandler:)
. The framework will call this method to get the representation of this object, when necessary. In your implementation,typeIdentifier
will always come fromwritableTypeIdentifiersForItemProvider
. Because you only listed one type, you don’t need to worry about checking this argument. - Define a
do-catch
statement to catch any errors while encoding. - Create a
JSONEncoder
instance, set it to produce human-friendly output and then attempt to encode the current object. If encoding succeeds, you call the completion handler for the block and pass the encoded object to it. You’re able to pass theTodoItem
intoJSONEncoder
because it already conforms toCodable
. - If anything went wrong during the encoding, call the completion handler, but use the
error
object instead. - Return
nil
forProgress
because your encode operation was not asynchronous. It doesn’t need to report any progress back to the caller because it has already finished by this point.
Codable
, see our tutorial, Encoding and Decoding in Swift.Now that the system knows how to handle TodoItem
, you’ll implement drag support.
Making a View Draggable
Once you have a supported model object, adding drag support to a SwiftUI view is simple. All you need to do is implement onDrag(_:)
on the view you want to be draggable.
Open ActiveTodoView.swift and add the following modifier to TodoItemView
:
.onDrag {
NSItemProvider(object: item)
}
This lets TodoItemView support drag and drop gestures. The closure returns the draggable data.
onDelete(perform:)
and onMove(perform:)
, you can apply onDrag(_:)
to any view. Take care to add this modifier to the TodoItemView
within ForEach
, rather than where the other two modifiers are located.
Build and run, but this time, use an iPad or an iPad simulator. In the Active list, hold your finger down on an item until you see it lift out of the List
, then drag it around inside the app. It will look like this:
Why on an iPad?
Currently a SwiftUI List
isn’t compatible with drag and drop on the iPhone. If you were to try this on an iPhone, unfortunately, nothing would happen.
Until a future version of SwiftUI fixes this problem, the simplest fix is to get rid of the List
. Open ContentView.swift and replace List
around the active and completed TODO items with a ScrollView
.
Build and run back on the iPhone:
You can now drag items on an iPhone, but there’s a cost to this simple fix. You lost the editing functionality you built in the first part of this tutorial.
Tap Edit and notice the indicators for deleting and moving items no longer appear. That’s because only List
supports onDelete(perform:)
and onMove(perform:)
.
Hopefully, future versions of SwiftUI will let you have both at the same time. For now, you’ll need to choose one or the other on the iPhone.
For the rest of this tutorial, you can ignore the loss of editing support in List
.
Preparing for Drop Support
Since you’ve implemented NSItemProviderWriting
to support dragging, you might have guessed you now need to implement NSItemProviderReading
to implement dropping.
Open TodoItem.swift and add the following to the end of the file:
extension TodoItem: NSItemProviderReading {
// 1
static var readableTypeIdentifiersForItemProvider: [String] {
[typeIdentifier]
}
// 2
static func object(
withItemProviderData data: Data,
typeIdentifier: String
) throws -> TodoItem {
// 3
let decoder = JSONDecoder()
return try decoder.decode(TodoItem.self, from: data)
}
}
As you might expect, the implementation is similar to NSItemProviderWriting
except it performs the conversion in the opposite direction. Here, you:
- Specify
readableTypeIdentifiersForItemProvider
, a list of the type identifiers this implementation can manage. Again, only specify the customtypeIdentifier
from earlier. - Let
object(withItemProviderData:typeIdentifier:)
receive the data along with the type identifier used when writing it. Since you only support one type of data, you can ignore this second parameter. This method returnsTodoItem
, which only works becauseTodoItem
is afinal
class. - Create a
JSONDecoder
instance and attempt to decode the provided data into theTodoItem
type, then return it.
Unlike before, you have a bit more work to do before you can add drop support to a view. In the next section, you’ll look at what you need to do to offer a flexible approach to dropping an object.
Conforming to DropDelegate
The most flexible drop modifier for SwiftUI requires you provide a custom type conforming to DropDelegate
.
Create a new file for the delegate. Highlight the Models group, then select File ▸ New ▸ File… ▸ Swift File and call it TodoDropDelegate.swift. Replace the contents with the following:
import SwiftUI
struct TodoDropDelegate: DropDelegate {
// 1
@Binding var focusId: Int?
// 2
func performDrop(info: DropInfo) -> Bool {
// 3
guard info.hasItemsConforming(to: [TodoItem.typeIdentifier]) else {
return false
}
// 4
let itemProviders = info.itemProviders(for: [TodoItem.typeIdentifier])
guard let itemProvider = itemProviders.first else {
return false
}
// 5
itemProvider.loadObject(ofClass: TodoItem.self) { todoItem, _ in
let todoItem = todoItem as? TodoItem
// 6
DispatchQueue.main.async {
self.focusId = todoItem?.id
}
}
return true
}
}
Here’s what TodoDropDelegate
does:
- Declares
focusId
as a binding of an optionalInt
, which you’ll use to pass the identifier of the dropped TODO item back up the view chain. - Implements
performDrop(info:)
, which the framework calls when the drop operation takes place. It receives a parameter of typeDropInfo
that contains information about the drop operation. It returnstrue
if the drop is handled andfalse
if it isn’t. - Ensures something of the expected type is available with the
guard
statement. If not, returnfalse
. - Returns a collection of items matching the specified type identifiers with
itemProviders(for:)
. You only care about one type identifier, so you pass it as the sole element in the array. You’ll only process a single item, obtained usingfirst
on the results. If you wanted to deal with many items, you’d need to loop over them. - You take the resulting
NSItemProvider
and callloadObject(ofClass:completionHandler:)
. It, in turn, will call through to the method you implemented as part ofNSItemProviderReading
. - Inside the completion closure, you attempt to cast the object to
TodoItem
and assign its identifier tofocusId
. Be careful to update your binding only on the main thread by usingDispatchQueue
.
Dropping Between Views
For the final piece of the puzzle, open ContentView.swift and add the following modifier to FocusTodoView
underneath its padding()
modifier:
.onDrop(
of: [TodoItem.typeIdentifier],
delegate: TodoDropDelegate(focusId: $focusId))
Here, you indicate you only want to accept objects with TodoItem
‘s type identifier and pass an instance of the delegate you just created with a binding to the focusId
state you added earlier.
Build and run. And there you have it! Drag any TODO item from the active list and drop it in the focused area. You can even tap the Completed toggle in the focus area, and you’ll see both elements reflect the change in status.
Trying Out Another Way to Drop
Using DropDelegate
isn’t the only way to support dropping. To demonstrate another approach, you’ll add support for dropping an active TODO into the Completed section and marking it as completed at the same time.
Open ContentView.swift and add the following modifier to the CompletedTodoView
:
// 1
.onDrop(of: [TodoItem.typeIdentifier], isTargeted: nil) { itemProviders in
// 2
for itemProvider in itemProviders {
itemProvider.loadObject(ofClass: TodoItem.self) { todoItem, _ in
// 3
guard let todoItem = todoItem as? TodoItem else { return }
DispatchQueue.main.async {
todoList.updateTodo(withId: todoItem.id, isCompleted: true)
}
}
}
// 4
return true
}
This might look familiar:
- Instead of
onDrop(of:delegate:)
, useonDrop(of:isTargeted:perform:)
, which allows you to perform the drop operation directly inside the closure. - For each
NSItemProvider
passed to the closure, load the TODO, as before, by usingloadObject(ofClass:completionHandler:)
. - Attempt to cast the loaded object to a
TodoItem
. If that succeeds, callupdateTodo(withId:isCompleted:)
to updateTodoItem
on the main thread. - Return
true
to tell the system you’ve handled the drop event.
Build and run. Drop any active item into the completed list, and you’ll see it marked as completed.
Congratulations! By now, you should have a good handle on how to add editing items and dragging and dropping to your own apps.
Where to Go From Here
Access the final project using the Download Materials button at the top or bottom of this tutorial.
In this tutorial, you explored SwiftUI’s support for user management of the content displayed inside a list. You implemented edit and delete operations and added drag and drop for your custom data type to your app. You also studied the trade-offs on some platforms when using drag and drop.
If you want to explore the concepts in this tutorial further, here are some good resources:
- Chapter 11, “Lists & Navigation” in SwiftUI by Tutorials covers creating and displaying data using lists in more depth.
- The 2019 WWDC presentation SwiftUI Essentials explains Apple’s guidelines on how views and lists fit together.
- You’ll find more on drag and drop in our Drag and Drop Tutorial for iOS and in our intermediate-level tutorial Drag and Drop Tutorial for SwiftUI.
If you have any questions or comments, ask in the forum below!
Comments