Understanding the UICollectionViewLayout with the Photos App

Hello, Habr! My name is Nikita, I work on mobile SDKs at ABBYY, and I also work on the UI component for scanning and conveniently viewing multi-page documents on a smartphone. This component reduces the time for developing applications based on ABBYY Mobile Capture technology and consists of several parts. Firstly, a camera for scanning documents; secondly, an editor screen with the capture results (that is, automatically taken photos) and a screen for correcting the borders of the document.

It is enough for the developer to call a couple of methods – and here in his application a camera is already available that automatically scans documents. But, in addition to the configured cameras, you need to provide customers with convenient access to the scan results, i.e. automatically taken photos. And if the client scans the contract or charter, then there can be a lot of such photos.

In this post I will talk about the difficulties that arose during the implementation of the editor screen with the results of document capture. The screen itself is two UICollectionViewI will call them big and small. I will omit the possibilities of manually adjusting the borders of the document and other work with the document, and I will focus on animations and layout features during the scroll. Below on GIF you can see what happened in the end. A link to the repository will be at the end of the article.

As references, I often pay attention to Apple system applications. When you carefully look at animations and other interface solutions of their applications, you begin to admire their attentive attitude to various trifles. Now we will look at the application as a reference Photos (iOS 12). I will draw your attention to the specific features of this application, and then we will try to implement them.

We will touch on most of the possibilities of customization. UICollectionViewFlowLayout, let's see how such common tricks as parallax and caroucel are implemented, and also discuss the problems associated with custom animations when inserting and deleting cells.

Feature Review

To add specifics, I will describe what specific little things pleased me in the application Photos, and then I will implement them in the appropriate order.

  1. Parallax effect in a large collection
  2. Elements of a small collection are centered.
  3. Dynamic size of items in a small collection
  4. The logic of placing the elements of a small cell depends not only on the contentOffset, but also on user interactions
  5. Custom animations for move and delete
  6. The index of the "active" cell is not lost when changing orientation

1. Parallax

What is parallax?

Parallax scrolling is a technique in computer graphics where background images move past the camera more slowly than foreground images, creating an illusion of depth in a 2D scene and adding to the sense of immersion in the virtual experience.

You can notice that when scrolling, the frame of the cell moves faster than the image that is in it.

Let's get started! Create a subclass of the cell, put the UIImageView into it.

class PreviewCollectionViewCell: UICollectionViewCell {
    
    private (set) var imageView = UIImageView ()
A.
    override init (frame: CGRect) {
        super.init (frame: frame)
        addSubview (imageView)
        clipsToBounds = true
        imageView.snp.makeConstraints {
            $ 0.edges.equalToSuperview ()
        }
    }
}

Now you need to understand how to shift imageViewcreating a parallax effect. To do this, you need to redefine the behavior of cells during scrolling. Apple:

Avoid subclassing UICollectionView. The collection view has little or no appearance of its own. Instead, it pulls all of its views from your data source object and all of the layout-related information from the layout object. If you are trying to lay out items in three dimensions, the proper way to do it is to implement a custom layout that sets the 3D transform of each cell and view appropriately.

Ok let's create your layout object. At UICollectionView there is a property collectionViewLayout, from whom she learns information about the positioning of cells. UICollectionViewFlowLayout is an implementation of abstract UICollectionViewLayoutwhich is the property collectionViewLayout.

UICollectionViewLayout is waiting for someone to subclass it and provide the appropriate content. UICollectionViewFlowLayout is a concrete class of UICollectionViewLayout that has all its four members implemented, in the way that the cells will be arranged in a grid manner.

Create a subclass UICollectionViewFlowLayout and redefine it layoutAttributesForElements (in :). The method returns an array UICollectionViewLayoutAttributesthat provides information on how to display a particular cell.

The collection requests attributes every time it changes. contentOffset, as well as with disability layout. In addition, create custom attributes by adding a property parallaxValue, which determines how much the frame’s offset from the frame of the cell is delayed. For subclasses of attributes, you must override them Nscopiyng. Apple:

If you subclass and implement any custom layout attributes, you must also override the inherited isEqual: method to compare the values ​​of your properties. In iOS 7 and later, the collection view does not apply layout attributes if those attributes have not changed. It determines whether the attributes have changed by comparing the old and new attribute objects using the isEqual: method. Because the default implementation of this method checks only the existing properties of this class, you must implement your own version of the method to compare any additional properties. If your custom properties are all equal, call super and return the resulting value at the end of your implementation.

How to find out parallaxValue? Let's calculate how much you need to move the center of the cell so that it stands in the center. If this distance is greater than the width of the cell, then hammer on it. Otherwise, divide this distance by the width cells. The closer this distance is to zero, the weaker the parallax effect.

class ParallaxLayoutAttributes: UICollectionViewLayoutAttributes {
    var parallaxValue: CGFloat?
}
A.
class PreviewLayout: UICollectionViewFlowLayout {
    var offsetBetweenCells: CGFloat = 44
A.
    override func shouldInvalidateLayout (forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }
A.
    override class var layoutAttributesClass: AnyClass {
        return ParallaxLayoutAttributes.self
    }
A.
    override func layoutAttributesForElements (in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return super.layoutAttributesForElements (in: rect)?
            .compactMap {$ 0.copy () as? ParallaxLayoutAttributes}
            .compactMap (prepareAttributes)
    }
A.
    private func prepareAttributes (attributes: ParallaxLayoutAttributes) -> ParallaxLayoutAttributes {
        guard let collectionView = self.collectionView else {return attributes}
A.
        let width = itemSize.width
        let centerX = width / 2
        let distanceToCenter = attributes.center.x - collectionView.contentOffset.x
        let relativeDistanceToCenter = (distanceToCenter - centerX) / width
A.
        if abs (relativeDistanceToCenter)> = 1 {
            attributes.parallaxValue = nil
            attributes.transform = .identity
        } else {
            attributes.parallaxValue = relativeDistanceToCenter
            attributes.transform = CGAffineTransform (translationX: relativeDistanceToCenter * offsetBetweenCells, y: 0)
        }
        return attributes
    }
}

image

When the collection receives the necessary attributes, their cells apply. This behavior can be overridden in the subclass of the cell. Move imageView by an amount depending on parallaxValue. However, for the correct operation of shifting pictures with contentMode == .aspectFit this is not enough, because the frame of the picture does not match the frame imageViewby which content is truncated when clipsToBounds == true. Apply a mask that matches the size of the image with the appropriate contentMode and we will update it if necessary. Now everything works!

extension PreviewCollectionViewCell {
A.
    override func layoutSubviews () {
A.
        super.layoutSubviews ()
        guard let imageSize = imageView.image? .size else {return}
        let imageRect = AVMakeRect (aspectRatio: imageSize, insideRect: bounds)
A.
        let path = UIBezierPath (rect: imageRect)
        let shapeLayer = CAShapeLayer ()
        shapeLayer.path = path.cgPath
        layer.mask = shapeLayer
    }
    
    override func apply (_ layoutAttributes: UICollectionViewLayoutAttributes) {
A.
        guard let attrs = layoutAttributes as? ParallaxLayoutAttributes else {
            return super.apply (layoutAttributes)
        }
        let parallaxValue = attrs.parallaxValue ?? 0
        let transition = - (bounds.width * 0.3 * parallaxValue)
        imageView.transform = CGAffineTransform (translationX: transition, y: .zero)
    }
}

2. Elements of a small collection are centered

Everything is very simple here. This effect can be achieved by setting large insets left and right. It is necessary that when scrolling to the right / left, bouncing It started only when the last cell came out of visible content. That is, the visible content should equal the size of the cell.

extension ThumbnailFlowLayout {
A.
    var farInset: CGFloat {
      guard let collection = collectionView else {return .zero}
      return (collection.bounds.width - itemSize.width) / 2
    }
    
    var insets: UIEdgeInsets {
        UIEdgeInsets (top: .zero, left: farInset, bottom: .zero, right: farInset)
    }
A.
    override func prepare () {
        collectionView? .contentInset = insets
        super.prepare ()
    }
}

image

More about centering: when the collection finishes scrolling, the layout requests contentOffsetwhere you want to stop. To do this, override targetContentOffset (forProposedContentOffset: withScrollingVelocity :). Apple:

If you want the scrolling behavior to snap to specific boundaries, you can override this method and use it to change the point at which to stop. For example, you might use this method to always stop scrolling on a boundary between items, as opposed to stopping in the middle of an item.

To make everything beautiful, we will always stop in the center of the nearest cell. Calculating the center of the nearest cell is a rather trivial task, but you need to be careful and consider contentInset.

override func targetContentOffset (forProposedContentOffset proposedContentOffset: CGPoint,
                                  withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collection = collectionView else {
        return super.targetContentOffset (forProposedContentOffset: proposedContentOffset,
                                         withScrollingVelocity: velocity)
    }
    let cellWithSpacing = itemSize.width + config.distanceBetween
    let relative = (proposedContentOffset.x + collection.contentInset.left) / cellWithSpacing
    let leftIndex = max (0, floor (relative))
    let rightIndex = min (ceil (relative), CGFloat (itemsCount))
    let leftCenter = leftIndex * cellWithSpacing - collection.contentInset.left
    let rightCenter = rightIndex * cellWithSpacing - collection.contentInset.left
A.
    if abs (leftCenter - proposedContentOffset.x) <abs (rightCenter - proposedContentOffset.x) {
        return CGPoint (x: leftCenter, y: proposedContentOffset.y)
    } else {
        return CGPoint (x: rightCenter, y: proposedContentOffset.y)
    }
}

3. The dynamic size of the elements of a small collection

If you scroll a large collection, contentOffset changes in a small one. Moreover, the central cell of a small collection is not as large as the rest. Side cells have a fixed size, and the central one is the same as the aspect ratio of the picture that it contains.

You can use the same technique as in the case of parallax. Create custom UICollectionViewFlowLayout for a small collection and redefined prepareAttributes (attributes:. Considering that further the logic of the layout of a small collection will be complicated, we will create a separate entity for storing and calculating the geometry of the cells.

struct Cell {
    let indexPath: IndexPath
A.
    let dims: Dimensions
    let state: State
A.
    func updated (new state: State) -> Cell {
        return Cell (indexPath: indexPath, dims: dims, state: state)
    }
}
A.
extension Cell {
    struct Dimensions {
A.
        let defaultSize: CGSize
        let aspectRatio: CGFloat
        let inset: CGFloat
        let insetAsExpanded: CGFloat
    }
A.
    struct State {
A.
        let expanding: CGFloat
A.
        static var `default`: State {
            State (expanding: .zero)
        }
    }
}

UICollectionViewFlowLayout has the property collectionViewContentSize, which determines the size of the area that can be scrolled. In order not to complicate our life, let us leave it constant, independent of the size of the central cell. For the correct geometry for each cell you need to know aspectRatio pictures and remoteness of the cell center from contentOffset. The closer the cell, the closer it is. size.width / size.height to aspectRatio. When resizing a specific cell, we move the remaining cells (to the right and left of it) using affineTransform. It turns out that to calculate the geometry of a particular cell, you need to know the attributes of neighbors (visible).

extension Cell {
A.
    func attributes (from layout: ThumbnailLayout,
                    with sideCells: [Cell]) -> UICollectionViewLayoutAttributes? {
A.
        let attributes = layout.layoutAttributesForItem (at: indexPath)
A.
        attributes? .size = size
        attributes? .center = center
A.
        let translate = sideCells.reduce (0) {(current, cell) -> CGFloat in
            if indexPath < cell.indexPath {
                return current - cell.additionalWidth / 2
            }
            if indexPath > cell.indexPath {
                return current + cell.additionalWidth / 2
            }
            return current
        }
        attributes? .transform = CGAffineTransform (translationX: translate, y: .zero)
A.
        return attributes
    }
    
    var additionalWidth: CGFloat {
        (dims.defaultSize.height * dims.aspectRatio - dims.defaultSize.width) * state.expanding
    }
    
    var size: CGSize {
        CGSize (width: dims.defaultSize.width + additionalWidth,
               height: dims.defaultSize.height)
    }
    
    var center: CGPoint {
        CGPoint (x: CGFloat (indexPath.row) * (dims.defaultSize.width + dims.inset) + dims.defaultSize.width / 2,
                y: dims.defaultSize.height / 2)
    }
}

state.expanding considered almost the same as parallaxValue.

func cell (for index: IndexPath, offsetX: CGFloat) -> Cell {
A.
    let cell = Cell (
        indexPath: index,
        dims: Cell.Dimensions (
            defaultSize: itemSize,
            aspectRatio: dataSource (index.row),
            inset: config.distanceBetween,
            insetAsExpanded: config.distanceBetweenFocused),
        state: .default)
A.
    guard let attribute = cell.attributes (from: self, with: []) else {return cell}
A.
    let cellOffset = attribute.center.x - itemSize.width / 2
    let widthWithOffset = itemSize.width + config.distanceBetween
    if abs (cellOffset - offsetX) < widthWithOffset {
        let expanding = 1 - abs(cellOffset - offsetX) / widthWithOffset
        return cell.updated(by: .expand(expanding))
    }
    return cell
}
​
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return (0 .. <itemsCount)
        .map {IndexPath (row: $ 0, section: 0)}
        .map {cell (for: $ 0, offsetX: offsetWithoutInsets.x)}
        .compactMap {$ 0.attributes (from: self, with: cells)}
}

4. The logic of placing the elements of a small cell depends not only on the contentOffset, but also on user interactions

When a user scrolls through a small collection, all cells are the same size. When scrolling through a large collection, this is not so. (see gifs 3 and 5) Let's write an animator that will update the layout properties. ThumbnailLayout. The animator will keep in himself Displaylink and 60 times per second to call a block, providing access to the current progress. It’s easy to screw various easing functions. The implementation can be viewed on the github at the link at the end of the post.

Let's get into ThumbnailLayout property expandingRatewhich will be multiplied expanding of all Cell. It turns out that expandingRate says how much aspectRatio A particular picture will affect its size if it becomes centered. At expandingRate == 0 all cells will be the same size. At the start of the scroll of a small collection, we will launch an animator that translates expandingRate to 0, and at the end of the scroll, on the contrary, to 1. In fact, when updating the layout, the size of the central cell will change and transform side. No mess with contentOffset and twitches!

class ScrollAnimation: NSObject {
A.
    enum `Type` {
        case begin
        case end
    }
A.
    let type: Type
A.
    func run (completion: @escaping () -> Void) {
        let toValue: CGFloat = self.type == .begin? 0: 1
        let currentExpanding = thumbnails.config.expandingRate
        let duration = TimeInterval (0.15 * abs (currentExpanding - toValue))
A.
        let animator = Animator (onProgress: {current, _ in
            let rate = currentExpanding + (toValue - currentExpanding) * current
            self.thumbnails.config.expandingRate = rate
            self.thumbnails.invalidateLayout ()
        }, easing: .easeInOut)
A.
        animator.animate (duration: duration) {_ in
            completion ()
        }
    }
}

func scrollViewWillBeginDragging (_ scrollView: UIScrollView) {
    if scrollView == thumbnails.collectionView {
        handle (event: .beginScrolling) // call ScrollAnimation.run (type: .begin)
    }
}
A.
func scrollViewDidEndDragging (_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if scrollView == thumbnails.collectionView &&! decelerate {
        thumbnailEndScrolling ()
    }
}
A.
func scrollViewDidEndDecelerating (_ scrollView: UIScrollView) {
    if scrollView == thumbnails.collectionView {
        thumbnailEndScrolling ()
    }
}
A.
func thumbnailEndScrolling () {
    handle (event: .endScrolling) // call ScrollAnimation.run (type: .end)
}

5. Custom animations for move and delete

There are many articles telling how to make custom animations to update cells, but in our case they will not help us. Articles and tutorials describe how to override attributes of an updated cell. In our case, a change in the layout of the deleted cell causes side effects – it changes expanding neighboring cell, which tends to take the place of deleted during the animation.

Content update in UICollectionViewFlowLayout works as follows. After deleting / adding a cell, the method starts prepare (forCollectionViewUpdates :)giving an array UICollectionViewUpdateItemwhich tells us which cells on which indexes have updated / deleted / added. Next, the layout will call a group of methods

finalLayoutAttributesForDisappearingItem (at :)
initialLayoutAttributesForAppearingDecorationElement (ofKind: at :)

and their friends for decoration / supplementary views. When the attributes for the updated data are received, called finalizeCollectionViewUpdates. Apple:

The collection view calls this method as the last step before proceeding to animate any changes into place. This method is called within the animation block used to perform all of the insertion, deletion, and move animations so you can create additional animations using this method as needed. Otherwise, you can use it to perform any last minute tasks associated with managing your layout object’s state information.

The trouble is that we can only specialize attributes for updated cells, and we need to change them in all cells, and in different ways. The new central cell should change aspectRatioand side – transform.

Having examined how the default animation of collection cells during deletion / insertion works, it became known that layer cells in finalizeCollectionViewUpdates contain CABasicAnimation, which can be changed there if you want to customize the animation for the remaining cells. Everything got worse when the logs showed that between performBatchUpdates and prepare (forCollectionViewUpdates :) is called prepareAttributes (attributes :), and there may already be the wrong number of cells, although collectionViewUpdates not yet begun, it’s very difficult to maintain and understand this. What can be done about this? You can disable these built-in animations!

final override func prepare (forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
    super.prepare (forCollectionViewUpdates: updateItems)
    CATransaction.begin ()
    CATransaction.setDisableActions (true)
}
A.
final override func finalizeCollectionViewUpdates () {
    CATransaction.commit ()
}

Armed with the animators already written, we will do all the necessary animations on request for deletion, and update dataSource we will run at the end of the animation. Thus, we will simplify the animation of the collection when updating, since we ourselves control when the number of cells will change.

func delete (
    at indexPath: IndexPath,
    dataSourceUpdate: @escaping () -> Void,
    completion: (() -> Void)?) {
A.
    DeleteAnimation (thumbnails: thumbnails, preview: preview, index: indexPath) .run {
        let previousCount = self.thumbnails.itemsCount
        if previousCount == indexPath.row + 1 {
            self.activeIndex = previousCount - 1
        }
        dataSourceUpdate ()
        self.thumbnails.collectionView? .deleteItems (at: [indexPath])
        self.preview.collectionView? .deleteItems (at: [indexPath])
        completion? ()
    }
}

How will such animations work? Let's in ThumbnailLayout store optional trims that update the geometry of specific cells.

class ThumbnailLayout {
A.
    typealias CellUpdate = (Cell) -> Cell
    var updates: [IndexPath: CellUpdate] = [:]
    
    // ...
    
    override func layoutAttributesForElements (in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
A.
        let cells = (0 ..< itemsCount)
            .map { IndexPath(row: $0, section: 0) }
            .map { cell(for: $0, offsetX: offsetWithoutInsets.x) }
            .map { cell -> Cell in
                if let update = self.config.updates[cell.indexPath] {
                    return update (cell)
                }
                return cell
        }
        return cells.compactMap {$ 0.attributes (from: self, with: cells)
    }
}

Having such a tool, you can do anything with the geometry of the cells, throwing updates during the work of the animator and removing them in the compliment. There is also the possibility of combining updates.

updates[index] = newUpdate (updates[index])

The removal animation code is rather cumbersome, it is in the file DeleteAnimation.swift in the repository. The animation of focus switching between cells is implemented in the same way.

6. The index of the “active” cell is not lost when changing orientation

scrollViewDidScroll (_ scrollView :) called even if it just pops in contentOffset some value, as well as when changing orientation. When the scroll of two collections is synchronized, some problems may arise during the updates of the layout. The following trick helps: on the layout updates you can set scrollView.delegate in nil.

extension ScrollSynchronizer {
A.
    private func bind () {
        preview.collectionView? .delegate = self
        thumbnails.collectionView? .delegate = self
    }
A.
    private func unbind () {
        preview.collectionView? .delegate = nil
        thumbnails.collectionView? .delegate = nil
    }
}

When updating cell sizes at the time of orientation change, it will look like this:

extension PhotosViewController {
A.
    override func viewWillTransition (to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition (to: size, with: coordinator)
A.
        contentView.synchronizer.unbind ()
        coordinator.animate (alongsideTransition: nil) { [weak self] _ in
            self? .contentView.synchronizer.bind ()
        }
    }
}

So that when changing orientation, do not lose the desired contentOffsetcan be updated targetIndexPath in scrollView.delegate. When you change the orientation, the layout will be disabled if you redefine shouldInvalidateLayout (forBoundsChange :). When changing bounds Layout will ask for clarification contentOffset, to clarify it, you need to redefine targetContentOffset (forProposedContentOffset :). Apple:

During layout updates, or when transitioning between layouts, the collection view calls this method to give you the opportunity to change the proposed content offset to use at the end of the animation. You might override this method if the animations or transition might cause items to be positioned in a way that is not optimal for your design.

The collection view calls this method after calling the prepare () and collectionViewContentSize methods.

override func targetContentOffset (forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
    let targetOffset = super.targetContentOffset (forProposedContentOffset: proposedContentOffset)
    guard let layoutHandler = layoutHandler else {
        return targetOffset
    }
    let offset = CGFloat (layoutHandler.targetIndex) / CGFloat (itemsCount)
    return CGPoint (
        x: collectionViewContentSize.width * offset - farInset,
        y: targetOffset.y)
}


Thanks for reading!

All code can be found at github.com/YetAnotherRzmn/PhotosApp

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *