Home iOS & Swift Tutorials

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.

4.3/5 7 Ratings

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.
Note: This tutorial assumes you are familiar with the basics of SwiftUI 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.

Drag Todo app after building and running the sample project

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 TodoItems 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:

  1. items: A dictionary holding a reference to each TodoItem. You can access each reference easily via its identifier.
  2. activeItemIds: An array of identifiers representing active (incomplete) TodoItems. You’ll use the ordered array to reflect the contents of the Active section you’ll display in ContentView.
  3. 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.

Note: You can manage data flow in many different ways in SwiftUI. While this tutorial won’t go into any more details, check out Understanding Data Flow in SwiftUI if you’d like to learn more.

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:

  1. Tell SwiftUI which content to delete.
  2. 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:

  1. Iterate over each index in IndexSet and remove the associated TodoItem from the dictionary.
  2. 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:

Drag Todo app after entering edit mode with delete support

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.

Note: To read more about the 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.

Drag Todo app in the dedicated 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.

Reordering items within Drag Todo

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.

The Add Button and Completed section, both disabled while 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:

  1. Defines the todoList environment property and holds an optional reference to focusId, an Int representing the identifier of the TODO on which the user chooses to focus.
  2. Uses the existing TodoItemView to display the item if the focusId is set and the TodoList contains an item with that identifier.
  3. Otherwise, displays a placeholder label to prompt the user to drag and drop a TODO item into this view.
  4. Sets the frame to fill the available width for the view, regardless of the width of the contents, along with some padding.
  5. 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:

The UI of the FocusTodoView in both populated and empty state

Note: If the preview canvas didn’t automatically open, navigate to Xcode’s menu and click Editor ▸ Canvas to reveal it.

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:

Drag Todo app after adding a custom drop area

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:

  1. 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 that NSItemProvider represents.
  2. Implement writableTypeIdentifiersForItemProvider for NSItemProviderWriting and return your unique type identifier.
  3. 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 from writableTypeIdentifiersForItemProvider. Because you only listed one type, you don’t need to worry about checking this argument.
  4. Define a do-catch statement to catch any errors while encoding.
  5. 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 the TodoItem into JSONEncoder because it already conforms to Codable.
  6. If anything went wrong during the encoding, call the completion handler, but use the error object instead.
  7. Return nil for Progress 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.
Note: For more information about 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.

Note: Unlike 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:

An item in the Drag Todo app being dragged around the screen

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:

An item in Drag Todo being dragged around an iPhone screen

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:).

The Edit functionality no longer works after switching from List to ScrollView

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:

  1. Specify readableTypeIdentifiersForItemProvider, a list of the type identifiers this implementation can manage. Again, only specify the custom typeIdentifier from earlier.
  2. 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 returns TodoItem, which only works because TodoItem is a final class.
  3. Create a JSONDecoder instance and attempt to decode the provided data into the TodoItem 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:

  1. Declares focusId as a binding of an optional Int, which you’ll use to pass the identifier of the dropped TODO item back up the view chain.
  2. Implements performDrop(info:), which the framework calls when the drop operation takes place. It receives a parameter of type DropInfo that contains information about the drop operation. It returns true if the drop is handled and false if it isn’t.
  3. Ensures something of the expected type is available with the guard statement. If not, return false.
  4. 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 using first on the results. If you wanted to deal with many items, you’d need to loop over them.
  5. You take the resulting NSItemProvider and call loadObject(ofClass:completionHandler:). It, in turn, will call through to the method you implemented as part of NSItemProviderReading.
  6. Inside the completion closure, you attempt to cast the object to TodoItem and assign its identifier to focusId. Be careful to update your binding only on the main thread by using DispatchQueue.

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.

Dragging a TODO item into the focus area at the top of the screen

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:

  1. Instead of onDrop(of:delegate:), use onDrop(of:isTargeted:perform:), which allows you to perform the drop operation directly inside the closure.
  2. For each NSItemProvider passed to the closure, load the TODO, as before, by using loadObject(ofClass:completionHandler:).
  3. Attempt to cast the loaded object to a TodoItem . If that succeeds, call updateTodo(withId:isCompleted:) to update TodoItem on the main thread.
  4. 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.

TODO items being marked as completed as they are dropped into the Completed section

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:

If you have any questions or comments, ask in the forum below!

Average Rating

4.3/5

Add a rating for this content

7 ratings

More like this

Contributors

Comments