diff --git a/LayoutKit.playground/Pages/CollectionView Animation.xcplaygroundpage/Contents.swift b/LayoutKit.playground/Pages/CollectionView Animation.xcplaygroundpage/Contents.swift new file mode 100644 index 00000000..d997a0c4 --- /dev/null +++ b/LayoutKit.playground/Pages/CollectionView Animation.xcplaygroundpage/Contents.swift @@ -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 diff --git a/LayoutKit.playground/contents.xcplayground b/LayoutKit.playground/contents.xcplayground index 8fb4aed1..04d8fa14 100644 --- a/LayoutKit.playground/contents.xcplayground +++ b/LayoutKit.playground/contents.xcplayground @@ -12,5 +12,6 @@ + \ No newline at end of file diff --git a/Sources/Views/ReloadableView.swift b/Sources/Views/ReloadableView.swift index f00c7a44..b3b579d7 100644 --- a/Sources/Views/ReloadableView.swift +++ b/Sources/Views/ReloadableView.swift @@ -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 @@ -93,6 +96,10 @@ extension UICollectionView: ReloadableView { completion?() }) } + + open func contentView(forIndexPath indexPath: IndexPath) -> UIView? { + return self.cellForItem(at: indexPath)?.contentView + } } // MARK: - UITableView @@ -144,4 +151,8 @@ extension UITableView: ReloadableView { completion?() } + + open func contentView(forIndexPath indexPath: IndexPath) -> UIView? { + return self.cellForRow(at: indexPath)?.contentView + } } diff --git a/Sources/Views/ReloadableViewLayoutAdapter.swift b/Sources/Views/ReloadableViewLayoutAdapter.swift index 97614cc0..a53cba17 100644 --- a/Sources/Views/ReloadableViewLayoutAdapter.swift +++ b/Sources/Views/ReloadableViewLayoutAdapter.swift @@ -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( + 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 { + + 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`.