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

Declarative UICollectionView

Declarative UICollectionView

iOSDC Japan 2018

8889da6a67db3667b0694d993c9a962c?s=128

Yosuke Ishikawa

August 31, 2018
Tweet

Transcript

  1. એݴత 6*$PMMFDUJPO7JFX JTILBXB

  2. w גࣜձࣾ9$50 w 4XJGU ,PUMJO (P +BWB4DSJQU w "1*,JU %*,JU

    w 4XJGU࣮ફೖ໳ J041SPHSBNNJOH JTILBXB
  3. None
  4. ࣮૷͠·͢

  5. class VenueDetailViewController: UIViewController { var venue: Venue var reviews: [Review]

    var relatedVenues: [Venue] }
  6. func collectionView( _ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int

    { switch section { case 0: return 1 case 1: return reviews.count case 2: return relatedVenues.count default: fatalError("unknown section \(section)") } }
  7. func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell

    { switch indexPath.section { case 0: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } }
  8. func collectionView( _ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath:

    IndexPath) -> UICollectionReusableView { guard kind == UICollectionView.elementKindSectionHeader else { fatalError() } switch indexPath.section { case 1: let view = collectionView.dequeueReusableCell( withReuseIdentifier: "SectionHeader", for: indexPath) as! SectionHeaderCell view.bind("Reviews") return view case 2: let view = collectionView.dequeueReusableCell( withReuseIdentifier: "SectionHeader", for: indexPath) as! SectionHeaderCell view.bind("Related Venues") return view default: fatalError() } }
  9. func collectionView( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section:

    Int) -> CGSize { let flowLayout = collectionViewLayout as! UICollectionViewFlowLayout switch section { case 0: return .zero case 1: return reviews.isEmpty ? .zero : flowLayout.headerReferenceSize case 2: return relatedVenues.isEmpty ? .zero : flowLayout.headerReferenceSize default: fatalError() } }
  10. w ίʔυ͔Β݁Ռ͕૝૾ͮ͠Β͍ w JOEFYʹґଘͨ͠৚݅෼ذ͕͋Ϳͳ͍༧ײ w ৚݅෼ذ͕ॏෳ͍ͯ͠ΔͷͰߋʹ͋Ϳͳ͍

  11. ͳΜͱ͔͠Α͏

  12. ॲཧͷϑϩʔΛ௥ͬͯΈΔ

  13. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX switch section { case 0: return 1 case

    1: return reviews.count case 2: return relatedVenues.count default: fatalError() } w WFOVF w SFWJFXT w SFMBUFE7FOVFT ηϧͷ਺͸ʁ  Ͱ͢ʂ
  14. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX ൪໨ͷηϧ͸ʁ  7FOVF0VUMJOF$FMMͰ͢ʂ switch indexPath.section { case 0:

    let cell = collectionView.dequeueReusableCell( withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } w WFOVF w SFWJFXT w SFMBUFE7FOVFT
  15. าҾ͍ͯଊ͑ͯΈΔ

  16. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX w WFOVF w SFWJFXT w SFMBUFE7FOVFT w OVNCFS0G*UFNT

    w DFMM'PS*UFN"U σʔλม׵ ঢ়ଶΛද͢σʔλ 6*$PMMFDUJPO7JFXͷσʔλ
  17. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX ঢ়ଶΛද͢σʔλ 6*$PMMFDUJPO7JFXͷσʔλ w OVNCFS0G*UFNT w DFMM'PS*UFN"U ϝιου͝ͱʹ σʔλม׵

    w WFOVF w SFWJFXT w SFMBUFE7FOVFT
  18. ʮঢ়ଶΛද͢σʔλʯ͔Β ʮ6*$PMMFDUJPO7JFXͷσʔλʯ͸ԕ͍ͷʹ
 ϝιου͝ͱʹΠν͔Βม׵͍ͯ͠Δ

  19. σʔλม׵Λ؍࡯͢Δ

  20. func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

    switch section { case 0: return 1 case 1: return reviews.count case 2: return relatedVenues.count default: fatalError("unknown section \(section)") } } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { switch indexPath.section { case 0: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } }
  21. func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

    switch section { case 0: return 1 case 1: return reviews.count case 2: return relatedVenues.count default: fatalError("unknown section \(section)") } } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { switch indexPath.section { case 0: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } } ৚݅෼ذ͕ॏෳ͍ͯ͠Δ
  22. func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

    switch section { case 0: return 1 case 1: return reviews.count case 2: return relatedVenues.count default: fatalError("unknown section \(section)") } } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { switch indexPath.section { case 0: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } } ݸͷ7FOVF0VUMJOF$FMMΛฦͨ͢Ίͷॲཧ
  23. func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

    switch section { case 0: return 1 case 1: return reviews.count case 2: return relatedVenues.count default: fatalError("unknown section \(section)") } } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { switch indexPath.section { case 0: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } } SFWJFXTDPVOUݸͷ3FWJFX$FMMΛฦͨ͢Ίͷॲཧ
  24. w ৚݅෼ذηϧͷछྨΛܾΊΔॲཧ w ࣮ߦจ6*$PMMFDUJPO7JFXʹ஋Λ౉͢ॲཧ

  25. ॲཧΛஈ֊ʹ෼͚Δ

  26. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX σʔλม׵ σʔλม׵ 6*$PMMFDUJPO7JFXʹ஋Λ౉͢ॲཧ ηϧͷछྨΛܾΊΔॲཧ w WFOVF w SFWJFXT

    w SFMBUFE7FOVFT w DFMM%FDMBSBUJPOT w OVNCFS0G*UFNT w DFMM'PS*UFN"U 6*$PMMFDUJPO7JFXͷσʔλ ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  27. ஈ֊໨ͷॲཧ

  28. w ηϧͷछྨͱඥ͚ͮΔ஋ͷϖΞ w 6*$PMMFDUJPO7JFX$FMMͱରͷؔ܎ enum CellDeclaration: Equatable { case outline(Venue)

    case sectionHeader(String) case review(Review) case relatedVenue(Venue) } ੒Ռ෺
  29. w WFOVF7FOVF  w SFWJFXT<> w SFMBUFE7FOVFT<> w DFMM%FDMBSBUJPOT<
 $FMM%FDMBSBUJPOPVUMJOF

    7FOVF  
 > ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  30. w WFOVF7FOVF  w SFWJFXT<
 3FWJFX JE  
 3FWJFX

    JE  
 > w SFMBUFE7FOVFT<> w DFMM%FDMBSBUJPOT<
 $FMM%FDMBSBUJPOPVUMJOF 7FOVF  
 $FMM%FDMBSBUJPOTFDUJPO)FBEFS 
 $FMM%FDMBSBUJPOSFWJFX 3FWJFX JE  
 $FMM%FDMBSBUJPOSFWJFX 3FWJFX JE  
 > ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  31. struct Data: CellsDeclarator { var venue: Venue var reviews: [Review]

    var relatedVenues: [Venue] func declareCells(_ cell: (CellDeclaration) -> Void) { cell(.outline(venue)) if !reviews.isEmpty { cell(.sectionHeader("Reviews")) for review in reviews { cell(.review(review)) } } if !relatedVenues.isEmpty { cell(.sectionHeader("Related Venues")) for relatedVenue in relatedVenues { cell(.relatedVenue(relatedVenue)) } } } } ͜ͷؔ਺ΛݺΜͰηϧΛએݴ͍ͯ͘͠
  32. struct Data: CellsDeclarator { var venue: Venue var reviews: [Review]

    var relatedVenues: [Venue] func declareCells(_ cell: (CellDeclaration) -> Void) { cell(.outline(venue)) if !reviews.isEmpty { cell(.sectionHeader("Reviews")) for review in reviews { cell(.review(review)) } } if !relatedVenues.isEmpty { cell(.sectionHeader("Related Venues")) for relatedVenue in relatedVenues { cell(.relatedVenue(relatedVenue)) } } } }
  33. protocol CellsDeclarator { associatedtype CellDeclaration func declareCells(_ cell: (CellDeclaration) ->

    Void) } extension CellsDeclarator { var cellDeclarations: [CellDeclaration] { var declarations = [] as [CellDeclaration] declareCells { declaration in declarations.append(declaration) } return declarations } }
  34. ஈ֊໨ͷॲཧ

  35. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX σʔλม׵ σʔλม׵ 6*$PMMFDUJPO7JFXʹ஋Λ౉͢ॲཧ ڞ௨ͷ࢓૊ΈͰߦ͏ ηϧͷछྨΛܾΊΔॲཧ ը໘͝ͱʹߦ͏ w WFOVF

    w SFWJFXT w SFMBUFE7FOVFT w DFMM%FDMBSBUJPOT w OVNCFS0G*UFNT w DFMM'PS*UFN"U 6*$PMMFDUJPO7JFXͷσʔλ ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  36. σʔλม׵ w OVNCFS0G*UFNT w DFMM'PS*UFN"U<> w DFMM%FDMBSBUJPOT<
 $FMM%FDMBSBUJPOPVUMJOF 7FOVF 

    
 $FMM%FDMBSBUJPOTFDUJPO)FBEFS 
 $FMM%FDMBSBUJPOSFWJFX *UFN JE  
 $FMM%FDMBSBUJPOSFWJFX *UFN JE  
 > ίϨΫγϣϯΛද͢σʔλ 6*$PMMFDUJPO7JFXͷσʔλ 6*$PMMFDUJPO7JFXʹ஋Λ౉͢ॲཧ ڞ௨ͷ࢓૊ΈͰߦ͏
  37. 6*$PMMFDUJPO7JFX$FMMΛ ฦͨ͢Ίʹඞཁͳ͜ͱ͸ʁ

  38. func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    switch indexPath.section { case 0: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VenueOutline", for: indexPath) as! VenueOutlineCell cell.bind(venue) return cell case 1: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Review", for: indexPath) as! ReviewCell cell.bind(reviews[indexPath.item]) return cell case 2: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RelatedVenue", for: indexPath) as! RelatedVenueCell cell.bind(relatedVenues[indexPath.item]) return cell default: fatalError() } } ηϧͷEFRVFVF ηϧ΁ͷ஋ͷඥ͚ͮ
  39. protocol BindableNibCell { static var nib: UINib { get }

    static var reuseIdentifier: String { get } 
 associatedtype Value func bind(_ value: Value) } ηϧ΁ͷ஋ͷඥ͚ͮ ηϧͷEFRVFVF ඞཁͳ΋ͷΛηϧʹఏڙͤ͞Δ
  40. struct CellBinder { let nib: UINib let reuseIdentifier: String let

    configureCell: (UICollectionViewCell) -> Void fileprivate init<Cell: BindableNibCell>(cellType: Cell.Type, value: Cell.Value) { self.nib = cellType.nib self.reuseIdentifier = cellType.reuseIdentifier self.configureCell = { cell in guard let cell = cell as? Cell else { fatalError("Could not cast UICollectionView cell to \(Cell.self)") } cell.bind(value) } } } extension BindableNibCell { static func makeBinder(with value: Value) -> CellBinder { return CellBinder(cellType: Self.self, value: value) } } ηϧ΁ͷ஋ͷඥ͚ͮ ηϧͷEFRVFVF DFMM'PS*UFN"U಺Ͱ࢖͑ΔΑ͏ʹܕΛফڈ
  41. ͜ΕΛ࢖ͬͯ6*$PMMFDUJPO7JFX%BUB4PVSDFΛ࣮૷

  42. class CollectionViewDataSource<CellDeclaration>: NSObject, UICollectionViewDataSource { var cellDeclarations = [] as

    [CellDeclaration] private var registeredReuseIdentifiers = [] as [String] private let binderFromDeclaration: (CellDeclaration) -> CellBinder init(binderFromDeclaration: @escaping (CellDeclaration) -> CellBinder) { self.binderFromDeclaration = binderFromDeclaration super.init() } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return cellDeclarations.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cellBinder = binderFromDeclaration(cellDeclarations[indexPath.item]) if !registeredReuseIdentifiers.contains(cellBinder.reuseIdentifier) { collectionView.register(cellBinder.nib, forCellWithReuseIdentifier: cellBinder.reuseIdentifier) registeredReuseIdentifiers.append(cellBinder.reuseIdentifier) } let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellBinder.reuseIdentifier, for: indexPath) cellBinder.configureCell(cell) return cell } } $FMM%FDMBSBUJPOͱ$FMM#JOEFSͷϚοϐϯάΛҾ਺ʹऔΔ $FMM%FDMBSBUJPOΛ$FMM#JOEFSΛม׵ͯ͠6*$PMMFDUJPO7JFX$FMMΛฦ͢
  43. ͜ΕΛ7FOVF%FUBJM7JFX$POUSPMMFSͰ ΠϯελϯεԽͯ͠࢖͏

  44. final class VenueDetailViewController: UIViewController { private let dataSource = CollectionViewDataSource<CellDeclaration>

    { cellDeclaration in switch cellDeclaration { case .outline(let venue): return VenueOutlineCell.makeBinder(with: venue) case .sectionHeader(let title): return SectionHeaderCell.makeBinder(with: title) case .review(let review): return ReviewCell.makeBinder(with: review) case .relatedVenue(let venue): return RelatedVenueCell.makeBinder(with: venue) } } ... } $FMM%FDMBSBUJPOͱ$FMM#JOEFSΛରԠ͚ͮ
  45. ׬੒

  46. 7FOVF%FUBJM7JFX$POUSPMMFS 6*$PMMFDUJPO7JFX σʔλม׵ σʔλม׵ 6*$PMMFDUJPO7JFXʹ஋Λ౉͢ॲཧ ڞ௨ͷ࢓૊ΈͰߦ͏ ηϧͷछྨΛܾΊΔॲཧ ը໘͝ͱʹߦ͏ w WFOVF

    w SFWJFXT w SFMBUFE7FOVFT w DFMM%FDMBSBUJPOT w OVNCFS0G*UFNT w DFMM'PS*UFN"U 6*$PMMFDUJPO7JFXͷσʔλ ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  47. None
  48. ;Γ͔͑Γ

  49. final class VenueDetailViewController: UIViewController { enum CellDeclaration: Equatable { case

    outline(Venue) case sectionHeader(String) case review(Review) case relatedVenue(Venue) } struct Data: CellsDeclarator { var venue: Venue var reviews: [Review] var relatedVenues: [Venue] func declareCells(_ cell: (CellDeclaration) -> Void) { cell(.outline(venue)) if !reviews.isEmpty { cell(.sectionHeader("Reviews")) for review in reviews { cell(.review(review)) } } if !relatedVenues.isEmpty { cell(.sectionHeader("Related Venues")) for relatedVenue in relatedVenues { cell(.relatedVenue(relatedVenue)) } } } } ... }
  50. final class VenueDetailViewController: UIViewController { enum CellDeclaration: Equatable { case

    outline(Venue) case sectionHeader(String) case review(Review) case relatedVenue(Venue) } struct Data: CellsDeclarator { var venue: Venue var reviews: [Review] var relatedVenues: [Venue] func declareCells(_ cell: (CellDeclaration) -> Void) { cell(.outline(venue)) if !reviews.isEmpty { cell(.sectionHeader("Reviews")) for review in reviews { cell(.review(review)) } } if !relatedVenues.isEmpty { cell(.sectionHeader("Related Venues")) for relatedVenue in relatedVenues { cell(.relatedVenue(relatedVenue)) } } } } ... }
  51. final class VenueDetailViewController: UIViewController { ... @IBOutlet private weak var

    collectionView: UICollectionView! private let dataSource = CollectionViewDataSource<CellDeclaration> { cellDeclaration in switch cellDeclaration { case .outline(let venue): return VenueOutlineCell.makeBinder(with: venue) case .sectionHeader(let title): return SectionHeaderCell.makeBinder(with: title) case .review(let review): return ReviewCell.makeBinder(with: review) case .relatedVenue(let venue): return RelatedVenueCell.makeBinder(with: venue) } } private var data: Data! { didSet { dataSource.cellDeclarations = data.cellDeclarations collectionView.reloadData() } } }
  52. w ηϧͷએݴ͸දࣔ݁Ռͱಉ͡ߏ଄ͳͷͰಡΈ΍͍͢ w ॳݟͰ͸Ͳ͏΍ͬͯಈ͘ͷ͔ཧղ͢Δͷʹֻ͕͔࣌ؒΔ w ௕ظతʹ͸ಡΈ΍͢͞ͷϝϦοτ͕উͪͦ͏ Մಡੑ

  53. ࣮૷ίετ w ηϧͷએݴ͸දࣔ݁Ռͱಉ͡ߏ଄ͳͷͰॻ͖΍͍͢ w ίʔυͷ૯ྔ΋ݮͬͨ w ॏෳͨ͠৚݅෼ذΛഉআͰ͖ͨͷͰϛεͮ͠Β͘ͳͬͨ w ૝ఆ͍ͯ͠ͳ͍࢖͍ํͰ͸ίετ্͕͕ΔՄೳੑ͕͋Δ

  54. w ηϧͷએݴͷςετ͕Մೳʹͳͬͨ w ैདྷ͸Ұؾʹஈ֊ͷॲཧΛ͍ͯͨ͠ͷͰ೉͔ͬͨ͠ ςελϏϦςΟ

  55. final class VenueDetailViewController: UIViewController { enum CellDeclaration: Equatable { case

    outline(Venue) case sectionHeader(String) case review(Review) case relatedVenue(Venue) } struct Data: CellsDeclarator { var venue: Venue var reviews: [Review] var relatedVenues: [Venue] func declareCells(_ cell: (CellDeclaration) -> Void) { cell(.outline(venue)) if !reviews.isEmpty { cell(.sectionHeader("Reviews")) for review in reviews { cell(.review(review)) } } if !relatedVenues.isEmpty { cell(.sectionHeader("Related Venues")) for relatedVenue in relatedVenues { cell(.relatedVenue(relatedVenue)) } } } } ... } ͜͜ͷ݁Ռͷ<$FMM%FDMBSBUJPO>͸ςετՄೳ extension CellsDeclarator { var cellDeclarations: [CellDeclaration] { var declarations = [] as [CellDeclaration] declareCells { declaration in declarations.append(declaration) } return declarations } }
  56. func testEmptyRelatedVenues() { let venue = Venue(photo: nil, name: "Kaminarimon")

    let review1 = Review(authorImage: nil, authorName: "Yosuke Ishikawa", body: "Foo") let review2 = Review(authorImage: nil, authorName: "Masatake Yamoto", body: "Bar") let data = VenueDetailViewController.Data( venue: venue, reviews: [ review1, review2, ], relatedVenues: []) XCTAssertEqual(data.cellDeclarations, [ .outline(venue), .sectionHeader("Reviews"), .review(review1), .review(review2), ]) }
  57. ·ͱΊ