Xcoding with Alfian

Software Development Videos & Tutorials

Building a Trello-like iOS App with Drag & Drop API

Alt text

Drag & Drop is the user interaction that works naturally when we want to build an app where user can move data around the screen. It’s being used heavily by apps like Trello, Jira, and many others to move data between boards.

Building an iOS app with drag & drop interaction before the release of iOS 11 SDK was not a straightforward process. In my previous experience, there were several tedious tasks that we have to perform manually, such as:

  1. Add a long press gesture recognizer to the view that we want to drag.
  2. Create a snapshot view when user begins to drag.
  3. Handle updating of the dragged snapshot coordinate as user drag the snapshot around the screen.
  4. Detect and handle when the user drops the item into a coordinate location.
  5. Sync the dropped data destination and dragged data source, then update the UI respectively.

Even with all those tasks, it’s really hard to get it working smoothly. Thankfully with the release of iOS 11, Apple finally provided the Drag & Drop API into the Cocoa Touch SDK. While iOS 12 is the latest version of the mobile operating system, iOS 11 was the biggest OS update for iPad since the device initial introduction in January 2010 by Steve Jobs as a third device that sits between PC and Smartphone. It offers support for multitasking with several apps running simultaneously on Split Screen and Slide Over interface. Dock from macOS was also introduced for the first time that allows user to customize their favorite apps and recently used apps.

Drag & Drop API is the best way to communicate between apps. Here are several of the main features:

  1. Support system-wide drag & drop on iOS. Move text, images, and files between apps using Split View or dragging to app icon on Dock.
  2. The API automatically handles all the animations when dragging and dropping a view. Developers only need to implement the protocols for greater flexibility and customization.
  3. Support dragging multiple items by dragging an item and tap on other items to select.

Drag & Drop API in a Nutshell

Based on Apple Documentation page, here are the brief summaries of the Drag & Drop API overview:

  1. Drag & Drop works both in a single app or between multiple apps. In iPhone environment, drag and drop only works in a single app. In multiple apps environment, the app that the user begins to drag a view with will become the source app. The destination app is the app that the user drop the view to.
  2. When the user is in the process of performing dragging and dropping gestures, the system initiates the Drag Activity. The system also manages the state of the object that the user is dragging by Drag Session.
  3. The UIView can be configured to support drag and drop using object that implements UIDragDelegate and UIDropDelegate. Both UITableView and UICollectionView also have its own separated view properties and protocols to handle drag and drop between the cells. The API provides flexibility to customize the behavior of the drag & drop.
  4. The system also securely and automatically handles moving and loading data between apps using UTI (Uniform Type Identifier) for text, images, contacts, and many others.

What We Will Build

In this tutorial, we will use the drag & drop API to build a Trello like app with several basic features:

  1. Create a board.
  2. Add items to a board.
  3. Move items between board using the drag & drop.

To successfully create those features, here are the tasks that we will be implementing in the project:

  1. Application flow diagram.
  2. Create and setup our initial project in Xcode.
  3. Create a model for the Board with list containing the items.
  4. Setup the user interface layout.
  5. Setup the View Controller and Cells.
  6. Handle dragging an item in UITableView with UITableViewDragDelegate.
  7. Handle dropping an item in UITableView with UITableViewDropDelegate.

You can clone the finished project repository in the GitHub repository page at alfianlosari/KanbanDragDropiOS.

Without further ado, let’s get started by understanding the application flow diagram.

Application Flow Diagram

Alt text

To build an app that displays a collection of boards with items, we need several UIKit components to plan our strategy accordingly. Here are the overviews of the flow:

  1. Our main screen will use the UICollectionViewController with UICollectionViewFlowLayout.
  2. We will setup the UICollectionViewLayout scroll direction property to Horizontal so that we can scroll the board horizontally.
  3. We will create a prototype UICollectionViewCell with a UITableView as its Content View. The UICollectionViewController will pass the reference of a Board model when the UICollectionView dequeues its cell.
  4. The UICollectionViewCell will act as the datasource and delegate of the UITableView. It also provides the data for the item when UITableView dequeues its cell.
  5. We will set the UITableView properties to enable the drag interaction, and also set the UICollectionViewCell as the UITableViewDragDelegate and UITableViewDropDelegate of the UITableView.

Getting Started with the Xcode Project

To begin, open Xcode and create a new project. Choose Single View App as the template, and uncheck use unit tests and Core Data. Name the project anything you want and click confirm to create the project.

Delete the ViewController.swift file, then also delete theUIViewController.swift file in main.storyboard. We will come back to the storyboard later to configure the app UI. Next, we’ll create the Model to represent a Board containing list of items.

Create the Model for the Board

Create a new File and name it Board.swift. Inside the file, declare a class with the same name as the file.

class Board: Codable {
    
    var title: String
    var items: [String]
    
    init(title: String, items: [String]) {
        self.title = title
        self.items = items
    }
}

In the class declaration, we declare 2 instance properties with an initializer:

  1. Title of the board as String.
  2. List of items as array of String.

That’s all for our Model. We just keep it simple and clean for the purpose of this tutorial.

Setup the User Interface Layout

Let’s create our app user interface, open main.storyboard and do the following steps:

  1. Click and drag Collection View Controller from the object library.
  2. Select it, then go to Editor, and click on embed in Navigation Controller. Set it as the Initial View Controller.
  3. Select the Collection View inside the Collection View Controller.
  4. In the Attribute Inspector, set the property of Scroll Direction to Horizontal, and set the background color to #0070BF.
  5. In the Size Inspector, set the min spacing to 10 for both Cells and Lines. Then, set the Section Insets for all directions to 0. At last, set the Cell Size width to 320 and height to 480.
  6. Select the Collection View Cell and set its identifier to Cell.
  7. Open the object library, then drag a Table View inside the Collection View Cell.
  8. Set the Table View auto layout constraints for leading, trailing, top, bottom to 0. Make sure to uncheck Constrain to margins.
  9. Drag a Table View Cell from the object library into the Table View, set its identifier to Cell. Then, set it’s style to Basic.
  10. Drag an UIView to the TableView Header View, set the height to 44.
  11. Drag an UIButton to the TableView Header View you just create, then set its constraints for all directions to 0. At last, set the title text to Add.

After you perform all those steps above, your storyboard scene should look like this.

Alt text

Setup Board Collection View Cell

Create a new File and name it BoardCollectionViewCell. Type or paste this snippet of code below into the file.

import UIKit
class BoardCollectionViewCell: UICollectionViewCell {
    
    @IBOutlet weak var tableView: UITableView!
    var board: Board?
    weak var parentVC: BoardCollectionViewController?
    
    override func awakeFromNib() {
        super.awakeFromNib()
        
        self.layer.masksToBounds = true
        self.layer.cornerRadius = 10.0
tableView.dataSource = self
        tableView.delegate = self
        tableView.tableFooterView = UIView()
    }
    
    func setup(with board: Board) {
        self.board = board
        tableView.reloadData()
    }
    
    @IBAction func addTapped(_ sender: Any) {
        let alertController = UIAlertController(title: "Add Item", message: nil, preferredStyle: .alert)
        alertController.addTextField(configurationHandler: nil)
        alertController.addAction(UIAlertAction(title: "Add", style: .default, handler: { (_) in
            guard let text = alertController.textFields?.first?.text, !text.isEmpty else {
                return
            }
            
            guard let data = self.board else {
                return
            }
            
            data.items.append(text)
            let addedIndexPath = IndexPath(item: data.items.count - 1, section: 0)
            
            self.tableView.insertRows(at: [addedIndexPath], with: .automatic)
            self.tableView.scrollToRow(at: addedIndexPath, at: UITableView.ScrollPosition.bottom, animated: true)
        }))
        
        alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
        parentVC?.present(alertController, animated: true, completion: nil)
    }
}
extension BoardCollectionViewCell: UITableViewDataSource, UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return board?.items.count ?? 0
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return board?.title
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = "\(board!.items[indexPath.row])"
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
    
}

Here are the brief explanation of what the code does:

  1. Create a UICollectionViewCell subclass with the name of BoardCollectionViewCell.
  2. We declare 3 instance properties: the tableView, board model, and weak reference to the BoardCollectionViewController.
  3. Override awakeFromNib method so that we can set the cell’s layer to make the corners a bit rounded. Next, we set the table view’s delegate and datasource to the BoardCollectionViewCell.
  4. We create an IBAction method which is invoked when the user taps on the Add button. Inside the method, we create a UIAlertController with alert style, and add a UITextField to enter the name of the item to be added to the board. When a user adds the item, we just append it to the board model items array. Then, we tell the Table View to insert the new row at the bottom and scroll to the specifiedIndexPath.
  5. Create the setupWithBoard method that accepts a Board model as the parameter. It stores the passed board into the instance property, then invoke the Table View reloadData method to update the view.
  6. Create an extension that implements the UITableViewDataSource and UITableViewDelegate.
  7. For the tableView:numberOfRowsInSection:, we just return the number of items in our board.
  8. In tableView:cellForRowAtIndexPath:, we dequeue the Table View Cell with identifier we set in storyboard, get the item using the IndexPath row from the board items, and set the cell textLabel text property with the item to display it.

That’s it, make sure you have set the class of the UICollectionViewCell in the storyboard as BoardCollectionViewCell. Then connect the Add Button touchUpInside action to the addTapped: selector.

Setup Board Collection View Controller

Create a new File and name it BoardCollectionViewController. Type or paste this snippet of code below into the file.

import UIKit

class BoardCollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
    
    var boards = [
        Board(title: "Todo", items: ["Database Migration", "Schema Design", "Storage Management", "Model Abstraction"]),
        Board(title: "In Progress", items: ["Push Notification", "Analytics", "Machine Learning"]),
        Board(title: "Done", items: ["System Architecture", "Alert & Debugging"])
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupAddButtonItem()
        updateCollectionViewItem(with: view.bounds.size)
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        updateCollectionViewItem(with: size)
    }
  
    private func updateCollectionViewItem(with size: CGSize) {
        guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else {
            return
        }
        layout.itemSize = CGSize(width: 225, height: size.height * 0.8)
    }
    
    func setupAddButtonItem() {
        let addButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addListTapped(_:)))
        navigationItem.rightBarButtonItem = addButtonItem
    }

    @objc func addListTapped(_ sender: Any) {
        let alertController = UIAlertController(title: "Add List", message: nil, preferredStyle: .alert)
        alertController.addTextField(configurationHandler: nil)
        alertController.addAction(UIAlertAction(title: "Add", style: .default, handler: { (_) in
            guard let text = alertController.textFields?.first?.text, !text.isEmpty else {
                return
            }
            
            self.boards.append(Board(title: text, items: []))
            
            let addedIndexPath = IndexPath(item: self.boards.count - 1, section: 0)
            
            self.collectionView.insertItems(at: [addedIndexPath])
            self.collectionView.scrollToItem(at: addedIndexPath, at: UICollectionView.ScrollPosition.centeredHorizontally, animated: true)
        }))
        
        alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
        present(alertController, animated: true)
    }
    
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return boards.count
    }
    
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! BoardCollectionViewCell
        
        cell.setup(with: boards[indexPath.item])
        return cell
    }
    
}

Here is a brief summary of what the code actually do:

  1. Create a subclass of UICollectionViewController with the name of BoardCollectionViewController. It also implements the UICollectionDelegateFlowLayout because we have set our Collection View Controller to use the flow layout in the storyboard.
  2. In viewDidLoad, we invoke the setupAddButtonItem method. This method adds an UIBarButtonItem to the NavigationBar right bar button item. We set the target action selector to the addListTapped: method. At last, we invoke the updateCollectionViewItem passing the size of our view to set the item size of the cell.
  3. In updateCollectionViewItem method, we calculate the height of Collection View Cell item dynamically to be 0.8 of the view height. The width of the cell is fixed at 225 points. This method will also be invoked when the screen get rotated passing the new size of the view .
  4. When addListTapped is invoked, we create an UIAlertController with alert style. Next, we add the UITextField to enter the name of the board that the user wants to create. After the user fills the Text Field and confirm, we create a new Board with the text from the user as the title property and append it to the Board array. Lastly, we tell the Collection View to insert the new item to the last indexPath and scroll automatically to the new position.
  5. For the collectionView:numberOfItemsInSection:, we simply return the size of the boards array.
  6. For the collectionView:cellForItemAtIndexPath:, we dequeue the cell using the identifier we set in storyboard and cast it to BoardCollectionViewCell. Then, we get the board using the indexPath item and invoke the cell’s setupWithBoard: method passing the board to the cell.

Run the app for the first time

Try to build and run the app for the first time, you should see the list of boards with their items. Also, try to add new boards and items, and scroll horizontally around the screen. Next, we are going to implement the Drag & Drop API into the Table View to move the items around the boards.

Alt text

Handle Dragging an Item in UITableView with UITableViewDragDelegate

Adding the drag support to the Table View is pretty straightforward. All we need to do is set 2 properties, the dragInteractionEnabled to true and dragDelegate to the BoardCollectionViewCell. Open the BoardCollectionViewCell and go to theawakeFromNib method and add these lines:

...
    override func awakeFromNib() {
     ....
   tableView.dragInteractionEnabled = true
      tableView.dragDelegate = self
    }
...

The compiler will complain because the BoardCollectionViewCell has not implemented the UITableViewDragDelegate yet. So, go to the bottom of the file and add the snippet code below as the extension.

extension BoardCollectionViewCell: UITableViewDragDelegate {
    
   func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        guard let board = board, let stringData = board.items[indexPath.row].data(using: .utf8) else {
            return []
        }
        
        let itemProvider = NSItemProvider(item: stringData as NSData, typeIdentifier: kUTTypePlainText as String)
        let dragItem = UIDragItem(itemProvider: itemProvider)
        session.localContext = (board, indexPath, tableView)
        
        return [dragItem]
    }
    
}

Here are the explanations of what the UITableViewDragDelegate does:

Alt text
  1. The UITableViewDragDelegate requires the delegate to implement the method tableView:itemsForBeginningSession:atIndexPath: that returns the array of UIDragItem objects. This method will be used by the Table View to begin drag session with a given indexPath. We need to return the array of the UIDragItem that is not empty if we want to start the drag session.
  2. The UIDragItem is initialized by passing the NSDragItemProvider. The NSDragItemProvider is the data representation of the items we want to drag and drop to. It uses the UTIs (Uniform Type Identifiers to represent data such as Plain Text, Image, Contact Card and many others. It acts as a contract/promise between the drag source app and the drop destination app to handle and load those data based on those representations.
  3. Inside the method, we get the item from the board using the indexPath and convert it to theData type using utf8 as the encoding.
  4. We initialize the NSItemProvider passing the data and set its type identifier to kUTTypePlainText. To be able to use the constant, you need to import MobileCoreServices at the top of your source file.
  5. Next, we initialize the UIDragItem with the item provider. Also, we set the localContext property to attach additional information to the drag item. Here, we assign the tuple containing the board, indexPath, and the table view which will be used later when we drop an item to different Table View.
  6. At last, we return the array containing the UIDragItem we created.

Try to build and run the app, then perform dragging of an item inside the board. Voila! You can initiate the dragging of an item around the screen. Surely, dropping the item won’t do anything right now. Let’s implement the dropping of an item next!

Alt text

Handle Dropping an Item in UITableView with UITableViewDropDelegate

Next, we are going to add the drop support to the Table View. It’s almost the same with adding drag support. We just need to set the Table View dropDelegate property to the BoardCollectionViewCell. Go to the BoardCollectionViewCell awakeFromNib method and add this at the bottom.

...
    override func awakeFromNib() {
     ....
      tableView.dropDelegate = self
    }
...

The compiler will complain because the BoardCollectionViewCell has not implemented the UITableViewDropDelegate yet. So, go to the bottom of the file, and add the snippet code below as the extension.

extension BoardCollectionViewCell: UITableViewDropDelegate {
    
    func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
        if coordinator.session.hasItemsConforming(toTypeIdentifiers: [kUTTypePlainText as String]) {
            coordinator.session.loadObjects(ofClass: NSString.self) { (items) in
                guard let string = items.first as? String else {
                    return
                }
                var updatedIndexPaths = [IndexPath]()
                
                switch (coordinator.items.first?.sourceIndexPath, coordinator.destinationIndexPath) {
                case (.some(let sourceIndexPath), .some(let destinationIndexPath)):
                    // Same Table View
                    if sourceIndexPath.row < destinationIndexPath.row {
                        updatedIndexPaths =  (sourceIndexPath.row...destinationIndexPath.row).map { IndexPath(row: $0, section: 0) }
                    } else if sourceIndexPath.row > destinationIndexPath.row {
                        updatedIndexPaths =  (destinationIndexPath.row...sourceIndexPath.row).map { IndexPath(row: $0, section: 0) }
                    }
                    self.tableView.beginUpdates()
                    self.board?.items.remove(at: sourceIndexPath.row)
                    self.board?.items.insert(string, at: destinationIndexPath.row)
                    self.tableView.reloadRows(at: updatedIndexPaths, with: .automatic)
                    self.tableView.endUpdates()
                    break
                    
                case (nil, .some(let destinationIndexPath)):
                    // Move data from a table to another table
                    self.removeSourceTableData(localContext: coordinator.session.localDragSession?.localContext)
                    self.tableView.beginUpdates()
                    self.board?.items.insert(string, at: destinationIndexPath.row)
                    self.tableView.insertRows(at: [destinationIndexPath], with: .automatic)
                    self.tableView.endUpdates()
                    break
                    
                    
                case (nil, nil):
                    // Insert data from a table to another table
                    self.removeSourceTableData(localContext: coordinator.session.localDragSession?.localContext)
                    self.tableView.beginUpdates()
                    self.board?.items.append(string)
                    self.tableView.insertRows(at: [IndexPath(row: self.board!.items.count - 1 , section: 0)], with: .automatic)
                    self.tableView.endUpdates()
                    break
                    
                default: break
                    
                }
            }
        }
    }
    
    func removeSourceTableData(localContext: Any?) {
        if let (dataSource, sourceIndexPath, tableView) = localContext as? (Board, IndexPath, UITableView) {
            tableView.beginUpdates()
            dataSource.items.remove(at: sourceIndexPath.row)
            tableView.deleteRows(at: [sourceIndexPath], with: .automatic)
            tableView.endUpdates()
        }
    }
    
    func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
        return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
    }
    
}

Here is a brief summary of what the code do:

  1. We implement the required method tableView:dropSessionDidUpdateSession:withDestinationIndexPath:. What this method does is telling the system how we want to consume the dropped item data via the UITableViewDragProposal in the specified indexPath when user dragging the item around the Table View.
  2. The UITableViewDragProposal accepts 3 types of operations, copy, move, or cancel. In our app case, we only want to move the data, so we return the UITableViewDragProposal with the move as the operation and set the intent to insertAtDestinationIndexPath.
  3. At last, we need to implement tableView:performDropWithCoordinator:. This method will be invoked when user lift the finger from the screen. Inside the method, we have the opportunity to load dragged item data representation from the coordinator’s session drag items. We also need to handle syncing of our datasource. Then, we ask the Table View to perform animations of the move operation. Remember that we can drag item between different Table Views.
  4. Inside the method, we asks the coordinator’s session if it has an item that conforms to the kUTTypePlainText. If yes, we load the object using the loadObjectOfClass method passing the NSString as the type. The system will perform the loading of the dragged item data and pass it into a closure.
  5. We use a switch conditional statement for the dragged item source indexPath and the session destination indexPath.
  6. If the source indexPath and destination indexPath exist, that means the user is dragging and dropping within the same Table View. Here, we just remove the source item from the board using the source indexPath, then insert the item using the destination indexPath. We then tell the Table View to reload the rows between the source and destination indexPath.
  7. If the sourceIndexPath is nil and the destination indexPath exists, that means the user is dragging and dropping between different Table View in same app. To handle this, we can retrieve the source table view, source IndexPath, and source board using the localContext from the drag session that we attach in UITableViewDragDelegate method. We remove the items from the source board using the source indexPath row and tell the source table view to delete the row.At last, we insert the item to the destination board using destination indexPath row and tell the destination table view to insert the row.
  8. The last case is when the destination and source indexPath are nil. This happens when user drag a text from a different app to our app. In this case we just need to insert the item into our destination board and tells the Table View to insert the item.

Try to build and run the app, you should be able to move the item around the boards using Drag & Drop! If you want more challenges, you can check the project GitHub repository to see how to handle the deletion of an item by dropping the item to custom UIView with UIDragDelegate.

Conclusion

Congratulations, you made it! We have finished building our Trello like app using the Drag & Drop API. The API is really simple and powerful to use and we only scratch the surface of the what the API can do here. There are many more features to explore, like creating a placeholder when dropping an item that will took time to load the data, drag and drop multiple items, and many other awesome features. So what are you waiting for? Let’s keep learning and build new things. Improve our world using technology to make it a better place for us to live. Stay calm and keep on 😘 Cocoa. Drag an Drop API Apple Developer.