Create Draggable Calendar View in SwiftUI

Learn how to use onDrag and onDrop for creating a calendar view

Create Draggable Calendar View in SwiftUI
Page content

While I was looking at my twitter for inspiration, a tweet from Sean Allen caught my eyes. He made an amazing calendar component with a drag and drop capabilities of its item. I look up the possibilities of using the native SwiftUI approach without any UIKit hack or workaround to do so. In this article, I will explain step by step as well a brief explanation of the API I used to make the calendar view in SwiftUI. There is a good stackoverflow answer to implement drag and drop on 1 level item, but it could not work in case we need to drag and drop the item of the parent item to another parent item, hence I created a workable solution to solve that problem.

Construct the data

First, we do need basic data to start with. We only need two models in this use case, a ScheduleItem and a CalendarViewModel. The ScheduleItem is representing the sub-item of the calendar that we want to drag and drop, but the CalendarViewModel is the model that we want to place within the Calendar view, hence we do need around 30/31 CalendarViewModel per month.

 1struct ScheduleItem: Identifiable, Equatable {
 2    let id: String = UUID().uuidString
 3    let name: String? // I will explain later why we need an optional name. This is part of the fake concept I will explain later.
 4}
 5
 6struct CalendarViewModel: Identifiable, Equatable {
 7    let id: String = UUID().uuidString
 8    let date: String
 9    var items: [ScheduleItem] // I make it var because we will mutate this later
10}

Layouting the Calendar Grid and Calendar Items

In this tutorial, I will create a lite version of a calendar view. We only need a LazyVGrid with 7 columns that represents 7 days per week. We also will create a collection of CalendarViewModel as a @State and also one of the ScheduleItem as a dragged object.

 1struct CalendarView: View {
 2    @State private var models: [CalendarViewModel] = []
 3    @State private var dragging: ScheduleItem?
 4
 5    private let sevenColumnGrid = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7)
 6
 7    var body: some View {
 8        VStack {          
 9              LazyVGrid(columns: sevenColumnGrid) {
10                  // ** 2. items ** //
11              }
12              .animation(.default, value: models)
13        }
14        .background(Color.white)
15    }
16}

In the code below, this is the part where we are layouting the Item view. We only need to iterate the calendar items and make a View on top of it. If you notice you will see I made two separate views, one is the view of the item with a name is exist and the other one is the view for the blank item. I put this way instead of a blank Spacer because of a reason that I will explain in the caveat section later.

 1// 2. items put this inside the LazyVGrid
 2
 3ForEach(models) { model in
 4    VStack {
 5        Text(model.date)
 6            .frame(maxWidth: .infinity, alignment: .trailing)
 7        ForEach(model.items) { item in
 8          if let name = item.name {
 9              VStack {
10                  Spacer()
11                  Text(item.name)
12                      .frame(maxWidth: .infinity, alignment: .center)
13                  Spacer()
14              }
15              .background(Color.green.opacity(0.3))
16              .cornerRadius(4)              
17          } else {
18              VStack {
19                  Spacer()
20                  Text("")
21                      .frame(maxWidth: .infinity, alignment: .center)
22                  Spacer()
23              }              
24              .background(Color.white)              
25          }          
26        }
27        if model.items.isEmpty {
28            Spacer()
29        }
30    }
31    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 100, alignment: .center)
32}

The calendar view now will be look like below!.

calendar

Understanding onDrag and onDrop API

What is onDrag ? onDrag is a method that is being used for activating the view as the initial source of drag and drop operation, which means it will return View that is being attached with the user gesture input by the time the method is being called. Every time the drag operation begins, the attached view will be used as the preview image. onDrag modifier has a closure that will create and return NSItemProvider. NSItemProvider is the class that informs the system about the content and the type of the draggable item, in this case, we force it as NSString since the UTType we choose is .text (We use NSString not String, because NSItemProvider is part of NSObject, which indicates it can behave as an Objective-C objects so we do need to inject a String data type that could be used in Objective-C which is NSString).

How about onDrop? So basically onDrop will ask for a UTType data type which is a data type that represents a type of data to load, send, or receive. UTType data type describes type information of data such as a unique identifier, lookup method name, or even some of the metadata as part of additional information to be sent along with the data. In this case, I will just use UTType.text type because we just drop simple text data from a struct.

So after all of the theory above, here is the implementation of the onDrag and onDrop on our calendar view. (Put this inside the ForEach of the Grid).

 1VStack {
 2    Text(model.date)
 3        .frame(maxWidth: .infinity, alignment: .trailing)
 4    ForEach(model.items) { item in
 5        if let name = item.name {
 6            VStack {
 7                Spacer()
 8                Text(name)
 9                    .frame(maxWidth: .infinity, alignment: .center)
10                Spacer()
11            }
12            .background(Color.green.opacity(0.3))
13            .cornerRadius(4)
14            .overlay(dragging?.id == item.id ? Color.green.opacity(0.3) : Color.clear)
15            .onDrag {
16                self.dragging = item
17                return NSItemProvider(object: item.id as NSString)
18            }
19            .onDrop(of: [UTType.text],
20                    delegate: DropDelegateImpl(item: item,
21                                               listData: $models,
22                                               current: $dragging))
23        } else {
24            VStack {
25                Spacer()
26                Text("")
27                    .frame(maxWidth: .infinity, alignment: .center)
28                Spacer()
29            }
30            .background(Color.white)
31            .onDrop(of: [UTType.text],
32                    delegate: DropDelegateImpl(item: item,
33                                               listData: $models,
34                                               current: $dragging))
35        }
36    }
37    if model.items.isEmpty {
38        Spacer()
39    }
40}
41.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100, alignment: .center)

Understanding DropDelegate

For both onDrag and onDrop are working as expected, they require us to implement DropDelegate on the onDrop behavior. The purpose of DropDelegate is to give us the ability to interact with a drop operation in a view modified to accept drops. If we don’t need to do complex behavior or interaction then you can proceed with another onDrop modifier which has a perform closure for doing the drop interaction. In this case, since we do need to detect where the item is coming from and to whom the item is expected to be dropped, as well as performing a swap operation we need a custom DropDelegate. This DropDelegate will be applied to all of the calendar items as part of a parameter of view modifier of onDrop modifier.

 1struct DropDelegateImpl: DropDelegate {
 2    let item: ScheduleItem // 0.
 3    @Binding var listData: [CalendarViewModel] // 1.
 4    @Binding var current: ScheduleItem? // 2.
 5
 6    func dropEntered(info: DropInfo) {
 7        guard let current = current, item != current else {
 8            return
 9        } // 3.
10        let from = listData.first { cvm in
11            return cvm.items.contains(current)
12        } // 4.
13        let to = listData.first { cvm in
14            return cvm.items.contains(item)
15        } // 5.
16
17        guard var from = from, var to = to, from != to else {
18            return
19        } // 6.
20
21        if let toItems = to.items.first(where: { $0.id == item.id }),
22           toItems.id != current.id { // 7.
23            let fromIndex = listData.firstIndex(of: from) ?? 0 // 8.
24            let toIndex = listData.firstIndex(of: to) ?? 0 // 9.
25            to.items.append(current) // 10.
26            to.items.removeAll(where: { $0.name == nil}) // 11.
27            from.items.removeAll(where: { $0.id == current.id }) // 12.
28            if from.items.isEmpty {
29                from.items.append(ScheduleItem(name: nil)) // 13.
30            }
31            listData[toIndex].items = to.items // 14.
32            listData[fromIndex].items = from.items // 15.
33        }
34    }
35
36    func dropUpdated(info: DropInfo) -> DropProposal? {
37        return DropProposal(operation: .move) // 16.
38    }
39
40    func performDrop(info: DropInfo) -> Bool {
41        self.current = nil // 17.
42        return true
43    }
44}

From the code above, there are some notable operations I do: 0. First we need to set the item of the current schedule object.

  1. Then we need to bind the listData which is the whole CalendarViewModel.
  2. This step we need to bind also the current dragging schedule item. Remember schedule item is part of CalendarViewModel.
  3. Pretty obvious step, block any operation if both item and current are the same things.
  4. Retrieving the correct Calendar model that contains the current dragged item.
  5. Retrieving the correct Calendar model that contains the destination item.
  6. Obvious step, both step numbers 4 and 5 should not be the same object. I make it as var because we will mutate the value of the struct.
  7. We start to filter only take the destination item that matches with the id of the current schedule object from number 1.
  8. Now we need to get the index of the calendar object which contains the dragged item.
  9. We also need to get the index of the calendar object which contains the destination item.
  10. Now we will add the dragged item to the destination array of items.
  11. We also need to remove all of the items that contain nil name. This is one of the tricks I need to do in order for the item can be dragged to the empty calendar. (I will explain in the caveat section).
  12. After that we need to remove the item from the source calendar.
  13. This step, is part of the caveat section, where we need to append the source calendar an empty item object (fake object) if the array of items on that calendar is empty.
  14. Now we need to assign the item of our Bindable calendar object. This one is assigning the destination object with the new list of items.
  15. Same as step 14 but this one is assigning the source object with the new list of items.
  16. One of the delegate functions is dropUpdated which we need to feed our DropDelegate with the DropProposal. DropProposal is the behavior of the drop action, which we need to declare the DropOperation. There are 4 types of DropOperation which are cancel, copy, forbidden, and move. See more info here. I would assume this one is for telling the system what is the expected behavior of the dropping action then the system can decide what the system can do for resolving the session of the drag and drop operation, however, I don’t see any effect in our use case (yet), probably because we only move the text UTType instead of other possible UTType.
  17. performDrop is the delegate function to determine whether we can perform the drop interaction or not. We set the return to true, but we set back the current or dragged item as nil to tell the system we finish dragging the item.

Above are the explanation of steps I made within my DropDelegate object. Now let’s take a look back on step 11 with the fake object at the section below.

To understand more about DropDelegate, DropInfo and DropProposal, please visit this documentation from Apple. https://developer.apple.com/documentation/swiftui/dropdelegate.

calendar-gif

Caveat

There is a single caveat here that I need to do in order we can do any drag and drop behavior within an item to another calendar that has no data/item in their calendar view. If we only drag the calendar it is possible to do so because we will apply all of the onDrag modifiers to all of the calendar views, then the system can tell the object that we want to compare and swap the calendar. But in this case, we want to drag the sub-item of the calendar, and the best case we should do in the UI part, that we will put an empty Spacer to the Calendar view instead of the Item view and we only put the onDrag behavior on the Item view. However, this is not possible, because we will make the onDrag and onDrop not recognizing the destination item view (because we only put a Spacer and we can not put the onDrag and onDrop in that spacer since no data can be compared within that spacer). So in order we can compare the item, we need to make a fake item object with a nil string name, and we place it all over the Calendar view that has no sub-items, then we can still put onDrag and onDrop modifier on top of that Item view, and the system can recognize the item to be compared. At the moment, I am still thinking this is a hacky way and not clean enough for this behavior, if anyone found out the better solution please let me know!.

Conclusion

In my opinion, Drag and Drop behavior in SwiftUI are quite hard to implement at first and need a bit learning curve to understand how it behaves. However by the time you try it out, it is easier than I thought. Both of them indeed are quite tricky to implement, but it doesn’t mean we need to scare of them. I suggest trying it out and you can make a lot of cool stuff with it. I hope my article could help you to understand more about the basic foundation of how to do drag and drop in SwiftUI, if you have any questions and better solutions please reach me out on twitter!