User interactions with UICollectionView

In the previous chapter, you saw how a UITableView uses delegation to handle user interactions. If a user does something with a cell, for instance, tap it, the delegate can respond to that and perform a certain action in response. The UICollectionView works exactly the same way, except that some of the details may vary. A UICollectionView can't be reordered as easily, for example, and it doesn't support swipe gestures for deletion. Because of this, these actions don't have any corresponding delegate methods in UICollectionViewDelegate. They can be implemented regardless, and in this subsection, you'll see how you can do it.

The interactions you'll implement are as follows:

  • Cell selection
  • Cell deletion
  • Cell reordering

Cell selection is the easiest to implement; the collection view has a delegate method for this. Cell deletion and reordering are a little bit harder because you'll need to write some custom code for them to work. So, let's start with the easy one; cell selection.

Cell selection

Implementing cell selection for UICollectionView works the same for UICollectionView as it does for UITableView. With the knowledge you gained from Chapter 1, UITableView Touch Up , you should be able to implement this on your own. However, because the previous chapter just showed a simple alert view, it might be nice to implement something that's more interesting now. If you tap on a cell right now, nothing really happens. However, users like to see some feedback on their actions. So let's implement some of that precious touch feedback.

The feedback that you'll implement is in the form of movement. The tapped cell will slightly shrink and expand its image view with a little bounce effect. In Chapter 4, Immersing Your Users with Animation, we'll go more in-depth with animation and some of the powerful things you can do with it. UIKit provides powerful and pretty easy-to-use methods to animate views with. Add the following code in collectionView(_:didSelectItemAt:):

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 
guard let cell = collectionView.cellForItem(at: indexPath) as? ContactCollectionViewCell else { return } 
 
    UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseOut], animations: { 
        cell.contactImage.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) 
        }, completion: { finished in 
            UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseIn], animations: { 
                cell.contactImage.transform = CGAffineTransform.identity 
                }, completion: nil) 
    }) 
} 

This first thing to note in the preceding snippet is that you can ask UICollectionView for a cell based on an IndexPath. The cellForItem(_:) method returns an optional UICollectionViewCell. There might not be a cell at the requested IndexPath; if this is the case, this method will return nil. Otherwise, a UICollectionView instance is returned. This needs to be cast to ContactCollectionViewCell. Using as? for this cast makes it safe; it allows the cast to fail. If either the cast fails or there is no cell retrieved, the function gets canceled by returning immediately.

If the cell retrieval succeeds, an animation is started. Animations such as these are created through UIView class's static animate method. This method has a couple of versions, from basic to really advanced. We're using a version that's somewhere in between. We pass it the animation duration, delay, some easing, and the state, that the animation should animate to. Finally, it's passed a completion handler that reverses the animation, removing the transformation from the image.

For now, you don't have to worry about these details and how they can be tweaked. Chapter 4, Immersing Your Users with Animation, will cover animation in depth, and you'll know everything you need to know afterwards. If you test your app now, you can tap on a cell, and as a result, the image view will get a bit smaller and then bigger again. This is the kind of responsive interaction that users love.

Cell deletion

Any good contacts app allows the removal of contacts. The first iteration of HelloContacts implemented this through a swipe gesture on the UITableView. This presented the user with a delete button that removes the contact when it is tapped. This was done with a UITableViewDelegate method.

If you open up the documentation for UICollectionViewDelegate, you'll find that there is no method present for cell editing or deleting. This means that it's up to you to implement the delete behavior yourself. A very naive implementation of this could be to double tap or long-press a cell, remove a contact from the main contacts array, and reload the data on the UICollectionView. If you would implement deletion like this, it would work. However, it wouldn't look very good, and you can do way better.

UICollectionView provides methods that can be used to update the UICollectionView class's content properly. Properly in this context means that the resulting layout update isn't to just jump from one state to the other. Instead, the layout transition will be animated, resulting in a very nice and smooth experience for your user. Being a good iOS programmer isn't just about getting the job done, reloading the content would get the job done, but it's also about creating beautiful user experiences.

So, let's find out which method UICollectionView has implemented to make these animated layout changes possible. To find out, you should open up the documentation for UICollectionView. When you scroll down to the Symbols section, you'll see that there's a subsection called Inserting, Moving and Deleting Items. This is the section we're interested in for the cell deletion. The deleteItems(_:) looks of particular interest for this use case. The discussion section for this method verifies that this method performs the task at hand; deleting cells from a certain IndexPath.

The final interaction pattern and implementation for this functionality will be as follows:

  1. The user long-presses on a cell.
  2. A confirmation will appear in the form of an action sheet.
  3. If the user accepts deletion, the contact will be removed and the layout animates to the new state, and the contact is removed from the contacts array.

    Note

    To detect certain interactions, such as double tapping, swiping, pinching, and long-pressing, we use gesture recognizers. A gesture recognizer is an object provided by UIKit that specializes in detecting certain gestures and invoking a certain selector (method) on a target. This method will then handle the gesture accordingly.

To implement this, add a gesture recognizer to the entire collection view. You don't add the gesture recognizer to the cell because this would mean that more gesture recognizers are active than strictly needed. The handling of taps would also be complicated because we'd need some way to figure out which cell is currently displaying which contact. You'll see that having a single recognizer leads to a pretty simple implementation. Add the following lines to the end of the viewDidLoad method:

let longPressRecognizer = UILongPressGestureRecognizer(target: self,  
action: #selector(self.receivedLongPress(gestureRecognizer:))) 
 
collectionView.addGestureRecognizer(longPressRecognizer) 

The first line sets up the long press gesture recognizer. The target for this gesture recognizer is self. This means that the current instance of our ViewController will be the object that is called whenever the long-press is detected. The second argument is an action. The action is passed in the form of a selector. A selector is written like this: #selector(YOUR_METHOD_HERE). Selectors are roughly the same as references to methods. It tells the long press recognizer that it should call the part between the parentheses on the target that was specified.

The second line adds the gesture recognizer to the collection view. This means that the collectionView will be the view that receives and detects the long press. The ViewController will get notified and handles the long press.

With the recognizer added to the collection view, it's time to implement the receivedLongPress(_:) method as follows:

func receivedLongPress(gestureRecognizer: UILongPressGestureRecognizer) { 
    let tappedPoint = gestureRecognizer.location(in: collectionView) 
 
    guard let tappedIndexPath = collectionView.indexPathForItem(at: tappedPoint), 
        let tappedCell = collectionView.cellForItem(at: tappedIndexPath) else { return } 
 
    let confirmDialog = UIAlertController(title: "Delete this contact?",  
message: "Are you sure you want to delete this contact?",  
preferredStyle: .actionSheet) 
 
    let deleteAction = UIAlertAction(title: "Yes", style: .destructive,  
handler: { action in 
        self.contacts.remove(at: tappedIndexPath.row) 
        self.collectionView.deleteItems(at: [tappedIndexPath]) 
        }) 
    let cancelAction = UIAlertAction(title: "No", style: .cancel, handler: nil) 
 
    confirmDialog.addAction(deleteAction) 
    confirmDialog.addAction(cancelAction) 
 
    if let popOver = confirmDialog.popoverPresentationController { 
        popOver.sourceView = tappedCell 
    } 
 
    present(confirmDialog, animated: true, completion: nil) 
} 

There is quite a lot going on in this method so let's break it down into smaller sections as follows:

let tappedPoint = gestureRecognizer.location(in: collectionView) 
 
guard let tappedIndexPath = collectionView.indexPathForItem(at: tappedPoint), 
    let tappedCell = collectionView.cellForItem(at: tappedIndexPath) else { return } 

This first section is the set-up part for this method. It extracts the tapped point from the gesture recognizer. This point is then used to ask the collection view for the index path that belongs to this point. The found index path is used to find out which cell was tapped. Note that these last two steps are preceded by the guard statement. It's not guaranteed that the user actually long-pressed on a part where there is a cell present in the collection view. So, indexPathForItem(_:) returns an optional value. This also applies to cellForItem(_:). It's not guaranteed that the index path that's passed to that method actually returns a cell. If either of these method calls returns nil, we return immediately; no cell can be deleted for the pressed point. If they do succeed, the code moves on to the next part as follows:

let confirmDialog = UIAlertController(title: "Delete this contact?",  
                message: "Are you sure you want to delete this contact?",  
                preferredStyle: .actionSheet) 
 
let deleteAction = UIAlertAction(title: "Yes", style: .destructive,  
            handler: { action in 
        self.contacts.remove(at: tappedIndexPath.row) 
                self.collectionView.deleteItems(at: [tappedIndexPath]) 
            }) 
let cancelAction = UIAlertAction(title: "No", style: .cancel, handler: nil) 
 
confirmDialog.addAction(deleteAction) 
confirmDialog.addAction(cancelAction) 

The preceding code should look familiar to you. It's very similar to the code that displayed an alert when a user tapped on a cell in the table view. First, it creates a UIAlertController. The main difference here is the preferred style. By using the .actionSheet preferred style, we make sure that this alert is presented as an alert sheet that pops up from the bottom of the screen.

The most interesting part of this snippet is the deleteAction. The handler for this action is called whenever the user selects it in the action sheet. The order of operations inside of the handler is extremely important. Whenever you update a collection view by adding or removing items, your data source must be updated first. If you don't do this, an internal inconsistency error occurs and your app crashes. If you want to see what this looks like, just reverse the operations.

Note

If you update an UICollectionView in a way that updates its actual contents, always make sure to update the underlying first. Failing to do so will make your app crash due to an internal inconsistency error.

The following last couple of lines in the snippet attach the created actions to UIAlertController:

if let popOver = confirmDialog.popoverPresentationController { 
    popOver.sourceView = tappedCell 
} 
 
present(confirmDialog, animated: true, completion: nil) 

This final snippet implements some defensive programming that's not directly relevant to the HelloContacts application for now. The first three lines check whether the confirm dialog has a popoverPresentationController associated with it. For any iPhone-only app, this will always be nil. However, devices with regular/regular trait collections do set this property automatically. The devices do not display an action sheet on the bottom of the screen but use a popover instead. If you fail to provide the sourceRect or sourceView for this property, and if it exists, your app will crash because it doesn't know where to display the popover. Therefore, it's important to be aware of this and whenever possible to go ahead and set this property, just to be safe.

Note

Whenever you display an action sheet, make sure that you add a sourceRect or sourceView to the popoverPresentationController, if it exists. Devices with a larger screen use popovers instead of action sheets, and failing to provide a source for this popover results in crashes.

Then, the final line presents the sheet, just like we did before.

This is all that's needed to implement cell deletion, go ahead and test it by long-pressing on some cells. The action sheet will pop up and if you remove the cell, the update is nicely animated. Even though UICollectionView doesn't provide delegate methods the way UITableView does, the implementation for cell deletion wasn't too hard to come up with.

Now let's have a look at cell reordering.

Cell reordering

Since UICollectionView doesn't support reordering in the same convenient way UITableView does, it takes a bit more work to set it up. For UITableView, we only had to set a property on the cells and implement a delegate method. This was very simple and worked well straight out of the box.

To reorder cells in UICollectionView, it's required to implement a couple of steps. Luckily, the documentation provides great information on reordering; if you look at the page for UICollectionView, you'll see that there are four methods related to reordering. Each needs to be called in a certain state for reordering.

The endInteractiveMovement() and beginInteractiveMovementForItem(at:) methods are interesting because after calling these methods, a data source method on UICollectionView is called. When ending the interactive movement, the UICollectionView will tell the data source to update the selected item to a new index path. When beginning an interactive movement, the UICollectionView will ensure that the movement is supported by the data source.

The collection view won't keep track of moving the cell around on its own; this needs to implemented. A pan gesture recognizer could be added to achieve this, but the existing long press gesture recognizer can also keep track of the movement.

In order to reuse the existing long press gesture recognizer without causing conflicts with deletion, an edit button will be added. If the edit button is active, reordering is enabled and if it's inactive, the deletion of cells is enabled when long-pressing.

The steps to implement cell reordering are as follows:

  1. Refactoring the long press handler so it calls methods based on the editing state to prevent it from becoming a long, confusing method.
  2. Implementing the sequence of methods for cell reordering based on the long press gesture state.
  3. Implementing the required data source methods to allow interactive movement and updating the underlying data.
  4. Adding the edit button to the navigation item.

Refactoring the long press handler

Because the long press handler will now be used differently based on the isEditing state of the ViewController, it's a wise idea to separate the two different paths to different methods. The gesture recognizer handler will still make sure that a valid cell and index path are used and after that it will call out to the correct method. A placeholder for the reorder sequence will be added immediately, and it will be implemented in the next step as follows:

func receivedLongPress(gestureRecognizer: UILongPressGestureRecognizer) { 
    let tappedPoint = gestureRecognizer.location(in: collectionView) 
    guard let tappedIndexPath = collectionView.indexPathForItem(at: tappedPoint), 
        let tappedCell = collectionView.cellForItem(at: tappedIndexPath) else { return } 
 
    if isEditing { 
        reorderContact(withCell: tappedCell, atIndexPath: tappedIndexPath, gesture: gestureRecognizer) 
    } else { 
        deleteContact(withCell: tappedCell, atIndexPath: tappedIndexPath) 
    } 
} 
 
func reorderContact(withCell cell: UICollectionViewCell, atIndexPath indexPath: IndexPath, gesture: UILongPressGestureRecognizer) { 
 
} 
 
func deleteContact(withCell cell: UICollectionViewCell, atIndexPath indexPath: IndexPath) { 
    // implementation from before 
} 

The preceding code demonstrates how there are now two paths with methods that get called based on the edit state. The deleteContact(withCell:UICollectionViewCell, atIndexPath: IndexPath) method is unchanged for the most part, there's just a few variables from the code before that would need to be renamed. This is an exercise for you.

Implementing the reorder method calls

Step two in the process of implementing the reordering of cells is to keep the collection view informed of the state it needs to be in. This is done by tracking the long press gesture recognizer state and calling appropriate methods on the collection view. Any time the long press gesture recognizer updates, either when the gesture was first recognized, ended, moved around, or got canceled, the handler is called. The handler will detect that the ViewController is in edit mode, and the reorder method will be called. Its implementation would look like the following:

func reorderContact(withCell cell: UICollectionViewCell, atIndexPath indexPath: IndexPath, gesture: UILongPressGestureRecognizer) { 
    switch(gesture.state) { 
    case .began: 
        collectionView.beginInteractiveMovementForItem(at: indexPath) 
        UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut], animations: { 
            cell.transform = CGAffineTransform(scaleX: 1.1, y: 1.1) 
            }, completion: nil) 
        break 
    case .changed: 
        collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: collectionView)) 
        break 
    case .ended: 
        collectionView.endInteractiveMovement() 
        break 
    default: 
        collectionView.cancelInteractiveMovement() 
        break 
    } 
 
} 

The gesture's state property is used to switch it on. If the gesture just began, the collection view will enter the reordering mode. We also perform an animation on the cell so the users can see that their gesture was properly registered.

If the gesture has changed, because the user dragged the cell around, we tell this to the collection view. The current position of the gesture is passed along so that the cell can be moved to the correct direction. If needed, the entire layout will animate to show the cell in its new location.

If the gesture ended, the collection view is just notified of this. The default case is to cancel the interactive movement. Any state that isn't in the states above is invalid for this use case, and the collection view should reset itself as if the editing never even began. The next step is to implement the required data source methods so the method calls above are allowed to do their work.

Implementing the data source methods

There are two required methods to implement from UICollectionViewDataSource. The first method will tell the collection view whether it's okay for a certain item to be moved around. The second is responsible for updating the underlying data source based on the new cell order. Let's jump to the implementation right away as follows:

func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool { 
    return true 
} 
 
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { 
    let movedContact = contacts.remove(at: sourceIndexPath.row) 
    contacts.insert(movedContact, at: destinationIndexPath.row) 
} 

These implementations should be fairly straightforward. If asked whether an item can move, the return value is true because in this app all items can move. Also, updating the order in the data is done just the same as it was done in the previous chapter.

Adding the edit button

To tie this all together, the edit button needs to be implemented. This button will toggle the ViewController edit mode, and it enables the rest of the code we implemented to do its job. First add the button to the navigationItem, just like before as follows:

navigationItem.rightBarButtonItem = editButtonItem() 

After doing this, everything will already work! However, it will not be very clear to the user that something is different from before. The cells will look exactly the same as they did earlier and that's not ideal. In a perfect world, the cells would start shaking, just like they do on the user's Springboard when they want to rearrange apps. We won't implement this for now since we haven't covered animations in depth yet, and this animation would be pretty advanced. What we'll do for now is change the cell's background color using the following code. This should give the user some indication that they're in a different mode than before:

override func setEditing(_ editing: Bool, animated: Bool) { 
    super.setEditing(editing, animated: animated) 
 
    for visibleCell in collectionView.visibleCells() { 
        guard let cell = visibleCell as? ContactCollectionViewCell 
          else { continue } 
 
        if editing { 
            UIView.animate(withDuration: 0.2, delay: 0, options: 
              [.curveEaseOut], animations: { 
                cell.backgroundColor = UIColor(red: 0.9, green: 0.9, 
                  blue: 0.9, alpha: 1) 
                }, completion: nil) 
        } else { 
            UIView.animate(withDuration: 0.2, delay: 0, options: 
              [.curveEaseOut], animations: { 
                cell.backgroundColor = UIColor.clear() 
                }, completion: nil) 
        } 
    } 
} 

The setEditing(editing:Bool, animated: Bool) method is a great place to kick off a quick animation that changes the cell's background color. If the cell is in editing mode, the color is set to a very light gray, and if the cell is in normal mode, the background color is reset to the default clear color.

This is everything we need to do to allow the user to reorder their contacts. Despite the lack of a simple property and delegate method, this wasn't too complex. UICollectionView actually has a good support for reordering and implementing; it is very doable. If you're using a UICollectionViewController instead of a UIViewController to display the contacts, reordering is very easy. You only need to implement the data source methods; all the other work is baked right in to the UICollectionViewController. Now you know how.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.118.32.222