diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f3bde0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,191 @@ +##### +# OS X temporary files that should never be committed +# +# c.f. http://www.westwind.com/reference/os-x/invisibles.html + +.DS_Store + +# c.f. http://www.westwind.com/reference/os-x/invisibles.html + +.Trashes + +# c.f. http://www.westwind.com/reference/os-x/invisibles.html + +*.swp + +# *.lock - this is used and abused by many editors for many different things. +# For the main ones I use (e.g. Eclipse), it should be excluded +# from source-control, but YMMV + +*.lock + +# +# profile - REMOVED temporarily (on double-checking, this seems incorrect; I can't find it in OS X docs?) +#profile + + +#### +# Xcode temporary files that should never be committed +# +# NB: NIB/XIB files still exist even on Storyboard projects, so we want this... + +*~.nib + + +#### +# Xcode build files - +# +# NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData" + +DerivedData/ + +# NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build" + +build/ + + +##### +# Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups) +# +# This is complicated: +# +# SOMETIMES you need to put this file in version control. +# Apple designed it poorly - if you use "custom executables", they are +# saved in this file. +# 99% of projects do NOT use those, so they do NOT want to version control this file. +# ..but if you're in the 1%, comment out the line "*.pbxuser" + +# .pbxuser: http://lists.apple.com/archives/xcode-users/2004/Jan/msg00193.html + +*.pbxuser + +# .mode1v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html + +*.mode1v3 + +# .mode2v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html + +*.mode2v3 + +# .perspectivev3: http://stackoverflow.com/questions/5223297/xcode-projects-what-is-a-perspectivev3-file + +*.perspectivev3 + +# NB: also, whitelist the default ones, some projects need to use these +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + + +#### +# Xcode 4 - semi-personal settings +# +# +# OPTION 1: --------------------------------- +# throw away ALL personal settings (including custom schemes! +# - unless they are "shared") +# +# NB: this is exclusive with OPTION 2 below +xcuserdata + +# OPTION 2: --------------------------------- +# get rid of ALL personal settings, but KEEP SOME OF THEM +# - NB: you must manually uncomment the bits you want to keep +# +# NB: this is exclusive with OPTION 1 above +# +#xcuserdata/**/* + +# (requires option 2 above): Personal Schemes +# +#!xcuserdata/**/xcschemes/* + +#### +# XCode 4 workspaces - more detailed +# +# Workspaces are important! They are a core feature of Xcode - don't exclude them :) +# +# Workspace layout is quite spammy. For reference: +# +# /(root)/ +# /(project-name).xcodeproj/ +# project.pbxproj +# /project.xcworkspace/ +# contents.xcworkspacedata +# /xcuserdata/ +# /(your name)/xcuserdatad/ +# UserInterfaceState.xcuserstate +# /xcsshareddata/ +# /xcschemes/ +# (shared scheme name).xcscheme +# /xcuserdata/ +# /(your name)/xcuserdatad/ +# (private scheme).xcscheme +# xcschememanagement.plist +# +# + +#### +# Xcode 4 - Deprecated classes +# +# Allegedly, if you manually "deprecate" your classes, they get moved here. +# +# We're using source-control, so this is a "feature" that we do not want! + +*.moved-aside + +#### +# UNKNOWN: recommended by others, but I can't discover what these files are +# +# ...none. Everything is now explained. + +#### +# +# Pods and Gems +# +/pods +.bundle/ + +# Builds +*.ipa + +# ssl certificates +*.crt +.idea/.name +.idea/babylon-partners.iml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/runConfigurations/Babylon.xml +.idea/runConfigurations/BabylonAnalysis.xml +.idea/scopes/scope_settings.xml +.idea/vcs.xml +.idea/workspace.xml +.idea/xcode.xml + +Iconr\n +Crashlytics.framework +Crashlytics.framework/ +Crashlytics.* +Crashlytics +Babylon/dist/ +dist.zip +Babylon.xcworkspace +Babylon.xcworkspace/ +Pods/ +Pods + +# Orig files skipped +*.orig + +Carthage/Checkouts/* +Carthage/Build/* +backboneLocalizationBuilder +.idea/babylon-ios.iml +.idea/runConfigurations/Babylon_STAGING1.xml + +# Fastlane +fastlane/README.md +fastlane/report.xml +vendor/ diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..389f774 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +4.0 \ No newline at end of file diff --git a/Bento.podspec b/Bento.podspec new file mode 100644 index 0000000..8d0a620 --- /dev/null +++ b/Bento.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + + s.name = "Bento" + s.version = "0.1" + s.summary = "Component based abstraction on top of UITableView and UICollectionView" + + s.description = <<-DESC + Component-based abstraction on top of UITableView and UICollectionView. + Provides a declarative way to render data in UITableView and UICollectionView + DESC + + s.homepage = "https://github.com/Babylonpartners/Bento" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "Babylon iOS" => "ios.development@babylonhealth.com" } + s.ios.deployment_target = '9.0' + s.source = { :git => "https://github.com/Babylonpartners/Bento.git", :tag => "#{s.version}" } + s.source_files = 'Bento/*.swift', 'Bento/**/*.swift' + + s.dependency "FlexibleDiff", "= 0.0.5" +end diff --git a/Bento/Adapters/SectionedFormAdapter.swift b/Bento/Adapters/SectionedFormAdapter.swift new file mode 100644 index 0000000..f88142c --- /dev/null +++ b/Bento/Adapters/SectionedFormAdapter.swift @@ -0,0 +1,103 @@ +import UIKit +import FlexibleDiff + +final class SectionedFormAdapter + : NSObject, + UITableViewDataSource, + UITableViewDelegate { + private var sections: [Section] = [] + private weak var tableView: UITableView? + + init(with tableView: UITableView) { + self.sections = [] + self.tableView = tableView + super.init() + tableView.dataSource = self + tableView.delegate = self + } + + + func update(sections: [Section], with animation: TableViewAnimation) { + guard let tableView = tableView else { + return + } + let diff = TableViewSectionDiff(oldSections: self.sections, + newSections: sections, + animation: animation) + self.sections = sections + diff.apply(to: tableView) + } + func update(sections: [Section]) { + self.sections = sections + tableView?.reloadData() + } + + func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let component = node(at: indexPath).component + let reuseIdentifier = component.reuseIdentifier + guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as? TableViewCell else { + tableView.register(TableViewCell.self, forCellReuseIdentifier: reuseIdentifier) + return self.tableView(tableView, cellForRowAt: indexPath) + } + let componentView: UIView + if let containedView = cell.containedView { + componentView = containedView + } else { + componentView = component.generate() + cell.install(view: componentView) + } + component.render(in: componentView) + return cell + + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return sections[section].header + .map { + return self.render(node: $0, in: tableView) + } + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return sections[section].footer + .map { + return self.render(node: $0, in: tableView) + } + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return sections[section].header == nil ? CGFloat.leastNonzeroMagnitude : UITableViewAutomaticDimension + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return sections[section].footer == nil ? CGFloat.leastNonzeroMagnitude : UITableViewAutomaticDimension + } + + private func node(at indexPath: IndexPath) -> Node { + return sections[indexPath.section].rows[indexPath.row] + } + + private func render(node: AnyRenderable, in tableView: UITableView) -> UIView { + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: node.reuseIdentifier) as? TableViewHeaderFooterView else { + tableView.register(TableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: node.reuseIdentifier) + return render(node: node, in: tableView) + } + let componentView: UIView + if let containedView = header.containedView { + componentView = containedView + } else { + componentView = node.generate() + header.install(view: componentView) + } + node.render(in: componentView) + return header + } +} diff --git a/Bento/Adapters/TableViewAnimation.swift b/Bento/Adapters/TableViewAnimation.swift new file mode 100644 index 0000000..6bfb43b --- /dev/null +++ b/Bento/Adapters/TableViewAnimation.swift @@ -0,0 +1,18 @@ +import UIKit + +public struct TableViewAnimation { + let sectionInsertion: UITableViewRowAnimation + let sectionDeletion: UITableViewRowAnimation + let rowDeletion: UITableViewRowAnimation + let rowInsertion: UITableViewRowAnimation + + public init(sectionInsertion: UITableViewRowAnimation = .fade, + sectionDeletion: UITableViewRowAnimation = .fade, + rowDeletion: UITableViewRowAnimation = .fade, + rowInsertion: UITableViewRowAnimation = .fade) { + self.sectionInsertion = sectionInsertion + self.sectionDeletion = sectionDeletion + self.rowDeletion = rowDeletion + self.rowInsertion = rowInsertion + } +} diff --git a/Bento/Bento.h b/Bento/Bento.h new file mode 100644 index 0000000..8aa372a --- /dev/null +++ b/Bento/Bento.h @@ -0,0 +1,5 @@ +#import + +FOUNDATION_EXPORT double BentoVersionNumber; + +FOUNDATION_EXPORT const unsigned char BentoVersionString[]; diff --git a/Bento/Bento/Box.swift b/Bento/Bento/Box.swift new file mode 100644 index 0000000..15ba818 --- /dev/null +++ b/Bento/Bento/Box.swift @@ -0,0 +1,60 @@ +import UIKit + +precedencegroup ComposingPrecedence { + associativity: left + higherThan: NodeConcatenationPrecedence +} + +precedencegroup NodeConcatenationPrecedence { + associativity: left + higherThan: SectionConcatenationPrecedence +} + +precedencegroup SectionConcatenationPrecedence { + associativity: left + higherThan: AdditionPrecedence +} + +infix operator |-+: SectionConcatenationPrecedence +infix operator |---+: NodeConcatenationPrecedence +infix operator |---*: NodeConcatenationPrecedence +infix operator |---?: NodeConcatenationPrecedence +infix operator <>: ComposingPrecedence + +public struct Box { + public let sections: [Section] + + public init(sections: [Section]) { + self.sections = sections + } + + public static var empty: Box { + return Box(sections: []) + } +} + +public func |-+(lhs: Box, rhs: Section) -> Box { + return Box(sections: lhs.sections + [rhs]) +} + +extension UITableView { + public func render(_ box: Box) { + let adapter: SectionedFormAdapter = getAdapter() + adapter.update(sections: box.sections, with: TableViewAnimation()) + } + + public func render(_ box: Box, animated: Bool) { + let adapter: SectionedFormAdapter = getAdapter() + if animated { + adapter.update(sections: box.sections, with: TableViewAnimation()) + } else { + adapter.update(sections: box.sections) + } + } + + public func render(_ box: Box, with animation: TableViewAnimation) { + let adapter: SectionedFormAdapter = getAdapter() + + adapter.update(sections: box.sections, with: animation) + } +} diff --git a/Bento/Bento/If.swift b/Bento/Bento/If.swift new file mode 100644 index 0000000..c1983c6 --- /dev/null +++ b/Bento/Bento/If.swift @@ -0,0 +1,28 @@ +public struct If { + let condition: () -> Bool + let generator: () -> T + + public static func iff(_ condition: @autoclosure @escaping () -> Bool, _ generator: @autoclosure @escaping () -> T) -> If { + return If(condition: condition, generator: generator) + } + + public static func iff(_ condition: @escaping () -> Bool, _ generator: @escaping () -> T) -> If { + return If(condition: condition, generator: generator) + } +} + +public func |---?(lhs: Section, rhs: If>) -> Section { + if rhs.condition() { + return lhs + |---+ rhs.generator() + } + return lhs +} + +public func |---?(lhs: Section, rhs: If<[Node]>) -> Section { + if rhs.condition() { + return lhs + |---* rhs.generator() + } + return lhs +} diff --git a/Bento/Bento/Node.swift b/Bento/Bento/Node.swift new file mode 100644 index 0000000..4b682a0 --- /dev/null +++ b/Bento/Bento/Node.swift @@ -0,0 +1,31 @@ +import UIKit + +public struct Node: Equatable { + let id: Identifier + let component: AnyRenderable + + init(id: Identifier, component: AnyRenderable) { + self.id = id + self.component = component + } + + public init(id: Identifier, component: R) where R.View: UIView { + self.init(id: id, component: AnyRenderable(component)) + } + + public static func == (lhs: Node, rhs: Node) -> Bool { + return lhs.id == rhs.id && lhs.component == rhs.component + } +} + +public func <> (id: RowId, component: R) -> Node where R.View: UIView { + return Node(id: id, component: component) +} + +public func |---+(lhs: Node, rhs: Node) -> [Node] { + return [lhs, rhs] +} + +public func |---+(lhs: [Node], rhs: Node) -> [Node] { + return lhs + [rhs] +} diff --git a/Bento/Bento/Section.swift b/Bento/Bento/Section.swift new file mode 100644 index 0000000..87dea6e --- /dev/null +++ b/Bento/Bento/Section.swift @@ -0,0 +1,73 @@ +import UIKit + +public struct Section: Equatable { + let id: SectionId + let header: AnyRenderable? + let footer: AnyRenderable? + let rows: [Node] + + public init(id: SectionId, + header: Header, + footer: Footer, + rows: [Node] = []) + where Header.View: UIView, Footer.View: UIView { + self.id = id + self.header = AnyRenderable(header) + self.footer = AnyRenderable(footer) + self.rows = rows + } + + public init(id: SectionId, + header: Header, + rows: [Node] = []) where Header.View: UIView { + self.id = id + self.header = AnyRenderable(header) + self.footer = nil + self.rows = rows + } + + public init(id: SectionId, + footer: Footer, + rows: [Node] = []) where Footer.View: UIView { + self.id = id + self.header = nil + self.footer = AnyRenderable(footer) + self.rows = rows + } + + public init(id: SectionId, + rows: [Node] = []) { + self.id = id + self.header = nil + self.footer = nil + self.rows = rows + } + + init(id: SectionId, + header: AnyRenderable?, + footer: AnyRenderable?, + rows: [Node]) { + self.id = id + self.header = header + self.footer = footer + self.rows = rows + } + + public static func hasEqualMetadata(_ lhs: Section, _ rhs: Section) -> Bool { + return lhs.header == rhs.header && lhs.footer == rhs.footer + } + + public static func == (lhs: Section, rhs: Section) -> Bool { + return lhs.id == rhs.id + && hasEqualMetadata(lhs, rhs) + && lhs.rows == rhs.rows + } +} + +public func |---+(lhs: Section, rhs: Node) -> Section { + return Section(id: lhs.id, header: lhs.header, footer: lhs.footer, rows: lhs.rows + [rhs]) +} + +public func |---*(lhs: Section, rhs: [Node]) -> Section { + return Section(id: lhs.id, header: lhs.header, footer: lhs.footer, rows: lhs.rows + rhs) +} diff --git a/Bento/Diff/TableViewSectionDiff.swift b/Bento/Diff/TableViewSectionDiff.swift new file mode 100644 index 0000000..40e5507 --- /dev/null +++ b/Bento/Diff/TableViewSectionDiff.swift @@ -0,0 +1,120 @@ +import UIKit +import FlexibleDiff + +struct TableViewSectionDiff { + private let oldSections: [Section] + private let newSections: [Section] + private let animation: TableViewAnimation + + init(oldSections: [Section], + newSections: [Section], + animation: TableViewAnimation) { + self.oldSections = oldSections + self.newSections = newSections + self.animation = animation + } + + func apply(to tableView: UITableView) { + let diff = SectionedChangeset(previous: oldSections, + current: newSections, + sectionIdentifier: { $0.id }, + areMetadataEqual: Section.hasEqualMetadata, + items: { $0.rows }, + itemIdentifier: { $0.id }, + areItemsEqual: ==) + apply(diff: diff, to: tableView) + } + + private func apply(diff: SectionedChangeset, to tableView: UITableView) { + tableView.beginUpdates() + for section in diff.sections.mutations { + if let headerView = tableView.headerView(forSection: section), + let node = newSections[section].header { + update(view: headerView, with: node) + } + if let footerView = tableView.footerView(forSection: section), + let node = newSections[section].footer { + update(view: footerView, with: node) + } + } + tableView.insertSections(diff.sections.inserts, with: animation.sectionInsertion) + tableView.deleteSections(diff.sections.removals, with: animation.sectionDeletion) + apply(sectionMutations: diff.mutatedSections, to: tableView, with: animation) + tableView.moveSections(diff.sections.moves) + tableView.endUpdates() + } + + private func apply(sectionMutations: [SectionedChangeset.MutatedSection], + to tableView: UITableView, + with animation: TableViewAnimation) { + for sectionMutation in sectionMutations { + tableView.deleteRows(at: sectionMutation.deletedIndexPaths, with: animation.rowDeletion) + tableView.insertRows(at: sectionMutation.insertedIndexPaths, with: animation.rowInsertion) + tableView.perform(moves: sectionMutation.movedIndexPaths) + [sectionMutation.changeset.moves.lazy + .flatMap { $0.isMutated ? ($0.source, $0.destination) : nil }, + sectionMutation.changeset.mutations.lazy.map { ($0, $0) }] + .joined() + .forEach { source, destination in + guard let cell = tableView.cellForRow(at: [sectionMutation.source, source]) else { return } + update(cell: cell, with: newSections[sectionMutation.destination].rows[destination]) + } + } + } + + private func update(view: UIView, with node: AnyRenderable) { + guard let headerFooterView = view as? TableViewHeaderFooterView, + let containedView = headerFooterView.containedView else { return } + node.render(in: containedView) + } + + private func update(cell: UITableViewCell, with node: Node) { + guard let cell = cell as? TableViewCell, + let contentView = cell.containedView else { return } + node.component.render(in: contentView) + } +} + +extension SectionedChangeset.MutatedSection { + var deletedIndexPaths: [IndexPath] { + return changeset.removals.map { IndexPath(row: $0, section: source) } + } + + var insertedIndexPaths: [IndexPath] { + return changeset.inserts.map { IndexPath(row: $0, section: destination) } + } + + var movedIndexPaths: [UITableView.Move] { + return changeset.moves.map { + let source = IndexPath(row: $0.source, section: self.source) + let destination = IndexPath(row: $0.destination, section: self.source) + + return UITableView.Move(source: source, destination: destination) + } + } +} + +extension Changeset { + func deletedIndexPaths(for section: Int) -> [IndexPath] { + return removals.map { IndexPath(row: $0, section: section) } + } +} + +extension UITableView { + struct Move { + let source: IndexPath + let destination: IndexPath + } + + func moveSections(_ moves: [Changeset.Move]) { + for move in moves { + moveSection(move.source, toSection: move.destination) + } + } + + func perform(moves: [Move]) { + for move in moves { + moveRow(at: move.source, to: move.destination) + } + } +} diff --git a/Bento/Helpers.swift b/Bento/Helpers.swift new file mode 100644 index 0000000..39d8e1d --- /dev/null +++ b/Bento/Helpers.swift @@ -0,0 +1,50 @@ +import UIKit + +public protocol NibLoadable { + static var nib: UINib { get } +} + +public extension NibLoadable where Self: UIView { + static var nib: UINib { + return UINib(nibName: String(describing: self), bundle: Bundle(for: self)) + } + + static func loadFromNib() -> Self { + return nib.instantiate(withOwner: nil, options: nil).first as! Self + } +} + +extension UIView { + func pinToEdges(of view: UIView) { + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: topAnchor), + view.leftAnchor.constraint(equalTo: leftAnchor), + view.bottomAnchor.constraint(equalTo: bottomAnchor), + view.rightAnchor.constraint(equalTo: rightAnchor) + ]) + } +} + +extension UITableView { + private struct AssociatedKey { + static let key = UnsafeMutablePointer.allocate(capacity: 1) + } + func getAdapter() -> SectionedFormAdapter { + guard let adapter = objc_getAssociatedObject(self, AssociatedKey.key) as? SectionedFormAdapter else { + let adapter = SectionedFormAdapter(with: self) + objc_setAssociatedObject(self, AssociatedKey.key, adapter, .OBJC_ASSOCIATION_RETAIN) + return getAdapter() + } + return adapter + } +} + +extension Optional { + func zip(with other: T?, _ selector: (Wrapped, T) -> R) -> Optional { + guard let unwrapped = self, let other = other else { + return nil + } + return selector(unwrapped, other) + } +} diff --git a/Bento/Info.plist b/Bento/Info.plist new file mode 100644 index 0000000..1007fd9 --- /dev/null +++ b/Bento/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/Bento/Renderable/AnyRenderable.swift b/Bento/Renderable/AnyRenderable.swift new file mode 100644 index 0000000..d5b9631 --- /dev/null +++ b/Bento/Renderable/AnyRenderable.swift @@ -0,0 +1,62 @@ +import UIKit + +struct AnyRenderable: Renderable { + var reuseIdentifier: String { + return base.reuseIdentifier + } + + private let base: AnyRenderableBoxBase + + init(_ base: Base) where Base.View: UIView { + self.base = AnyRenderableBox(base) + } + + func generate() -> UIView { + return base.generate() + } + + func render(in view: UIView) { + base.render(in: view) + } + + static func ==(lhs: AnyRenderable, rhs: AnyRenderable) -> Bool { + return lhs.base.equals(to: rhs.base) + } +} + +private class AnyRenderableBox: AnyRenderableBoxBase where Base.View: UIView { + override var reuseIdentifier: String { + return base.reuseIdentifier + } + + private let base: Base + + init(_ base: Base) { + self.base = base + super.init() + } + + override func render(in view: UIView) { + base.render(in: view as! Base.View) + } + + override func generate() -> UIView { + return base.generate() + } + + override func equals(to other: AnyRenderableBoxBase) -> Bool { + guard let other = other as? AnyRenderableBox + else { return false } + return self.base == other.base + } +} + +private class AnyRenderableBoxBase { + var reuseIdentifier: String { fatalError() } + + init() {} + + func render(in view: UIView) { fatalError() } + func generate() -> UIView { fatalError() } + func equals(to other: AnyRenderableBoxBase) -> Bool { fatalError() } +} diff --git a/Bento/Renderable/Renderable.swift b/Bento/Renderable/Renderable.swift new file mode 100644 index 0000000..ab08c5c --- /dev/null +++ b/Bento/Renderable/Renderable.swift @@ -0,0 +1,34 @@ +import UIKit + +public protocol Renderable: Equatable { + associatedtype View + + var reuseIdentifier: String { get } + + func generate() -> View + func render(in view: View) +} + +public extension Renderable where Self: AnyObject { + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs === rhs + } +} + +public extension Renderable { + var reuseIdentifier: String { + return String(describing: View.self) + } +} + +public extension Renderable where View: UIView { + func generate() -> View { + return View() + } +} + +public extension Renderable where View: UIView & NibLoadable { + func generate() -> View { + return View.loadFromNib() + } +} diff --git a/Bento/Views/TableViewCell.swift b/Bento/Views/TableViewCell.swift new file mode 100644 index 0000000..6168807 --- /dev/null +++ b/Bento/Views/TableViewCell.swift @@ -0,0 +1,22 @@ +import UIKit + +final class TableViewCell: UITableViewCell { + + var containedView: UIView? = nil + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + selectionStyle = .none + } + + func install(view: UIView) { + self.containedView = view + contentView.addSubview(view) + view.pinToEdges(of: contentView) + } +} diff --git a/Bento/Views/TableViewHeaderFooterView.swift b/Bento/Views/TableViewHeaderFooterView.swift new file mode 100644 index 0000000..6aa06ec --- /dev/null +++ b/Bento/Views/TableViewHeaderFooterView.swift @@ -0,0 +1,11 @@ +import UIKit + +final class TableViewHeaderFooterView: UITableViewHeaderFooterView { + var containedView: UIView? = nil + + func install(view: UIView) { + self.containedView = view + contentView.addSubview(view) + view.pinToEdges(of: contentView) + } +} diff --git a/BentoTests/AnyRenderableTests.swift b/BentoTests/AnyRenderableTests.swift new file mode 100644 index 0000000..812e414 --- /dev/null +++ b/BentoTests/AnyRenderableTests.swift @@ -0,0 +1,68 @@ +import Nimble +import XCTest +import UIKit +@testable import Bento + +class AnyRenderableTests: XCTestCase { + func testShouldPassthroughBehaviours() { + let testView = TestView() + + let base = TestRenderable(reuseIdentifier: "Test", + generate: { testView }, + render: { $0.hasInvoked = true }) + let renderable = AnyRenderable(base) + + expect(renderable.reuseIdentifier) == "Test" + + let view = renderable.generate() + expect(view) === testView + + expect(testView.hasInvoked) == false + + renderable.render(in: testView) + expect(testView.hasInvoked) == true + } + + func testDefaultEqualityImplementation() { + let base = AnyRenderable(TestDefaultEqualityRenderable()) + + expect(base) == base + expect(base) != AnyRenderable(TestDefaultEqualityRenderable()) + expect(base) != AnyRenderable(TestCustomEqualityRenderable(value: 0)) + } + + func testComponentCustomEquality() { + let base = AnyRenderable(TestCustomEqualityRenderable(value: 0)) + + expect(base) == base + expect(base) == AnyRenderable(TestCustomEqualityRenderable(value: 0)) + expect(base) != AnyRenderable(TestCustomEqualityRenderable(value: 1)) + expect(base) != AnyRenderable(TestDefaultEqualityRenderable()) + } +} + +private class TestView: UIView { + var hasInvoked = false +} + +private final class TestRenderable: Renderable { + let reuseIdentifier: String + let generateAction: () -> TestView + let renderAction: (TestView) -> Void + + init(reuseIdentifier: String, + generate: @escaping () -> TestView, + render: @escaping (TestView) -> Void) { + self.reuseIdentifier = reuseIdentifier + self.generateAction = generate + self.renderAction = render + } + + func render(in view: TestView) { + renderAction(view) + } + + func generate() -> TestView { + return generateAction() + } +} diff --git a/BentoTests/Info.plist b/BentoTests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/BentoTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/BentoTests/NodeTests.swift b/BentoTests/NodeTests.swift new file mode 100644 index 0000000..967e765 --- /dev/null +++ b/BentoTests/NodeTests.swift @@ -0,0 +1,25 @@ +import Nimble +import XCTest +import UIKit +@testable import Bento + +class NodeTests: XCTestCase { + func testEqaulity() { + expect(template) == template + } + + func testEqualityMutatedComponent() { + expect(template) != Node(id: TestRowId.first, + component: TestCustomEqualityRenderable(value: 1)) + } + + func testEqualityMutatedId() { + expect(template) != Node(id: TestRowId.second, + component: TestCustomEqualityRenderable(value: 0)) + } +} + +private var template: Node { + return Node(id: TestRowId.first, + component: TestCustomEqualityRenderable(value: 0)) +} diff --git a/BentoTests/SectionTests.swift b/BentoTests/SectionTests.swift new file mode 100644 index 0000000..f7ec3fa --- /dev/null +++ b/BentoTests/SectionTests.swift @@ -0,0 +1,220 @@ +import Nimble +import XCTest +import UIKit +@testable import Bento + +class SectionTests: XCTestCase { + func testMetadataEqualitySelfEquality() { + expect(Section.hasEqualMetadata(template, template)) == true + } + + func testMetadataEqualityDifferentFooter() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + footer: TestCustomEqualityRenderable(value: 2), + rows: [] + ) + + expect(Section.hasEqualMetadata(section, section)) == true + expect(Section.hasEqualMetadata(template, section)) == false + } + + func testMetadataEqualityDifferentHeader() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: -1), + footer: TestCustomEqualityRenderable(value: 1), + rows: [] + ) + + expect(Section.hasEqualMetadata(section, section)) == true + expect(Section.hasEqualMetadata(template, section)) == false + } + + func testMetadataEqualityDifferentHeaderType() { + let section = Section( + id: TestSectionId.first, + header: TestDefaultEqualityRenderable(), + footer: TestCustomEqualityRenderable(value: 1), + rows: [] + ) + + expect(Section.hasEqualMetadata(section, section)) == true + expect(Section.hasEqualMetadata(template, section)) == false + } + + func testMetadataEqualityDifferentFooterType() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + footer: TestDefaultEqualityRenderable(), + rows: [] + ) + + expect(Section.hasEqualMetadata(section, section)) == true + expect(Section.hasEqualMetadata(template, section)) == false + } + + func testMetadataEqualityOmittedFooter() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + rows: [] + ) + + expect(Section.hasEqualMetadata(section, section)) == true + expect(Section.hasEqualMetadata(template, section)) == false + } + + func testMetadataEqualityOmittedHeader() { + let section = Section( + id: TestSectionId.first, + footer: TestCustomEqualityRenderable(value: 1), + rows: [] + ) + + expect(Section.hasEqualMetadata(section, section)) == true + expect(Section.hasEqualMetadata(template, section)) == false + } + + func testEqualitySelfEquality() { + expect(templateWithNodes) == templateWithNodes + } + + func testEqualityMutatedFooter() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + footer: TestCustomEqualityRenderable(value: .max), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityMutatedHeader() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: .max), + footer: TestCustomEqualityRenderable(value: 1), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityMutatedNodes() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: .max), + footer: TestCustomEqualityRenderable(value: 1), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3)), + Node(id: .second, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + + func testEqualityMutatedFooterWithDifferentComponentType() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + footer: TestDefaultEqualityRenderable(), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityMutatedHeaderWithDifferentComponentType() { + let section = Section( + id: TestSectionId.first, + header: TestDefaultEqualityRenderable(), + footer: TestCustomEqualityRenderable(value: 1), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityMutatedNodesWithDifferentComponentType() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: .max), + footer: TestCustomEqualityRenderable(value: 1), + rows: [Node(id: .first, component: TestDefaultEqualityRenderable())] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityOmittedFooter() { + let section = Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityOmittedHeader() { + let section = Section( + id: TestSectionId.first, + footer: TestCustomEqualityRenderable(value: 1), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityOmittedHeaderFooter() { + let section = Section( + id: TestSectionId.first, + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) + + expect(section) == section + expect(templateWithNodes) != section + } + + func testEqualityOmittedNodes() { + let section = Section( + id: TestSectionId.second, + header: TestCustomEqualityRenderable(value: 0), + footer: TestCustomEqualityRenderable(value: 1), + rows: [] + ) + + expect(section) == section + expect(templateWithNodes) != section + } +} + +private var template: Section { + return Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + footer: TestCustomEqualityRenderable(value: 1), + rows: [] + ) +} + +private var templateWithNodes: Section { + return Section( + id: TestSectionId.first, + header: TestCustomEqualityRenderable(value: 0), + footer: TestCustomEqualityRenderable(value: 1), + rows: [Node(id: .first, component: TestCustomEqualityRenderable(value: 3))] + ) +} diff --git a/BentoTests/TestId.swift b/BentoTests/TestId.swift new file mode 100644 index 0000000..c47071b --- /dev/null +++ b/BentoTests/TestId.swift @@ -0,0 +1,9 @@ +enum TestRowId { + case first + case second +} + +enum TestSectionId { + case first + case second +} diff --git a/BentoTests/TestRenderable.swift b/BentoTests/TestRenderable.swift new file mode 100644 index 0000000..71d0870 --- /dev/null +++ b/BentoTests/TestRenderable.swift @@ -0,0 +1,20 @@ +import UIKit +@testable import Bento + +struct TestCustomEqualityRenderable: Renderable { + typealias View = UIView + + let value: Int + + func render(in view: UIView) {} + + static func == (lhs: TestCustomEqualityRenderable, rhs: TestCustomEqualityRenderable) -> Bool { + return lhs.value == rhs.value + } +} + +final class TestDefaultEqualityRenderable: Renderable { + typealias View = UIView + + func render(in view: UIView) {} +} diff --git a/Example.xcodeproj/project.pbxproj b/Example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bcda95e --- /dev/null +++ b/Example.xcodeproj/project.pbxproj @@ -0,0 +1,1002 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 48; + objects = { + +/* Begin PBXBuildFile section */ + 0A5856497265C834BA23E39C /* Pods_Bento.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 669406B2CB5D5B435C8E0C3D /* Pods_Bento.framework */; }; + 515F94097C1F82B7E5C2DE0C /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515F9EAF847DD1D68DE3A48C /* Section.swift */; }; + 5829D269200FB092001E020D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5829D268200FB092001E020D /* AppDelegate.swift */; }; + 5829D26B200FB092001E020D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5829D26A200FB092001E020D /* ViewController.swift */; }; + 5829D26E200FB092001E020D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5829D26C200FB092001E020D /* Main.storyboard */; }; + 5829D270200FB092001E020D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5829D26F200FB092001E020D /* Assets.xcassets */; }; + 5829D273200FB092001E020D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5829D271200FB092001E020D /* LaunchScreen.storyboard */; }; + 58467B03202B33F200577C77 /* TableViewSectionDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58467B02202B33F200577C77 /* TableViewSectionDiff.swift */; }; + 5857BECA2056F02C0085EB9C /* If.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5857BEC92056F02C0085EB9C /* If.swift */; }; + 587F0718201B355800ACD219 /* TableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587F0717201B355800ACD219 /* TableViewCell.swift */; }; + 58BA75602016303B0050D5F1 /* Bento.h in Headers */ = {isa = PBXBuildFile; fileRef = 58BA755E2016303B0050D5F1 /* Bento.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 58BA7584201633CC0050D5F1 /* Renderable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BA7583201633CC0050D5F1 /* Renderable.swift */; }; + 58BA7586201634120050D5F1 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BA7585201634120050D5F1 /* Helpers.swift */; }; + 58D0F12D207F573B00A24E96 /* TableViewAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0F12C207F573B00A24E96 /* TableViewAnimation.swift */; }; + 58E98E5B2016571F00F78BAE /* IconTextCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58E98E552016571F00F78BAE /* IconTextCell.xib */; }; + 58E98E5C2016571F00F78BAE /* ToggleCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58E98E562016571F00F78BAE /* ToggleCell.xib */; }; + 58E98E5D2016571F00F78BAE /* ToggleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E98E572016571F00F78BAE /* ToggleCell.swift */; }; + 58E98E5E2016571F00F78BAE /* EmptySpaceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58E98E582016571F00F78BAE /* EmptySpaceCell.xib */; }; + 58E98E5F2016571F00F78BAE /* EmptySpaceCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E98E592016571F00F78BAE /* EmptySpaceCell.swift */; }; + 58E98E602016571F00F78BAE /* IconTextCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E98E5A2016571F00F78BAE /* IconTextCell.swift */; }; + 58E98E63201889B900F78BAE /* AnyRenderable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E98E62201889B900F78BAE /* AnyRenderable.swift */; }; + 58FC4427207CF2BB00DA3614 /* AnyRenderableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3EF77F205D866F00D043AC /* AnyRenderableTests.swift */; }; + 58FC4428207CF2BB00DA3614 /* TestId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A784702205EB61D00FA597E /* TestId.swift */; }; + 58FC4429207CF2BB00DA3614 /* TestRenderable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A784700205EB5E000FA597E /* TestRenderable.swift */; }; + 58FC442A207CF2BB00DA3614 /* NodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7846FC205EAF7C00FA597E /* NodeTests.swift */; }; + 58FC442B207CF2BB00DA3614 /* SectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7846FE205EAF8400FA597E /* SectionTests.swift */; }; + 58FC442F207CF37700DA3614 /* script in Resources */ = {isa = PBXBuildFile; fileRef = 58FC442E207CF37700DA3614 /* script */; }; + 58FC4430207CF3CD00DA3614 /* Bento.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58BA755C2016303B0050D5F1 /* Bento.framework */; }; + 58FC4431207CF3CD00DA3614 /* Bento.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 58BA755C2016303B0050D5F1 /* Bento.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 58FC4441207CFBD700DA3614 /* MovieComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC4437207CFBD600DA3614 /* MovieComponentView.swift */; }; + 58FC4442207CFBD700DA3614 /* LoadingIndicatorView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58FC4438207CFBD600DA3614 /* LoadingIndicatorView.xib */; }; + 58FC4443207CFBD700DA3614 /* SegmetControlView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58FC4439207CFBD700DA3614 /* SegmetControlView.xib */; }; + 58FC4444207CFBD700DA3614 /* SegmetControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC443A207CFBD700DA3614 /* SegmetControlView.swift */; }; + 58FC4445207CFBD700DA3614 /* LoadingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC443B207CFBD700DA3614 /* LoadingIndicatorView.swift */; }; + 58FC4446207CFBD700DA3614 /* ButtonComponentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58FC443C207CFBD700DA3614 /* ButtonComponentView.xib */; }; + 58FC4447207CFBD700DA3614 /* ButtonComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC443D207CFBD700DA3614 /* ButtonComponentView.swift */; }; + 58FC4448207CFBD700DA3614 /* IconTitleDetailsView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58FC443E207CFBD700DA3614 /* IconTitleDetailsView.xib */; }; + 58FC4449207CFBD700DA3614 /* IconTitleDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC443F207CFBD700DA3614 /* IconTitleDetailsView.swift */; }; + 58FC444A207CFBD700DA3614 /* MovieComponentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58FC4440207CFBD700DA3614 /* MovieComponentView.xib */; }; + 58FC444D207CFBE200DA3614 /* BookAppointmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC444B207CFBE100DA3614 /* BookAppointmentViewController.swift */; }; + 58FC444E207CFBE200DA3614 /* MoviesListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC444C207CFBE200DA3614 /* MoviesListViewController.swift */; }; + 6F931C93E8302BAF822EF6CA /* Pods_BentoTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22A31973BB9DF6DFF814E803 /* Pods_BentoTests.framework */; }; + A95093B3B7AD6DC69644BCA6 /* SectionedFormAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9509BC2762C8B4277B973D8 /* SectionedFormAdapter.swift */; }; + A9509880661501C40B50E453 /* TableViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A950970749997A21E1BF7777 /* TableViewHeaderFooterView.swift */; }; + A9509C4FC3664040FF3649CD /* Node.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95093DB10046D731018127D /* Node.swift */; }; + A9509FB12CAD89179FAA03B0 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = A950967CC717BBF3B02B766D /* Box.swift */; }; + B9C557FE8D42BCB9CF36F50D /* Pods_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D4B544CA77089EE732C95DFA /* Pods_Example.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 58FC4422207CF29F00DA3614 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5829D25D200FB092001E020D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5829D264200FB092001E020D; + remoteInfo = Example; + }; + 58FC4432207CF3CD00DA3614 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5829D25D200FB092001E020D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 58BA755B2016303B0050D5F1; + remoteInfo = Bento; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 58FC4434207CF3CE00DA3614 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 58FC4431207CF3CD00DA3614 /* Bento.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0CD73CCD16DCAF7438ED8B3A /* Pods-Bento.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Bento.release.xcconfig"; path = "Pods/Target Support Files/Pods-Bento/Pods-Bento.release.xcconfig"; sourceTree = ""; }; + 0EEA17DEEC6D16C7ED60C8EC /* Pods-Bento.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Bento.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Bento/Pods-Bento.debug.xcconfig"; sourceTree = ""; }; + 22A31973BB9DF6DFF814E803 /* Pods_BentoTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_BentoTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3378B758632CFCD21DE35F7E /* Pods_FormsKitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FormsKitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 515F9EAF847DD1D68DE3A48C /* Section.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; + 5829D265200FB092001E020D /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5829D268200FB092001E020D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 5829D26A200FB092001E020D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 5829D26D200FB092001E020D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 5829D26F200FB092001E020D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 5829D272200FB092001E020D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 5829D274200FB092001E020D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 58467B02202B33F200577C77 /* TableViewSectionDiff.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewSectionDiff.swift; sourceTree = ""; }; + 5857BEC92056F02C0085EB9C /* If.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = If.swift; sourceTree = ""; }; + 587F0717201B355800ACD219 /* TableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCell.swift; sourceTree = ""; }; + 58BA755C2016303B0050D5F1 /* Bento.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Bento.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 58BA755E2016303B0050D5F1 /* Bento.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Bento.h; sourceTree = ""; }; + 58BA755F2016303B0050D5F1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 58BA7583201633CC0050D5F1 /* Renderable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Renderable.swift; sourceTree = ""; }; + 58BA7585201634120050D5F1 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; + 58D0F12C207F573B00A24E96 /* TableViewAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewAnimation.swift; sourceTree = ""; }; + 58E98E552016571F00F78BAE /* IconTextCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IconTextCell.xib; sourceTree = ""; }; + 58E98E562016571F00F78BAE /* ToggleCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ToggleCell.xib; sourceTree = ""; }; + 58E98E572016571F00F78BAE /* ToggleCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToggleCell.swift; sourceTree = ""; }; + 58E98E582016571F00F78BAE /* EmptySpaceCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = EmptySpaceCell.xib; sourceTree = ""; }; + 58E98E592016571F00F78BAE /* EmptySpaceCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptySpaceCell.swift; sourceTree = ""; }; + 58E98E5A2016571F00F78BAE /* IconTextCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IconTextCell.swift; sourceTree = ""; }; + 58E98E62201889B900F78BAE /* AnyRenderable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyRenderable.swift; sourceTree = ""; }; + 58FC441D207CF29F00DA3614 /* BentoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BentoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 58FC4421207CF29F00DA3614 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 58FC442C207CF2F500DA3614 /* Bento.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Bento.podspec; sourceTree = ""; }; + 58FC442E207CF37700DA3614 /* script */ = {isa = PBXFileReference; lastKnownFileType = folder; path = script; sourceTree = ""; }; + 58FC4435207CF7F100DA3614 /* circle.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = circle.yml; sourceTree = ""; }; + 58FC4437207CFBD600DA3614 /* MovieComponentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieComponentView.swift; sourceTree = ""; }; + 58FC4438207CFBD600DA3614 /* LoadingIndicatorView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = LoadingIndicatorView.xib; sourceTree = ""; }; + 58FC4439207CFBD700DA3614 /* SegmetControlView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SegmetControlView.xib; sourceTree = ""; }; + 58FC443A207CFBD700DA3614 /* SegmetControlView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmetControlView.swift; sourceTree = ""; }; + 58FC443B207CFBD700DA3614 /* LoadingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingIndicatorView.swift; sourceTree = ""; }; + 58FC443C207CFBD700DA3614 /* ButtonComponentView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ButtonComponentView.xib; sourceTree = ""; }; + 58FC443D207CFBD700DA3614 /* ButtonComponentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ButtonComponentView.swift; sourceTree = ""; }; + 58FC443E207CFBD700DA3614 /* IconTitleDetailsView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = IconTitleDetailsView.xib; sourceTree = ""; }; + 58FC443F207CFBD700DA3614 /* IconTitleDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IconTitleDetailsView.swift; sourceTree = ""; }; + 58FC4440207CFBD700DA3614 /* MovieComponentView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MovieComponentView.xib; sourceTree = ""; }; + 58FC444B207CFBE100DA3614 /* BookAppointmentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookAppointmentViewController.swift; sourceTree = ""; }; + 58FC444C207CFBE200DA3614 /* MoviesListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesListViewController.swift; sourceTree = ""; }; + 669406B2CB5D5B435C8E0C3D /* Pods_Bento.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Bento.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6EAC305899441AD3C9360A66 /* Pods-BentoTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BentoTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-BentoTests/Pods-BentoTests.release.xcconfig"; sourceTree = ""; }; + 9A3EF77F205D866F00D043AC /* AnyRenderableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyRenderableTests.swift; sourceTree = ""; }; + 9A7846FA205D89C000FA597E /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 9A7846FC205EAF7C00FA597E /* NodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeTests.swift; sourceTree = ""; }; + 9A7846FE205EAF8400FA597E /* SectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionTests.swift; sourceTree = ""; }; + 9A784700205EB5E000FA597E /* TestRenderable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRenderable.swift; sourceTree = ""; }; + 9A784702205EB61D00FA597E /* TestId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestId.swift; sourceTree = ""; }; + 9DCDD776FBC4BC33B6A0B451 /* Pods-Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.release.xcconfig"; path = "Pods/Target Support Files/Pods-Example/Pods-Example.release.xcconfig"; sourceTree = ""; }; + 9E43D0D1EDE6C25D9A736A5C /* Pods-Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Example/Pods-Example.debug.xcconfig"; sourceTree = ""; }; + A95093DB10046D731018127D /* Node.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Node.swift; sourceTree = ""; }; + A950967CC717BBF3B02B766D /* Box.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = ""; }; + A950970749997A21E1BF7777 /* TableViewHeaderFooterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewHeaderFooterView.swift; sourceTree = ""; }; + A9509BC2762C8B4277B973D8 /* SectionedFormAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SectionedFormAdapter.swift; sourceTree = ""; }; + CEAFF72B4ED185325805F614 /* Pods-BentoTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BentoTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-BentoTests/Pods-BentoTests.debug.xcconfig"; sourceTree = ""; }; + D4B544CA77089EE732C95DFA /* Pods_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D8F70F2914E4B36CCB7DB090 /* Pods_FormsKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FormsKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5829D262200FB092001E020D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B9C557FE8D42BCB9CF36F50D /* Pods_Example.framework in Frameworks */, + 58FC4430207CF3CD00DA3614 /* Bento.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 58BA75582016303B0050D5F1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0A5856497265C834BA23E39C /* Pods_Bento.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 58FC441A207CF29F00DA3614 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6F931C93E8302BAF822EF6CA /* Pods_BentoTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 581ED77E2022041C00EC9584 /* Diff */ = { + isa = PBXGroup; + children = ( + 58467B02202B33F200577C77 /* TableViewSectionDiff.swift */, + ); + path = Diff; + sourceTree = ""; + }; + 5829D25C200FB092001E020D = { + isa = PBXGroup; + children = ( + 58FC4435207CF7F100DA3614 /* circle.yml */, + 58FC442C207CF2F500DA3614 /* Bento.podspec */, + 58FC442E207CF37700DA3614 /* script */, + 5829D267200FB092001E020D /* Example */, + 58BA755D2016303B0050D5F1 /* Bento */, + 58FC441E207CF29F00DA3614 /* BentoTests */, + 5829D266200FB092001E020D /* Products */, + 8A668AF20B1285F69E250C39 /* Pods */, + 7D7D5AFB203495BA731D7A3E /* Frameworks */, + ); + sourceTree = ""; + }; + 5829D266200FB092001E020D /* Products */ = { + isa = PBXGroup; + children = ( + 5829D265200FB092001E020D /* Example.app */, + 58BA755C2016303B0050D5F1 /* Bento.framework */, + 58FC441D207CF29F00DA3614 /* BentoTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 5829D267200FB092001E020D /* Example */ = { + isa = PBXGroup; + children = ( + 58E98E542016570800F78BAE /* Cells */, + 58FC444B207CFBE100DA3614 /* BookAppointmentViewController.swift */, + 58FC444C207CFBE200DA3614 /* MoviesListViewController.swift */, + 5829D268200FB092001E020D /* AppDelegate.swift */, + 5829D26A200FB092001E020D /* ViewController.swift */, + 5829D26C200FB092001E020D /* Main.storyboard */, + 5829D26F200FB092001E020D /* Assets.xcassets */, + 5829D271200FB092001E020D /* LaunchScreen.storyboard */, + 5829D274200FB092001E020D /* Info.plist */, + ); + path = Example; + sourceTree = ""; + }; + 587F0716201B354900ACD219 /* Views */ = { + isa = PBXGroup; + children = ( + 587F0717201B355800ACD219 /* TableViewCell.swift */, + A950970749997A21E1BF7777 /* TableViewHeaderFooterView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 58BA755D2016303B0050D5F1 /* Bento */ = { + isa = PBXGroup; + children = ( + 581ED77E2022041C00EC9584 /* Diff */, + 587F0716201B354900ACD219 /* Views */, + 58E98E612018898700F78BAE /* Renderable */, + 58BA7585201634120050D5F1 /* Helpers.swift */, + A95095B51B6349DC59C77D6D /* Bento */, + A950945F6360B851C3E87B61 /* Adapters */, + 58BA755E2016303B0050D5F1 /* Bento.h */, + 58BA755F2016303B0050D5F1 /* Info.plist */, + ); + path = Bento; + sourceTree = ""; + }; + 58E98E542016570800F78BAE /* Cells */ = { + isa = PBXGroup; + children = ( + 58FC443D207CFBD700DA3614 /* ButtonComponentView.swift */, + 58FC443C207CFBD700DA3614 /* ButtonComponentView.xib */, + 58FC443F207CFBD700DA3614 /* IconTitleDetailsView.swift */, + 58FC443E207CFBD700DA3614 /* IconTitleDetailsView.xib */, + 58FC443B207CFBD700DA3614 /* LoadingIndicatorView.swift */, + 58FC4438207CFBD600DA3614 /* LoadingIndicatorView.xib */, + 58FC4437207CFBD600DA3614 /* MovieComponentView.swift */, + 58FC4440207CFBD700DA3614 /* MovieComponentView.xib */, + 58FC443A207CFBD700DA3614 /* SegmetControlView.swift */, + 58FC4439207CFBD700DA3614 /* SegmetControlView.xib */, + 58E98E592016571F00F78BAE /* EmptySpaceCell.swift */, + 58E98E582016571F00F78BAE /* EmptySpaceCell.xib */, + 58E98E5A2016571F00F78BAE /* IconTextCell.swift */, + 58E98E552016571F00F78BAE /* IconTextCell.xib */, + 58E98E572016571F00F78BAE /* ToggleCell.swift */, + 58E98E562016571F00F78BAE /* ToggleCell.xib */, + ); + path = Cells; + sourceTree = ""; + }; + 58E98E612018898700F78BAE /* Renderable */ = { + isa = PBXGroup; + children = ( + 58BA7583201633CC0050D5F1 /* Renderable.swift */, + 58E98E62201889B900F78BAE /* AnyRenderable.swift */, + ); + path = Renderable; + sourceTree = ""; + }; + 58FC441E207CF29F00DA3614 /* BentoTests */ = { + isa = PBXGroup; + children = ( + 9A3EF77F205D866F00D043AC /* AnyRenderableTests.swift */, + 9A7846FC205EAF7C00FA597E /* NodeTests.swift */, + 9A7846FE205EAF8400FA597E /* SectionTests.swift */, + 9A784700205EB5E000FA597E /* TestRenderable.swift */, + 9A784702205EB61D00FA597E /* TestId.swift */, + 58FC4421207CF29F00DA3614 /* Info.plist */, + ); + path = BentoTests; + sourceTree = ""; + }; + 7D7D5AFB203495BA731D7A3E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9A7846FA205D89C000FA597E /* XCTest.framework */, + D4B544CA77089EE732C95DFA /* Pods_Example.framework */, + D8F70F2914E4B36CCB7DB090 /* Pods_FormsKit.framework */, + 3378B758632CFCD21DE35F7E /* Pods_FormsKitTests.framework */, + 669406B2CB5D5B435C8E0C3D /* Pods_Bento.framework */, + 22A31973BB9DF6DFF814E803 /* Pods_BentoTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 8A668AF20B1285F69E250C39 /* Pods */ = { + isa = PBXGroup; + children = ( + 9E43D0D1EDE6C25D9A736A5C /* Pods-Example.debug.xcconfig */, + 9DCDD776FBC4BC33B6A0B451 /* Pods-Example.release.xcconfig */, + 0EEA17DEEC6D16C7ED60C8EC /* Pods-Bento.debug.xcconfig */, + 0CD73CCD16DCAF7438ED8B3A /* Pods-Bento.release.xcconfig */, + CEAFF72B4ED185325805F614 /* Pods-BentoTests.debug.xcconfig */, + 6EAC305899441AD3C9360A66 /* Pods-BentoTests.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + A950945F6360B851C3E87B61 /* Adapters */ = { + isa = PBXGroup; + children = ( + A9509BC2762C8B4277B973D8 /* SectionedFormAdapter.swift */, + 58D0F12C207F573B00A24E96 /* TableViewAnimation.swift */, + ); + path = Adapters; + sourceTree = ""; + }; + A95095B51B6349DC59C77D6D /* Bento */ = { + isa = PBXGroup; + children = ( + A950967CC717BBF3B02B766D /* Box.swift */, + A95093DB10046D731018127D /* Node.swift */, + 515F9EAF847DD1D68DE3A48C /* Section.swift */, + 5857BEC92056F02C0085EB9C /* If.swift */, + ); + path = Bento; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 58BA75592016303B0050D5F1 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 58BA75602016303B0050D5F1 /* Bento.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 5829D264200FB092001E020D /* Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5829D277200FB092001E020D /* Build configuration list for PBXNativeTarget "Example" */; + buildPhases = ( + F9B2D855C871F7375D08CE89 /* [CP] Check Pods Manifest.lock */, + 5829D261200FB092001E020D /* Sources */, + 5829D262200FB092001E020D /* Frameworks */, + 5829D263200FB092001E020D /* Resources */, + F3A402B41E8435B72A4021ED /* [CP] Embed Pods Frameworks */, + 6DB87146A951B53A1EF8B7E2 /* [CP] Copy Pods Resources */, + 58FC4434207CF3CE00DA3614 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 58FC4433207CF3CD00DA3614 /* PBXTargetDependency */, + ); + name = Example; + productName = Example; + productReference = 5829D265200FB092001E020D /* Example.app */; + productType = "com.apple.product-type.application"; + }; + 58BA755B2016303B0050D5F1 /* Bento */ = { + isa = PBXNativeTarget; + buildConfigurationList = 58BA75652016303B0050D5F1 /* Build configuration list for PBXNativeTarget "Bento" */; + buildPhases = ( + 9BD93503D97BCFF5F3E85050 /* [CP] Check Pods Manifest.lock */, + 58BA75572016303B0050D5F1 /* Sources */, + 58BA75582016303B0050D5F1 /* Frameworks */, + 58BA75592016303B0050D5F1 /* Headers */, + 58BA755A2016303B0050D5F1 /* Resources */, + 08AB483FDD187C65C8CF51AE /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Bento; + productName = FormsKit; + productReference = 58BA755C2016303B0050D5F1 /* Bento.framework */; + productType = "com.apple.product-type.framework"; + }; + 58FC441C207CF29F00DA3614 /* BentoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 58FC4424207CF29F00DA3614 /* Build configuration list for PBXNativeTarget "BentoTests" */; + buildPhases = ( + BDDFD27BED1DC7410CF781AB /* [CP] Check Pods Manifest.lock */, + 58FC4419207CF29F00DA3614 /* Sources */, + 58FC441A207CF29F00DA3614 /* Frameworks */, + 58FC441B207CF29F00DA3614 /* Resources */, + DAC9DAFDC8FB92E3E44DF516 /* [CP] Embed Pods Frameworks */, + A465C0F64C1B229C7A6997E4 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 58FC4423207CF29F00DA3614 /* PBXTargetDependency */, + ); + name = BentoTests; + productName = BentoTests; + productReference = 58FC441D207CF29F00DA3614 /* BentoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5829D25D200FB092001E020D /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + ORGANIZATIONNAME = babylonhealth; + TargetAttributes = { + 5829D264200FB092001E020D = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Automatic; + }; + 58BA755B2016303B0050D5F1 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 0920; + ProvisioningStyle = Automatic; + }; + 58FC441C207CF29F00DA3614 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 5829D260200FB092001E020D /* Build configuration list for PBXProject "Example" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 5829D25C200FB092001E020D; + productRefGroup = 5829D266200FB092001E020D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5829D264200FB092001E020D /* Example */, + 58BA755B2016303B0050D5F1 /* Bento */, + 58FC441C207CF29F00DA3614 /* BentoTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5829D263200FB092001E020D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 58FC4446207CFBD700DA3614 /* ButtonComponentView.xib in Resources */, + 58FC444A207CFBD700DA3614 /* MovieComponentView.xib in Resources */, + 58FC442F207CF37700DA3614 /* script in Resources */, + 5829D273200FB092001E020D /* LaunchScreen.storyboard in Resources */, + 58FC4443207CFBD700DA3614 /* SegmetControlView.xib in Resources */, + 5829D270200FB092001E020D /* Assets.xcassets in Resources */, + 58E98E5E2016571F00F78BAE /* EmptySpaceCell.xib in Resources */, + 58FC4448207CFBD700DA3614 /* IconTitleDetailsView.xib in Resources */, + 5829D26E200FB092001E020D /* Main.storyboard in Resources */, + 58E98E5C2016571F00F78BAE /* ToggleCell.xib in Resources */, + 58E98E5B2016571F00F78BAE /* IconTextCell.xib in Resources */, + 58FC4442207CFBD700DA3614 /* LoadingIndicatorView.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 58BA755A2016303B0050D5F1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 58FC441B207CF29F00DA3614 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 08AB483FDD187C65C8CF51AE /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Bento/Pods-Bento-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 6DB87146A951B53A1EF8B7E2 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Example/Pods-Example-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9BD93503D97BCFF5F3E85050 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Bento-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + A465C0F64C1B229C7A6997E4 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-BentoTests/Pods-BentoTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + BDDFD27BED1DC7410CF781AB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-BentoTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DAC9DAFDC8FB92E3E44DF516 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-BentoTests/Pods-BentoTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/FlexibleDiff/FlexibleDiff.framework", + "${BUILT_PRODUCTS_DIR}/Nimble/Nimble.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FlexibleDiff.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Nimble.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-BentoTests/Pods-BentoTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F3A402B41E8435B72A4021ED /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${SRCROOT}/Pods/Target Support Files/Pods-Example/Pods-Example-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/FlexibleDiff/FlexibleDiff.framework", + "${BUILT_PRODUCTS_DIR}/Kingfisher/Kingfisher.framework", + "${BUILT_PRODUCTS_DIR}/ReactiveCocoa/ReactiveCocoa.framework", + "${BUILT_PRODUCTS_DIR}/ReactiveFeedback/ReactiveFeedback.framework", + "${BUILT_PRODUCTS_DIR}/ReactiveSwift/ReactiveSwift.framework", + "${BUILT_PRODUCTS_DIR}/Result/Result.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FlexibleDiff.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Kingfisher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactiveCocoa.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactiveFeedback.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactiveSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Result.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Example/Pods-Example-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F9B2D855C871F7375D08CE89 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Example-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5829D261200FB092001E020D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5829D26B200FB092001E020D /* ViewController.swift in Sources */, + 58FC4447207CFBD700DA3614 /* ButtonComponentView.swift in Sources */, + 58FC4441207CFBD700DA3614 /* MovieComponentView.swift in Sources */, + 58FC444D207CFBE200DA3614 /* BookAppointmentViewController.swift in Sources */, + 58E98E5D2016571F00F78BAE /* ToggleCell.swift in Sources */, + 58FC4445207CFBD700DA3614 /* LoadingIndicatorView.swift in Sources */, + 58E98E5F2016571F00F78BAE /* EmptySpaceCell.swift in Sources */, + 5829D269200FB092001E020D /* AppDelegate.swift in Sources */, + 58FC4449207CFBD700DA3614 /* IconTitleDetailsView.swift in Sources */, + 58FC4444207CFBD700DA3614 /* SegmetControlView.swift in Sources */, + 58E98E602016571F00F78BAE /* IconTextCell.swift in Sources */, + 58FC444E207CFBE200DA3614 /* MoviesListViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 58BA75572016303B0050D5F1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 58E98E63201889B900F78BAE /* AnyRenderable.swift in Sources */, + 58BA7586201634120050D5F1 /* Helpers.swift in Sources */, + 58D0F12D207F573B00A24E96 /* TableViewAnimation.swift in Sources */, + 58BA7584201633CC0050D5F1 /* Renderable.swift in Sources */, + 5857BECA2056F02C0085EB9C /* If.swift in Sources */, + A9509FB12CAD89179FAA03B0 /* Box.swift in Sources */, + 58467B03202B33F200577C77 /* TableViewSectionDiff.swift in Sources */, + A9509C4FC3664040FF3649CD /* Node.swift in Sources */, + 587F0718201B355800ACD219 /* TableViewCell.swift in Sources */, + A95093B3B7AD6DC69644BCA6 /* SectionedFormAdapter.swift in Sources */, + A9509880661501C40B50E453 /* TableViewHeaderFooterView.swift in Sources */, + 515F94097C1F82B7E5C2DE0C /* Section.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 58FC4419207CF29F00DA3614 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 58FC4427207CF2BB00DA3614 /* AnyRenderableTests.swift in Sources */, + 58FC442A207CF2BB00DA3614 /* NodeTests.swift in Sources */, + 58FC4428207CF2BB00DA3614 /* TestId.swift in Sources */, + 58FC4429207CF2BB00DA3614 /* TestRenderable.swift in Sources */, + 58FC442B207CF2BB00DA3614 /* SectionTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 58FC4423207CF29F00DA3614 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5829D264200FB092001E020D /* Example */; + targetProxy = 58FC4422207CF29F00DA3614 /* PBXContainerItemProxy */; + }; + 58FC4433207CF3CD00DA3614 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 58BA755B2016303B0050D5F1 /* Bento */; + targetProxy = 58FC4432207CF3CD00DA3614 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 5829D26C200FB092001E020D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 5829D26D200FB092001E020D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 5829D271200FB092001E020D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 5829D272200FB092001E020D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 5829D275200FB092001E020D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.2; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5829D276200FB092001E020D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.2; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5829D278200FB092001E020D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9E43D0D1EDE6C25D9A736A5C /* Pods-Example.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = Example/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5829D279200FB092001E020D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9DCDD776FBC4BC33B6A0B451 /* Pods-Example.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = Example/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 58BA75662016303B0050D5F1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0EEA17DEEC6D16C7ED60C8EC /* Pods-Bento.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Bento/Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.FormsKit; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 58BA75672016303B0050D5F1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0CD73CCD16DCAF7438ED8B3A /* Pods-Bento.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Bento/Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.FormsKit; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 58FC4425207CF29F00DA3614 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CEAFF72B4ED185325805F614 /* Pods-BentoTests.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = BentoTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.BentoTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 58FC4426207CF29F00DA3614 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6EAC305899441AD3C9360A66 /* Pods-BentoTests.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = BentoTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.BentoTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5829D260200FB092001E020D /* Build configuration list for PBXProject "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5829D275200FB092001E020D /* Debug */, + 5829D276200FB092001E020D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5829D277200FB092001E020D /* Build configuration list for PBXNativeTarget "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5829D278200FB092001E020D /* Debug */, + 5829D279200FB092001E020D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 58BA75652016303B0050D5F1 /* Build configuration list for PBXNativeTarget "Bento" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58BA75662016303B0050D5F1 /* Debug */, + 58BA75672016303B0050D5F1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 58FC4424207CF29F00DA3614 /* Build configuration list for PBXNativeTarget "BentoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58FC4425207CF29F00DA3614 /* Debug */, + 58FC4426207CF29F00DA3614 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 5829D25D200FB092001E020D /* Project object */; +} diff --git a/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..6d2a51b --- /dev/null +++ b/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example.xcodeproj/xcshareddata/xcschemes/Bento.xcscheme b/Example.xcodeproj/xcshareddata/xcschemes/Bento.xcscheme new file mode 100644 index 0000000..70598b3 --- /dev/null +++ b/Example.xcodeproj/xcshareddata/xcschemes/Bento.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..e13f3df --- /dev/null +++ b/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example.xcworkspace/contents.xcworkspacedata b/Example.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..a37cf19 --- /dev/null +++ b/Example.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Example/AppDelegate.swift b/Example/AppDelegate.swift new file mode 100644 index 0000000..c823851 --- /dev/null +++ b/Example/AppDelegate.swift @@ -0,0 +1,7 @@ +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? +} + diff --git a/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/Contents.json b/Example/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/chuck_norris_walker.imageset/Contents.json b/Example/Assets.xcassets/chuck_norris_walker.imageset/Contents.json new file mode 100644 index 0000000..538a879 --- /dev/null +++ b/Example/Assets.xcassets/chuck_norris_walker.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "chuck_norris_walker.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/chuck_norris_walker.imageset/chuck_norris_walker.png b/Example/Assets.xcassets/chuck_norris_walker.imageset/chuck_norris_walker.png new file mode 100644 index 0000000..120352d Binary files /dev/null and b/Example/Assets.xcassets/chuck_norris_walker.imageset/chuck_norris_walker.png differ diff --git a/Example/Assets.xcassets/consultantIcon.imageset/Contents.json b/Example/Assets.xcassets/consultantIcon.imageset/Contents.json new file mode 100644 index 0000000..d7bd953 --- /dev/null +++ b/Example/Assets.xcassets/consultantIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "consultantIcon.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/consultantIcon.imageset/consultantIcon.pdf b/Example/Assets.xcassets/consultantIcon.imageset/consultantIcon.pdf new file mode 100644 index 0000000..46ed398 Binary files /dev/null and b/Example/Assets.xcassets/consultantIcon.imageset/consultantIcon.pdf differ diff --git a/Example/Assets.xcassets/deleteIcon.imageset/Contents.json b/Example/Assets.xcassets/deleteIcon.imageset/Contents.json new file mode 100644 index 0000000..9677caa --- /dev/null +++ b/Example/Assets.xcassets/deleteIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "deleteIcon.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/deleteIcon.imageset/deleteIcon.pdf b/Example/Assets.xcassets/deleteIcon.imageset/deleteIcon.pdf new file mode 100644 index 0000000..a203547 Binary files /dev/null and b/Example/Assets.xcassets/deleteIcon.imageset/deleteIcon.pdf differ diff --git a/Example/Assets.xcassets/disclosureIndicatorGrey.imageset/Contents.json b/Example/Assets.xcassets/disclosureIndicatorGrey.imageset/Contents.json new file mode 100644 index 0000000..de38e86 --- /dev/null +++ b/Example/Assets.xcassets/disclosureIndicatorGrey.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "disclosureIndicatorGrey.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/disclosureIndicatorGrey.imageset/disclosureIndicatorGrey.pdf b/Example/Assets.xcassets/disclosureIndicatorGrey.imageset/disclosureIndicatorGrey.pdf new file mode 100644 index 0000000..4658c8a Binary files /dev/null and b/Example/Assets.xcassets/disclosureIndicatorGrey.imageset/disclosureIndicatorGrey.pdf differ diff --git a/Example/Assets.xcassets/helpIcon.imageset/Contents.json b/Example/Assets.xcassets/helpIcon.imageset/Contents.json new file mode 100644 index 0000000..caf1511 --- /dev/null +++ b/Example/Assets.xcassets/helpIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "helpIcon.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/helpIcon.imageset/helpIcon.pdf b/Example/Assets.xcassets/helpIcon.imageset/helpIcon.pdf new file mode 100644 index 0000000..050009a Binary files /dev/null and b/Example/Assets.xcassets/helpIcon.imageset/helpIcon.pdf differ diff --git a/Example/Assets.xcassets/phone.imageset/Contents.json b/Example/Assets.xcassets/phone.imageset/Contents.json new file mode 100644 index 0000000..2f1dae0 --- /dev/null +++ b/Example/Assets.xcassets/phone.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "phoneOnIcon.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/phone.imageset/phoneOnIcon.pdf b/Example/Assets.xcassets/phone.imageset/phoneOnIcon.pdf new file mode 100644 index 0000000..2345684 Binary files /dev/null and b/Example/Assets.xcassets/phone.imageset/phoneOnIcon.pdf differ diff --git a/Example/Assets.xcassets/plane.imageset/Contents.json b/Example/Assets.xcassets/plane.imageset/Contents.json new file mode 100644 index 0000000..738f554 --- /dev/null +++ b/Example/Assets.xcassets/plane.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "plane.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/plane.imageset/plane.png b/Example/Assets.xcassets/plane.imageset/plane.png new file mode 100644 index 0000000..313f072 Binary files /dev/null and b/Example/Assets.xcassets/plane.imageset/plane.png differ diff --git a/Example/Assets.xcassets/timeIcon.imageset/Contents.json b/Example/Assets.xcassets/timeIcon.imageset/Contents.json new file mode 100644 index 0000000..6b63baf --- /dev/null +++ b/Example/Assets.xcassets/timeIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "timeIcon.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/timeIcon.imageset/timeIcon.pdf b/Example/Assets.xcassets/timeIcon.imageset/timeIcon.pdf new file mode 100644 index 0000000..37a6d3d Binary files /dev/null and b/Example/Assets.xcassets/timeIcon.imageset/timeIcon.pdf differ diff --git a/Example/Assets.xcassets/video.imageset/Contents.json b/Example/Assets.xcassets/video.imageset/Contents.json new file mode 100644 index 0000000..ef46362 --- /dev/null +++ b/Example/Assets.xcassets/video.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "videoOnIcon.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/video.imageset/videoOnIcon.pdf b/Example/Assets.xcassets/video.imageset/videoOnIcon.pdf new file mode 100644 index 0000000..a830374 Binary files /dev/null and b/Example/Assets.xcassets/video.imageset/videoOnIcon.pdf differ diff --git a/Example/Assets.xcassets/wifi.imageset/Contents.json b/Example/Assets.xcassets/wifi.imageset/Contents.json new file mode 100644 index 0000000..24dd9c4 --- /dev/null +++ b/Example/Assets.xcassets/wifi.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "wifi.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/wifi.imageset/wifi.png b/Example/Assets.xcassets/wifi.imageset/wifi.png new file mode 100644 index 0000000..3997397 Binary files /dev/null and b/Example/Assets.xcassets/wifi.imageset/wifi.png differ diff --git a/Example/Base.lproj/LaunchScreen.storyboard b/Example/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f83f6fd --- /dev/null +++ b/Example/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Base.lproj/Main.storyboard b/Example/Base.lproj/Main.storyboard new file mode 100644 index 0000000..5a44795 --- /dev/null +++ b/Example/Base.lproj/Main.storyboard @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/BookAppointmentViewController.swift b/Example/BookAppointmentViewController.swift new file mode 100644 index 0000000..c380b39 --- /dev/null +++ b/Example/BookAppointmentViewController.swift @@ -0,0 +1,224 @@ +import UIKit +import Bento +import ReactiveSwift +import ReactiveFeedback +import enum Result.NoError + +final class BookAppointmentViewController: UIViewController { + enum SectionId { + case user + case consultantDate + case audioVideo + case symptoms + case book + } + + enum RowId { + case user + case consultant + case date + case audioVideo + case symptoms + } + + @IBOutlet weak var tableView: UITableView! + + lazy var viewModel = BookAppointmentViewModel(renderer: BookAppointmentViewModel.Renderer(patient: Patient(id: "1", + firstName: "Chuck", + lastName: "Norris"))) + + override func viewDidLoad() { + super.viewDidLoad() + setupTableView() + viewModel.box + .producer + .startWithValues(tableView.render) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.reload() + } + + private func setupTableView() { + tableView.estimatedSectionFooterHeight = 18 + tableView.estimatedSectionHeaderHeight = 18 + tableView.sectionHeaderHeight = UITableViewAutomaticDimension + tableView.sectionFooterHeight = UITableViewAutomaticDimension + } +} + +final class BookAppointmentViewModel { + private let state: Property + private let reloadObserver: Signal.Observer + let box: Property> + + init(renderer: Renderer) { + let (reloadSignal, reloadObserver) = Signal.pipe() + self.state = Property(initial: State.loading, + reduce: BookAppointmentViewModel.reduce, + feedbacks: [ + BookAppointmentViewModel.whenLoading(), + BookAppointmentViewModel.reload(with: reloadSignal) + ]) + self.reloadObserver = reloadObserver + self.box = state.map { return renderer.render(state: $0, onBook: reloadObserver.send(value:)) } + } + + func reload() { + reloadObserver.send(value: ()) + } + + enum State { + case loading + case loaded(Appointment) + } + + enum Event { + case reload + case loaded(Appointment) + } + + private static func reduce(state: State, event: Event) -> State { + switch event { + case let .loaded(appointment): + return State.loaded(appointment) + case .reload: + return State.loading + } + } + + private static func reload(with trigger: Signal) -> Feedback { + return Feedback { _ in + return trigger.map { Event.reload } + } + } + + private static func whenLoading() -> Feedback { + return Feedback(effects: { (state) -> SignalProducer in + guard case .loading = state else { return .empty } + return SignalProducer + .timer(interval: .milliseconds(700), on: QueueScheduler.main) + .map { date in + return Event.loaded(Appointment(consultantType: .GP, + date: date, + appointmentType: .video)) + } + }) + } + + final class Renderer { + private let patient: Patient + private let dateFormatter = DateFormatter(format: "dd/MM, HH:mm") + + init(patient: Patient) { + self.patient = patient + } + + enum SectionId { + case user + case consultantDate + case audioVideo + case symptoms + case book + } + + enum RowId { + case user + case consultant + case date + case audioVideo + case symptoms + case loading + } + + func render(state: State, onBook: @escaping () -> Void) -> Box { + switch state { + case .loading: + return renderLoading() + case let .loaded(appointment): + return render(appointment: appointment, onBook: onBook) + } + } + + private func renderLoading() -> Box { + return Box.empty + |-+ Section(id: SectionId.user, + header: EmptySpaceComponent(spec: EmptySpaceComponent.Spec(height: 20, color: .clear))) + |---+ RowId.user <> IconTitleDetailsComponent(icon: #imageLiteral(resourceName:"chuck_norris_walker"), + title: "\(patient.firstName) \(patient.lastName)", + subtitle: "") + |-+ Section(id: SectionId.consultantDate, + header: EmptySpaceComponent(spec: EmptySpaceComponent.Spec(height: 20, color: .clear))) + |---+ RowId.loading <> LoadingIndicatorComponent(isLoading: true) + } + + private func render(appointment: Appointment, onBook: @escaping () -> Void) -> Box { + return Box.empty + |-+ Section(id: SectionId.user, + header: EmptySpaceComponent(spec: EmptySpaceComponent.Spec(height: 20, color: .clear))) + |---+ RowId.user <> IconTitleDetailsComponent(icon: #imageLiteral(resourceName:"chuck_norris_walker"), + title: "\(patient.firstName) \(patient.lastName)", + subtitle: "") + |-+ Section(id: SectionId.consultantDate, + header: EmptySpaceComponent(spec: EmptySpaceComponent.Spec(height: 20, color: .clear))) + |---+ RowId.consultant <> IconTitleDetailsComponent(icon: #imageLiteral(resourceName:"consultantIcon"), + title: "Consultant type", + subtitle: render(consultantType: appointment.consultantType)) + |---+ RowId.date <> IconTitleDetailsComponent(icon: #imageLiteral(resourceName:"timeIcon"), + title: "Date & time", + subtitle: dateFormatter.string(from: appointment.date)) + |-+ Section(id: SectionId.audioVideo, + header: EmptySpaceComponent(spec: EmptySpaceComponent.Spec(height: 20, color: .clear))) + |---+ RowId.audioVideo <> SegmetControlComponent(firstIcon: #imageLiteral(resourceName:"video"), + secondIcon: #imageLiteral(resourceName:"phone"), + selectedIndex: appointment.appointmentType == .video ? 0 : 1, + onSegmentSelected: { print("Selected index", $0) }) + |-+ Section(id: SectionId.audioVideo, + header: EmptySpaceComponent(spec: EmptySpaceComponent.Spec(height: 20, color: .clear))) + |-+ Section(id: SectionId.audioVideo, + header: ButtonComponent(buttonTitle: "Book", onButtonPressed: onBook)) + } + + private func render(consultantType: ConsultantType) -> String { + switch consultantType { + case .GP: + return "GP" + case .specialist: + return "Specialist" + case .therapist: + return "Therapist" + } + } + } +} + +struct Patient { + let id: String + let firstName: String + let lastName: String +} + +struct Appointment { + let consultantType: ConsultantType + let date: Date + let appointmentType: AppointmentType +} + +enum ConsultantType { + case GP + case specialist + case therapist +} + +enum AppointmentType { + case video + case phone +} + +extension DateFormatter { + convenience init(format: String) { + self.init() + self.dateFormat = format + } +} diff --git a/Example/Cells/ButtonComponentView.swift b/Example/Cells/ButtonComponentView.swift new file mode 100644 index 0000000..522e3f2 --- /dev/null +++ b/Example/Cells/ButtonComponentView.swift @@ -0,0 +1,25 @@ +import UIKit +import Bento + +final class ButtonComponentView: UIView, NibLoadable { + @IBOutlet weak var button: UIButton! + var onButtonPressed: (() -> Void)? + @IBAction func buttonPressed(_ sender: Any) { + onButtonPressed?() + } +} + +final class ButtonComponent: Renderable { + private let buttonTitle: String + private let onBattonPressed: () -> Void + + init(buttonTitle: String, onButtonPressed: @escaping () -> Void) { + self.buttonTitle = buttonTitle + self.onBattonPressed = onButtonPressed + } + + func render(in view: ButtonComponentView) { + view.button.setTitle(buttonTitle, for: .normal) + view.onButtonPressed = onBattonPressed + } +} diff --git a/Example/Cells/ButtonComponentView.xib b/Example/Cells/ButtonComponentView.xib new file mode 100644 index 0000000..6bb3be4 --- /dev/null +++ b/Example/Cells/ButtonComponentView.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/EmptySpaceCell.swift b/Example/Cells/EmptySpaceCell.swift new file mode 100644 index 0000000..bf1f40a --- /dev/null +++ b/Example/Cells/EmptySpaceCell.swift @@ -0,0 +1,27 @@ +import UIKit +import Bento + +final class EmptySpaceCell: UIView, NibLoadable { + @IBOutlet weak var heightConstraint: NSLayoutConstraint! +} + +final class EmptySpaceComponent: Renderable { + struct Spec { + let height: CGFloat + let color: UIColor + } + private let spec: Spec + + init(spec: Spec) { + self.spec = spec + } + + func render(in view: EmptySpaceCell) { + let animation = CABasicAnimation(keyPath: "backgroundColor") + animation.fromValue = view.layer.backgroundColor + animation.toValue = spec.color.cgColor + view.heightConstraint.constant = spec.height + view.layer.add(animation, forKey: nil) + view.layer.backgroundColor = spec.color.cgColor + } +} diff --git a/Example/Cells/EmptySpaceCell.xib b/Example/Cells/EmptySpaceCell.xib new file mode 100644 index 0000000..ccaca5f --- /dev/null +++ b/Example/Cells/EmptySpaceCell.xib @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/IconTextCell.swift b/Example/Cells/IconTextCell.swift new file mode 100644 index 0000000..c555fd3 --- /dev/null +++ b/Example/Cells/IconTextCell.swift @@ -0,0 +1,23 @@ +import UIKit +import Bento + +final class IconTextCell: UIView, NibLoadable { + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var iconView: UIImageView! +} + +class IconTextComponent: Renderable { + private let title: String? + private let image: UIImage? + + init(image: UIImage? = nil, + title: String? = nil) { + self.image = image + self.title = title + } + + func render(in view: IconTextCell) { + view.titleLabel.text = title + view.iconView.image = image + } +} diff --git a/Example/Cells/IconTextCell.xib b/Example/Cells/IconTextCell.xib new file mode 100644 index 0000000..e38b253 --- /dev/null +++ b/Example/Cells/IconTextCell.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/IconTitleDetailsView.swift b/Example/Cells/IconTitleDetailsView.swift new file mode 100644 index 0000000..fcaa083 --- /dev/null +++ b/Example/Cells/IconTitleDetailsView.swift @@ -0,0 +1,31 @@ +import UIKit +import Bento + +final class IconTitleDetailsView: UIView, NibLoadable { + @IBOutlet weak var iconImageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var subtitleLabel: UILabel! + + override func layoutSubviews() { + super.layoutSubviews() + iconImageView.layer.cornerRadius = iconImageView.frame.width / 2 + } +} + +final class IconTitleDetailsComponent: Renderable { + private let icon: UIImage + private let title: String + private let subtitle: String + + init(icon: UIImage, title: String, subtitle: String) { + self.icon = icon + self.title = title + self.subtitle = subtitle + } + + func render(in view: IconTitleDetailsView) { + view.iconImageView.image = icon + view.titleLabel.text = title + view.subtitleLabel.text = subtitle + } +} diff --git a/Example/Cells/IconTitleDetailsView.xib b/Example/Cells/IconTitleDetailsView.xib new file mode 100644 index 0000000..c48ce15 --- /dev/null +++ b/Example/Cells/IconTitleDetailsView.xib @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/LoadingIndicatorView.swift b/Example/Cells/LoadingIndicatorView.swift new file mode 100644 index 0000000..0d539c4 --- /dev/null +++ b/Example/Cells/LoadingIndicatorView.swift @@ -0,0 +1,20 @@ +import UIKit +import Bento + +final class LoadingIndicatorView: UIView, NibLoadable{ + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! +} + +final class LoadingIndicatorComponent: Renderable { + private let isLoading: Bool + + init(isLoading: Bool) { + self.isLoading = isLoading + } + + func render(in view: LoadingIndicatorView) { + view.activityIndicator.hidesWhenStopped = true + (isLoading ? view.activityIndicator.startAnimating : view.activityIndicator.stopAnimating)() + } +} + diff --git a/Example/Cells/LoadingIndicatorView.xib b/Example/Cells/LoadingIndicatorView.xib new file mode 100644 index 0000000..7a80512 --- /dev/null +++ b/Example/Cells/LoadingIndicatorView.xib @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/MovieComponentView.swift b/Example/Cells/MovieComponentView.swift new file mode 100644 index 0000000..0c060a5 --- /dev/null +++ b/Example/Cells/MovieComponentView.swift @@ -0,0 +1,23 @@ +import UIKit +import Bento +import Kingfisher + +final class MovieComponent: Renderable { + private let movie: Movie + + init(movie: Movie) { + self.movie = movie + } + + func render(in view: MovieComponentView) { + view.title.text = movie.title + view.imageView.kf + .setImage(with: movie.posterURL, + options: [KingfisherOptionsInfoItem.transition(ImageTransition.fade(0.2))]) + } +} + +final class MovieComponentView: UIView, NibLoadable { + @IBOutlet weak var title: UILabel! + @IBOutlet weak var imageView: UIImageView! +} diff --git a/Example/Cells/MovieComponentView.xib b/Example/Cells/MovieComponentView.xib new file mode 100644 index 0000000..5106a9e --- /dev/null +++ b/Example/Cells/MovieComponentView.xib @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/SegmetControlView.swift b/Example/Cells/SegmetControlView.swift new file mode 100644 index 0000000..e914c47 --- /dev/null +++ b/Example/Cells/SegmetControlView.swift @@ -0,0 +1,32 @@ +import UIKit +import Bento + +final class SegmetControlView: UIView, NibLoadable { + @IBOutlet weak var segmentedControl: UISegmentedControl! + var onSegmentSelected: ((Int) -> Void)? + + @IBAction private func segmentedControlSelected() { + onSegmentSelected?(segmentedControl.selectedSegmentIndex) + } +} + +final class SegmetControlComponent: Renderable { + private let firstIcon: UIImage + private let secondIcon: UIImage + private let selectedIndex: Int + private let onSegmentSelected: (Int) -> Void + + init(firstIcon: UIImage, secondIcon: UIImage, selectedIndex: Int = 0, onSegmentSelected: @escaping (Int) -> Void) { + self.firstIcon = firstIcon + self.secondIcon = secondIcon + self.selectedIndex = selectedIndex + self.onSegmentSelected = onSegmentSelected + } + + func render(in view: SegmetControlView) { + view.segmentedControl.setImage(firstIcon, forSegmentAt: 0) + view.segmentedControl.setImage(secondIcon, forSegmentAt: 1) + view.segmentedControl.selectedSegmentIndex = selectedIndex + view.onSegmentSelected = onSegmentSelected + } +} diff --git a/Example/Cells/SegmetControlView.xib b/Example/Cells/SegmetControlView.xib new file mode 100644 index 0000000..8e8054a --- /dev/null +++ b/Example/Cells/SegmetControlView.xib @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Cells/ToggleCell.swift b/Example/Cells/ToggleCell.swift new file mode 100644 index 0000000..cda1b00 --- /dev/null +++ b/Example/Cells/ToggleCell.swift @@ -0,0 +1,46 @@ +import UIKit +import Bento + +class ToggleCell: UIView, NibLoadable { + @IBOutlet weak var iconView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var toggle: UISwitch! + + var onToggle: ((Bool) -> Void)? + + override func awakeFromNib() { + super.awakeFromNib() + toggle.addTarget(self, + action: #selector(ToggleCell.onToggleChange), + for: .valueChanged) + } + + @objc private func onToggleChange() { + onToggle?(toggle.isOn) + } +} + +class ToggleComponent: Renderable { + private let isOn: Bool + private let title: String? + private let icon: UIImage? + private let onToggle: ((Bool) -> Void)? + + init(isOn: Bool, + title: String? = nil, + icon: UIImage? = nil, + onToggle: ((Bool) -> Void)?) { + self.isOn = isOn + self.title = title + self.icon = icon + self.onToggle = onToggle + } + + func render(in view: ToggleCell) { + view.iconView.image = icon + view.titleLabel.text = title + view.onToggle = onToggle + view.toggle.setOn(isOn, animated: true) + } + +} diff --git a/Example/Cells/ToggleCell.xib b/Example/Cells/ToggleCell.xib new file mode 100644 index 0000000..ebc0189 --- /dev/null +++ b/Example/Cells/ToggleCell.xib @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Info.plist b/Example/Info.plist new file mode 100644 index 0000000..16be3b6 --- /dev/null +++ b/Example/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Example/MoviesListViewController.swift b/Example/MoviesListViewController.swift new file mode 100644 index 0000000..d56ddc0 --- /dev/null +++ b/Example/MoviesListViewController.swift @@ -0,0 +1,365 @@ +import UIKit +import ReactiveSwift +import ReactiveCocoa +import ReactiveFeedback +import Result +import Kingfisher +import Bento + +final class MoviesListViewController: UIViewController { + @IBOutlet weak var tableView: UITableView! + private lazy var viewModel = PaginationViewModel() + private let (retrySignal, retryObserver) = Signal.pipe() + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.sectionHeaderHeight = 0 + tableView.sectionFooterHeight = 0 + viewModel.box.producer.take(first: 1).startWithValues(tableView.render) + viewModel.box.producer.skip(first: 1).startWithValues { [tableView] in + tableView?.render($0, animated: false) + } + + viewModel.nearBottomBinding <~ tableView!.rac_nearBottomSignal + viewModel.retryBinding <~ retrySignal + } + + func showAlert(for error: NSError) { + let alert = UIAlertController(title: "Error", + message: error.localizedDescription, + preferredStyle: .alert) + let action = UIAlertAction(title: "Retry", style: .cancel, handler: { _ in + self.retryObserver.send(value: ()) + }) + alert.addAction(action) + present(alert, animated: true, completion: nil) + } +} + + +final class PaginationViewModel { + private let token = Lifetime.Token() + private let lifetime: Lifetime + private let state: Property + private let renderer = PaginationViewModel.Renderer() + + let box: Property> + let nearBottomBinding: BindingTarget + let retryBinding: BindingTarget + + init() { + let (nearBottomSignal, nearBottomObserver) = Signal.pipe() + let (retrySignal, retryObserver) = Signal.pipe() + + let feedbacks = [ + PaginationViewModel.whenPaging(nearBottomSignal: nearBottomSignal), + PaginationViewModel.pagingFeedback(), + PaginationViewModel.whenError(retrySignal: retrySignal), + PaginationViewModel.whenRetry() + ] + self.lifetime = Lifetime(token) + self.nearBottomBinding = BindingTarget(lifetime: lifetime, action: nearBottomObserver.send) + self.retryBinding = BindingTarget(lifetime: lifetime, action: retryObserver.send) + self.state = Property(initial: State.initial, + reduce: State.reduce, + feedbacks: feedbacks) + self.box = Property(initial: Box.empty, then: state.producer.filterMap(renderer.render)) + } + + private static func whenPaging(nearBottomSignal: Signal) -> Feedback { + return Feedback { state -> Signal in + if case .paging = state { + return .empty + } + return nearBottomSignal + .map { Event.startLoadingNextPage } + } + } + + private static func pagingFeedback() -> Feedback { + return Feedback(query: { $0.nextPage }) { (nextPage) -> SignalProducer in + URLSession.shared.fetchMovies(page: nextPage) + .map(Event.response) + .flatMapError { error in + SignalProducer(value: Event.failed(error)) + } + } + } + + private static func whenError(retrySignal: Signal) -> Feedback { + return Feedback { state -> Signal in + guard case .error = state else { return .empty } + return retrySignal.map { Event.retry } + } + } + + private static func whenRetry() -> Feedback { + return Feedback { state -> SignalProducer in + guard case .retry(let context) = state else { return .empty } + return URLSession.shared.fetchMovies(page: context.batch.page + 1) + .map(Event.response) + .flatMapError { error in + return SignalProducer(value: Event.failed(error)) + } + } + } + + struct Context { + var batch: Results + var movies: [Movie] + + static var empty: Context { + return Context(batch: Results.empty(), movies: []) + } + } + + enum State { + case initial + case paging(context: Context) + case loadedPage(context: Context) + case error(error: NSError, context: Context) + case retry(context: Context) + + var newMovies: [Movie]? { + switch self { + case .loadedPage(context:let context): + return context.movies + default: + return nil + } + } + + private var context: Context { + switch self { + case .initial: + return Context.empty + case .paging(context:let context): + return context + case .loadedPage(context:let context): + return context + case .error(error:_, context:let context): + return context + case .retry(context:let context): + return context + } + } + + var nextPage: Int? { + switch self { + case .paging(context:let context): + return context.batch.page + 1 + case .initial: + return 1 + default: + return nil + } + } + + static func reduce(state: State, event: Event) -> State { + switch event { + case .reload: + return initial + case .startLoadingNextPage: + return .paging(context: state.context) + case .response(let batch): + var copy = state.context + copy.batch = batch + copy.movies += batch.results + return .loadedPage(context: copy) + case .failed(let error): + return .error(error: error, context: state.context) + case .retry: + return .retry(context: state.context) + } + } + } + + enum Event { + case reload + case startLoadingNextPage + case response(Results) + case failed(NSError) + case retry + } + + final class Renderer { + func render(state: State) -> Box? { + switch state { + case .initial: + return renderLoading() + case .loadedPage(let context): + return render(movies: context.movies) + default: + return nil + } + } + + private func render(movies: [Movie]) -> Box { + let rows = movies.map { movie in + return RowId.movie(movie) <> MovieComponent(movie: movie) + } + return Box.empty + |-+ Section(id: SectionId.noId) + |---* rows + } + + private func renderLoading() -> Box { + return Box.empty + |-+ Section(id: SectionId.noId) + |---+ Node(id: RowId.loading, component: LoadingIndicatorComponent(isLoading: true)) + } + + enum SectionId { + case noId + } + + enum RowId: Hashable { + case loading + case movie(Movie) + + var hashValue: Int { + switch self { + case .loading: + return -1 + case .movie(let movie): + return movie.hashValue + } + } + + static func ==(lhs: RowId, rhs: RowId) -> Bool { + switch (lhs, rhs) { + case let (.movie(lhsMovie), .movie(rhsMovie)): + return lhsMovie == rhsMovie + case (.loading, .loading): + return true + default: + return false + } + } + } + + } +} + +// MARK: - ⚠️ Danger ⚠️ Boilerplate + +extension UIScrollView { + var rac_contentOffset: Signal { + return self.reactive.signal(forKeyPath: "contentOffset") + .filterMap { change in + guard let value = change as? NSValue else { + return nil + } + return value.cgPointValue + } + } + + var rac_nearBottomSignal: Signal { + func isNearBottomEdge(scrollView: UIScrollView, edgeOffset: CGFloat = 44.0) -> Bool { + return scrollView.contentOffset.y + scrollView.frame.size.height + edgeOffset > scrollView.contentSize.height + } + + return rac_contentOffset + .filterMap { _ in + if isNearBottomEdge(scrollView: self) { + return () + } + return nil + } + } +} + + +// Key for https://www.themoviedb.org API +let apiKey = "" +let correctKey = "d4f0bdb3e246e2cb3555211e765c89e3" + +struct Results: Codable { + let page: Int + let totalResults: Int + let totalPages: Int + let results: [Movie] + + static func empty() -> Results { + return Results.init(page: 0, totalResults: 0, totalPages: 0, results: []) + } + + enum CodingKeys: String, CodingKey { + case page + case totalResults = "total_results" + case totalPages = "total_pages" + case results + } +} + +struct Movie: Codable, Hashable { + let id: Int + let overview: String + let title: String + let posterPath: String? + + var posterURL: URL? { + return posterPath + .map { + "https://image.tmdb.org/t/p/w342/\($0)" + } + .flatMap(URL.init(string:)) + } + + enum CodingKeys: String, CodingKey { + case id + case overview + case title + case posterPath = "poster_path" + } + + var hashValue: Int { + return id.hashValue ^ overview.hashValue ^ title.hashValue ^ (posterPath ?? "").hashValue + } + + static func ==(lhs: Movie, rhs: Movie) -> Bool { + return lhs.id == rhs.id && + lhs.overview == rhs.overview && + lhs.title == rhs.title && + lhs.posterPath == rhs.posterPath + } +} + +var shouldFail = false + +func switchFail() { + shouldFail = !shouldFail +} + +extension URLSession { + func fetchMovies(page: Int) -> SignalProducer { + return SignalProducer.init({ (observer, lifetime) in + let url = URL(string: "https://api.themoviedb.org/3/discover/movie?api_key=\(shouldFail ? apiKey : correctKey)&sort_by=popularity.desc&page=\(page)")! +// switchFail() + let task = self.dataTask(with: url, completionHandler: { (data, response, error) in + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 { + let error = NSError(domain: "come.reactivefeedback", + code: 401, + userInfo: [NSLocalizedDescriptionKey: "Forced failure to illustrate Retry"]) + observer.send(error: error) + } else if let data = data { + do { + let results = try JSONDecoder().decode(Results.self, from: data) + observer.send(value: results) + } catch { + observer.send(error: error as NSError) + } + } else if let error = error { + observer.send(error: error as NSError) + observer.sendCompleted() + } else { + observer.sendCompleted() + } + }) + + lifetime += AnyDisposable(task.cancel) + task.resume() + }) + } +} diff --git a/Example/ViewController.swift b/Example/ViewController.swift new file mode 100644 index 0000000..dd74c79 --- /dev/null +++ b/Example/ViewController.swift @@ -0,0 +1,121 @@ +import UIKit +import Bento + +class ViewController: UIViewController { + enum State { + case airplaneMode + case wifi + } + + enum SectionId: Hashable { + case first + case second + } + + enum RowId: Hashable { + case space + case note + case toggle + } + + @IBOutlet weak var tableView: UITableView! + + private var state = State.airplaneMode { + didSet { + renderState() + } + } + + override func viewDidLoad() { + super.viewDidLoad() + setupTableView() + renderState() + } + + private func renderState() { + switch state { + case .airplaneMode: + let box = Box.empty + |-+ renderFirstSection() + |---+ renderToggle() + |---+ renderIconText() + |-+ renderSecondSection() + |---+ renderIconText() + |---+ renderToggle() + + tableView.render(box) + case .wifi: + let box = Box.empty + |-+ renderFirstSection() + |---+ renderIconText() + |---+ renderToggle() + |-+ renderSecondSection() + |---+ renderToggle() + |---+ renderIconText() + + tableView.render(box) + } + } + + private func setupTableView() { + tableView.estimatedSectionFooterHeight = 18 + tableView.estimatedSectionHeaderHeight = 18 + tableView.sectionHeaderHeight = UITableViewAutomaticDimension + tableView.sectionFooterHeight = UITableViewAutomaticDimension + } + + private func renderFirstSection() -> Section { + switch state { + case .airplaneMode: + let headerSpec = EmptySpaceComponent.Spec(height: 20, color: .black) + let footerSpec = EmptySpaceComponent.Spec(height: 20, color: .cyan) + let headerComponent = EmptySpaceComponent(spec: headerSpec) + let footerComponent = EmptySpaceComponent(spec: footerSpec) + + return Section(id: SectionId.first, header: headerComponent, footer: footerComponent) + case .wifi: + let footerSpec = EmptySpaceComponent.Spec(height: 100, color: .green) + let footerComponent = EmptySpaceComponent(spec: footerSpec) + return Section(id: SectionId.first, footer: footerComponent) + } + } + + private func renderSecondSection() -> Section { + switch state { + case .airplaneMode: + let headerSpec = EmptySpaceComponent.Spec(height: 20, color: .orange) + let footerSpec = EmptySpaceComponent.Spec(height: 20, color: .yellow) + let headerComponent = EmptySpaceComponent(spec: headerSpec) + let footerComponent = EmptySpaceComponent(spec: footerSpec) + + return Section(id: SectionId.first, header: headerComponent, footer: footerComponent) + case .wifi: + let headerSpec = EmptySpaceComponent.Spec(height: 30, color: .purple) + let headerComponent = EmptySpaceComponent(spec: headerSpec) + return Section(id: SectionId.first, header: headerComponent) + } + } + + private func renderToggle() -> Node { + let component = ToggleComponent(isOn: self.state == .airplaneMode, + title: "Airplane mode", + icon: #imageLiteral(resourceName:"plane"), + onToggle: { isOn in + if isOn { + self.state = State.airplaneMode + } else { + self.state = State.wifi + } + }) + return RowId.toggle <> component + } + + private func renderIconText() -> Node { + switch state { + case .airplaneMode: + return RowId.note <> IconTextComponent(image: #imageLiteral(resourceName: "wifi"), title: "WIFI Off") + case .wifi: + return RowId.note <> IconTextComponent(image: #imageLiteral(resourceName: "wifi"), title: "WIFI On") + } + } +} diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..14e1439 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'xcpretty' \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24111f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Babylon Partners Limited + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..c4e03d1 --- /dev/null +++ b/Podfile @@ -0,0 +1,16 @@ +use_frameworks! + +pod "FlexibleDiff", "= 0.0.5" + +target 'Example' do + pod 'ReactiveFeedback' + pod 'ReactiveCocoa' + pod 'Kingfisher' +end + +target 'Bento' do +end + +target 'BentoTests' do + pod 'Nimble' +end diff --git a/README.md b/README.md new file mode 100644 index 0000000..5226a0c --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ + +# [Bento](https://en.wikipedia.org/wiki/Bento) 🍱 弁当 + +> #### is a single-portion take-out or home-packed meal common in Japanese cuisine. A traditional bento holds rice or noodles, fish or meat, with pickled and cooked vegetables, in a box. + +**Bento** is a Swift library for building component-based interfaces on top of `UITableView`. + +- **Declarative:** provides a painless approach for building `UITableView` interfaces +- **Diffing:** reloads your UI with beautiful animations when your data changes +- **Component-based:** Design reusable components and share your custom UI across multiple screens of your app + +In our experience it makes UI-related code easier to build and maintain. Our aim is to make the UI a function of state (`UI = f(state)`), which makes `Bento` a perfect fit for Reactive Programming. + +## Content 📋 + +- [What's it like?](#whats-it-like) +- [How does it work?](#how-does-it-work) +- [How do components look?](#how-do-components-look) +- [Samples](#samples) +- [Installation](#installation) +- [State of the project](#state-of-the-project) +- [Contribute](#contribute) + +### What's it like? 🧐 + +When building a `Box`, all you need to care about are `Sections`s and `Node`s. + +```swift +let box = Box.empty + |-+ Section(id: SectionId.user, + header: EmptySpaceComponent(height: 24, color: .clear)) + |---+ RowId.user <> IconTitleDetailsComponent(icon: image, title: patient.name) + |-+ Section(id: SectionId.consultantDate, + header: EmptySpaceComponent(height: 24, color: .clear)) + |---+ RowId.loading <> LoadingIndicatorComponent(isLoading: true) + +tableView.render(box) +``` + +### How does it work? 🤔 + +#### Box 📦 + +`Box ` is a fundamental component of the library, essentially a virtual representation of the `UITableView` content. It has two generic parameters - `SectionId` and `RowId` - which are unique identifiers for `Section` and `Node`, used by the [diffing engine](https://github.com/RACCommunity/FlexibleDiff) to perform animated changes of the `UITableView` content. + +#### Sections and Nodes 🏗 + +A `Section` and a `Node` are building blocks of the `Box`: + +- The `Section` is an abstraction of `UITableView`'s section, which defines whether there is going to be any header or footer. +- The `Node` is an abstraction of `UITableView`'s row, it defines how it's going be rendered. + +```swift +struct Section { + let id: SectionId + let header: AnyRenderable? + let footer: AnyRenderable? + let rows: [Node] +} + +public struct Node { + let id: Identifier + let component: AnyRenderable +} +``` + + +#### Identity 🎫 +Identity is one of the key concepts, which is used by the diffing algorithm to perform changes. + + > For general business concerns, full inequality of two instances does not necessarily mean inequality in terms of identity — it just means the data being held has changed if the identity of both instances is the same. + + (More info [here](https://github.com/RACCommunity/FlexibleDiff).) + +There are `SectionId` and `RowId` which define identity of the `Section` and the `Row` respectively. + +#### Renderable 🖼 + +`Renderable` is similar to [React](https://github.com/facebook/react)'s [Component](https://reactjs.org/docs/react-component.html)s. It's an abstraction of the real `UITableViewCell` that is going to be displayed. The idea is to make it possible to develop small independent components that can be reused across many parts of your app. + +```swift +public protocol Renderable: class { + associatedtype View: UIView + + func render(in view: View) +} + +class IconTextComponent: Renderable { + private let title: String + private let image: UIImage + + init(image: UIImage, + title: String) { + self.image = image + self.title = title + } + + func render(in view: IconTextCell) { + view.titleLabel.text = title + view.iconView.image = image + } +} +``` + +#### Bento's arithmetics 💡 + +There are several custom operators that provide syntax sugar to make it easier to build `Bento`s: + +```swift +precedencegroup ComposingPrecedence { + associativity: left + higherThan: NodeConcatenationPrecedence +} + +precedencegroup NodeConcatenationPrecedence { + associativity: left + higherThan: SectionConcatenationPrecedence +} + +precedencegroup SectionConcatenationPrecedence { + associativity: left + higherThan: AdditionPrecedence +} + +infix operator <>: ComposingPrecedence +infix operator |-+: SectionConcatenationPrecedence +infix operator |--+: NodeConcatenationPrecedence + +let bento = Box.empty // 3 + |-+ Section() // 2 + |---+ RowId.id <> Component() // 1 +``` + +As you can see, `<>` has a BitwiseShiftPrecedence, `|---+` has a `NodeConcatenationPrecedence `, which is higher then `|-+`, `SectionConcatenationPrecedence`, which means that Nodes will be computed first. The order of the expression above is: + +1. `RowId.id <> Component()` => `Node` +2. `Section() |---+ Node()` => `Section` +3. `Box() |-+ Section()` => `Box` + +### Examples 😎 + +Sections | Appoitment | Movies +--- | --- | --- +![](Resources/example1.gif) | ![](Resources/example2.gif) | ![](Resources/example3.gif) + +### Installation 💾 + +* Cocopods + +```ruby +target 'MyApp' do + pod 'Bento' +end +``` +* Carthage (TODO) + + +### State of the project 🤷‍♂️ + +Feature | Status +--- | --- +`UITableView` | ✅ +`UICollectionView` | ❌ +Free functions as alternative to the operators | ❌ + +### Contributing ✍️ + +Contributions are very welcome and highly appreciated! ❤️ + +How to contribute: + +- If you have any questions feel free to create an issue with a `question` label; +- If you have a feature request create an issue with a `Feature request` label; +- If you found a bug feel free to create an issue with a `bug` label or open a PR with a fix. diff --git a/Resources/example1.gif b/Resources/example1.gif new file mode 100644 index 0000000..becec8a Binary files /dev/null and b/Resources/example1.gif differ diff --git a/Resources/example2.gif b/Resources/example2.gif new file mode 100644 index 0000000..d2651e1 Binary files /dev/null and b/Resources/example2.gif differ diff --git a/Resources/example3.gif b/Resources/example3.gif new file mode 100644 index 0000000..764b5a9 Binary files /dev/null and b/Resources/example3.gif differ diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..0f8ce10 --- /dev/null +++ b/circle.yml @@ -0,0 +1,9 @@ +machine: + xcode: + version: "9.2" +test: + override: + - curl https://cocoapods-specs.circleci.com/fetch-cocoapods-repo-from-s3.sh | bash -s cf + - pod install --verbose + - script/test iphonesimulator "platform=iOS Simulator,name=iPhone 6,OS=11.2" Bento + - script/test iphonesimulator "platform=iOS Simulator,name=iPhone 6,OS=11.2" Example build diff --git a/script/test b/script/test new file mode 100755 index 0000000..f3f97fe --- /dev/null +++ b/script/test @@ -0,0 +1,29 @@ +#!/bin/bash + +if [[ "$1" == "macosx" ]]; then + XCODE_ACTION="build" +fi + +if [[ "$1" == "iphonesimulator" || "$1" == "appletvsimulator" ]]; then + XCODE_ACTION="build-for-testing test-without-building" +fi + +if [[ -n "$4" ]]; then + XCODE_ACTION="$4" +fi + +set -o pipefail +xcodebuild $XCODE_ACTION \ + -workspace Example.xcworkspace \ + -scheme "$3" \ + -sdk "$1" \ + -destination "$2" \ + -configuration Release \ + ENABLE_TESTABILITY=YES \ + GCC_GENERATE_DEBUGGING_SYMBOLS=NO \ + RUN_CLANG_STATIC_ANALYZER=NO | bundle exec xcpretty +result=$? + +if [ "$result" -ne 0 ]; then + exit $result +fi \ No newline at end of file