Skip to content

Commit

Permalink
Add CollectionView (#59)
Browse files Browse the repository at this point in the history
* Init CollectionView

* Add Collection Example

* Add CollectionViewBox to project

* Format code

* Refactor Collection View

* Fix Collection View example style

* Add rule to hound.yml

* Format code

* Improve error message
  • Loading branch information
hodovani committed Mar 8, 2019
1 parent f7b5384 commit bb0b585
Show file tree
Hide file tree
Showing 13 changed files with 371 additions and 6 deletions.
2 changes: 2 additions & 0 deletions .hound.yml
@@ -1,2 +1,4 @@
fail_on_violations: true

swiftlint:
config_file: .swiftlint.yml
4 changes: 4 additions & 0 deletions Example/Tokamak.xcodeproj/project.pbxproj
Expand Up @@ -22,6 +22,7 @@
A67717202226DC7C0028A6F3 /* Gameboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A677171F2226DC7C0028A6F3 /* Gameboard.swift */; };
A67717222226E7FD0028A6F3 /* Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A67717212226E7FD0028A6F3 /* Menu.swift */; };
A6D5AF87221B131400DBF186 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D5AF86221B131400DBF186 /* Image.swift */; };
A6D6538122312263007FA886 /* CollectionExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D6538022312263007FA886 /* CollectionExample.swift */; };
A6FEF7952227C1CC008BB292 /* Scroll.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FEF7942227C1CC008BB292 /* Scroll.swift */; };
C449B806DFEE55B6CEE6478C /* libPods-TokamakDemo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B96B435A9D67621D318616E /* libPods-TokamakDemo.a */; };
D11DB6432219C03000013FC3 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11DB6422219C03000013FC3 /* Timer.swift */; };
Expand Down Expand Up @@ -60,6 +61,7 @@
A677171F2226DC7C0028A6F3 /* Gameboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gameboard.swift; sourceTree = "<group>"; };
A67717212226E7FD0028A6F3 /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = "<group>"; };
A6D5AF86221B131400DBF186 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = "<group>"; };
A6D6538022312263007FA886 /* CollectionExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExample.swift; sourceTree = "<group>"; };
A6FEF7942227C1CC008BB292 /* Scroll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Scroll.swift; sourceTree = "<group>"; };
A9EEF813955DAEEFE1D52ED4 /* Pods-TokamakDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TokamakDemo.debug.xcconfig"; path = "Pods/Target Support Files/Pods-TokamakDemo/Pods-TokamakDemo.debug.xcconfig"; sourceTree = "<group>"; };
C6DA99382B6892EAB361742F /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = "<group>"; };
Expand Down Expand Up @@ -203,6 +205,7 @@
A62AC6532223F5CD009B3B25 /* TextField.swift */,
D1BB3D2F2223F6B400C30062 /* Animation.swift */,
A6FEF7942227C1CC008BB292 /* Scroll.swift */,
A6D6538022312263007FA886 /* CollectionExample.swift */,
);
path = Components;
sourceTree = "<group>";
Expand Down Expand Up @@ -319,6 +322,7 @@
A62AC65922243DCB009B3B25 /* Cell.swift in Sources */,
D11DB6432219C03000013FC3 /* Timer.swift in Sources */,
A6FEF7952227C1CC008BB292 /* Scroll.swift in Sources */,
A6D6538122312263007FA886 /* CollectionExample.swift in Sources */,
A62AC65622243CC3009B3B25 /* SnakeGame.swift in Sources */,
D1F7185322159E09004E5951 /* Controls.swift in Sources */,
D1DEEC2922009E8000C525EE /* ModalRouter.swift in Sources */,
Expand Down
66 changes: 66 additions & 0 deletions Example/Tokamak/Components/CollectionExample.swift
@@ -0,0 +1,66 @@
//
// CollectionExample.swift
// TokamakDemo
//
// Created by Matvii Hodovaniuk on 3/7/19.
// Copyright © 2019 Tokamak. All rights reserved.
//

import Tokamak

enum ElementaryParticles: String, CaseIterable {
case up
case down
case charm
case strange
case top
case bottom
case gluon
case photon
case higs
case electron
case muon
case tau
case electronNeutrino = "Electron Neutrino"
case muonNeutrino = "Muon Neutrino"
case tauNeutrino = "Tau Neutrino"
case zBoson = "Z Boson"
case wBoson = "W Boson"
}

extension ElementaryParticles: CustomStringConvertible {
var description: String { return rawValue.localizedCapitalized }
}

private struct Cells: SimpleCellProvider {
static func cell(
props: Null,
item: ElementaryParticles,
path: CellPath
) -> AnyNode {
return Label.node(.init(Style(
[CenterY.equal(to: .parent),
Height.equal(to: 44),
Leading.equal(to: .parent),
Trailing.equal(to: .parent)]
)), "\(item.description)")
}

typealias Props = Null

typealias Model = [[ElementaryParticles]]
}

struct CollectionExample: PureLeafComponent {
typealias Props = Null

static func render(props: Props) -> AnyNode {
return CollectionView<Cells>.node(.init(
Style(
Edges.equal(to: .parent, inset: 20),
backgroundColor: .white
),
model: [ElementaryParticles.allCases]
))
}
}
3 changes: 3 additions & 0 deletions Example/Tokamak/Router.swift
Expand Up @@ -22,6 +22,7 @@ enum AppRoute: String, CaseIterable {
case animation
case snakeGame = "Snake Game"
case scrollView = "Scroll"
case collection = "Collection View"
}

extension AppRoute: CustomStringConvertible {
Expand Down Expand Up @@ -71,6 +72,8 @@ struct Router: NavigationRouter {
result = SnakeGame.node()
case .scrollView:
result = ScrollViewExample.node()
case .collection:
result = CollectionExample.node()
}

return NavigationItem.node(
Expand Down
65 changes: 65 additions & 0 deletions Sources/Tokamak/Components/Host/CollectionView.swift
@@ -0,0 +1,65 @@
//
// CollectionView.swift
// Tokamak
//
// Created by Matvii Hodovaniuk on 3/6/19.
//

public struct CollectionView<T: CellProvider>: HostComponent {
public struct Props: Equatable, StyleProps {
public let cellProps: T.Props
public let model: T.Model
public let onSelect: Handler<CellPath>?
public let style: Style?

public init(
_ style: Style? = nil,
cellProps: T.Props,
onSelect: Handler<CellPath>? = nil,
singleSection: T.Model.Element
) {
self.cellProps = cellProps
model = T.Model.single(section: singleSection)
self.onSelect = onSelect
self.style = style
}

public init(
_ style: Style? = nil,
cellProps: T.Props,
model: T.Model,
onSelect: Handler<CellPath>? = nil
) {
self.cellProps = cellProps
self.model = model
self.onSelect = onSelect
self.style = style
}
}

public typealias Children = Null
}

extension CollectionView.Props where T.Props == Null {
public init(
_ style: Style? = nil,
model: T.Model,
onSelect: Handler<CellPath>? = nil
) {
cellProps = Null()
self.model = model
self.onSelect = onSelect
self.style = style
}

public init(
_ style: Style? = nil,
onSelect: Handler<CellPath>? = nil,
singleSection: T.Model.Element
) {
cellProps = Null()
model = T.Model.single(section: singleSection)
self.onSelect = onSelect
self.style = style
}
}
4 changes: 2 additions & 2 deletions Sources/Tokamak/Components/Host/ListView.swift
Expand Up @@ -35,7 +35,7 @@ extension Array: SectionedModel

public protocol CellProvider {
associatedtype Props: Equatable
associatedtype Identifier: RawRepresentable
associatedtype Identifier: RawRepresentable, CaseIterable
where Identifier.RawValue == String
associatedtype Model: SectionedModel & Equatable

Expand All @@ -46,7 +46,7 @@ public protocol CellProvider {
) -> (Identifier, AnyNode)
}

public enum SingleIdentifier: String {
public enum SingleIdentifier: String, CaseIterable {
case single
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/Tokamak/Components/Props/Constraint/Edges.swift
Expand Up @@ -22,4 +22,22 @@ public struct Edges: Equatable {
) -> Constraint {
return .edges(Edges(target: .external(target), insets: insets))
}

public static func equal(
to target: Constraint.Target,
inset: Double
) -> Constraint {
return .edges(Edges(target: .external(target), insets: Insets(
top: inset, bottom: inset, left: inset, right: inset
)))
}

public static func equal(
to target: Constraint.SafeAreaTarget,
inset: Double
) -> Constraint {
return .edges(Edges(target: target, insets: Insets(
top: inset, bottom: inset, left: inset, right: inset
)))
}
}
148 changes: 148 additions & 0 deletions Sources/TokamakUIKit/Boxes/CollectionViewBox.swift
@@ -0,0 +1,148 @@
//
// CollectionViewBox.swift
// TokamakUIKit
//
// Created by Matvii Hodovaniuk on 3/7/19.
//

import Tokamak
import UIKit

final class TokamakCollectionCell: UICollectionViewCell {
// FIXME: `component` has a strong reference to `box` through its own
// property `target`, should that be `weak` to break a potential reference
// cycle?
fileprivate var component: UIKitRenderer.Mounted?
}

private final class DataSource<T: CellProvider>: NSObject,
UICollectionViewDataSource {
weak var viewController: UIViewController?
weak var renderer: UIKitRenderer?
var props: CollectionView<T>.Props

init(
_ props: CollectionView<T>.Props,
_ viewController: UIViewController,
_ renderer: UIKitRenderer?
) {
self.props = props
self.viewController = viewController
self.renderer = renderer
}

func numberOfSections(in collectionView: UICollectionView) -> Int {
return props.model.count
}

func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
return props.model[section].count
}

func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let item = props.model[indexPath.section][indexPath.row]

let (id, node) = T.cell(
props: props.cellProps,
item: item,
path: CellPath(indexPath)
)

if let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: id.rawValue, for: indexPath
) as? TokamakCollectionCell, let component = cell.component {
renderer?.update(component: component, with: node)
return cell
} else {
if let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: id.rawValue, for: indexPath
) as? TokamakCollectionCell {
if let component = cell.component {
renderer?.update(component: component, with: node)
} else if let viewController = viewController {
cell.component = renderer?.mount(
with: node,
to: ViewBox(cell, viewController, node)
)
}
return cell
} else {
fatalError("unknown cell type returned from dequeueReusableCell")
}
}
}
}

private final class Delegate<T: CellProvider>:
NSObject,
UICollectionViewDelegate {
var onSelect: ((CellPath) -> ())?

func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath
) {
onSelect?(CellPath(indexPath))
}

init(_ props: CollectionView<T>.Props) {
onSelect = props.onSelect?.value
}
}

final class TokamakCollectionView: UICollectionView, Default {
static var defaultValue: TokamakCollectionView {
let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
return TokamakCollectionView(frame: .zero, collectionViewLayout: layout)
}
}

final class CollectionViewBox<T: CellProvider>: ViewBox<TokamakCollectionView> {
private let dataSource: DataSource<T>

// this delegate stays as a constant and doesn't create a reference cycle
// swiftlint:disable:next weak_delegate
private let delegate: Delegate<T>

var props: CollectionView<T>.Props {
get {
return dataSource.props
}
set {
let oldModel = dataSource.props.model
dataSource.props = newValue
delegate.onSelect = newValue.onSelect?.value
if oldModel != newValue.model {
view.reloadData()
}
}
}

init(
_ view: TokamakCollectionView,
_ viewController: UIViewController,
_ component: UIKitRenderer.MountedHost,
_ props: CollectionView<T>.Props,
_ renderer: UIKitRenderer
) {
dataSource = DataSource(props, viewController, renderer)
delegate = Delegate(props)
view.dataSource = dataSource
view.delegate = delegate

for id in T.Identifier.allCases {
view.register(
TokamakCollectionCell.self,
forCellWithReuseIdentifier: id.rawValue
)
}
super.init(view, viewController, component.node)
}
}

0 comments on commit bb0b585

Please sign in to comment.