41

UICollectionView Tutorial: Reusable Views, Selection and Reordering [FREE]

 5 years ago
source link: https://www.tuicool.com/articles/hit/iUbyY3E
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Update note : Mark Struzinski updated this tutorial for iOS 12, Swift 4.2 and Xcode 10. Brandon Trebitowski wrote the original.

In this UICollectionView tutorial, you’ll learn how to implement reusable views for section headers, select cells, update your layout based on selection, and reorder with drag-and-drop.

You learned how to use the Flickr API to populate a list of cells in a UICollectionView in an earlier tutorial . This tutorial uses that project as a starting point.

You can download the starter project using the Download Materials button at the top of this page. You’ll need to update the API key in the project; the earlier tutorial explains how to do this.

Adding Section Headers

The app currently displays a new section for each search performed. You’ll add a new section header, using the search text as the section title. To display this section header, you’ll use UICollectionReusableView .

This class is similar to UICollectionViewCell , except it usually displays headers and footers. You’ll add this element directly to the storyboard and create a subclass so it can dynamically update its title.

Ready to get started?

Create a new file to represent the custom class for the header. Select File ▸ New ▸ File . Select Cocoa Touch Subclass , make sure it’s a subclass of UICollectionReusableView , and name it FlickrPhotoHeaderView . Click Next and Create to add the file.

NbQJvqb.png!web

Next, open Main.storyboard and select the collection view in the FlickrPhotosViewController scene. Select the Attributes inspector and check the Section Header checkbox in the Accessories section. Notice that a UICollectionReusableView appears in the storyboard.

i6zIRbv.png!web

Select the reusable view, open the Size inspector and set the height to 90. Back in the Attributes inspector, set the Reuse Identifier to FlickrPhotoHeaderView . Set the Background color to Group Table View Background Color to give it a nice offset from the rest of the collection view. Open the Identity inspector and set the Custom Class to FlickrPhotoHeaderView .

Now, open the object library with the key combination Command-Shift-L and drag a label onto the reusable view. Use the guides to center it. Update the Font to System 32 and use the Auto Layout align button at the bottom of the storyboard window to pin it to the horizontal and vertical center of the view.

7rMFVrQ.png!web

Connecting the Section Header to Data

With the reusable view still selected, open the Assistant editor, making sure that FlickrPhotoHeaderView.swift opens. If it opens to the wrong file, manually open FlickrPhotoHeaderView.swift . Control—drag from the label in the header view over to the file, and name the outlet label .

class FlickrPhotoHeaderView: UICollectionReusableView {
  @IBOutlet weak var label: UILabel!
}

Finally, implement a new data source method to wire everything up. Open FlickrPhotosViewController.swift and add the following method in the UICollectionViewDataSource extension:

override func collectionView(_ collectionView: UICollectionView, 
                             viewForSupplementaryElementOfKind kind: String, 
                             at indexPath: IndexPath) -> UICollectionReusableView {
  // 1
  switch kind {
  // 2
  case UICollectionView.elementKindSectionHeader:
    // 3
    guard 
      let headerView = collectionView.dequeueReusableSupplementaryView(
        ofKind: kind,
        withReuseIdentifier: "\(FlickrPhotoHeaderView.self)",
        for: indexPath) as? FlickrPhotoHeaderView 
      else {
        fatalError("Invalid view type")
    }

    let searchTerm = searches[indexPath.section].searchTerm
    headerView.label.text = searchTerm
    return headerView
  default:
    // 4
    assert(false, "Invalid element type")
  }
}

This method is similar to collectionView(_:cellForItemAt:) , but it returns a UICollectionReusableView instead of a UICollectionViewCell .

Here is the breakdown of this method:

1. Use the kind supplied to the delegate method, ensuring you receive the correct element type.

2. The UICollectionViewFlowLayout supplies UICollectionView.elementKindSectionHeader for you. By checking the header box in an earlier step, you told the flow layout to begin supplying headers. If you weren’t using the flow layout, you wouldn’t get this behavior for free.

3. Dequeue the header using the storyboard identifier and set the text on the title label.

4. Place an assert here to ensure this is the right response type.

This is a good place to build and run. You’ll get a nice section header for each search, and the layout adapts nicely in rotation scenarios on all device types:

mUri6rM.png!web

Interacting With Cells

In this section, you’ll learn some different ways to interact with cells. First, you’ll learn to select a single cell and change the collection view layout to display the selected cell in a larger size. Then, you’ll learn how to select multiple cells to share images. Finally, you’ll use the drag and drop system introduced in iOS 11 to reorder cells by dragging them into new spots.

Selecting a Single Cell

UICollectionView can animate layout changes. In this section, you’ll select a cell and have the layout animate changes in response.

First, add the following computed property to the top of FlickrPhotosViewController . This keeps track of the currently selected cell:

// 1
var largePhotoIndexPath: IndexPath? {
  didSet {
    // 2  
    var indexPaths: [IndexPath] = []
    if let largePhotoIndexPath = largePhotoIndexPath {
      indexPaths.append(largePhotoIndexPath)
    }

    if let oldValue = oldValue {
      indexPaths.append(oldValue)
    }
    // 3
    collectionView.performBatchUpdates({
      self.collectionView.reloadItems(at: indexPaths)
    }) { _ in
      // 4
      if let largePhotoIndexPath = self.largePhotoIndexPath {
        self.collectionView.scrollToItem(at: largePhotoIndexPath,
                                         at: .centeredVertically,
                                         animated: true)
      }
    }
  }
}

Here’s an explanation:

  1. largePhotoIndexPath is an Optional that holds the currently selected photo item.
  2. When this property changes, you must also update the collection view. didSet is an easy way to manage this. You may have two cells that need to be reloaded if the user had previously selected a different cell or tapped the same cell a second time to deselect it.
  3. performBatchUpdates(_:completion:) will animate changes to the collection view.
  4. Once the animation completes, scroll the selected cell to the middle of the screen.

Tapping a cell will cause the collection view to select it. You want to take this opportunity to set the largePhotoIndexPath property, but you don’t want to actually select it. Selecting it might cause issues later when you’re implementing multiple selection. UICollectionViewDelegate helps you here.

In a new extension at the bottom of the file, implement the following method which tells the collection view if it should select a specific cell:

// MARK: - UICollectionViewDelegate
extension FlickrPhotosViewController {
  override func collectionView(_ collectionView: UICollectionView, 
                               shouldSelectItemAt indexPath: IndexPath) -> Bool {
    if largePhotoIndexPath == indexPath {
      largePhotoIndexPath = nil
    } else {
      largePhotoIndexPath = indexPath
    }

    return false
  }
}

This method is pretty simple. If the IndexPath of the cell the user tapped is already selected, set largePhotoIndexPath to nil . Otherwise, set it to the current value of indexPath . This will fire the didSet property observer you just implemented.

To update the size of the cell the user just tapped, you need to modify collectionView(_:layout:sizeForItemAt:) . Add the following code to the beginning of the method:

if indexPath == largePhotoIndexPath {
  let flickrPhoto = photo(for: indexPath)
  var size = collectionView.bounds.size
  size.height -= (sectionInsets.top + sectionInsets.bottom)
  size.width -= (sectionInsets.left + sectionInsets.right)
  return flickrPhoto.sizeToFillWidth(of: size)
}

This logic calculates the size of the cell to fill as much of the collection view as possible while maintaining the cell’s aspect ratio. Since you’re increasing the size of the cell, you need a larger image to make it look good. This requires downloading the larger image upon request.

Providing Selection Feedback

Next, follow the steps to add some UI feedback showing activity while the image downloads.

Open Main.storyboard and open your object library. Drag an activity indicator onto the image view in the collection view cell. Open the Attributes inspector, set the style to Large White , and check the Hides When Stopped button. Using the layout guides, drag the indicator to the center of the ImageView, then use the Align menu to set constraints to center the indicator horizontally and vertically.

Open the Assistant editor, making sure FlickrPhotoCell.swift is open, and Control-drag from the activity indicator onto FlickrPhotoCell , naming the outlet activityIndicator :

@IBOutlet weak var activityIndicator: UIActivityIndicatorView!

While in FlickrPhotoCell.swift , add the following to give the cell control over its border:

override var isSelected: Bool {
  didSet {
    imageView.layer.borderWidth = isSelected ? 10 : 0
  }
}

override func awakeFromNib() {
  super.awakeFromNib()
  imageView.layer.borderColor = themeColor.cgColor
  isSelected = false
}

Loading the Large Image

Now open FlickrPhotosViewController.swift and add a convenience method in the private extension to download the large version of a Flickr image:

func performLargeImageFetch(for indexPath: IndexPath, flickrPhoto: FlickrPhoto) {
  // 1
  guard let cell = collectionView.cellForItem(at: indexPath) as? FlickrPhotoCell else {
    return
  }

  // 2
  cell.activityIndicator.startAnimating()

  // 3
  flickrPhoto.loadLargeImage { [weak self] result in
    // 4
    guard let self = self else {
      return
    }

    // 5
    switch result {
    // 6
    case .results(let photo):
      if indexPath == self.largePhotoIndexPath {
        cell.imageView.image = photo.largeImage
      }
    case .error(_):
      return
    }
  }
}

Here is a step by step of the code above:

  1. Make sure you’ve properly dequeued a cell of the right type.
  2. Start the activity indicator to show network activity.
  3. Use the convenience method on FlickrPhoto to start an image download.
  4. Since you’re in a closure that captures self, ensure the view controller is still a valid object.
  5. Switch on the result type. If successful, and if the indexPath the fetch was performed with matches the current largePhotoIndexPath , then set the imageView on the cell to the photo’s largeImage .

Finally, in FlickrPhotosViewController , replace collectionView(_:cellForItemAt:) with the following:

override func collectionView(_ collectionView: UICollectionView, 
                             cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
  guard let cell = collectionView.dequeueReusableCell(
    withReuseIdentifier: reuseIdentifier,
    for: indexPath) as? FlickrPhotoCell else {
      preconditionFailure("Invalid cell type")
  }

  let flickrPhoto = photo(for: indexPath)

  // 1
  cell.activityIndicator.stopAnimating()

  // 2
  guard indexPath == largePhotoIndexPath else {
    cell.imageView.image = flickrPhoto.thumbnail
    return cell
  }

  // 3
  guard flickrPhoto.largeImage == nil else {
    cell.imageView.image = flickrPhoto.largeImage
    return cell
  }

  // 4
  cell.imageView.image = flickrPhoto.thumbnail

  // 5
  performLargeImageFetch(for: indexPath, flickrPhoto: flickrPhoto)

  return cell
}

Here is an explanation of the work being done above:

  1. Stop the activity indicator in case it was currently active.
  2. If the largePhotoIndexPath does not match the indexPath of the current cell, set the image to thumbnail and return.
  3. If the largePhotoIndexPath isn’t nil , set the image to the large image and return.
  4. At this point, this is a cell in need of a large image display. Set the thumbnail first.
  5. Call the private convenience method you created above to fetch the large image version and return the cell.

Now is a great time to build and run to check your work. Perform a search, then select an image cell. You’ll see it scales up and animates to the center of the screen. Tapping it again animates it back to its original state.

eqi2QrM.png!web

Excellent work! Next, you’ll implement multiple selection and sharing.

Multiple Selection

The process for selecting cells in UICollectionView is very similar to that of UITableView . Be sure to let UICollectionView know to allow multiple selection by setting a property.

The sharing flow follows these steps:

  1. The user taps the Share bar button to invoke the multiple selection mode.
  2. The user then taps as many photos as desired, adding each to an array.
  3. The user taps the share button again, bringing up the native share sheet.
  4. When the share action completes or cancels, the cells deselect and the collection view returns to single selection mode.

First, add the properties you’ll need to support this new feature to the top of FlickrPhotosViewController :

private var selectedPhotos: [FlickrPhoto] = []
private let shareLabel = UILabel()

selectedPhotos keeps track of all currently selected photos while in sharing mode. shareTextLabel gives the user feedback about how many photos are currently selected.

Next, add the following method to the private extension:

func updateSharedPhotoCountLabel() {
  if sharing {
    shareLabel.text = "\(selectedPhotos.count) photos selected"
  } else {
    shareLabel.text = ""
  }

  shareLabel.textColor = themeColor

  UIView.animate(withDuration: 0.3) {
    self.shareLabel.sizeToFit()
  }
}

You’ll call this method to keep the shareLabel text up to date. This method checks the sharing property. If the app is currently sharing photos, it will properly set the label text and animate the size change to fit along with all the other elements in the navigation bar.

Keeping Track of Sharing

Next, add the following property below largePhotoIndexPath :

var sharing: Bool = false {
  didSet {
    // 1
    collectionView.allowsMultipleSelection = sharing

    // 2
    collectionView.selectItem(at: nil, animated: true, scrollPosition: [])
    selectedPhotos.removeAll()

    guard let shareButton = self.navigationItem.rightBarButtonItems?.first else {
      return
    }

    // 3
    guard sharing else {
      navigationItem.setRightBarButton(shareButton, animated: true)
      return
    }

    // 4
    if largePhotoIndexPath != nil {
      largePhotoIndexPath = nil
    }

    // 5
    updateSharedPhotoCountLabel()

    // 6
    let sharingItem = UIBarButtonItem(customView: shareLabel)
    let items: [UIBarButtonItem] = [
      shareButton,
      sharingItem
    ]

    navigationItem.setRightBarButtonItems(items, animated: true)
  }
}

sharing is a Bool with a property observer similar to largePhotoIndexPath . It’s responsible for tracking and updating when this view controller enters and leaves sharing mode.

After the property is set, here is how the property observer responds:

  1. Set the allowsMultipleSelection property of the collection view to the value of the sharing property.
  2. Deselect all cells, scroll to the top and remove any existing share items from the array.
  3. If sharing is not enabled, set the share bar button to the default state and return.
  4. Make sure largePhotoIndexPath is set to nil .
  5. Call the convenience method you created above to update the share label.
  6. Update the bar button items accordingly.

Adding a Share Button

Open Main.storyboard and drag a bar button item onto the FlickrPhotosViewController to the right of the search bar in the navigation bar. Set the System Item to Action in the Attributes inspector to give it the hare icon. Open the assistant editor, ensuring that FlickrPhotosViewController.swift is selected, and Control—drag from the share button to the View controller. Create an Action named share and set the Type to UIBarButtonItem .

Fill in the method as follows:

guard !searches.isEmpty else {
    return
}

guard !selectedPhotos.isEmpty else {
  sharing.toggle()
  return
}

guard sharing else {
  return
}

// TODO: Add photo sharing logic!

Currently, this method will ensure that the user has performed some searches and the sharing property is true . Now, turn on the ability to select cells. Add the following code to the top of collectionView(_:shouldSelectItemAt:) :

guard !sharing else {
  return true
}

This will allow selection while the user is in sharing mode.

Next, add the following method to the UICollectionViewDelegate extension below collectionView(_:shouldSelectItemAt:) :

override func collectionView(_ collectionView: UICollectionView, 
                             didSelectItemAt indexPath: IndexPath) {
  guard sharing else {
    return
  }

  let flickrPhoto = photo(for: indexPath)
  selectedPhotos.append(flickrPhoto)
  updateSharedPhotoCountLabel()
}

This method allows selecting and adding a photo to the sharedPhotos array and updates the shareLabel text.

Next, still in the UICollectionViewDelegate extension, implement the following method:

override func collectionView(_ collectionView: UICollectionView, 
                             didDeselectItemAt indexPath: IndexPath) {
  guard sharing else {
    return
  }

  let flickrPhoto = photo(for: indexPath)
  if let index = selectedPhotos.firstIndex(of: flickrPhoto) {
    selectedPhotos.remove(at: index)
    updateSharedPhotoCountLabel()
  }
}

This method removes an item from the selectedPhotos array and updates the label if a selected cell is tapped to deselect it.

Finally, go back to your // TODO comment in share(_:) and replace it with an implementation of the share sheet:

let images: [UIImage] = selectedPhotos.compactMap { photo in
  if let thumbnail = photo.thumbnail {
    return thumbnail
  }

  return nil
}

guard !images.isEmpty else {
  return
}

let shareController = UIActivityViewController(
  activityItems: images,
  applicationActivities: nil)
shareController.completionWithItemsHandler = { _, _, _, _ in
  self.sharing = false
  self.selectedPhotos.removeAll()
  self.updateSharedPhotoCountLabel()
}

shareController.popoverPresentationController?.barButtonItem = sender
shareController.popoverPresentationController?.permittedArrowDirections = .any
present(shareController, animated: true, completion: nil)

This method will find all FlickrPhoto objects in the selectedPhotos array, ensure their thumbnail images are not nil and pass them off to a UIActivityController for presentation. iOS handles the job of presenting any system apps or services that can handle a list of images.

Once again, check your work!

Build and run, perform a search, and tap the share button in the navigation bar. Select multiple images, and watch the label update in real time as you select new cells:

B7fIZnZ.png!web

Tap the share button again and the native share sheet will appear. If you’re on a device, you can select any app or service that accepts images to share:

f2M7v23.png!web

Reordering Cells

In this last segment, you’ll learn how to use the native drag-and-drop feature to reorder cells in the collection view. iOS has some nice convenience methods built in for UITableView and UICollectionView , which take advantage of the drag-and-drop features that were added in iOS 11.

To use drag and drop, you have to be aware of two protocols. In a collection view, UICollectionViewDragDelegate drives drag interactions and UICollectionViewDropDelegate drives drop interactions. You’ll implement the drag delegate first, test behavior, then complete this feature with a drop delegate.

Implementing Drag Interactions

Add a new extension to the bottom of FlickrPhotosViewController.swift to add conformance to UICollectionViewDragDelegate :

// MARK: - UICollectionViewDragDelegate
extension FlickrPhotosViewController: UICollectionViewDragDelegate {
  func collectionView(_ collectionView: UICollectionView,
                      itemsForBeginning session: UIDragSession,
                      at indexPath: IndexPath) -> [UIDragItem] {
    let flickrPhoto = photo(for: indexPath)
    guard let thumbnail = flickrPhoto.thumbnail else {
      return []
    }
    let item = NSItemProvider(object: thumbnail)
    let dragItem = UIDragItem(itemProvider: item)
    return [dragItem]
  }
}

collectionView(_:itemsForBeginning:at:) is the only required method for this protocol, and it’s also the only one you need for this feature.

In this method, you find the correct photo in the cached array and ensure it has a thumbnail. The must return an array of UIDragItem objects. UIDragItem is initialized with an NSItemProvider object. You can initialize an NSItemProvider with any data object you wish to provide.

Next, you need to let the collection view know that it’s able to handle drag interactions. Implement viewDidLoad() above share(_:) :

override func viewDidLoad() {
  super.viewDidLoad()
  collectionView.dragInteractionEnabled = true
  collectionView.dragDelegate = self
}

In this method, you let the collection view know that drag interactions are enabled and set the drag delegate to self .

Now’s a great time to build and run. Perform a search, and now you’ll be able to long-press on a cell to see the drag interaction. You’ll see it lift up and you’ll be able to drag it around, but you won’t be able to drop it anywhere just yet.

vAbEneQ.png!web

Next up, it’s time to implement drop behavior.

Implementing Drop Interactions

Now you’ll need to implement some methods from UICollectionViewDropDelegate to enable the collection view to accept dropped items from a drag session. This will also allow you to reorder the cells by taking advantage of the provided index paths from the drop methods.

At the end of FlickrPhotosViewController.swift , create another extension to conform to UICollectionViewDropDelegate :

// MARK: - UICollectionViewDropDelegate
extension FlickrPhotosViewController: UICollectionViewDropDelegate {
  func collectionView(_ collectionView: UICollectionView, 
                      canHandle session: UIDropSession) -> Bool {
    return true
  }
}

Typically in this method, you would inspect the proposed drop items and decide if you wanted to accept them. Since you’re only enabling drag-and-drop for one item type in this app, you simply return true here.

Before you implement the next method, go back up to the private extension on FlickrPhotosViewController and implement two convenience methods that will help with the next bit of work.

Place these methods right under photo(for:) :

func removePhoto(at indexPath: IndexPath) {
  searches[indexPath.section].searchResults.remove(at: indexPath.row)
}
  
func insertPhoto(_ flickrPhoto: FlickrPhoto, at indexPath: IndexPath) {
  searches[indexPath.section].searchResults.insert(flickrPhoto, at: indexPath.row)
}

To update the source data array with changes, you need to make it mutable. Open FlickrSearchResults.swift and update searchResults as follows:

var searchResults: [FlickrPhoto]

Making the Drop

Next, switch back to FlickrPhotosViewController.swift and implement the following method inside the drop delegate extension:

func collectionView(_ collectionView: UICollectionView, 
                    performDropWith coordinator: UICollectionViewDropCoordinator) {
  // 1
  guard let destinationIndexPath = coordinator.destinationIndexPath else {
    return
  }
  
  // 2
  coordinator.items.forEach { dropItem in
    guard let sourceIndexPath = dropItem.sourceIndexPath else {
      return
    }

    // 3
    collectionView.performBatchUpdates({
      let image = photo(for: sourceIndexPath)
      removePhoto(at: sourceIndexPath)
      insertPhoto(image, at: destinationIndexPath)
      collectionView.deleteItems(at: [sourceIndexPath])
      collectionView.insertItems(at: [destinationIndexPath])
    }, completion: { _ in
      // 4
      coordinator.drop(dropItem.dragItem,
                        toItemAt: destinationIndexPath)
    })
  }
}

This delegate method accepts the drop items and performs maintenance on the collection view and the underlying data storage array to properly reorder the dropped items. You’ll see a new object here: UICollectionViewDropCoordinator . This is a UIKit object that gives you more information about the proposed drop items.

Here’s what happens, in detail:

  1. Get the destinationIndexPath from the drop coordinator.
  2. Loop through the items — an array of UICollectionViewDropItem objects — and ensure each has a sourceIndexPath .
  3. Perform batch updates on the collection view, removing items from the array at the source index and inserting them in the destination index. After that is complete, perform deletes and updates on the collection view cells.
  4. After completion, perform the drop action.

Go back up to viewDidLoad() and let the collection view know about the dropDelegate :

collectionView.dropDelegate = self

OK, one final method to go. Implement the following method at the bottom of the drop delegate extension:

func collectionView(
  _ collectionView: UICollectionView,
  dropSessionDidUpdate session: UIDropSession,
  withDestinationIndexPath destinationIndexPath: IndexPath?)
  -> UICollectionViewDropProposal {
  return UICollectionViewDropProposal(
    operation: .move,
    intent: .insertAtDestinationIndexPath)
}

The UIKit drop session calls this method constantly during interaction to interpolate where the user is dragging the items and give other objects a chance to react to the session.

This delegate method causes the collection view respond to the drop session with a UICollectionViewDropProposal that indicates the drop will move items and insert them at the destination index path.

OK, time to build and run.

You’ll now see a drag session coupled with some really nice behavior in your collection view. As you drag your item over the screen, other items shift to move out of the way, indicating the drop can be accepted at that position. You can even reorder items between different search sections!

ry6RJjB.png!web

Where to Go From Here?

Congratulations! You’ve just finished a whirlwind tour of some pretty advanced UICollectionView features!

Along the way, you learned how to delineate sections with reusable headers, select cells and update cell layout, perform multiple selection, reorder items and much more. You can download the final project using the Download Materials link at the top or bottom of this tutorial.

If you have any questions or comments about UICollectionView or this tutorial, please join the forum discussion below.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK