A declarative library for building component-based user interfaces

Carbon

A declarative library for building component-based user interfaces in UITableView and UICollectionView.

Carbon is a library for building component-based user interfaces in UITableView and UICollectionView inspired by React.
This make it painless to build and maintain the complex UIs.

Uses DifferenceKit which is highly optimized based on Paul Heckel's paper for diffing.
Declarative design and diffing algorithm make your code more predictable, debugging easier and providing beautiful animations to users.

Our goal is similar to IGListKit and Epoxy, we respect those library as pioneers.

Examples

renderer.render(
    Section(
        id: ID.greet,
        header: ViewNode(Header(title: "GREET")),
        cells: [
            CellNode(HelloMessage(name: "Vincent")),
            CellNode(HelloMessage(name: "Jules")),
            CellNode(HelloMessage(name: "Butch"))
        ],
        footer: ViewNode(Footer(text: "? Tap anywhere"))
    )
)


Getting Started

Build for Development

$ git clone https://github.com/ra1028/Carbon.git
$ cd Carbon/
$ make setup
$ open Carbon.xcworkspace

Basic Usage

Described here are the fundamentals for building list UIs with Carbon.
The API document will help you understand the details of each type.
For more advanced usage, see the Advanced Guide.
And the more practical examples are here.

Component

Component is the base unit of the UI in Carbon.
All elements are made up of components, and it can be animated by diffing update.
UIView, UIViewController, and its subclasses are laid out with edge constraints by default. Other classes can also be rendered as Content by implementing layout function to component.

The value returned by referenceSize is used as the size of component on the list UI. Note that UITableView ignores width.
If returning nil, it falls back to default such as UITableView.rowHeight or UICollectionViewFlowLayout.itemSize.
Therefore, automatic sizing can also be used.

Definition below is the simplest implementation. It's not mandatory to conform to Equatable.
If the component have values that can't equality comparison ​​such as closure, it can explicitly shift off the comparisons by implementing shouldContentUpdate.

struct HelloMessage: Component, Equatable {
    var name: String

    func renderContent() -> UILabel {
        return UILabel()
    }

    func render(in content: UILabel) {
        content.text = "Hello \(name)"
    }

    func referenceSize(in bounds: CGRect) -> CGSize? {
        return CGSize(width: bounds.width, height: 44)
    }
}

ViewNode

This is a node representing header or footer.
The node is wrap an instance of type conforming to Component protocol and works as an intermediary with DifferenceKit for diffing.

ViewNode(HelloMessage(name: "Vincent"))

CellNode

CellNode is a node representing cell.
Unlike in the ViewNode, this needs an id which Hashable type to identify from among a lot of cells.
The id is used to find the same component in the list data before and after changed, then calculate the following kind of diff.

  • deletes
  • inserts
  • moves
  • updates
CellNode(id: 0, HelloMessage(name: "Jules"))

The id can be predefined if conforming to IdentifiableComponent.

CellNode(HelloMessage(name: "Jules"))

Section

Section has a header, a footer and a group of cells.
A group of cells can be contains nil, then skipped rendering of it cell.
This also needs to specify id for identify from among multiple sections, then can be calculate the several kind of diff.

  • section deletes
  • section inserts
  • section moves
  • section updates
let emptySection = Section(id: 0)
let hidesHelloMia: Bool = ...

let section = Section(
    id: "hello",
    header: ViewNode(HelloMessage(name: "Vincent")),
    cells: [
        CellNode(HelloMessage(name: "Jules")),
        CellNode(HelloMessage(name: "Butch")),
        hidesHelloMia ? nil : CellNode(HelloMessage(name: "Mia"))
    ],
    footer: ViewNode(HelloMessage(name: "Marsellus"))
)

Renderer

The components are displayed on top of list UI by render via Renderer.
Boilerplates such as registering element types to a table view are no longer needed in Carbon.

The adapter acts as delegate and dataSource, the updater handles updates.
You can also change the behaviors by inheriting the class and customizing it.
There are also UITableViewReloadDataUpdater andUICollectionViewReloadDataUpdater which update by reloadData without diffing update.
Note that it doesn't retain target inside Renderer because it avoids circular references.

When render called again, the updater calculates the diff from currently rendering components and update them with system animation.
Since there are several style syntaxes for passing group of sections, please check the API docs.

@IBOutlet var tableView: UITableView!

lazy var renderer = Renderer(
    target: tableView,
    adapter: UITableViewAdapter(),
    updater: UITableViewUpdater()
)
@IBOutlet var collectionView: UICollectionView!

lazy var renderer = Renderer(
    target: collectionView,
    adapter: UICollectionViewFlowLayoutAdapter(),
    updater: UICollectionViewUpdater()
)
let hidesBottomSection: Bool = ...

renderer.render(
    Section(
        id: "top section",
        cells: [
            CellNode(HelloMessage(name: "Vincent")),
            CellNode(HelloMessage(name: "Jules")),
            CellNode(HelloMessage(name: "Butch"))
        ]
    ),
    hidesBottomSection ? nil : Section(
        id: "bottom section",
        header: ViewNode(HelloMessage(name: "Pumpkin")),
        cells: [
            CellNode(HelloMessage(name: "Marsellus")),
            CellNode(HelloMessage(name: "Mia"))
        ],
        footer: ViewNode(HelloMessage(name: "Honey Bunny"))
    )
)

Advanced Guide

Custom Content

Of course, the content of component can use custom class. You can also instantiate it from Xib.
It can be inherited whichever class, but the common means is inherit UIView or UIViewController.

class HelloMessageContent: UIView {
    @IBOutlet var label: UILabel!
}
struct HelloMessage: Component, Equatable {
    var name: String

    func renderContent() -> HelloMessageContent {
        return HelloMessageContent.loadFromNib()  // Extension for instantiate from Xib. Not in Carbon.
    }

    func render(in content: HelloMessageContent) {
        content.label.text = "Hello \(name)"
    }

    ...

IdentifiableComponent

IdentifiableComponent is a component that simply can predefine an identifier.
It can be omitted the definition of id if the component conforms to Hashable.

struct HelloMessage: IdentifiableComponent, Equatable {
    var name: String

    var id: String {
        return name
    }

    ...

Selection

Cell selection can be handled by setting didSelect to the instance of UITableViewAdapter or UICollectionViewAdapter.

adapter.didSelect { context in
    print(context.tableView)
    print(context.node)
    print(context.indexPath)
}

However, we recommend to make the Content of the component to the class inherited from UIControl.
It's more maintainable and extensible.

class MenuItemContent: UIControl {
    @IBOutlet var label: UILabel!

    var onSelect: (() -> Void)?

    @objc func handleSelect() {
        onSelect?()
    }

    override func awakeFromNib() {
        super.awakeFromNib()

        addTarget(self, action: #selector(handleSelect), for: .touchUpInside)
    }
}

struct MenuItem: Component {
    var text: String
    var onSelect: () -> Void

    func renderContent() -> MenuItemContent {
        return MenuItemContent.loadFromNib()
    }

    func render(in content: MenuItemContent) {
        content.label.text = text
        content.onSelect = onSelect
    }

    func shouldContentUpdate(with next: MenuItem) -> Bool {
        return text != next.text
    }

    func referenceSize(in bounds: CGRect) -> CGSize? {
        return CGSize(width: bounds.width, height: 44)
    }
}

In this way, in order to cancel the selection by scrolling, you need to implement the following extension.

extension UITableView {
    open override func touchesShouldCancel(in view: UIView) -> Bool {
        return true
    }
}

extension UICollectionView {
    open override func touchesShouldCancel(in view: UIView) -> Bool {
        return true
    }
}

GitHub