Using Spots Framework for Cross-Platform Development

In this Spots framework tutorial you’ll design an iOS app interface and port it over to macOS and tvOS, creating your very own cross-platform app interface. By Brody Eller.

Leave a rating/review
Save for later
Share

Spots is an open-source framework that enables you to design your UI for one platform, and use it on iOS, tvOS, and macOS. This lets you spend more time working on your app, and less time porting it to other platforms. Spots is also architected in such a way that it makes it incredibly easy to redesign your layout, by making use of the view model pattern. You can read more about what inspired the creators of Spots here.

Getting Started

In this tutorial, you’ll start off by making a simple app for iOS and use Spots to help you port the app to tvOS and macOS. Start by downloading the starter project here.

The starter project includes Spots, which has been pre-installed via Cocoapods. If you’re curious to learn more, you can look inside the Podfile to see how it’s set up, or check out our Cocoapods with Swift tutorial. You’ll use the imported Spots framework later to port your UI to JSON.

Open up Dinopedia.xcworkspace, and then open up the Dinopedia-iOS group. Then open up Main.storyboard within that group. You’ll notice that it contains an empty UINavigationController. Embedding UIViewControllers in a UINavigationController facilitates navigation between the UIViewControllers and makes it easy for you to set the UIViewControllers’ titles. You will work with both these features within this tutorial.

Note: At the time this tutorial was written, Spots did not compile cleanly with Swift 4 so you will see a warning that conversion to Swift 4 is available. When you build, you will see a number of other warnings in the Spots libraries. You’ll just have to ignore them for now.

Creating Your First View

To build a user interface in Spots, you first have to instantiate a custom view. In Spots, you make a custom view by creating a new subclass of UIView that conforms to ItemConfigurable. Then, you set up your constraints and the size of your view.

Create a new file inside the Dinopedia-iOS group named CellView.swift that inherits from UIView. At the top of the file, add the following code:

import Spots

Add the following code inside the CellView class:

lazy var titleLabel = UILabel()

You have now created a label that you will soon populate. By declaring the property as lazy, the label will be instantiated when it is first accessed. In this case, it means it will be instantiated when the label is actually going to be populated and displayed. Properties that are not declared as lazy are instantiated when the class or struct in which they are declared is instantiated.

Below where you declared the titleLabel, add the following code:

override init(frame: CGRect) {
  super.init(frame: frame)
  
  addSubview(titleLabel)
}

This overrides the view’s initializer, and will initialize the view and add the label.

Next, add the following required method below init(frame:):

required init?(coder aDecoder: NSCoder) {
  fatalError("init(coder:) has not been implemented")
}

In Swift, a subclass does not inherit its superclass’s designated initializer(s) by default. Since CellView.swift inherits from UIView, you must override all UIView‘s designated initializers.

Finally, you’ll implement three methods for configuring your view. First you will add constraints to the titleLabel you created earlier so that it displays nicely on the screen. Constraining the titleLabel is not enough; next you will need to populate the titleLabel with text.

Add the following new method at the bottom of the class:

func setupConstraints() {
   titleLabel.translatesAutoresizingMaskIntoConstraints = false
   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
}

These constraints position the label in the center of your view vertically and give it a width equal to that of your view, with a bit of padding on either side.

At the bottom of init(frame:), add the following code:

setupConstraints()

This will therefore add the constraints right when CellView is initialized.

Now add the following to the bottom of the file, outside of the class definition:

extension CellView: ItemConfigurable {
  
  func configure(with item: Item) {
    titleLabel.text = item.title
  }
  
  func computeSize(for item: Item) -> CGSize {
    return CGSize(width: bounds.width, height: 80)
  }
  
}

configure(with:) sets the label’s text with the data passed as a parameter. computeSize(for:) sets the size of the view.

Now it’s time to use your view. In order for the application to use your view, you’ll have to register it. Open AppDelegate.swift and add the following code:

import Spots

Then add the following to application(didFinishLaunchingWithOptions), before the return:

Configuration.register(view: CellView.self, identifier: "Cell")

This registers the view you just created with the identifier "Cell". This identifier lets you reference your view within the Spots framework.

Creating Your First ComponentModel

It’s time to work with the Spots framework. First, you will create a ComponentModel.

Open ViewController.swift (make sure you choose the one in Dinopedia-iOS!). Items make up your ComponentModel and contain the data for your application. This data will be what the user sees when running the app.

There are many properties associated with Items. For example:

  • title is the name of the dinosaur’s species.
  • kind is the identifier that you gave CellView.swift in the AppDelegate.swift above.
  • meta has additional attributes, like the dinosaur’s scientific name and diet. You’ll use some of these properties now.

Add the following code at the top of the file:

import Spots

Add the following inside the viewDidLoad(), below super.viewDidLoad().

let model = ComponentModel(kind: .list, items: [
  Item(title: "Tyrannosaurus Rex", kind: "Cell", meta: [
    "ScientificName": "Tyrannosaurus Rex",
    "Speed": "12mph",
    "Lived": "Late Cretaceous Period",
    "Weight": "5 tons",
    "Diet": "Carnivore",
]),
  Item(title: "Triceratops", kind: "Cell", meta: [
    "ScientificName": "Triceratops",
    "Speed": "34mph",
    "Lived": "Late Cretaceous Period",
    "Weight": "5.5 tons",
    "Diet": "Herbivore",
]),
  Item(title: "Velociraptor", kind: "Cell", meta: [
    "ScientificName": "Velociraptor",
    "Speed": "40mph",
    "Lived": "Late Cretaceous Period",
    "Weight": "15 to 33lbs",
    "Diet": "Carnivore",
]),
  Item(title: "Stegosaurus", kind: "Cell", meta: [
    "ScientificName": "Stegosaurus Armatus",
    "Speed": "7mph",
    "Lived": "Late Jurassic Period",
    "Weight": "3.4 tons",
    "Diet": "Herbivore",
]),
  Item(title: "Spinosaurus", kind: "Cell", meta: [
    "ScientificName": "Spinosaurus",
    "Speed": "11mph",
    "Lived": "Cretaceous Period",
    "Weight": "7.5 to 23 tons",
    "Diet": "Fish",
]),
  Item(title: "Archaeopteryx", kind: "Cell", meta: [
    "ScientificName": "Archaeopteryx",
    "Speed": "4.5mph Running, 13.4mph Flying",
    "Lived": "Late Jurassic Period",
    "Weight": "1.8 to 2.2lbs",
    "Diet": "Carnivore",
]),
  Item(title: "Brachiosaurus", kind: "Cell", meta: [
    "ScientificName": "Brachiosaurus",
    "Speed": "10mph",
    "Lived": "Late Jurassic Period",
    "Weight": "60 tons",
    "Diet": "Herbivore",
]),
  Item(title: "Allosaurus", kind: "Cell", meta: [
    "ScientificName": "Allosaurus",
    "Speed": "19 to 34mph",
    "Lived": "Late Jurassic Period",
    "Weight": "2.5 tons",
    "Diet": "Carnivore",
]),
  Item(title: "Apatosaurus", kind: "Cell", meta: [
    "ScientificName": "Apatosaurus",
    "Speed": "12mph",
    "Lived": "Late Jurassic Period",
    "Weight": "24.5 tons",
    "Diet": "Herbivore",
]),
  Item(title: "Dilophosaurus", kind: "Cell", meta: [
    "ScientificName": "Dilophosaurus",
    "Speed": "20mph",
    "Lived": "Early Jurassic Period",
    "Weight": "880lbs",
    "Diet": "Carnivore",
  ]),
])

The code here is fairly straightforward. At the top, you create a new ComponentModel of type list. This causes your view to render as a UITableView instance. Then, you create your array of Items with a specific title and kind. This contains your data and sets its view type to the identifier, "Cell", which you specified earlier in AppDelegate.swift.

Adding Your View to the Scene

To use your data, you’ll need to create a controller. Still inside viewDidLoad(), add the following below your model:

let component = Component(model: model)

The final steps to get your view on the screen are to create a SpotsController and add it to the screen, so let’s do that now. Still inside viewDidLoad(), add the following under your component:

let controller = SpotsController(components: [component])
controller.title = "Dinopedia"

This will create a new SpotsController and set its title, which the UINavigationController will use.

Finally, add the controller to the UINavigationController with:

setViewControllers([controller], animated: true)

The code above sets the stack of the UINavigationController, which at this point consists of SpotsController. If you had more than one UIViewController that you wanted within the UINavigationController‘s stack, you would simply add it inside the Array that currently holds [controller].

Build and run to see your dinosaurs!

dinosaur list at first run

Responding to Taps on Dinosaurs

You’ll notice, however, that you can’t tap on the dinosaurs to see more information about them. To respond when the user taps a cell, you need to implement the component(itemSelected:) method of the ComponentDelegate protocol.

Still in ViewController.swift, at the bottom of the file, make a new extension and implement the method by adding the following code:

extension ViewController: ComponentDelegate {
  func component(_ component: Component, itemSelected item: Item) {

  }
}

In the code above, your ViewController adopts ComponentDelegate so that it has the ability to respond when a user taps on a cell. Your ViewController conforms to ComponentDelegate by implementing the required method inside the extension.

First, you’ll want to retrieve the information about each dinosaur. When you made the ComponentModel, you stored the information in the meta property. Inside the component(itemSelected:) method you just added, make a new ComponentModel by adding the following code:

let itemMeta = item.meta

let newModel = ComponentModel(kind: .list, items: [
  Item(title: "Scientific Name: \(itemMeta["ScientificName"] as! String)", kind: "Cell"),
  Item(title: "Speed: \(itemMeta["Speed"] as! String)", kind: "Cell"),
  Item(title: "Lived: \(itemMeta["Lived"] as! String)", kind: "Cell"),
  Item(title: "Weight: \(itemMeta["Weight"] as! String)", kind: "Cell"),
  Item(title: "Diet: \(itemMeta["Diet"] as! String)", kind: "Cell")
])

Here, you create a property itemMeta and set it to the meta property of the item which the user tapped. itemMeta is a Dictionary of String to Any. When creating newModel, you retrieve the value associated with each key in itemMeta. Like before, the kind parameter is the identifier of CellView.swift that you declared in the AppDelegate.

Finally, add the following code underneath that which you just added:

let newComponent = Component(model: newModel) //1
newComponent.tableView?.allowsSelection = false //2

let detailController = SpotsController() //3
detailController.components = [newComponent]
detailController.title = item.title
detailController.view.backgroundColor = UIColor.white

pushViewController(detailController, animated: true) //4

This creates the Component and SpotsController and adds it to the scene. Breaking it down:

  1. First you instantiate newComponent, which has a property called tableView.
  2. You disable selection on the tableView.
  3. Next you instantiate detailController and add newComponent to the components property on detailController.
  4. Finally, you push the new controller.

If you were to build and run now, nothing would happen when you click on the cells. This is because you haven’t set the ViewController as the SpotsController‘s delegate.

Back inside viewDidLoad(), add the following where you defined the SpotsController:

controller.delegate = self

Build and run to see some more information about the dinosaurs in your app!

dinosaur detail

Converting to JSON

If you looked around in the project, you may have noticed the dinopedia.json file. Open it up and you’ll see that the JSON data looks very similar to the model you made. You’ll use this JSON file to port your app to tvOS and macOS. This is one of the selling points of Spots. You can create your controllers with simple JSON data. The idea being that you can move this JSON to come from your web server, making it very easy to create your views from data your server sends.

First, you’ll change your iOS app to use JSON instead of manually creating the model.

Open ViewController.swift and replace the contents of viewDidLoad() with the following:

super.viewDidLoad()

guard let jsonPath = Bundle.main.path(forResource: "dinopedia", ofType: "json") else { //1
  print("JSON Path Not Found")
  return
}

let jsonURL = URL(fileURLWithPath: jsonPath)
do {
  let jsonData = try Data(contentsOf: jsonURL, options: .mappedIfSafe)
  let jsonResult = try JSONSerialization.jsonObject(with: jsonData, 
                                                    options: .mutableContainers) as! [String: Any] //2
  
  let controller = SpotsController(jsonResult) //3
  controller.delegate = self
  controller.title = "Dinopedia"
  
  setViewControllers([controller], animated: true) //4
} catch {
  print("Error Creating View from JSON")
}

Here’s what you’re doing above:

  1. First, you find the path of the JSON file and create a URL with it.
  2. Then you retrieve the data and parse it into a Dictionary.
  3. Next, you create a new SpotsController, passing in the JSON.
  4. Finally, you add it to the scene.

Build and run to see your app. It looks just as it did before, but now you’re using JSON!

iOS views created from JSON

Porting to tvOS

Now that you’ve spent time creating your app on iOS, it’s time to port to tvOS. Luckily, it’s very easy to port your app to tvOS using Spots. You’ll reuse all the code you wrote for iOS!

Add each Swift file from your iOS target to the tvOS target, including AppDelegate.swift, by checking the boxes in the File Inspector on the right-hand side of Xcode.

Inside the tvOS version of Main.storyboard, a UINavigationController has already been added for you. Since iOS and tvOS both use UIKit, you can conveniently share all of your files! Build and run the tvOS target to see your app beautifully ported to tvOS.

dinopedia on tvOS

Porting to macOS

Unfortunately, macOS doesn’t use UIKit and takes a little more work to port. You can’t just reuse files like you did for tvOS. But you’ll reuse most of the code, with only a few minor changes here and there.

Inside the macOS target, open up Main.storyboard. A stack view is already set up for you. It contains a view on the left and right with a divider in the middle. Both views have outlets already made and wired up to ViewController.swift.

Now right click on the Dinopedia-macOS group and select New File…. Then select macOS\Cocoa Class and click Next. Name the class CellView with a subclass of NSView, and click Next. Then save it in the default location, making sure that the Dinopedia-macOS target is selected.

Now remove the call to draw() and add the following code to the top of the file:

import Spots

Inside CellView, define a new NSTextField called titleLabel:

lazy var titleLabel = NSTextField()

Implement the required methods for Spots:

override init(frame frameRect: NSRect) {
  super.init(frame: frameRect)
  
  addSubview(titleLabel)
}

required init?(coder decoder: NSCoder) {
  super.init(coder: decoder)
}

As with the implementation of iOS Dinopedia’s CellView, here the macOS CellView must override NSView‘s designated initializer.

Now, create the setupConstraints() method to set up the titleLabel:

func setupConstraints() {
  titleLabel.translatesAutoresizingMaskIntoConstraints = false
  NSLayoutConstraint.activate([
    titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
    titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
    titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
    ])
}

Here you are constraining titleLabel so that it is centered vertically within its super view and so that it has a slight margin of 16 points on either side relative to its super view.

Now add the following code at the end of init(frame:):

setupConstraints()

This ensures that setupConstraints() is called when CellView is initialized.

Finally, create a new extension at the bottom of the file to set up the size of the view:

extension CellView: ItemConfigurable {
  
  func configure(with item: Item) {
    titleLabel.stringValue = item.title
    titleLabel.isEditable = false
    titleLabel.isSelectable = false
    titleLabel.isBezeled = false
    titleLabel.drawsBackground = false
  }
  
  func computeSize(for item: Item) -> CGSize {
    return CGSize(width: item.size.width, height: 80)
  }

}

Here you give the titleLabel some text and set certain properties on the NSTextField. You also create a method that returns the size of the item.

The last step in setting up your view is to register it in the AppDelegate. Switch to AppDelegate.swift (the one inside Dinopedia-macOS) and add the following code to the top of the file:

import Spots

Add the following inside the AppDelegate:

override func awakeFromNib() {
  super.awakeFromNib()
  Configuration.register(view: CellView.self, identifier: "Cell")
}

Just like you did with registering CellView.swift‘s identifier in the AppDelegate.swift for the iOS and tvOS targets, you are performing a similar action above. However, since you use the view in a storyboard, you need register the view in awakeFromNib().

Now it’s time to set up your ViewController. Open up ViewController.swift (again, the one in Dinopedia-macOS) and add the following code to the top of the file:

import Spots

Add the following code to the end of viewDidLoad():

guard let jsonPath = Bundle.main.path(forResource: "dinopedia", ofType: "json") else { //1
  print("JSON Path Not Found")
  return
}

let jsonURL = URL(fileURLWithPath: jsonPath)
do {
  let jsonData = try Data(contentsOf: jsonURL, options: .mappedIfSafe)
  let jsonResult = try JSONSerialization.jsonObject(with: jsonData, 
                                                   options: .mutableContainers) as! [String: Any] //2
      
  let controller = SpotsController(jsonResult) //3
  controller.title = "Dinopedia" //4
      
  addChildViewController(controller) //5
  leftView.addSubview(controller.view)
  controller.view.translatesAutoresizingMaskIntoConstraints = false
  NSLayoutConstraint.activate([
    controller.view.leadingAnchor.constraint(equalTo: leftView.leadingAnchor, constant: 0),
    controller.view.trailingAnchor.constraint(equalTo: leftView.trailingAnchor, constant: 0),
    controller.view.topAnchor.constraint(equalTo: leftView.topAnchor, constant: 0),
    controller.view.bottomAnchor.constraint(equalTo: leftView.bottomAnchor, constant: 0)
    ])
} catch {
  print("Error Creating View from JSON")
}

There’s a lot going on there, but it’s relatively straightforward:

  1. First you find the path to the dinopedia.json file.
  2. You then retrieve that data and deserialize it into a Dictionary.
  3. Next you instantiate a new SpotsController.
  4. You subsequently set the UINavigationController‘s title.
  5. Finally, you add the SpotsController as a childViewController of ViewController and constrain it within ViewController.

You’ll notice that this is the same code used for iOS, but you add constraints to the SpotsController and add it to the leftView. You add constraints to the view to make sure it fills the entire view.

Create a new extension at the bottom of the file and implement ComponentDelegate:

extension ViewController: ComponentDelegate {
  func component(_ component: Component, itemSelected item: Item) {

  }
}

Here you are adopting and conforming to ComponentDelegate so that ViewController responds when the user clicks a cell.

You can repeat the same code used to retrieve the data, so add the following to component(itemSelected:):

let itemMeta = item.meta

let newModel = ComponentModel(kind: .list, items: [
  Item(title: "Scientific Name: \(itemMeta["ScientificName"] as! String)", kind: "Cell"),
  Item(title: "Speed: \(itemMeta["Speed"] as! String)", kind: "Cell"),
  Item(title: "Lived: \(itemMeta["Lived"] as! String)", kind: "Cell"),
  Item(title: "Weight: \(itemMeta["Weight"] as! String)", kind: "Cell"),
  Item(title: "Diet: \(itemMeta["Diet"] as! String)", kind: "Cell"),
  ])
let newComponent = Component(model: newModel)

You’ll need to remove the SpotsController on the righthand pane and replace it with a new SpotsController whenever the user selects a new dinosaur. To do this you check if a SpotsController has been added to the right, and remove it if it has. Then you can add a new SpotsController to the right.

Add the following to the end of component(itemSelected:):

if childViewControllers.count > 1 {
  childViewControllers.removeLast()
  rightView.subviews.removeAll()
}

In this code, you determine if there is more than one view controller in childViewControllers. This check is important to make sure that childViewControllers.removeLast() can be successfully executed. If childViewControllers.removeLast() is called and there is not at least one childViewControllers, then the app would crash because childViewControllers.removeLast() would be trying to remove something that does not exist. You subsequently remove all the subviews on rightView since these subviews will be replaced with the user’s new dinosaur selection.

Now that you have a clear space to add your new SpotsController, add the following to the end of component(itemSelected:):

let detailController = SpotsController()
detailController.components = [newComponent]
detailController.title = item.title

addChildViewController(detailController)
rightView.addSubview(detailController.view)
detailController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
  detailController.view.leadingAnchor.constraint(equalTo: rightView.leadingAnchor, constant: 0),
  detailController.view.trailingAnchor.constraint(equalTo: rightView.trailingAnchor, constant: 0),
  detailController.view.topAnchor.constraint(equalTo: rightView.topAnchor, constant: 0),
  detailController.view.bottomAnchor.constraint(equalTo: rightView.bottomAnchor, constant: 0)
  ])

Again, this repeats from iOS, but adds constraints to the new view to fill the space.

Now that SpotsController conforms to ComponentDelegate, it’s time to set SpotsController as the delegate. Back inside viewDidLoad(), add the following where you defined the SpotsController:

controller.delegate = self

Before you build and run your macOS application, go to the macOS Project Editor and make sure you have a development team selected:

If a development team is not available, you may have to set up your macOS credentials. This Create Certificate Signing Request Tutorial is a helpful resource if you are unsure how to set up your credentials.

Now it is time to build and run to see your finished application running on macOS!

Where To Go From Here?

Well, that was a whirlwind tour of Spots! You’ve seen how you can build a simple UI using the framework, and port it from iOS to tvOS and macOS. Hopefully you can see how this could be useful. When the UI gets even more complex, this ease of porting becomes very useful. You’ve also seen how Spots uses the view model concept through its “controllers”, and how these can easily be created from JSON data.

Here’s the Final Project for this tutorial.

To learn more about Spots, you can check out the documentation as well as the getting started guide on Spots’ GitHub page.

If you have any questions feel free to join the discussion below!