Skip to content
This repository has been archived by the owner on Feb 17, 2021. It is now read-only.

Possible solution for updating UICollectionViewCells with animation #163

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//: [Previous](@previous)

import Foundation
import LayoutKit
import UIKit
import PlaygroundSupport

class ContentCollectionViewFlowLayout : UICollectionViewFlowLayout {

private var animations: [Animation] = []

func add(animations: [Animation]) {
self.animations.append(contentsOf: animations)
}

// MARK: UICollectionViewFlowLayout

override func finalizeCollectionViewUpdates() {
super.finalizeCollectionViewUpdates()
self.animations.forEach { animation in
animation.apply()
}
self.animations = []
}
}


class MyViewController : UIViewController {

lazy var layouts: [[Layout]] = {
return [
[
self.itemLayout(text: "Section 0 item 0"),
self.itemLayout(text: "Section 0 item 1")
],
[
self.itemLayout(text: "Section 1 item 0"),
self.itemLayout(text: "Section 1 item 1")
]
]
}()

let collectionViewLayout: ContentCollectionViewFlowLayout = {
let layout = ContentCollectionViewFlowLayout()
layout.sectionInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0)
return layout
}()

lazy var layoutAdapterCollectionView: LayoutAdapterCollectionView = {
let collectionView = LayoutAdapterCollectionView(frame: .zero, collectionViewLayout: self.collectionViewLayout)
collectionView.backgroundColor = .lightGray
collectionView.alwaysBounceVertical = true
return collectionView
}()

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.layoutAdapterCollectionView.frame = self.view.bounds
}

override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .white
self.view.addSubview(self.layoutAdapterCollectionView)
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)


self.layoutAdapterCollectionView.layoutAdapter.reload(
width: self.layoutAdapterCollectionView.bounds.width,
synchronous: true,
layoutProvider: self.layoutAdapter,
completion: nil
)
}


private func layoutAdapter() -> [Section<[Layout]>] {
return [
Section<[Layout]>(header: self.headerLayout(title: "Reload item"), items: self.layouts[0]),
Section<[Layout]>(header: self.headerLayout(title: "Invalidate layout item"), items: self.layouts[1])
]
}

private func headerLayout(title: String) -> Layout {
let labelLayout = LabelLayout(
text: title,
font: .boldSystemFont(ofSize: 24),
numberOfLines: 0,
alignment: .centerLeading,
viewReuseId: "headerlabel"
)

return InsetLayout(
insets: UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0),
sublayout: labelLayout
)
}

private func itemLayout(text: String, minHeight: CGFloat = 100, color: UIColor = .red) -> Layout {

let imageLayout = SizeLayout(
width: 80,
height: 80,
viewReuseId: "image",
config: { view in
view.backgroundColor = color
}
)

let labelLayout = LabelLayout(
text: text,
font: .systemFont(ofSize: 18),
numberOfLines: 0,
alignment: .centerLeading,
viewReuseId: "label"
)

let resizeButtonLayout = ButtonLayout(
type: .custom,
title: "Resize",
font: .systemFont(ofSize: 18),
contentEdgeInsets: UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8),
alignment: .centerTrailing,
viewReuseId: "button",
config: { [unowned self] button in
button.backgroundColor = .lightGray
button.addHandler(for: .touchUpInside, handler: { control in
self.updateCell(withSubview: button)
})
}
)

let stackLayout = StackLayout(
axis: .horizontal,
spacing: 10,
viewReuseId: "stackView",
sublayouts: [
imageLayout,
labelLayout,
resizeButtonLayout
],
config: { view in
view.backgroundColor = .white
}
)

return SizeLayout(minHeight: minHeight, sublayout: stackLayout)
}

private func updateCell(withSubview subview: UIView) {
guard let cell = self.findIndexPathForCell(withSubview: subview) else { return }
guard let indexPath = self.layoutAdapterCollectionView.indexPath(for: cell) else { return }

let randomNum: UInt32 = arc4random_uniform(100) + 100
let colors: [UIColor] = [.blue, .green, .yellow]

self.layouts[indexPath.section][indexPath.item] = self.itemLayout(
text: "Section \(indexPath.section) item \(indexPath.item)",
minHeight: CGFloat(randomNum),
color: colors[Int(arc4random_uniform(UInt32(colors.count)))]
)

if indexPath.section == 0 {
self.reloadItem(at: indexPath)
}
else {
self.invalidateItem(at: indexPath)
}

}

private func reloadItem(at indexPath: IndexPath) {

let batchUpdates = BatchUpdates()
batchUpdates.reloadItems = [indexPath]

self.layoutAdapterCollectionView.layoutAdapter.reload(
width: self.layoutAdapterCollectionView.bounds.width,
synchronous: true,
batchUpdates: batchUpdates,
layoutProvider: self.layoutAdapter
)
}

private func invalidateItem(at indexPath: IndexPath) {
let items: [IndexPath] = [indexPath]

self.layoutAdapterCollectionView.layoutAdapter.reload(
items: items,
width: self.layoutAdapterCollectionView.bounds.width,
layoutProvider: self.layoutAdapter,
completion: { animations in
self.collectionViewLayout.add(animations: animations)
let invalidationContext = UICollectionViewFlowLayoutInvalidationContext()
invalidationContext.invalidateItems(at: items)

self.layoutAdapterCollectionView.performBatchUpdates({
self.layoutAdapterCollectionView.collectionViewLayout.invalidateLayout(with: invalidationContext)
})
}
)
}

private func findIndexPathForCell(withSubview view: UIView) -> UICollectionViewCell? {

if let cell = view as? UICollectionViewCell {
return cell
}

if let superview = view.superview {
return findIndexPathForCell(withSubview: superview)
}

return nil
}

}


PlaygroundPage.current.liveView = MyViewController()
PlaygroundPage.current.needsIndefiniteExecution = true
1 change: 1 addition & 0 deletions LayoutKit.playground/contents.xcplayground
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
<page name='Counter'/>
<page name='Test'/>
<page name='TextView'/>
<page name='CollectionView Animation'/>
</pages>
</playground>
11 changes: 11 additions & 0 deletions Sources/Views/ReloadableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public protocol ReloadableView: class {
of concurrent inserts/updates/deletes as UICollectionView documents in `performBatchUpdates`.
*/
func perform(batchUpdates: BatchUpdates, completion: (() -> Void)?)

// Returns contentView for either a UICollectionViewCell or UITableViewCell
func contentView(forIndexPath indexPath: IndexPath) -> UIView?
}

// MARK: - UICollectionView
Expand Down Expand Up @@ -93,6 +96,10 @@ extension UICollectionView: ReloadableView {
completion?()
})
}

open func contentView(forIndexPath indexPath: IndexPath) -> UIView? {
return self.cellForItem(at: indexPath)?.contentView
}
}

// MARK: - UITableView
Expand Down Expand Up @@ -144,4 +151,8 @@ extension UITableView: ReloadableView {

completion?()
}

open func contentView(forIndexPath indexPath: IndexPath) -> UIView? {
return self.cellForRow(at: indexPath)?.contentView
}
}
48 changes: 48 additions & 0 deletions Sources/Views/ReloadableViewLayoutAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,54 @@ open class ReloadableViewLayoutAdapter: NSObject, ReloadableViewUpdateManagerDel
currentArrangement = arrangement
reloadableView?.reloadDataSynchronously()
}

/**
Computes layouts without applying them on the view. Instead it returns a list of animations that
that can be can be used to smoothly resize e.g CollectionViewCells.

See Playground page CollectionView Animation in LayoutKit.playground for example
*/
open func reload<T: Collection, U: Collection>(
items: [IndexPath],
width: CGFloat? = nil,
height: CGFloat? = nil,
layoutProvider: @escaping (Void) -> T,
completion: @escaping ([Animation]) -> Void) where U.Iterator.Element == Layout, T.Iterator.Element == Section<U> {

let start = CFAbsoluteTimeGetCurrent()
let operation = BlockOperation()

operation.addExecutionBlock { [weak self, weak operation] in
let arrangements: [Section<[LayoutArrangement]>] = layoutProvider().flatMap { sectionLayout in
if operation?.isCancelled ?? true {
return nil
}

return sectionLayout.map { (layout: Layout) -> LayoutArrangement in
return layout.arrangement(width: width, height: height)
}
}

let mainOperation = BlockOperation(block: {
let animations: [Animation] = items.flatMap({ indexPath in
guard let contentView = self?.reloadableView?.contentView(forIndexPath: indexPath) else { return nil }
let arrangement = arrangements[indexPath.section].items[indexPath.item]
return arrangement.prepareAnimation(for: contentView)
})
self?.currentArrangement = arrangements

let end = CFAbsoluteTimeGetCurrent()
self?.logger?("user: \((end-start).ms)")
completion(animations)
})

if let operation = operation, !operation.isCancelled {
OperationQueue.main.addOperation(mainOperation)
}
}

backgroundLayoutQueue.addOperation(operation)
}
}

/// A section in a `ReloadableView`.
Expand Down