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

Beyond the Grid — Nathan Eror

Realm
June 11, 2015

Beyond the Grid — Nathan Eror

When asked what UICollectionView is for, most iOS developers respond with some variation of 'It's like UITableView, but with a grid.' While this statement is mostly true, it greatly underestimates the utility of this powerful piece of UIKit. UICollectionView is actually a generic API for dynamically laying out a collection of views in an infinite scrollable canvas. The built in UICollectionViewFlowLayout implementation works very well for laying out a mostly uniform collection of views in a grid, but it only scratches the surface of UICollectionView. To go beyond the grid, developers can implement custom layouts dynamically driven by app data. Changes to the data can instantly affect the layouts. These changes can be automatically animated and even imbued with physical properties via UIKitDynamics. With a little math and a lot of creativity, UICollectionView can be used to render timelines, charts and graphs, parallax scrolling landscapes and more. The aim of this session is to show, through working examples, how flexible and powerful UICollectionView really is.

This talk was presented at AltConf in June 2015.

Realm

June 11, 2015
Tweet

More Decks by Realm

Other Decks in Programming

Transcript

  1. UICollectionViewLayout • collectionViewContentSize() • layoutAttributesForElementsInRect(_ rect: CGRect) • layoutAttributesForItemAtIndexPath(_ indexPath:

    NSIndexPath) • layoutAttributesForSupplementaryViewOfKind(_ kind: String, atIndexPath indexPath: NSIndexPath) • layoutAttributesForDecorationViewOfKind(_ decorationViewKind: String, atIndexPath indexPath: NSIndexPath)
  2. MODEL class Node: Hashable, Equatable, Printable { let name: String

    enum NodeType: Printable { case Normal case Important case Critical var nodeColors: (label:UIColor, background:UIColor) { switch(self) { case .Normal: return (label:UIColor.blackColor(), background:UIColor.lightGrayColor()) case .Important: return (label:UIColor.blackColor(), background:UIColor.yellowColor()) case .Critical: return (label:UIColor.whiteColor(), background:UIColor.redColor()) } } var description: String { switch(self) { case .Normal: return "Normal" case .Important: return "Important" case .Critical: return "Critical" } } } let nodeType: NodeType var parent: Node? var children = Set<Node>()
  3. DATA CONTROLLER class SchematicDataController: NSObject { var nodes = Set<Node>()

    var sections = [[Node]]() var maxNodesInSection = 0 func performFetch() { let tree = Node(name:"Root", type:.Normal) { [ Node(name:"Child 1", type:.Normal) { [ Node(name:"Child 1-1", type:.Important), Node(name:"Child 1-2", type:.Critical) ] }, Node(name:"Child 2", type:.Normal), Node(name:"Child 3", type:.Normal) { [ Node(name:"Child 3-1", type:.Normal) { [ Node(name:"Child 3-1-1", type:.Critical), Node(name:"Child 3-1-2", type:.Normal) ] }, Node(name:"Child 3-2", type:.Important) ] } ] } self.nodes = Set([tree]) self.cacheSections() } func nodeAtIndexPath(indexPath: NSIndexPath) -> Node? { if let section = self.sections.optionalElementAtIndex(indexPath.section) { return section.optionalElementAtIndex(indexPath.row) } return nil } func indexPathForNode(node: Node) -> NSIndexPath? { for (sectionIndex, section) in enumerate(self.sections) { for (itemIndex, item) in enumerate(section) { if node == item { return NSIndexPath(forItem: itemIndex, inSection: sectionIndex) } } } return nil } // MARK: - Internal
  4. LAYOUT MODEL struct SectionDescription { typealias Item = (size: CGSize,

    parents: NSIndexSet) var offset: CGFloat var size: CGSize = CGSizeZero var itemPadding: CGFloat = 40 var items: [Item] { didSet { self.recalculateSize() } } init(offset: CGFloat = 0.0, items: [Item] = [Item]()) { self.offset = offset self.items = items self.recalculateSize() } var maxX: CGFloat { return offset + size.width } mutating func recalculateSize() { self.size = reduce(self.items, CGSizeZero) { accum, item in return CGSize(width: max(item.size.width, accum.width), height: accum.height + item.size.height + self.itemPadding) } } func frameForItemAtIndex(index: Int) -> CGRect { let indexFloat = CGFloat(index) let previousNodeSizes = reduce(self.items[0..<index], 0.0) { accum, item in accum + item.size.height } let y = (self.itemPadding / 2.0) + previousNodeSizes + (indexFloat * self.itemPadding) let thisItem = self.items[index] let centeringXOffset = (self.size.width - thisItem.size.width) / 2.0 let origin = CGPoint(x: self.offset + centeringXOffset, y: y) return CGRect(origin: origin, size: thisItem.size) } }
  5. LAYOUT override func prepareLayout() { if self.sectionDescriptions == nil {

    self.sectionDescriptions = [SectionDescription]() if let dataController = self.dataController { for (sectionIndex, nodes) in enumerate(dataController.sections) { let sectionIndexFloat = CGFloat(sectionIndex) let offset = sectionMargin + (sectionIndexFloat * self.nodeSize.width) + (sectionIndexFloat * self.sectionPadding) var sectionInfo = SectionDescription(offset: offset, items: map(nodes) { node in var indexSet = NSMutableIndexSet() if sectionIndex > 0 { if let parent = node.parent, indexPath = dataController.indexPathForNode(parent) where indexPath.section == sectionIndex - 1 { indexSet.addIndex(indexPath.item) } } return (size: self.nodeSize, parents: indexSet) }) self.sectionDescriptions!.append(sectionInfo) } } } } override func collectionViewContentSize() -> CGSize { if let dataController = self.dataController, sectionDescriptions = self.sectionDescriptions { var width: CGFloat = 0.0 if let lastSection = sectionDescriptions.last { width = lastSection.maxX + self.sectionMargin } let height = reduce(sectionDescriptions, 0.0) { max($0, $1.size.height) } return CGSize(width: width, height: height) } return super.collectionViewContentSize() }
  6. LAYOUT override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? { var attributes

    = [UICollectionViewLayoutAttributes]() if let sectionDescriptions = self.sectionDescriptions { attributes += flatMap(enumerate(sectionDescriptions)) { sectionIndex, section in var items = [UICollectionViewLayoutAttributes]() if section.offset >= rect.minX && section.offset <= rect.maxX { items += flatMap(enumerate(section.items)) { itemIndex, item in var itemAttributes = [UICollectionViewLayoutAttributes]() if rect.contains(section.frameForItemAtIndex(itemIndex)) { let indexPath = NSIndexPath(forItem: itemIndex, inSection: sectionIndex) itemAttributes.append(self.layoutAttributesForItemAtIndexPath(indexPath)) } return itemAttributes } } return items } } return attributes } override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! { let attributes = SchematicLayoutAttributes(forCellWithIndexPath: indexPath) if let sectionDescriptions = self.sectionDescriptions { attributes.frame = sectionDescriptions[indexPath.section].frameForItemAtIndex(indexPath.item) } return attributes }
  7. COLLECTION DATA SOURCE override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {

    return self.dataController?.sections.count ?? 0 } override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return self.dataController?.sections[section].count ?? 0 } override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCellWithReuseIdentifier(SchematicNodeCell.cellReuseIdentifier, forIndexPath: indexPath) as! SchematicNodeCell if let node = self.dataController?.nodeAtIndexPath(indexPath) { cell.nameLabel.text = node.name cell.nameLabel.textColor = node.nodeType.nodeColors.label cell.containerView.backgroundColor = node.nodeType.nodeColors.background } return cell }
  8. LAYOUT override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? { var attributes

    = [UICollectionViewLayoutAttributes]() if let sectionDescriptions = self.sectionDescriptions { attributes += flatMap(enumerate(sectionDescriptions)) { sectionIndex, section in var items = [UICollectionViewLayoutAttributes]() if section.offset >= rect.minX && section.offset <= rect.maxX { items += flatMap(enumerate(section.items)) { itemIndex, item in var itemAttributes = [UICollectionViewLayoutAttributes]() if rect.contains(section.frameForItemAtIndex(itemIndex)) { let indexPath = NSIndexPath(forItem: itemIndex, inSection: sectionIndex) itemAttributes.append(self.layoutAttributesForItemAtIndexPath(indexPath)) let nextSectionIndex = sectionIndex + 1 if let nextSection = sectionDescriptions.optionalElementAtIndex(nextSectionIndex) { let children = filter(enumerate(nextSection.items)) { childIndex, child in child.parents.containsIndex(itemIndex) } for (childIndex, child) in children { itemAttributes.append(self.layoutAttributesForSupplementaryViewOfKind(SchematicLayout.connectorViewKind, atIndexPath: NSIndexPath(forItem: childIndex, inSection: nextSectionIndex))) } } } return itemAttributes } } return items } } return attributes } override func layoutAttributesForSupplementaryViewOfKind(elementKind: String, atIndexPath indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! { let attributes = SchematicLayoutAttributes(forSupplementaryViewOfKind: elementKind, withIndexPath: indexPath) if let sectionDescriptions = self.sectionDescriptions, dataController = self.dataController { let node = dataController.nodeAtIndexPath(indexPath) let childSectionDescription = sectionDescriptions[indexPath.section] let childFrame = childSectionDescription.frameForItemAtIndex(indexPath.item) for parentIndex in childSectionDescription.items[indexPath.item].parents { let parentIndexPath = NSIndexPath(forItem: parentIndex, inSection: indexPath.section - 1) let parentFrame = sectionDescriptions[parentIndexPath.section].frameForItemAtIndex(parentIndexPath.item) let y = min(parentFrame.midY, childFrame.midY) attributes.frame = CGRect(x: parentFrame.maxX, y: y, width: abs(childFrame.minX - parentFrame.maxX), height: abs(childFrame.midY - parentFrame.midY)) attributes.connectorLineStartTop = parentFrame.midY > childFrame.midY } } return attributes }
  9. LAYOUT class SchematicLayoutAttributes: UICollectionViewLayoutAttributes, NSCopying { var connectorLineStartTop: Bool =

    true override func copyWithZone(zone: NSZone) -> AnyObject { var copy = super.copyWithZone(zone) as! SchematicLayoutAttributes copy.connectorLineStartTop = self.connectorLineStartTop return copy } }
  10. CONNECTOR VIEW class SchematicConnectorView: UICollectionReusableView { static let viewReuseIdentifier =

    "SchematicConnectorView" var lineStartTop: Bool = true override class func layerClass() -> AnyClass { return CAShapeLayer.self } var shapeLayer: CAShapeLayer { return self.layer as! CAShapeLayer } // ... INITILIZATION STRIPPED ... func sharedInit() { self.opaque = true self.shapeLayer.fillColor = nil self.shapeLayer.strokeColor = UIColor.lightGrayColor().CGColor self.shapeLayer.lineWidth = 2.0 } override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes!) { super.applyLayoutAttributes(layoutAttributes) if let attributes = layoutAttributes as? SchematicLayoutAttributes { self.lineStartTop = attributes.connectorLineStartTop } } override func layoutSubviews() { var start = CGPoint(x: self.bounds.minX, y: self.bounds.minY) var end = CGPoint(x: self.bounds.maxX, y: self.bounds.maxY) if self.lineStartTop { start.y = self.bounds.maxY end.y = self.bounds.minY } let path = UIBezierPath() path.moveToPoint(start) path.addLineToPoint(end) self.shapeLayer.path = path.CGPath } }
  11. COLLECTION DATA SOURCE override func collectionView(collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String,

    atIndexPath indexPath: NSIndexPath) -> UICollectionReusableView { return self.collectionView!.dequeueReusableSupplementaryViewOfKind(kind, withReuseIdentifier: SchematicLayout.connectorViewKind, forIndexPath: indexPath) as! UICollectionReusableView }
  12. INVALIDATION • invalidateLayoutWithContext(context: UICollectionViewLayoutInvalidationContext) • invalidationContextForPreferredLayoutAttributes(preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes)

    -> UICollectionViewLayoutInvalidationContext • shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool • shouldInvalidateLayoutForPreferredLayoutAttributes(preferredAttributes : UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool
  13. LAYOUT class SchematicLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext { var invalidatedSizesForIndexPaths: [NSIndexPath:CGSize]? func invalidateSize(size:

    CGSize, forIndexPath indexPath: NSIndexPath) { if self.invalidatedSizesForIndexPaths == nil { self.invalidatedSizesForIndexPaths = [NSIndexPath:CGSize]() } self.invalidatedSizesForIndexPaths![indexPath] = size } } override class func invalidationContextClass() -> AnyClass { return SchematicLayoutInvalidationContext.self } override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool { return false } override func shouldInvalidateLayoutForPreferredLayoutAttributes(preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool { if originalAttributes.representedElementCategory == .Cell { return !CGSizeEqualToSize(originalAttributes.size, preferredAttributes.size) } return false } func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes!
  14. LAYOUT override func invalidationContextForPreferredLayoutAttributes(preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext

    { var context = super.invalidationContextForPreferredLayoutAttributes(preferredAttributes, withOriginalAttributes: originalAttributes) as! SchematicLayoutInvalidationContext context.invalidateSize(preferredAttributes.size, forIndexPath: preferredAttributes.indexPath) let itemsInSection = self.sectionDescriptions?[preferredAttributes.indexPath.section].items ?? [] let indexPathsForItems: [NSIndexPath] = map(enumerate(itemsInSection)) { itemIndex, _ in NSIndexPath(forItem: itemIndex, inSection: preferredAttributes.indexPath.section) } context.invalidateItemsAtIndexPaths(indexPathsForItems) let nextSectionIndex = preferredAttributes.indexPath.section + 1 let itemsInNextSection = self.sectionDescriptions?.optionalElementAtIndex(nextSectionIndex)?.items ?? [] let indexPathsForChildren: [NSIndexPath] = flatMap(enumerate(itemsInSection)) { itemIndex, _ in var childIndexPaths = [NSIndexPath]() for (nextItemIndex, nextItem) in enumerate(itemsInNextSection) { if nextItem.parents.containsIndex(itemIndex) { childIndexPaths.append(NSIndexPath(forItem: nextItemIndex, inSection: nextSectionIndex)) } } return childIndexPaths } var connectorIndexPaths: [NSIndexPath] = indexPathsForChildren if preferredAttributes.indexPath.section > 0 { connectorIndexPaths += indexPathsForItems } context.invalidateSupplementaryElementsOfKind(SchematicLayout.connectorViewKind, atIndexPaths: connectorIndexPaths) return context }
  15. LAYOUT override func invalidateLayoutWithContext(context: UICollectionViewLayoutInvalidationContext) { if let context =

    context as? SchematicLayoutInvalidationContext, invalidatedSizesForIndexPaths = context.invalidatedSizesForIndexPaths where self.sectionDescriptions != nil { var firstSection = self.sectionDescriptions!.count for (indexPath, size) in invalidatedSizesForIndexPaths { var item = self.sectionDescriptions![indexPath.section].items[indexPath.item] item.size = size self.sectionDescriptions![indexPath.section].items[indexPath.item] = item firstSection = min(firstSection, indexPath.section) } self.recalculateSectionSizesInRange(firstSection..<self.sectionDescriptions!.count) } if context.invalidateEverything { self.sectionDescriptions = nil } super.invalidateLayoutWithContext(context) }