Recently I showed you how to add a gallery to your iOS app. One that you can use as a base and make it your own. The code is on GitHub, too.
However, there were a few things missing. First, there was no way to tell whether it was a video or an image just by looking at the thumbnails. Second, you could only choose a single picture.
This time we are going to fix those issues by adding
- Video duration on video thumbnails
- Multiple ordered selection
I am not going repeat the code from last time. I am lazy. I am just going to show you the changes that you need to make. At the end again you will be able to see the result with full source code on GitHub.
Videos
In the gallery there are thumbnails from videos, but they look exactly the same as those from images (not very user friendly).
Let’s add duration only on video thumbnails thus making it clear what they are and also providing something useful.
class GalleryViewCell : UICollectionViewCell {
...
private lazy var durationLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 13)
label.textAlignment = .right
label.textColor = .white
label.layer.shadowOffset = CGSize(width: 2, height: 2)
label.layer.shadowOpacity = 0.8
label.layer.shadowRadius = 2
label.layer.shadowColor = UIColor.black.cgColor
return label
}()
...
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(imageView)
contentView.addSubview(durationLabel)
contentView.clipsToBounds = true
}
func setup(with image: UIImage?, at indexPath: IndexPath, duration: TimeInterval?) {
let (bottomSpace, leadingSpace, trailingSpace) = computeSpace(at: indexPath)
NSLayoutConstraint.deactivate(currentConstraints)
currentConstraints = [
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: leadingSpace),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -trailingSpace),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -bottomSpace),
durationLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4),
durationLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4),
]
NSLayoutConstraint.activate(currentConstraints)
imageView.image = image
if let duration = duration, duration > 0 {
let seconds = Int(duration)
durationLabel.isHidden = false
durationLabel.text = String(format: "%d:%.2d", seconds / 60, seconds % 60)
} else {
durationLabel.isHidden = true
}
}
}
We just added a label at the bottom right, with some shadow to be visible even on light backgrounds. Then the provided duration is formated as 12:34. We also ensure that duration is only shown when it is meaningful.
Getting the duration is simple, because we already have it. Adding one parameter is all the work we need to do.
extension GalleryViewController: UICollectionViewDataSource {
...
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
...
cell.imageRequestID = PHImageManager.default().requestImage(for: asset, targetSize: thumbnailFetchSize, contentMode: .aspectFill, options: options) { image, _ in
cell.imageRequestID = nil
cell.setup(with: image, at: indexPath, duration: asset.duration)
}
}
}
We are done. Image and video thumbnails are now very clear.
Multi selection
Multiselection is a highly requested feature for galleries. After all you usually have more than single picture to share.
We are going to add support for multiselection to the GalleryViewCell
first.
class GalleryViewCell : UICollectionViewCell {
...
private lazy var indicatorLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 16)
label.textColor = .black
return label
}()
private lazy var indicatorView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor(red: 0.84, green: 0.91, blue: 1.00, alpha: 1.00)
view.layer.cornerRadius = 15
view.layer.borderWidth = 1
view.layer.borderColor = UIColor.black.withAlphaComponent(0.4).cgColor
view.layer.masksToBounds = true
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 30),
view.heightAnchor.constraint(equalToConstant: 30),
])
view.addSubview(indicatorLabel)
NSLayoutConstraint.activate([
indicatorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
indicatorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
return view
}()
...
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(imageView)
contentView.addSubview(durationLabel)
contentView.addSubview(indicatorView)
contentView.clipsToBounds = true
}
...
func setup(with image: UIImage?, at indexPath: IndexPath, duration: TimeInterval?, selectionIndex idx: Int?) {
....
setupSelection(selectionIndex: idx)
}
func setupSelection(selectionIndex idx: Int?) {
if let idx = idx {
imageView.layer.cornerRadius = 20
indicatorView.isHidden = false
indicatorLabel.text = "\(1 + idx)"
} else {
imageView.layer.cornerRadius = 0
indicatorView.isHidden = true
}
}
func setupSelection(selectionIndex idx: Int?, animated: Bool) {
if animated {
let transform = idx != nil ? CGAffineTransform(scaleX: 0.9, y: 0.9) : CGAffineTransform(scaleX: 1.1, y: 1.1)
UIView.animateKeyframes(withDuration: 0.3, delay: 0, options: [], animations: {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.7, animations: {
self.imageView.transform = transform
self.setupSelection(selectionIndex: idx)
})
UIView.addKeyframe(withRelativeStartTime: 0.7, relativeDuration: 0.3, animations: {
self.imageView.transform = CGAffineTransform.identity
})
})
} else {
setupSelection(selectionIndex: idx)
}
}
}
when an item is selected it will have a number with a round background in the top left corner. Otherwise it won’t have anything.
there is the standard setupSelection
, which is clear what it does but then there is also setupSelection(selectionIndex idx: Int?, animated: Bool)
.
When the user taps on an item to select or deselct it, we would like a little animation to happen. Just a better user experience.
The Controller
class GalleryViewController : UIViewController {
...
private var selection: [IndexPath] = []
private lazy var collectionView: UICollectionView = {
...
let collectionView = UICollectionView(frame: view.frame, collectionViewLayout: layout)
...
collectionView.allowsMultipleSelection = true
return collectionView
}()
....
}
The selection
property will hold the index paths for the selected items in order.
You might wonder why I don’t use UICollectionView.indexPathsForSelectedItems
instead.
In my testing it turns out that it doesn’t keep the items in order. When you select one more item,
the new one might end up at the end or somewhere in between previously selected items. I don’t like this.
The cell generation also needs an update
extension GalleryViewController: UICollectionViewDataSource {
...
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
...
let selectionIndex = selection.firstIndex(of: indexPath)
cell.imageRequestID = PHImageManager.default().requestImage(for: asset, targetSize: thumbnailFetchSize, contentMode: .aspectFill, options: options) { image, _ in
cell.imageRequestID = nil
cell.setup(with: image, at: indexPath, duration: asset.duration, selectionIndex: selectionIndex)
}
}
}
We extract and pass the selection index as expected by the cell.
Finally, we have to completely rework how tapping with selection and deselection work
extension GalleryViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let cell = collectionView.cellForItem(at: indexPath) as? GalleryViewCell else { return }
selection.append(indexPath)
cell.setupSelection(selectionIndex: selection.count - 1, animated: true)
}
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
guard let cell = collectionView.cellForItem(at: indexPath) as? GalleryViewCell else { return }
if let index = selection.firstIndex(of: indexPath) {
selection.remove(at: index)
}
cell.setupSelection(selectionIndex: nil, animated: true)
for visibleCell in collectionView.visibleCells {
guard let visibleCell = visibleCell as? GalleryViewCell else { continue }
guard let visibleIndexPath = collectionView.indexPath(for: visibleCell) else { continue }
guard let visibleIndex = selection.firstIndex(of: visibleIndexPath) else { continue }
visibleCell.setupSelection(selectionIndex: visibleIndex)
}
}
}
Selecting appends the cell indexPath and does a little animation.
Deselecting does the opposite (with animation) but also does something more.
If you have selected 5 items and then deselect item number 3, we would like the items with 4 and 5 now to have 3 and 4 on their labels respectively. This is what this last bit of code does.
You have everything for your gallery with multiselection
Next
This concludes these two part series on adding a gallery to your iOS app.
You can find all the code on GitHub