Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Using the latest UICollectionView APIs

Avatar for yhkaplan yhkaplan
September 18, 2021

Using the latest UICollectionView APIs

Avatar for yhkaplan

yhkaplan

September 18, 2021
Tweet

More Decks by yhkaplan

Other Decks in Programming

Transcript

  1. Intro — Joshua Kaplan (yhkaplan) — Senior iOS Engineer @

    GMO Pepabo working on minne — Tooling, frameworks, and architecture — Stay-at-home shiba-inu parent — He/him 2
  2. Topics skipped — Drag and drop — Cell editing —

    Headers and footers — Prefetching — Dynamic layout transitions 4
  3. Definition — Standard grid, complex grid, list, and more —

    Difference w/ UITableView — Manages multiple scrolling views — completely configurable layout — High performance, view recycling 6
  4. final class CollectionViewBasicsVC: UIViewController { private lazy var data =

    (0...100).compactMap { _ in ["pencil", "trash", "paperplane", "calendar", "lightbulb"].randomElement() } private lazy var layout: UICollectionViewFlowLayout = { let l = UICollectionViewFlowLayout() let halfWidth = view.bounds.width / 2 let halfWidthMinusMargins = halfWidth - 14 let height = halfWidthMinusMargins l.itemSize = CGSize(width: halfWidthMinusMargins, height: height) return l }() private lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) override func viewDidLoad() { super.viewDidLoad() view.addSubview(collectionView) collectionView.contentInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) collectionView.backgroundColor = .white collectionView.register(BasicCell.self, forCellWithReuseIdentifier: BasicCell.reuseID) collectionView.dataSource = self } } extension CollectionViewBasicsVC: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { data.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BasicCell.reuseID, for: indexPath) let systemName = data[indexPath.item] if let image = UIImage(systemName: systemName) { (cell as? BasicCell)?.configure(with: image) } return cell } } 9
  5. Code final class BasicCompositionalLayoutGridVC: UIViewController { enum Section: Hashable {

    case grid } private let data = ["pencil", "trash", "paperplane", "calendar", "lightbulb"] private lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) private lazy var layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0)) let item = NSCollectionLayoutItem(layoutSize: itemSize) item.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(0.5)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) return NSCollectionLayoutSection(group: group) } private lazy var dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) {...} override func viewDidLoad() {...} } 11
  6. List — iOS 14+ — Part of CompositionalLayout — All

    main UITableView styles available 12
  7. final class ListVC: UIViewController { enum Section: Hashable { case

    list } private let data = ["pencil", "trash", "paperplane", "calendar", "lightbulb"] private lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) private lazy var layout: UICollectionViewCompositionalLayout = { let config = UICollectionLayoutListConfiguration(appearance: .plain) return UICollectionViewCompositionalLayout.list(using: config) }() private lazy var dataSource = UICollectionViewDiffableDataSource<Section, String>( collectionView: collectionView ) { collectionView, indexPath, item in let registration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, item in var content = cell.defaultContentConfiguration() content.image = UIImage(systemName: item) content.text = item cell.contentConfiguration = content } let cell = collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) cell.accessories = [.disclosureIndicator()] return cell } override func viewDidLoad() {...} } 13
  8. Custom Layout — Subclass UICollectionViewFlowLayout or UICollectionViewLayout — Procedural and

    verbose — Powerful — UIKitDynamics — Easy to pop-in OSS layouts 20
  9. open class BouncyLayout: UICollectionViewFlowLayout { lazy var dynamicAnimator = UIDynamicAnimator(collectionViewLayout:

    self) var latestDelta: CGFloat = 0.0 var visibleIndexPaths: Set<IndexPath> = [] override init() {...} required public init?(coder: NSCoder) {...} override open func prepare() { super.prepare() // Need to overflow our actual visible rect slightly to avoid flickering. guard let collectionView = collectionView else { return } let rect = CGRect(origin: collectionView.bounds.origin, size: collectionView.frame.size) let visibleRect = rect.insetBy(dx: -100.0, dy: -100.0) guard let itemsInVisibleRect = super.layoutAttributesForElements(in: visibleRect) else { return } let itemsIndexPathsInVisibleRect: Set<IndexPath> = Set(itemsInVisibleRect.map { $0.indexPath }) // Step 1: Remove any behaviors that are no longer visible. let noLongerVisibleBehaviors = dynamicAnimator.behaviors.filter { behavior in guard let behaviorItem = (behavior as? UIAttachmentBehavior)?.items.first, let layoutAttribute = behaviorItem as? UICollectionViewLayoutAttributes else { return false } return !itemsIndexPathsInVisibleRect.contains(layoutAttribute.indexPath) } noLongerVisibleBehaviors.forEach { behavior in dynamicAnimator.removeBehavior(behavior) if let layoutAttribute = (behavior as? UIAttachmentBehavior)?.items.first as? UICollectionViewLayoutAttributes { visibleIndexPaths.remove(layoutAttribute.indexPath) } } // Step 2: Add any newly visible behaviors. // A "newly visible" item is one that is in the itemsInVisibleRect(Set|Array) but not in the visibleIndexPathsSet let newlyVisibleItems = itemsInVisibleRect.filter { !visibleIndexPaths.contains($0.indexPath) } let touchLocation = collectionView.panGestureRecognizer.location(in: collectionView) newlyVisibleItems.forEach { item in var center = item.center let springBehavior = UIAttachmentBehavior(item: item, attachedToAnchor: center) springBehavior.length = 0.0 springBehavior.damping = 0.8 springBehavior.length = 1.0 // If our touchLocation is not (0,0), we'll need to adjust our item's center "in flight" if CGPoint.zero != touchLocation { let yDistanceFromTouch = abs(touchLocation.y - springBehavior.anchorPoint.y) let xDistanceFromTouch = abs(touchLocation.x - springBehavior.anchorPoint.x) let scrollResistance = (yDistanceFromTouch + xDistanceFromTouch) / 1_500.0 if latestDelta < 0 { center.y += max(latestDelta, latestDelta * scrollResistance) } else { center.y += min(latestDelta, latestDelta * scrollResistance) } item.center = center } dynamicAnimator.addBehavior(springBehavior) visibleIndexPaths.insert(item.indexPath) } } open override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return dynamicAnimator.items(in: rect) as? [UICollectionViewLayoutAttributes] } open override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return dynamicAnimator.layoutAttributesForCell(at: indexPath) } open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { guard let collectionView = collectionView else { return false } let scrollView = collectionView let delta = newBounds.origin.y - scrollView.bounds.origin.y latestDelta = delta let touchLocation = collectionView.panGestureRecognizer.location(in: collectionView) dynamicAnimator.behaviors.forEach { behavior in guard let springBehavior = behavior as? UIAttachmentBehavior else { return } let yDistanceFromTouch = abs(touchLocation.y - springBehavior.anchorPoint.y) let xDistanceFromTouch = abs(touchLocation.x - springBehavior.anchorPoint.x) let scrollResistance = (yDistanceFromTouch + xDistanceFromTouch) / 1_500.0 if let item = springBehavior.items.first as? UICollectionViewLayoutAttributes { var center = item.center if delta < 0 { center.y += max(delta, delta * scrollResistance) } else { center.y += min(delta, delta * scrollResistance) } item.center = center dynamicAnimator.updateItem(usingCurrentState: item) } } return false } } 21
  10. UICollectionViewDiffableDataSource — Fits most cases — iOS 14 and 15

    added — Cell/section reordering — Updating specific sections — Reloading completely w/o diff for better performance on large changes — Animation behavior difficult to customize — SE-0240: Ordered Collection Diffing is your friend — Swift 5.1 — Find inserted, deleted, and updated items/sections, then simply use performBatchUpdates(_:completion:) 23
  11. Code final class BasicCompositionalLayoutGridVC: UIViewController { enum Section: Hashable {

    case grid } private let data = ["pencil", "trash", "paperplane", "calendar", "lightbulb"] private lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) private lazy var layout = UICollectionViewCompositionalLayout {...} private lazy var dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { collectionView, indexPath, item in let registration = UICollectionView.CellRegistration<UICollectionViewCell, String> { cell, indexPath, item in let image = UIImage(systemName: item) cell.contentConfiguration = ImageContentView.Config(image: image) } return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) } override func viewDidLoad() { super.viewDidLoad() view.addSubview(collectionView) var snapshot = NSDiffableDataSourceSnapshot<Section, String>() snapshot.appendSections([.grid]) snapshot.appendItems(data, toSection: .grid) dataSource.apply(snapshot, animatingDifferences: false) } } 24
  12. Cell configuration and updating — Useful for UITableView-style cells —

    Custom cells maybe more work than preferable 25
  13. List example final class ListVC: UIViewController { enum Section: Hashable

    { case list } private let data = ["pencil", "trash", "paperplane", "calendar", "lightbulb"] private lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) private lazy var layout: UICollectionViewCompositionalLayout = { let config = UICollectionLayoutListConfiguration(appearance: .plain) return UICollectionViewCompositionalLayout.list(using: config) }() private lazy var dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { collectionView, indexPath, item in let registration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { cell, indexPath, item in var content = cell.defaultContentConfiguration() content.image = UIImage(systemName: item) content.text = item cell.contentConfiguration = content } let cell = collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) cell.accessories = [.disclosureIndicator()] return cell } override func viewDidLoad() {...} } 26
  14. UIContentView final class ImageContentView: UIView, UIContentView { private var _configuration:

    Config private let imageView = UIImageView() var configuration: UIContentConfiguration { get { _configuration } set { guard let config = newValue as? Config else { return } _configuration = config imageView.image = _configuration.image } } init(config: Config) { _configuration = config super.init(frame: .zero) backgroundColor = .lightGray layoutImageView() imageView.image = _configuration.image } private func layoutImageView() {...} @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } struct Config: UIContentConfiguration { let image: UIImage? func makeContentView() -> UIView & UIContentView { ImageContentView(config: self) } func updated(for state: UIConfigurationState) -> ImageContentView.Config { self } } } 28
  15. Registration final class BasicCompositionalLayoutGridVC: UIViewController { enum Section: Hashable {

    case grid } private let data = ["pencil", "trash", "paperplane", "calendar", "lightbulb"] private lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) private lazy var layout = UICollectionViewCompositionalLayout {...} private lazy var dataSource = UICollectionViewDiffableDataSource<Section, String>(collectionView: collectionView) { collectionView, indexPath, item in let registration = UICollectionView.CellRegistration<UICollectionViewCell, String> { cell, indexPath, item in let image = UIImage(systemName: item) cell.contentConfiguration = ImageContentView.Config(image: image) } return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) } override func viewDidLoad() {...} } 29
  16. Updating — Update based on UICellConfigurationState w/o subclassing cell —

    UICellConfigurationState: isSelected, isHighlighted, isDisabled, etc — reconfigureItems(_:) — configurationUpdateHandler: UICollectionViewCell.ConfigurationUpdateHandler? cell.configurationUpdateHandler = { cell, state in var content = UIListContentConfiguration.cell().updated(for: state) content.text = "Hello world!" if state.isDisabled { content.textProperties.color = .systemGray } cell.contentConfiguration = content } 30
  17. Swi!UI — List and Grid — Performance — Customizability struct

    HomeView: View { private let columns = [GridItem(.adaptive(minimum: 80)), GridItem(.adaptive(minimum: 80))] var body: some View { ScrollView { LazyVGrid(columns: columns) { ForEach(viewModel.products, id: \.id) { product in ProductCard(product) } } } } } 32
  18. When to use UITableView — complex list-style customization — self-sizing

    cells w/ dynamic height are generally easier — Consider using SwiftUI.List too 33
  19. Conclusion — Find the right fit — Older ways and

    SwiftUI work too, but know what the latest APIs are — You mostly don't need UITableView anymore — UICollectionView is really flexible and performant — SwiftUI's Grid views are still somewhat lacking in comparison 34
  20. OSS Examples — Various layouts — jVirus/uicollectionview-layouts-kit — amirdew/CollectionViewPagingLayout —

    Abstracting many layouts WenchaoD/FSPagerView — Neat slanted layout yacir/CollectionViewSlantedLayout — Abstraction based on delegates airbnb/MagazineLayout — Pinterest-like layout ChernyshenkoTaras/SquareFlowLayout — Carousel layout: zepojo/UPCarouselFlowLayout — UIKitDynamics GitHub - roberthein/BouncyLayout: Make. It. Bounce. 36
  21. Timeline — 2016 or earlier / iOS 10 — DataSourcePrefetching

    — 2017 or earlier / iOS 11 — drag and drop — 2019 / iOS 13 — DiffableDataSource — CompositionalLayout — 2020 / iOS 14 — cell configuration — UICollectionView list style — DiffableDataSource & CompositionalLayout new features — 2021 / iOS 14.5 — UIListSeparatorConfiguration — 2021 / iOS 15 — cell configuration new features — DiffableDataSource & CompositionalLayout new features 37
  22. WWDC Videos — Advances in UICollectionView — Make blazing fast

    lists and collection views — Advances in diffable data sources — Lists in UICollectionView — Modern cell configuration — A Tour of UICollectionView — Drag and Drop with Collection and Table View — What's New in UICollectionView in iOS 10 39