StreamBaseKit is a Swift UI toolkit for Firebase. It surfaces Firebase queries as streams that are synched with Firebase in real time, fetched incrementally, and can be merged or split into multiple sections. These streams can be easily plugged into UI elements like table views.
This kit also includes a persistence layer that makes it easy to persist objects in Firebase.
Using Cocoapods, add to your Podfile
:
pod 'StreamBaseKit', '~> 0.1'
And, if you don't have it already:
platform :ios, '8.0'
use_frameworks!
If you don't have one already, sign up for a Firebase account.
To use streams, you'll want to know about these classes:
Class | Description |
---|---|
StreamBase | This is the main class that exposes a Firebase query as a Stream. |
StreamBaseItem | Base class for objects that appear in streams. |
StreamBaseDelegate | Delegate that is notified of stream changes. |
StreamTableViewAdapter | Adapter from streams to UITableViews. |
PartitionedStream | Split a stream into multiple sections. |
TransientStream | Stream that's not connected to Firebase. |
UnionStream | Stream for merging multiple streams. |
QueryBuilder | Helper for composing Firebase queries. |
To get started, you'll need to build from StreamBaseItem and StreamBase. Additionally, StreamTableViewAdapter provides some convenient functionality to connect streams with tables. Here's the basic outline:
MyItem.swift
class MyItem : StreamBaseItem {
var name: String?
func update(dict: [String: AnyObject) {
super.update(dict)
name = dict["name"] as? String
}
var dict: [String: AnyObject] {
var d = super.dict
d["name"] = name
return d
}
}
MyViewController.swift
class MyViewController : UIViewController {
var stream: StreamBase!
var adapter: StreamTableViewAdapter!
// etc...
override func viewDidLoad() {
// etc...
let firebaseRef = Firebase(url:"https://<YOUR-FIREBASE-APP>.firebaseio.com/")
stream = StreamBase(type: MyItem.self, ref: firebaseRef)
adapter = StreamTableViewAdapter(tableView: tableView)
stream.delegate = adapter
}
}
extension MyViewController : UITableViewDataSource {
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return stream.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("MyCell", forIndexPath: indexPath)
let item = stream[indexPath.row] as! MyItem
cell.titleLabel?.text = item.name
return cell
}
}
PartitionedStream allows you to define a partition function for splitting a stream into mulitple sections. This is useful, eg, in splitting a roster of users into organizers and participants. Building off of the previous example:
User.swift
class User : StreamBaseItem {
var isOrganizer: Bool
// etc...
}
MyViewController.swift
class MyViewController : UIViewController {
var pstream: PartitionedStream!
// etc...
override func viewDidLoad() {
// etc...
pstream = PartitionedStream(stream: stream, sectionTitles: ["organizers", "participants"]) { ($0 as! User).isOrganizer ? 0 : 1 }
pstream.delegate = adapter
}
}
extension MyViewController : UITableViewDataSource {
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return pstream[section].count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("MyCell", forIndexPath: indexPath)
let item = pstream[indexPath] as! User // Index partitioned stream with whole NSIndexPath
cell.titleLabel?.text = item.name
return cell
}
func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return pstream.sectionTitles[section]
}
}
PartitionedStream is convenient to use, but if the underlying data has hundreds or more elements you'll need to provide limits per section. You can do this by constructing multiple sections like this:
class MyViewController : UIViewController {
var organizerStream: StreamBase!
var participantStream: StreamBase!
var organizerAdapter: StreamTableViewAdapter!
var participantAdapter: StreamTableViewAdapter!
// etc...
override func viewDidLoad() {
// etc...
let organizerQuery = QueryBuilder(ref)
organizerQuery.limit = 100
organizerQuery.ordering = .Child("is_organizer")
organizerQuery.start = true
organizerStream = StreamBase(type: User.self, queryBuilder: organizerQuery)
organizerAdapter = StreamTableViewAdapter(tableView: tableView, section: 0)
let participantQuery = QueryBuilder(ref)
participantQuery.limit = 100
participantQuery.ordering = .Child("is_organizer")
participantQuery.end = false
participantStream = StreamBase(type: User.self, queryBuilder: participantQuery)
participantAdapter = StreamTableViewAdapter(tableView: tableView, section: 1)
}
}
extension MyViewController : UITableViewDataSource {
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return (section == 0) ? organizerStream.count : participantStream.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("MyCell", forIndexPath: indexPath)
let user: User
if indexPath.section == 0 {
user = organizerStream[indexPath.row] as! User
} else {
user = participantStream[indexPath.row] as! User
}
// etc...
return cell
}
}
Chat histories can grow long, so it's important to enable them to be incrementally fetched. To do this, we need to be able to insert temporary placeholders into the table to provide a ui for the fetch, and to actually perform the additional fetch. There are further details (such as knowing the actual size of the stream!), but this is a rough sketch of how this would work:
class MyViewController : UIViewController {
let maxToFetch = 100
var stream: StreamBase!
var unionStream: UnionStream!
var transientStream: TransientStream!
var fetchmore: FetchMoreItem?
override func viewDidLoad() {
// etc...
stream = StreamBase(type: MyItem.self, ref: firebaseRef, ascending: false, limit: maxToFetch)
transient = TransientStream()
unionStream = UnionStream(sources: stream, transient)
unionStream.delegate = self
}
func fetchMoreTapped(sender: UIButton) {
stream.fetchMore(maxToFetch, start: stream[0].key!) {
transient.remove(fetchmore)
fetchmore = nil
}
}
}
Here we extend the StreamBaseDelegate rather than use the StreamTableViewAdapter because we need to manipulate state in the controller.
extension MyViewController : StreamBaseDelegate {
override func streamDidFinishInitialLoad(error: NSError?) {
super.streamDidFinishInitialLoad(error)
if stream.count > maxToFetch {
fetchmore = FetchMoreItem(key: dropLast(stream[0].key))
transient.add(fetchmore)
}
}
}
StreamBaseKit also includes a persistence layer that uses a declarative approach: you state where something is stored, and the layer takes care of the rest. For example,
registry.resource(Group.self, "/group/@")
registry.resource(GroupMessage.self, "/group_message/$group/@")
This states that groups are stored under "/group", and messages, which are logically contained in groups, under "/group_message". (Recall that it's not a good practice to store them under "/group", say in "/group/$group/message/@", because fetches of the group would also fetch all of the messages.)
The "@" means that an auto-id is generated for create operations, and the object's key is used for updates and destroys. The "$" means that the value must be looked up using a ResourceContext... more on that below.
To use the persitence layer, you'll want to know about these classes:
Class | Description |
---|---|
ResourceBase | Core of persistence layer. |
ResourceContext | Helper for managing context in persistence layer. |
ResourceRegistry | Protocol for registering resources. |
You register resources with an the ResourceBase using the ResourceRegistry protocol. One approach is to put the ResourceBase in a singleton. For example:
Environment.swift
class Environment {
var resourceBase: ResourceBase!
static let sharedEnv: Environment = {
let env = Environment()
let firebase = Firebase(url: "https://<YOUR-FIREBASE-APP>.firebaseio.com")
env.resourceBase = ResourceBase(firebase: firebase)
let registry: ResourceRegistry = env.resourceBase
registry.resource(Group.self, "/group/@")
registry.resource(GroupMessage.self, "/group_message/$group/@")
// ...
return env
}()
}
In the initial view controller, use the Environment singleton to create the root resource context:
InitialViewController.swift
class InitialViewController : UIViewController {
var rootResourceContext: ResourceContext!
// ...
override func viewDidLoad() {
super.viewDidLoad()
rootResourceContext = ResourceContext(base: Environment.sharedEnv.resourceBase, resources: nil)
// ...
Now, this view controller can now create, update or delete Groups using the root resource context. For example:
let group = Group()
group.name = "group name"
rootResourceContext.create(group)
Recall that "$group" in "/group_message/$group/@" indicates a context key which must be filled in in order to persist a GroupMessage. The ResourceContext is responsible for doing this. Say you had a GroupViewController which allows users to message groups. In your initial view controller, before pushing the group view controller onto your navigation controller, you'd do something like this:
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
switch(segue.destinationViewController) {
case let groupVC as GroupViewController:
groupVC.resourceContext = resourceContext.push(["group": sender as! Group])
// ...
Now, when you call resourceContext.create(GroupMessage())
in GroupViewController it will know how to resolve the key "$group". Similarly, if you went deeper and could "like" messages in groups, you could do that by pushing yet another ResourceContext onto the stack transiently. That might look like:
func messageLikeTouched(sender: MessageLikeControl) {
resourceContext.push(["message": sender.message]).create(MessageLike())
}
It's also possible to specify counters which get incremented or decremented when objects are created or destroyed. Say you wanted to keep track of how many messages were in your group. You'd register a counter like:
registry.counter(Group.self, "message_count", GroupMessage.self)
The ResourceBase will take care of incrementing and decrementing the "message_count" when messages are created and destroyed. Since groups are registered under "/group/@", this counter will appear here: "/group/$group_key/message_count".
Note that this counter is maintained client-side, and so can become inconsistent over time. For example, Firebase transactions are not persisted across app restarts, so if the user makes changes while offline, and then closes the app, counters may not be updated.
ResourceBase has a number of hooks for subclasses to use when extending it. There are hooks for create, update and destroy that are invoked before, after commit to local storage, and after commit to remote storage. There is also a hook for logging so a server can handle side effects.
A simple example project is included. To build it:
$ git clone https://github.com/movem3nt/StreamBaseKit.git
$ cd StreamBaseKit/StreamBaseExample
$ pod install
$ open StreamBaseExample.xcworkspace
Set the active scheme to StreamBaseExample, and then hit command-R.
An alternative library to consider is FirebaseUI-iOS. It is the official client library for Firebase. It's written in Objective-C instead of Swift, and is simpler.
StreamBaseKit grew out of building Movem3nt, a complex social application, and addresses a variety of problems encountered in doing so. For example, iOS table views will auto-scroll if content is inserted on top, but Firebase appends new data to the bottom. To make these work well together for messaging-type apps, one needs to invert both the firebase collection and the table view.
StreamBaseKit also makes it easy to add more advanced functionality like splitting a collection into multiple table sections, and inserting transient content into the table like a "fetch more" control for incremental fetching.
The resource layer makes it much to keep your database persistence logic and ui view controller logic separate. It also provides the convenient counter feature.
Another consideration is that Firebase also has a Android UI library. StreamBaseKit does not (yet).