Declarative UICollectionView

Declarative UICollectionView

iOSDC Japan 2018

8889da6a67db3667b0694d993c9a962c?s=128

Yosuke Ishikawa

August 31, 2018
Tweet

Transcript

  1. 2.

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

    w 4XJGU࣮ફೖ໳ J041SPHSBNNJOH JTILBXB
  2. 3.
  3. 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)") } }
  4. 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() } }
  5. 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() } }
  6. 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() } }
  7. 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 ηϧͷ਺͸ʁ  Ͱ͢ʂ
  8. 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
  9. 16.

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

    w DFMM'PS*UFN"U σʔλม׵ ঢ়ଶΛද͢σʔλ 6*$PMMFDUJPO7JFXͷσʔλ
  10. 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() } }
  11. 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() } } ৚݅෼ذ͕ॏෳ͍ͯ͠Δ
  12. 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Λฦͨ͢Ίͷॲཧ
  13. 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Λฦͨ͢Ίͷॲཧ
  14. 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ͷσʔλ ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  15. 28.

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

    case sectionHeader(String) case review(Review) case relatedVenue(Venue) } ੒Ռ෺
  16. 29.
  17. 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  
 > ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  18. 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)) } } } } ͜ͷؔ਺ΛݺΜͰηϧΛએݴ͍ͯ͘͠
  19. 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)) } } } }
  20. 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 } }
  21. 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ͷσʔλ ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  22. 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ʹ஋Λ౉͢ॲཧ ڞ௨ͷ࢓૊ΈͰߦ͏
  23. 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 ηϧ΁ͷ஋ͷඥ͚ͮ
  24. 39.

    protocol BindableNibCell { static var nib: UINib { get }

    static var reuseIdentifier: String { get } 
 associatedtype Value func bind(_ value: Value) } ηϧ΁ͷ஋ͷඥ͚ͮ ηϧͷEFRVFVF ඞཁͳ΋ͷΛηϧʹఏڙͤ͞Δ
  25. 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಺Ͱ࢖͑ΔΑ͏ʹܕΛফڈ
  26. 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Λฦ͢
  27. 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ΛରԠ͚ͮ
  28. 45.
  29. 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ͷσʔλ ίϨΫγϣϯΛද͢σʔλ ঢ়ଶΛද͢σʔλ
  30. 47.
  31. 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)) } } } } ... }
  32. 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)) } } } } ... }
  33. 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() } } }
  34. 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 } }
  35. 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), ]) }
  36. 57.