This guide aims to show you how to start working with Spots
. It will run through building a tiny application that displays a list of contacts, first as a normal UITableView
and then we will gradually improve the application by adding an additional component to display information that we would deem more important and have that be displayed using an horizontal UICollectionView
.
This is what we will end up with at the end of the guide:
Let's build a small demo application called MyContacts
to show what it is like to work with Spots
in an application.
The first thing that we need to do is adapt and register your views so that Spots
can resolve them.
To use views in Spots
, they need to conform to ItemConfigurable
. It is a very lean protocol that has two required methods. The first is configure(with item: Item)
, this is where our view gets the model information so that we can properly set texts to your label etc. The second function is computeSize(for item: Item, containerSize: CGSize) -> CGSize
. This is used to give back an appropriate size for the view. It could return a static value or be computed based of the content coming from the model.
Let's kick things of by making a ContactView
that will be used to show contact information in a list.
import UIKit
import Spots
class ContactView: UIView, ItemConfigurable {
lazy var titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(titleLabel)
setupConstraints()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupConstraints() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.heightAnchor.constraint(equalToConstant: 32).isActive = true
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16).isActive = true
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16).isActive = true
}
func configure(with item: Item) {
titleLabel.text = item.title
}
func computeSize(for item: Item, containerSize: CGSize) -> CGSize {
return CGSize(width: containerSize.width,
height: 44)
}
}
Next up is to register the view in Spots
so that it can be resolved and used when the Component
will render its data. This is done early on in the applications life-cycle to ensure that all views are properly registered before trying to use them inside of a Component
. This is really easy, you pass the views type and an identifier to Configuration
and that's it.
Configuration.register(view: ContactView.self, identifier: "Contact")
You can also register a default view that will be used if the current identifier cannot be resolved. This is how we would register ContactView
as the default in our application.
Configuration.registerDefault(view: ContactView.self)
We now have to create some model data for our Component
.
All components in Spots
share the same data model, it is called ComponentModel
. The ComponentModel
is used as the data source for any UI object that will be used inside of Spots
. Let's create a small component model to be used in our application.
let model = ComponentModel(kind: .list, items: [
Item(title: "Sigvart Angel Hoel", kind: "Contact"),
Item(title: "Mathias Benjaminsen", kind: "Contact"),
Item(title: "Vasiliy Ermolovich", kind: "Contact"),
Item(title: "Felipe Espinoza", kind: "Contact"),
Item(title: "Epsen Høgbakk", kind: "Contact"),
Item(title: "Tim Kurvers", kind: "Contact"),
Item(title: "Damian Lopata", kind: "Contact"),
Item(title: "Sindre Moen", kind: "Contact"),
Item(title: "Torgeir Øverland", kind: "Contact"),
Item(title: "Francesco Rodriguez", kind: "Contact"),
Item(title: "Henriette Røseth", kind: "Contact"),
Item(title: "Peter Sergeev", kind: "Contact"),
Item(title: "John Terje Sirevåg", kind: "Contact"),
Item(title: "Chang Xiangzhong", kind: "Contact")
])
One interesting thing to point out is the kind
. In our case we want a simple UITableView
, in Spots
they are referred to as lists. kind
can also be grid
or carousel
. When using grid
, a UICollectionView
will be used to render the component. The same goes for .carousel
, but as the name implies, the user interaction and layout will be horizontal when using .carousel
instead of .grid
. Note that the views registered on Spots
are not required to inherit from UICollectionViewCell
or UITableViewCell
. Internally they will be wrapped in either ListWrapper
or GridWrapper
.
We are almost there now, what we need next is a Component
to hold the model. Because of Component
's polymorphic nature, there is only one Component
class.
During Component
's initialization, it will either create a UICollectionView
or UITableView
as its view.
let component = Component(model: model)
That's all you need to do to create a Component
with a UITableView
as its rendering foundation.
Next up is to create a controller that we can display on screen. Introducing the SpotsController
. It is very much like a normal UIViewController
as it doesn't really know what it is displaying, that is up to the Component
to decide based of the model information given to it by ComponentModel
.
let controller = SpotsController(components: [component])
Because SpotsController
is just like any other view controller, you display it in the exact same way as you would any other controller that you have in your application.
Let's wrap it in a UINavigation
controller so that we get a nice navigation bar at the top.
This is what our ApplicationDelegate
ended up looking like.
import UIKit
import Spots
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
Configuration.register(view: ContactView.self, identifier: "Contact")
Configuration.registerDefault(view: ContactView.self)
let window = UIWindow(frame: UIScreen.main.bounds)
let model = ComponentModel(kind: .list, items: [
Item(title: "Sigvart Angel Hoel", kind: "Contact"),
Item(title: "Mathias Benjaminsen", kind: "Contact"),
Item(title: "Vasiliy Ermolovich", kind: "Contact"),
Item(title: "Felipe Espinoza", kind: "Contact"),
Item(title: "Epsen Høgbakk", kind: "Contact"),
Item(title: "Tim Kurvers", kind: "Contact"),
Item(title: "Damian Lopata", kind: "Contact"),
Item(title: "Sindre Moen", kind: "Contact"),
Item(title: "Torgeir Øverland", kind: "Contact"),
Item(title: "Francesco Rodriguez", kind: "Contact"),
Item(title: "Henriette Røseth", kind: "Contact"),
Item(title: "Peter Sergeev", kind: "Contact"),
Item(title: "John Terje Sirevåg", kind: "Contact"),
Item(title: "Chang Xiangzhong", kind: "Contact")
])
let component = Component(model: model)
let controller = SpotsController(components: [component])
controller.title = "My contacts"
let navigationController = UINavigationController(rootViewController: controller)
window.backgroundColor = UIColor.white
window.rootViewController = navigationController
window.makeKeyAndVisible()
self.window = window
return true
}
}
If we run the application, this is what we end up with.
Right now the application doesn't really look like much, so let's add another component into the mix to see how that can improve on how the application looks and feels. If this were a real application, it would be nice to get a quick overview of contacts that were recently used. Let's add a carousel component at the top to see what that would look like.
Let's start by repeating some of our initial steps, so let's create a new view called RecentContactView
and register it on Spots
.
import UIKit
import Spots
class RecentContactView: UIView, ItemConfigurable {
lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor.darkGray
label.numberOfLines = 2
label.textAlignment = .center
label.backgroundColor = .lightGray
label.layer.cornerRadius = 4
label.layer.masksToBounds = true
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(titleLabel)
setupConstraints()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupConstraints() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.heightAnchor.constraint(equalToConstant: 66).isActive = true
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
}
func configure(with item: Item) {
titleLabel.text = item.title
}
func computeSize(for item: Item, containerSize: CGSize) -> CGSize {
return CGSize(width: containerSize.width,
height: 77)
}
}
As you can see, this view looks very similar to the ContactView
. More work was put into the styling of the title label and it returns a different size. Let's go ahead and register this view on Spots
.
Configuration.register(view: RecentContactView.self, identifier: "Recent")
Now we are ready to create a new ComponentModel
.
let recentModel = ComponentModel(
kind: .carousel,
layout: Layout(span: 3.5, itemSpacing: 5, inset: Inset(padding: 5)),
items: [
Item(title: "Francesco Rodriguez", kind: "Recent"),
Item(title: "Sindre Moen", kind: "Recent"),
Item(title: "Sigvart Angel Hoel", kind: "Recent"),
Item(title: "Torgeir Øverland", kind: "Recent"),
])
As you can see, we have added some additional properties to the model to configure it in the way that we want. To be more precise, we have giving the ComponentModel
a layout property. Because this component is being displayed as a carousel it would be nice to indicate that there is more information available than is visible on screen. This is easily achieved by using the span
property on Layout
. What span
does is to divide the Component
's parent views width by the span
and uses that width for all items in the carousel. So what we are actually expressing here is that we would like the carousel to show three and a half items on screen. We also add a bit of spacing between the items by setting itemSpacing
to 5. This translates into minimumInteritemSpacing
on UICollectionView
. And last but not least we add some spacing around the items by giving the layout some insets. The Inset
object has values for all directions (top, left , bottom right). As you might have guessed, setting them will add additional padding in the direction that you want. Here we use a convenience initializer with the label padding
, that will apply the same padding to all sides.
We are now ready to create an additional component for the new model.
let recentComponent = Component(model: recentModel)
And to add the Component
to the controller we need to include it in the collection of components in the controllers initializer.
let controller = SpotsController(components: [recentComponent, contactsComponent])
Because we want recentComponent
to be displayed before the contactsComponent
, we simply just add it at the top and that is how it will be displayed on screen.
We are now ready to run the application again to take another peek at what our application looks like, but before we do, we want to make one additional configuration for the carousel component. Let's set the background for carousels in our application to use a light gray color to clearly show where one component ends and the other begins.
This is done by assigning a configuration closure on Component
. It is a static closure that will be invoked during each Component
's setup method.
Component.configure = { component in
switch component.model.kind {
case .carousel:
component.view.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
case .list:
component.view.backgroundColor = .white
default:
break
}
}
So now all list components will use a white background and all carousels will have a very light gray color.
This is what our newly refactored AppDelegate.swift
looks like now.
import UIKit
import Spots
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
Configuration.register(view: ContactView.self, identifier: "Contact")
Configuration.register(view: RecentContactView.self, identifier: "Recent")
Configuration.registerDefault(view: ContactView.self)
Component.configure = { component in
switch component.model.kind {
case .carousel:
component.view.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
case .list:
component.view.backgroundColor = .white
default:
break
}
}
let window = UIWindow(frame: UIScreen.main.bounds)
let recentModel = ComponentModel(
kind: .carousel,
layout: Layout(span: 3.5, itemSpacing: 5, inset: Inset(padding: 5)),
items: [
Item(title: "Francesco Rodriguez", kind: "Recent"),
Item(title: "Sindre Moen", kind: "Recent"),
Item(title: "Sigvart Angel Hoel", kind: "Recent"),
Item(title: "Torgeir Øverland", kind: "Recent"),
])
let recentComponent = Component(model: recentModel)
let contactsModel = ComponentModel(kind: .list, items: [
Item(title: "Sigvart Angel Hoel", kind: "Contact"),
Item(title: "Mathias Benjaminsen", kind: "Contact"),
Item(title: "Vasiliy Ermolovich", kind: "Contact"),
Item(title: "Felipe Espinoza", kind: "Contact"),
Item(title: "Epsen Høgbakk", kind: "Contact"),
Item(title: "Tim Kurvers", kind: "Contact"),
Item(title: "Damian Lopata", kind: "Contact"),
Item(title: "Sindre Moen", kind: "Contact"),
Item(title: "Torgeir Øverland", kind: "Contact"),
Item(title: "Francesco Rodriguez", kind: "Contact"),
Item(title: "Henriette Røseth", kind: "Contact"),
Item(title: "Peter Sergeev", kind: "Contact"),
Item(title: "John Terje Sirevåg", kind: "Contact"),
Item(title: "Chang Xiangzhong", kind: "Contact")
])
let contactsComponent = Component(model: contactsModel)
let controller = SpotsController(components: [recentComponent, contactsComponent])
controller.title = "My contacts"
let navigationController = UINavigationController(rootViewController: controller)
window.backgroundColor = UIColor.white
window.rootViewController = navigationController
window.makeKeyAndVisible()
self.window = window
return true
}
The application is starting to take shape but we could still use some more information to the describe what is shown to the users so let's do just that.
Adding headers to a Component
is just as easy as adding an additional item to a list.
Let's make one more view that can work as our header view in this application.
import UIKit
import Spots
class HeaderView: UIView, ItemConfigurable {
lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .boldSystemFont(ofSize: 12)
label.textColor = UIColor.darkGray.withAlphaComponent(0.5)
label.numberOfLines = 2
label.layer.cornerRadius = 4
label.layer.masksToBounds = true
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(titleLabel)
setupConstraints()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupConstraints() {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.heightAnchor.constraint(equalToConstant: 30).isActive = true
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8).isActive = true
}
func configure(with item: Item) {
titleLabel.text = item.title
}
func computeSize(for item: Item, containerSize: CGSize) -> CGSize {
return CGSize(width: containerSize.width,
height: 30)
}
}
We register headers in the exact same way as you do any other view.
Configuration.register(view: HeaderView.self, identifier: "Header")
Now let's add it to the component models.
let recentModel = ComponentModel(
header: Item(title: "Recent contacts".uppercased(), kind: "Header"),
kind: .carousel,
layout: Layout(span: 3.5, itemSpacing: 5, inset: Inset(left: 5, bottom: 5, right: 5)),
items: [
Item(title: "Francesco Rodriguez", kind: "Recent"),
Item(title: "Sindre Moen", kind: "Recent"),
Item(title: "Sigvart Angel Hoel", kind: "Recent"),
Item(title: "Torgeir Øverland", kind: "Recent"),
])
let contactsModel = ComponentModel(
header: Item(title: "Contacts".uppercased(), kind: "Header"),
kind: .list,
items: [
Item(title: "Sigvart Angel Hoel", kind: "Contact"),
Item(title: "Mathias Benjaminsen", kind: "Contact"),
Item(title: "Vasiliy Ermolovich", kind: "Contact"),
Item(title: "Felipe Espinoza", kind: "Contact"),
Item(title: "Epsen Høgbakk", kind: "Contact"),
Item(title: "Tim Kurvers", kind: "Contact"),
Item(title: "Damian Lopata", kind: "Contact"),
Item(title: "Sindre Moen", kind: "Contact"),
Item(title: "Torgeir Øverland", kind: "Contact"),
Item(title: "Francesco Rodriguez", kind: "Contact"),
Item(title: "Henriette Røseth", kind: "Contact"),
Item(title: "Peter Sergeev", kind: "Contact"),
Item(title: "John Terje Sirevåg", kind: "Contact"),
Item(title: "Chang Xiangzhong", kind: "Contact")
])
Note that we made some slight changes to the Layout
object on recentModel
. We removed the top
inset to make the content fit nicely together with the header.
So our final version of AppDelegate
ended up looking like this.
import UIKit
import Spots
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
Configuration.register(view: ContactView.self, identifier: "Contact")
Configuration.register(view: RecentContactView.self, identifier: "Recent")
Configuration.register(view: HeaderView.self, identifier: "Header")
Configuration.registerDefault(view: ContactView.self)
Component.configure = { component in
switch component.model.kind {
case .carousel:
component.view.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
case .list:
component.view.backgroundColor = .white
default:
break
}
}
let window = UIWindow(frame: UIScreen.main.bounds)
let recentModel = ComponentModel(
header: Item(title: "Recent contacts".uppercased(), kind: "Header"),
kind: .carousel,
layout: Layout(span: 3.5, itemSpacing: 5, inset: Inset(left: 5, bottom: 5, right: 5)),
items: [
Item(title: "Francesco Rodriguez", kind: "Recent"),
Item(title: "Sindre Moen", kind: "Recent"),
Item(title: "Sigvart Angel Hoel", kind: "Recent"),
Item(title: "Torgeir Øverland", kind: "Recent"),
])
let recentComponent = Component(model: recentModel)
let contactsModel = ComponentModel(
header: Item(title: "Contacts".uppercased(), kind: "Header"),
kind: .list,
items: [
Item(title: "Sigvart Angel Hoel", kind: "Contact"),
Item(title: "Mathias Benjaminsen", kind: "Contact"),
Item(title: "Vasiliy Ermolovich", kind: "Contact"),
Item(title: "Felipe Espinoza", kind: "Contact"),
Item(title: "Epsen Høgbakk", kind: "Contact"),
Item(title: "Tim Kurvers", kind: "Contact"),
Item(title: "Damian Lopata", kind: "Contact"),
Item(title: "Sindre Moen", kind: "Contact"),
Item(title: "Torgeir Øverland", kind: "Contact"),
Item(title: "Francesco Rodriguez", kind: "Contact"),
Item(title: "Henriette Røseth", kind: "Contact"),
Item(title: "Peter Sergeev", kind: "Contact"),
Item(title: "John Terje Sirevåg", kind: "Contact"),
Item(title: "Chang Xiangzhong", kind: "Contact")
])
let contactsComponent = Component(model: contactsModel)
let controller = SpotsController(components: [recentComponent, contactsComponent])
controller.title = "My contacts"
let navigationController = UINavigationController(rootViewController: controller)
window.backgroundColor = UIColor.white
window.rootViewController = navigationController
window.makeKeyAndVisible()
self.window = window
return true
}
}
And when we run the application, this is what we see.