Adding a Gallery with multiselection to your iOS App

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.

Gallery

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.

Gallery

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

Gallery

Next

This concludes these two part series on adding a gallery to your iOS app.

You can find all the code on GitHub

Did you like this article?

Please share it

We are Stefan Fidanov & Vasil Lyutskanov. We share actionable advice about software development, freelancing and anything else that might be helpful.

It is everything that we have learned from years of experience working with customers from all over the world on projects of all sizes.

Let's work together
© 2024 Terlici Ltd · Terms · Privacy