Skip to content

Latest commit

 

History

History
567 lines (444 loc) · 21.2 KB

File metadata and controls

567 lines (444 loc) · 21.2 KB

Moveable Cells

There are two ways you can move UITableViewCells around in UIKit.

  1. Edit Mode
  2. Long Press

Edit Mode

demo

Edit mode is a mode you put a UITableView in when you call.

tableView.setEditing(true, animated: true)

This animates the table making the red delete buttons pop up, and when you click out of edit mode, they disappear.

To support edit you need to implement these two methods. canEditRowAt make each row edittable. And commit is what gets called when your changes are committed. In this example is is a Diffable Data Source.

// MARK: editing support

override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return true
}

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    // when deleting a row...
    if editingStyle == .delete {
        if let identifierToDelete = itemIdentifier(for: indexPath) {
            var snapshot = self.snapshot()
            snapshot.deleteItems([identifierToDelete])
            apply(snapshot)
        }
    }
}

Making the cells moveable

When in edit mode you also get the grabble hamburger menu items on the right hand side of each cell that you can grab and move.

The way this works is pointers, or source/destinationIdentifiers keep track of which row is selected, and where it is being asked to be moved to. Once there, the Diffable Data Source updates itself, and handles all the animations to make the move happen. If we weren't using a Diffable Data source we would have to do all that logic ourselves. Like in our next example with Long Press.

// MARK: reordering support
    
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
    return true
}
    
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {

    // get our source & destination (from & to)
    guard let sourceIdentifier = itemIdentifier(for: sourceIndexPath) else { return }
    guard sourceIndexPath != destinationIndexPath else { return }
    let destinationIdentifier = itemIdentifier(for: destinationIndexPath)
    
    // take snapshot before
    var snapshot = self.snapshot()

    // if moving within a section
    if let destinationIdentifier = destinationIdentifier {
        
        guard let sourceIndex = snapshot.indexOfItem(sourceIdentifier) else { return }
        guard let destinationIndex = snapshot.indexOfItem(destinationIdentifier) else { return }

        // delete it
        snapshot.deleteItems([sourceIdentifier])

        // figure out if before or after (and double check same section)
        let isAfter = destinationIndex > sourceIndex &&
            snapshot.sectionIdentifier(containingItem: sourceIdentifier) ==
            snapshot.sectionIdentifier(containingItem: destinationIdentifier)
                        
        // insert back either before or after
        if isAfter {
            snapshot.insertItems([sourceIdentifier], afterItem: destinationIdentifier)
        } else {
            snapshot.insertItems([sourceIdentifier], beforeItem: destinationIdentifier)
        }

    }
    // move between sections
    else {
        // get the new destination section
        let destinationSectionIdentifier = snapshot.sectionIdentifiers[destinationIndexPath.section]
        // delete where it was
        snapshot.deleteItems([sourceIdentifier])
        // add to where it is going
        snapshot.appendItems([sourceIdentifier], toSection: destinationSectionIdentifier)
    }
    
    apply(snapshot, animatingDifferences: false)
}

Source Edit Mode

//
//  EditMode.swift
//  DemoArcade
//
//  Created by Jonathan Rasmusson Work Pro on 2020-04-10.
//  Copyright © 2020 Rasmusson Software Consulting. All rights reserved.
//

/*
Abstract:
Sample demonstrating UITableViewDiffableDataSource using editing, reordering and header/footer titles support
*/

import UIKit

class EditMode: UIViewController {

    enum Section: Int {
        case visited = 0, bucketList
        func description() -> String {
            switch self {
            case .visited:
                return "Visited"
            case .bucketList:
                return "Bucket List"
            }
        }
        func secondaryDescription() -> String {
            switch self {
            case .visited:
                return "Trips I've made!"
            case .bucketList:
                return "Need to do this before I go!"
            }
        }
    }
    
    typealias SectionType = Section
    typealias ItemType = MountainsController.Mountain
    
    let reuseIdentifier = "reuse-id"
    
    // Subclassing our data source to supply various UITableViewDataSource methods
    
    class MoveableCellDataSource: UITableViewDiffableDataSource<SectionType, ItemType> {
        
        // MARK: header/footer titles support
        
        override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
            let sectionKind = Section(rawValue: section)
            return sectionKind?.description()
        }
        override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
            let sectionKind = Section(rawValue: section)
            return sectionKind?.secondaryDescription()
        }
        
        // MARK: reordering support
        
        override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
            return true
        }
        
        override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {

            // get our source & destination (from & to)
            guard let sourceIdentifier = itemIdentifier(for: sourceIndexPath) else { return }
            guard sourceIndexPath != destinationIndexPath else { return }
            let destinationIdentifier = itemIdentifier(for: destinationIndexPath)
            
            // take snapshot before
            var snapshot = self.snapshot()

            // if moving within a section
            if let destinationIdentifier = destinationIdentifier {
                
                guard let sourceIndex = snapshot.indexOfItem(sourceIdentifier) else { return }
                guard let destinationIndex = snapshot.indexOfItem(destinationIdentifier) else { return }

                // delete it
                snapshot.deleteItems([sourceIdentifier])

                // figure out if before or after (and double check same section)
                let isAfter = destinationIndex > sourceIndex &&
                    snapshot.sectionIdentifier(containingItem: sourceIdentifier) ==
                    snapshot.sectionIdentifier(containingItem: destinationIdentifier)
                                
                // insert back either before or after
                if isAfter {
                    snapshot.insertItems([sourceIdentifier], afterItem: destinationIdentifier)
                } else {
                    snapshot.insertItems([sourceIdentifier], beforeItem: destinationIdentifier)
                }

            }
            // move between sections
            else {
                // get the new destination section
                let destinationSectionIdentifier = snapshot.sectionIdentifiers[destinationIndexPath.section]
                // delete where it was
                snapshot.deleteItems([sourceIdentifier])
                // add to where it is going
                snapshot.appendItems([sourceIdentifier], toSection: destinationSectionIdentifier)
            }
            
            apply(snapshot, animatingDifferences: false)
        }
        
        // MARK: editing support

        override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
            return true
        }

        override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
            // when deleting a row...
            if editingStyle == .delete {
                if let identifierToDelete = itemIdentifier(for: indexPath) {
                    var snapshot = self.snapshot()
                    snapshot.deleteItems([identifierToDelete])
                    apply(snapshot)
                }
            }
        }
    }
    
    var dataSource: MoveableCellDataSource!
    var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureHierarchy()
        configureDataSource()
        configureNavigationItem()
    }
}

extension EditMode {
    func configureHierarchy() {
        tableView = UITableView(frame: .zero, style: .insetGrouped)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: reuseIdentifier)
        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])
    }
    
    func configureDataSource() {
        let formatter = NumberFormatter()
        formatter.groupingSize = 3
        formatter.usesGroupingSeparator = true
        
        // data source
        
        dataSource = MoveableCellDataSource(tableView: tableView) { (tableView, indexPath, mountain) -> UITableViewCell? in
            guard let cell = tableView.dequeueReusableCell(withIdentifier: self.reuseIdentifier) else {
                return UITableViewCell(style: .subtitle, reuseIdentifier: self.reuseIdentifier)
            }

            cell.textLabel?.text = mountain.name

            if let formattedHeight = formatter.string(from: NSNumber(value: mountain.height)) {
                cell.detailTextLabel?.text = "\(formattedHeight)M"
            }

            return cell
        }
        
        // initial data
        
        let snapshot = initialSnapshot()
        dataSource.apply(snapshot, animatingDifferences: false)
    }
    
    func initialSnapshot() -> NSDiffableDataSourceSnapshot<SectionType, ItemType> {
        let mountainsController = MountainsController()
        let limit = 8
        let mountains = mountainsController.filteredMountains(limit: limit)
        let bucketList = Array(mountains[0..<limit / 2])
        let visited = Array(mountains[limit / 2..<limit])

        var snapshot = NSDiffableDataSourceSnapshot<SectionType, ItemType>()
        snapshot.appendSections([.visited])
        snapshot.appendItems(visited)
        snapshot.appendSections([.bucketList])
        snapshot.appendItems(bucketList)
        return snapshot
    }
    
    func configureNavigationItem() {
        navigationItem.title = "Edit Mode"
        let editingItem = UIBarButtonItem(title: tableView.isEditing ? "Done" : "Edit", style: .plain, target: self, action: #selector(toggleEditing))
        navigationItem.rightBarButtonItems = [editingItem]
    }
    
    @objc
    func toggleEditing() {
        tableView.setEditing(!tableView.isEditing, animated: true)
        configureNavigationItem()
    }
}

Long Press

demo

Long Press uses a UILongPressGestureRecognizer to detect when the user has pressed a row in the table.

let longpress = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureRecognized(_:)))
tableView.addGestureRecognizer(longpress)

And then does a whole bunch of magic to take a picture (build a UIView) of the cell that was tapped, animates it up above the UITableView, moves it around with the gesture, and then animates/fades it back into the table.

Source Long Press

//
//  LongPress.swift
//  DemoArcade
//
//  Created by Jonathan Rasmusson Work Pro on 2020-04-10.
//  Copyright © 2020 Rasmusson Software Consulting. All rights reserved.
//

/*
Abstract:
Sample demonstrating UILongPressGestureRecognizer to move cells within a UITableView.
*/

import UIKit

class LongPress: UIViewController {
    
    var games = ["Space Invaders",
                "Dragon Slayer",
                "Disks of Tron",
                "Moon Patrol",
                "Galaga"]

    let cellId = "cellId"
    
    var tableView = UITableView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
    }
    
    func setupViews() {
        navigationItem.title = "UITableView"
        
        tableView.delegate = self
        tableView.dataSource = self
        
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellId)
        
        view = tableView
        
        ///
        let longpress = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureRecognized(_:)))
        tableView.addGestureRecognizer(longpress)
        ///
    }

}

// MARK: - LongPress

///
/// The way this works is
///  - the gestureRecognizer figure out where in the tableView we are (i.e. which row)
///  - it then creates an image of that row, and if the gesture moves enough to warrant a reorder, it
///     - adds it to the view
///     - pops it off the screen
///     - tells the tableView to move the cell
///     - and updates our data source

extension LongPress {
    
    @objc func longPressGestureRecognized(_ gestureRecognizer: UIGestureRecognizer) {
        
        // Figure out where we are in the tableView (i.e. which row was selected)
        let longPress = gestureRecognizer
        let state = longPress.state
        let locationInView = longPress.location(in: tableView)
        let indexPath = tableView.indexPathForRow(at: locationInView)
        
        // Create some inhouse data structures to track state and where we are
        struct My {
            static var cellSnapshot : UIView? = nil
            static var cellIsAnimating : Bool = false
            static var cellNeedToShow : Bool = false
        }
        
        struct Path {
            static var initialIndexPath : IndexPath? = nil
        }
        
        switch state {
            
        case UIGestureRecognizerState.began:
            
            // if we have have a row
            if indexPath != nil {
                
                // Take a snap shot (build a UIView) of selected row
                Path.initialIndexPath = indexPath
                let cell = tableView.cellForRow(at: indexPath!)
                My.cellSnapshot  = snapshotOfCell(cell!)
                
                // Add snap shot as a subview to the current cell, centered and faded out
                var center = cell?.center
                My.cellSnapshot!.center = center!
                My.cellSnapshot!.alpha = 0.0
                tableView.addSubview(My.cellSnapshot!)
                
                // Animate the snapshot in, while fading the original cell out.
                UIView.animate(withDuration: 0.25, animations: { () -> Void in
                    // Offset for gesture location
                    center?.y = locationInView.y
                    My.cellIsAnimating = true
                    My.cellSnapshot!.center = center!
                    
                    // Scale up our cell to make it look slightly bigger
                    My.cellSnapshot!.transform = CGAffineTransform(scaleX: 1.05, y: 1.05)
                    My.cellSnapshot!.alpha = 0.98
                    
                    // Fade out
                    cell?.alpha = 0.0
                }, completion: { (finished) -> Void in
                    // Once finished
                    if finished {
                        // Animiate original cell back in
                        My.cellIsAnimating = false
                        if My.cellNeedToShow {
                            My.cellNeedToShow = false
                            UIView.animate(withDuration: 0.25, animations: { () -> Void in
                                cell?.alpha = 1
                            })
                        } else {
                            // Keep original cell hidden
                            cell?.isHidden = true
                        }
                    }
                })
            }
            
        case UIGestureRecognizerState.changed:
            // As long as the user is dragging the cell
            if My.cellSnapshot != nil {
                // Move the cell with the drag
                var center = My.cellSnapshot!.center
                center.y = locationInView.y
                My.cellSnapshot!.center = center
                
                // If moves enough to warrant a new index
                if ((indexPath != nil) && (indexPath != Path.initialIndexPath)) {
                    // update data source
                    games.insert(games.remove(at: Path.initialIndexPath!.row), at: indexPath!.row)
                    // tell table to move the row
                    tableView.moveRow(at: Path.initialIndexPath!, to: indexPath!)
                    // update index so in sync with UI changes
                    Path.initialIndexPath = indexPath
                }
            }
        default:
            // Finally, when the gesture either ends or cancels,
            // both the table view and data source are up to date.
            // All you have to do is remove the snapshot from the table view and revert the fadeout.
            
            // For a better user experience, fade out the snapshot and make it smaller to match the size of the cell.
            // It appears as if the cell drops back into place.
            
            // If our cellSnapShot is still around
            if Path.initialIndexPath != nil {
                // Get the tableViewCell
                let cell = tableView.cellForRow(at: Path.initialIndexPath!)
                
                // Check whether we are in the middle of animating (remember we are in a big gesture touch loop).
                
                if My.cellIsAnimating {
                    // Either show our snapshot cell
                    My.cellNeedToShow = true
                } else {
                    // Or show the original cell if we are done
                    cell?.isHidden = false
                    cell?.alpha = 0.0
                }
                
                UIView.animate(withDuration: 0.25, animations: { () -> Void in
                    My.cellSnapshot!.center = (cell?.center)!
                    // Undo the scaling we did earlier
                    My.cellSnapshot!.transform = CGAffineTransform.identity
                    My.cellSnapshot!.alpha = 0.0  // fade out our cell
                    cell?.alpha = 1.0             // fade in the real one
                }, completion: { (finished) -> Void in
                    if finished {
                        Path.initialIndexPath = nil
                        My.cellSnapshot!.removeFromSuperview()
                        My.cellSnapshot = nil
                    }
                })
            }
        }
    }
    
    // Create an offset image of the cell you just selected
    func snapshotOfCell(_ inputView: UIView) -> UIView {
        UIGraphicsBeginImageContextWithOptions(inputView.bounds.size, false, 0.0)
        inputView.layer.render(in: UIGraphicsGetCurrentContext()!)
        let image = UIGraphicsGetImageFromCurrentImageContext()! as UIImage
        UIGraphicsEndImageContext()
        let cellSnapshot : UIView = UIImageView(image: image)
        cellSnapshot.layer.masksToBounds = false
        cellSnapshot.layer.cornerRadius = 0.0
        cellSnapshot.layer.shadowOffset = CGSize(width: -5.0, height: 0.0)
        cellSnapshot.layer.shadowRadius = 5.0
        cellSnapshot.layer.shadowOpacity = 0.4
        return cellSnapshot
    }
    
}

// MARK: - UITableView

extension LongPress: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

    }
}

extension LongPress: UITableViewDataSource {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)
        cell.textLabel?.text = games[indexPath.row]
        return cell
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return games.count
    }
}

Video

Links that help