UICollectionView Tutorial: Reusable Views, Selection and Reordering [FREE]
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.
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.
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.
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:
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:
-
largePhotoIndexPath
is anOptional
that holds the currently selected photo item. - 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. -
performBatchUpdates(_:completion:)
will animate changes to the collection view. - 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:
- Make sure you’ve properly dequeued a cell of the right type.
- Start the activity indicator to show network activity.
- Use the convenience method on
FlickrPhoto
to start an image download. - Since you’re in a closure that captures self, ensure the view controller is still a valid object.
- Switch on the result type. If successful, and if the indexPath the fetch was performed with matches the current
largePhotoIndexPath
, then set theimageView
on the cell to the photo’slargeImage
.
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:
- Stop the activity indicator in case it was currently active.
- If the
largePhotoIndexPath
does not match the indexPath of the current cell, set the image to thumbnail and return. - If the
largePhotoIndexPath
isn’tnil
, set the image to the large image and return. - At this point, this is a cell in need of a large image display. Set the thumbnail first.
- 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.
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:
- The user taps the Share bar button to invoke the multiple selection mode.
- The user then taps as many photos as desired, adding each to an array.
- The user taps the share button again, bringing up the native share sheet.
- 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:
- Set the
allowsMultipleSelection
property of the collection view to the value of thesharing
property. - Deselect all cells, scroll to the top and remove any existing share items from the array.
- If sharing is not enabled, set the share bar button to the default state and return.
- Make sure
largePhotoIndexPath
is set tonil
. - Call the convenience method you created above to update the share label.
- 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:
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:
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.
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:
- Get the
destinationIndexPath
from the drop coordinator. - Loop through the items — an array of
UICollectionViewDropItem
objects — and ensure each has asourceIndexPath
. - 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.
- 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!
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.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK