44

iOS Storyboards: Segues and More [FREE]

 4 years ago
source link: https://www.tuicool.com/articles/NBnY7jq
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Update note : Ehab Yosry Amer updated this tutorial for iOS 13, Xcode 11 and Swift 5. Nicholas Sakaimbo wrote the original.

In the previous tutorial — iOS Storyboards: Getting Started — you learned how to build an app prototype using only storyboards. Your prototype had all the screens and included navigation and flow. In this tutorial, you’ll build full functionality and convert that prototype to a complete app.

Getting Started

Use the Download Materials button at the top or bottom of this tutorial to download the starter project. It has the storyboard you finalized in the previous part, and a few Swift files containing some of the app’s logic, but no code is connected to the storyboard yet. That’s what you’ll do now. :]

The Project navigator of your starter project looks like this:

Storyboards-2_1-257x320.png

Along the way, you’ll create new view controllers and connect them to the existing storyboard. Time to get started.

Bringing the Players Screen to Life

Open Main.storyboard to take a look at all the scenes built in the previous part. The first screen you’ll give life to is the Players Scene , which has a table view. It was setup to use static cells and it already includes three cells.

To start, right-click on the View Controllers folder and select New File

Storyboards-2_2-480x246.png

Select Swift File and name it PlayersViewController.swift .

Storyboards-2_3.gif

The only code included in the file is an import statement:

import Foundation

It imports the Foundation framework which includes basic classes like String and Date . Replace it with this instead.

import UIKit

This imports the whole UIKit framework instead, which depends on Foundation , so you can use classes declared in both.

To define your first scene, add the following to PlayersViewController.swift , after the import statement:

class PlayersViewController: UITableViewController {
  var playersDataSource = PlayersDataSource()
}

You have defined a new class named PlayersViewController , and it’s a subclass of UITableViewController . You also defined the property playersDataSource , which is an instance of PlayersDataSource . This property provides the list of players so you can focus on showing them in the table view and not worry about how they are saved and loaded.

This view controller is a subclass of UITableViewController , because the scene on the storyboard is a table view.

Storyboards-2_4-480x164.png

Note : UITableViewController is a subclass of UIViewController , but the main view in this scene is a UITableView . So, in this case, it’s more convenient to using a table view controller right away.

Now, use the new class in the storyboard.

Storyboard, Please Meet My New Class

Open Main.storyboard . Select the Player Scene that has a table view in the Document Outline and, in the Identity inspector , click the drop-down button beside the Class field. It will show all the classes in your project that can fit your selected object. In this case, select PlayersViewController .

Storyboards-2_5.gif

Now, when the storyboard creates an instance of this scene, it will be of class PlayersViewController . Next, you must configure the table view to display data dynamically.

Dynamic Cells

First, change the the type of the content in the table view from Static Cells to Dynamic Prototypes . Then, delete two of the cells in the table view, so that there’s just one left.

Next, select the remaining cell, open the Attributes inspector and enter PlayerCell in the Identifier field.

Storyboards-2_6.gif

To tell the table view to create your custom cell, you need to be able to identify it. This is what the Identifier value is. It gives you the means to reference the cell you want from the code.

Next, add this extension at the end of PlayersViewController.swift :

extension PlayersViewController {
  override func tableView(
    _ tableView: UITableView,
    numberOfRowsInSection section: Int
  ) -> Int {
    playersDataSource.numberOfPlayers()
  }

  override func tableView(
    _ tableView: UITableView,
    cellForRowAt indexPath: IndexPath
  ) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(
      withIdentifier: "PlayerCell",
      for: indexPath)

    return cell
  }
}

You just created a new extension for PlayersViewController with two methods.

The first returns the number of cells to be displayed and the second creates and returns a cell using the identifier you set previously.

Note : Extensions are a great way to keep your code organized. You can define them in the same Swift file or in different files. Make it a habit to keep your code organized. You can learn more about extensions from Apple’s Swift docs .

Build and run, and you’ll see three identical cells. The information displayed on the cell is exactly what’s declared in the storyboard, multiplied by three. Time to make things more dynamic.

Storyboards-2_7-281x500.png

Defining a New Cell Class

The existing cell has two labels and one image, but there is no way to set these values dynamically via code. The default UITableViewCell doesn’t have access to such components.

Much like you did with the view controller, you’ll create a new cell class and define a reference between its components and the new class itself.

Right-click on the Views folder and select New File . This time, select Cocoa Touch Class . Then, set the name of the file to PlayerCell and make it a subclass of UITableViewCell .

Storyboards-2_8-444x320.png

This is a faster way to automatically create classes from Xcode instead of creating an empty file. It has everything already set up for you, and that boilerplate code will vary based on the subclass you specified.

For now, ignore the auto-generated methods and add this at the top of your class:

@IBOutlet weak var gameLabel: UILabel!
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var ratingImageView: UIImageView!

Those are the references you’ll use to access the elements on the cell. But what’s that @IBOutlet ? It’s a keyword for the storyboard and is short for Interface Builder Outlet . It tells the compiler that those properties will connect to some views on the storyboard.

Open Main.storyboard , select the cell in the Player Scene . In the Identity inspector, change the class to PlayerCell .

Storyboards-2_9-480x227.png

Now, open the Connections inspector ; you’ll see the following:

Storyboards-2_10-1-264x320.png

Those are the three properties that you defined in the class. Pretty smart, isn’t it? :]

Now, drag from the small circle beside the property name to the view on the cell that represents it.

Storyboards-2_11.gif

Do the same for all three to connect the views on the cell.

It’s now time to show real information about a player.

Binding the Players

The first option is to directly set the values on the cell inside tableView(_:cellForRowAt:) . The second is to pass a player instance to the cell and let the cell extract values from it.

Sometimes, the first option is more convenient and easier, but frequently you’ll end up having a lot of code in tableView(_:cellForRowAt:) . For the players, apply the second option.

In PlayerCell.swift , add the following right after the outlet declarations:

// 1:
var player: Player? {
  didSet {
    guard let player = player else { return }

    gameLabel.text = player.game
    nameLabel.text = player.name
    ratingImageView.image = image(forRating: player.rating)
  }
}

// 2:
private func image(forRating rating: Int) -> UIImage? {
  let imageName = "\(rating)Stars"
  return UIImage(named: imageName)
}

The first is a property of type Player , with a block that gets executed when its value is set. Such a block will set the values on the three components connected to the cell. The second is a method to fetch the appropriate image based on the rating of the player.

Now, go to PlayersViewController.swift , and right before the return of the cell in tableView(_:cellForRowAt:) add this:

cell.player = playersDataSource.player(at: indexPath)

Wait… What is this error!?

Storyboards-2_12-480x55.png

You added the player to the cell class, so why is Xcode complaining about it?

Right now, the return type of cell is still a UITableViewCell .

Storyboards-2_13-480x169.png

dequeueReusableCell(withIdentifier:for:) returns an instance of a cell with the identifier you provide as a type of UITableViewCell . But since you specified the class of that cell is PlayerCell , it’s safe to cast it accordingly.

All you need to do is change the line that creates the cell to the following:

let cell = tableView.dequeueReusableCell(
  withIdentifier: "PlayerCell",
  for: indexPath) as! PlayerCell

Now, Xcode is happy. Build and run and celebrate your first working screen! :]

Storyboards-2_14-281x500.png

Your app is now listing actual players. The next step is to add more players.

More Players, Please

Create a new Swift File named PlayerDetailsViewController.swift in the View Controllers folder and replace the import line with the following:

import UIKit

class PlayerDetailsViewController: UITableViewController {
  @IBOutlet weak var nameTextField: UITextField!
  @IBOutlet weak var detailLabel: UILabel!
}

Then, from Main.storyboard , set the class of the Add Player Scene to PlayerDetailsViewController .

Before attaching the outlets of the new view controller, there is something you should be aware of. You can’t attach outlets in a view controller to views in dynamic cells. Those cells won’t be displayed when the view controller is initialized. But for static cells, everything is set in the storyboard, so you can safely attach views inside static cells to outlets in the view controller.

Connect detailLabel to the label in the second cell titled Detail , and nameTextField to the text field in the first cell.

Storyboards-2_15.gif

Now, in PlayerDetailsViewController.swift add this property before the declaration of the outlets.

var game = "" {
  didSet {
    detailLabel.text = game
  }
}

This will save the name of the game. Any time this property value is updated, the label will also be updated.

Finally, add this at the end of the class:

override func viewDidLoad() {
  game = "Chess"
}

The view controller will call viewDidLoad() once when it loads all its views in memory. Since updating the game value will update the view, it makes sense to do this when the views finished loading.

Note : Outlets will have values after the view controller loads the views and executes viewDidLoad() , not with the initialization.

Build and run, tap the + button in the upper right corner to open the add player screen.

Storyboards-2_16-281x500.png

It doesn’t feel that much has changed except for the the game is now Chess . Before you add the functionality to add players, fix it so the Cancel and Done buttons do something.

Unwind Segues

Both Cancel and Done have a common behavior of returning back to the listing screen. Done will do more than just that, but start with that common action.

In PlayersViewController.swift , add the following extension:

extension PlayersViewController {
  @IBAction func cancelToPlayersViewController(_ segue: UIStoryboardSegue) {
  }

  @IBAction func savePlayerDetail(_ segue: UIStoryboardSegue) {
  }
}

Return to Main.storyboard . Control-drag from the Cancel button to the third icon in the bar above the scene in the storyboard. Select cancelToPlayersViewController: from the pop-up. Do the same for the Done button, but select savePlayerDetail: .

drag-1.gif

Build and run. Open the add player screen and try the two buttons.

Storyboards-2_18.gif

It worked, but how?

The two methods you added are Unwind Segues . They are basically exit segues to the view controller they are implemented on. Each button is using its own segue to return to the PlayersViewController .

Xcode identifies Unwind segues from their unique signature: A method that takes one parameter of type UIStoryboardSegue .

When you tap the + button from the listing screen, it triggers a segue to present a navigation controller, which in turn triggers a segue to show the add player scene. Unwind segues will automatically reverse all the segues from the scene you are exiting back to the scene you’re returning to.

You could have added only one method instead of two, but you still need to add the code for saving the new player with the Done action. For now, you finished the Cancel action. Now, it’s time to actually save newly created players.

Creating the New Player

There are two steps to save new players:

  1. Capture the player information (name, game and rating) inside the form, and create a full player instance with it.
  2. Pass the instance to the listing screen to save and display.

Sounds simple, right? The add player scene already has a text field to write the player’s name, and it’s already connected to an outlet, so that’s ready. You can use any rating value. For now, use one star since it’s a new player. The only missing item is the game. It’s set as Chess currently, but it can’t stay like that permanently.

Creating GamePicker View Controller

When you tap on the second cell in the add player scene, it opens a list of games. But, when you select one, nothing happens. Neither is chess selected by default.

To be able to do anything, you need to have a class for this view controller. Create a new Swift File named GamePickerViewController.swift . Replace the import statement with the following:

import UIKit

class GamePickerViewController: UITableViewController {
  let gamesDataSource = GamesDataSource()
}

Nothing special here, almost identical to the players list. A new type that subclasses UITableViewController , and contains a property that acts as the data source for games.

Open Main.storyboard , select the Choose Game Scene and change its class to GamePickerViewController .

Then select its Table View and change its Content value to Dynamic Prototypes , and the number of Prototype Cells to 1 .

Storyboards-2_20-480x183.png

Set the Identifier of the only cell left to GameCell .

Add the following at the end of GamePickerViewController.swift :

extension GamePickerViewController {
  override func tableView(
    _ tableView: UITableView,
    numberOfRowsInSection section: Int
  ) -> Int {
    gamesDataSource.numberOfGames()
  }

  override func tableView(
    _ tableView: UITableView,
    cellForRowAt indexPath: IndexPath
  ) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "GameCell", for: indexPath)
    cell.textLabel?.text = gamesDataSource.gameName(at: indexPath)

    return cell
  }
}

The first method returns the number of games the data source has. And the second creates a cell using the identifier you have set in the storyboard, then sets the text of textLabel to the game’s name.

Build and run, reach the games list, and it should look the same as before you applied any changes.

Storyboards-2_21-281x500.png

The Games list now shows the same data, but instead of being set on the storyboard, they are now loaded programmatically from gamesDataSource . Notice that you didn’t subclass the cell this time.

The cells in this scene have their Style set to Basic . That style gives the cell a default text label accessible through the property textLabel .

Storyboards-2_22-480x240.png

Selecting the Game

Tapping any of the games won’t do anything except give the cell a gray background. Definitely, that’s not the desired behavior. What it should do is:

  1. Select the new game.
  2. Return back to the form.
  3. Provide the form the selected game

You’ll do the second step first since its easier. Open PlayerDetailsViewController.swift and add the following code at the end of the file:

extension PlayerDetailsViewController {
  @IBAction func unwindWithSelectedGame(segue: UIStoryboardSegue) {
  }
}

This is a simple Unwind segue to return back to the Add Player Scene .

Return to Main.storyboard and Control-drag from the GameCell to the Exit icon above the scene. Select unwindWithSelectedGameWithSegue: under Selection Segue .

Storyboards-2_23-480x203.png

Build and run. Reach the games list and select any of the games.

Storyboards-2_24.gif

The games list will automatically exit once you select any item, and will return to the previous scene. Wouldn’t it be cool if the list can show what has already been selected?

Passing Parameters With Segues

Chess is already set as the default game, so why is it not showing a checkmark beside it when the games list appears? To implement this, the Add Player Scene needs to pass the game name to Choose Game Scene whenever it opens. But the storyboard is already handling opening the scene itself, and there is no code for this at all. But there’s a hook.

When a segue is triggered, the view controller that triggered it will call the method prepare(for:sender:) and will provide the segue that it’s handling. There are two important properties in the segue you’ll frequently use: identifier and destination . The identifier is just a string, just like the cell identifier you already used. It’s also empty by default. The destination is the view controller instance that the segue will go to.

Once you know which segue is happening from its identifier, you can cast the destination object to the view controller class you know. Let’s try that.

Open Main.storyboard and select the segue named Show segue to “Choose Game” . Then from the Attributes inspector change its Identifier to PickGame .

Storyboards-2_25-650x328.png

Then, in PlayerDetailsViewController.swift , add the following inside the class after viewDidLoad() :

override func prepare(for segue: UIStoryboardSegue, sender: Any?)  {
  if segue.identifier == "PickGame",
     let gamePickerViewController = segue.destination as? GamePickerViewController {
    gamePickerViewController.gamesDataSource.selectedGame = game
  }
}

If the segue’s identifier is PickGame , cast the destination view controller as GamePickerViewController so you can access its properties. Set the current value of game as the selected game.

Finally, in GamePickerViewController.swift , add the following in tableView(_:cellForRowAt:) right before the return:

if indexPath.row == gamesDataSource.selectedGameIndex {
  cell.accessoryType = .checkmark
} else {
  cell.accessoryType = .none
}

Build and run. Open the games list screen and you’ll see that Chess is already marked.

Storyboards-2_26-281x500.png

To finalize the game selection, in GamePickerViewController.swift , add the following at the end of the file:

extension GamePickerViewController {
  override func tableView(
    _ tableView: UITableView, 
    didSelectRowAt indexPath: IndexPath
  ) {
    // 1
    tableView.deselectRow(at: indexPath, animated: true)
    // 2
    if let index = gamesDataSource.selectedGameIndex {
      let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0))
      cell?.accessoryType = .none
    }
    // 3
    gamesDataSource.selectGame(at: indexPath)
    // 4
    let cell = tableView.cellForRow(at: indexPath)
    cell?.accessoryType = .checkmark
  }
}

Whenever a cell in a table view is selected, tableView(_:didSelectRowAt:) will execute the following steps:

  1. Remove the gray selection background that appears by default.
  2. Get the previously selected game and remove the checkmark from its cell.
  3. Select the new game in the data source.
  4. Mark the new cell with the checkmark.

Open PlayerDetailsViewController.swift and add the following within unwindWithSelectedGame(segue:) :

if let gamePickerViewController = segue.source as? GamePickerViewController,
   let selectedGame = gamePickerViewController.gamesDataSource.selectedGame {
  game = selectedGame
}

If the source view controller of the segue is of type GamePickerViewController and there is a valid selected game in the data source, then set the game to the new one.

Build and run, and choose a game other than chess. The value Chess is not updated! Why is that?

Storyboards-2_27.gif

Synchronizing Segues With Actions

When you select a cell, the unwind segue is triggered first, and the PlayerDetailsViewController is reading the selected game from the data source before tableView(_:didSelectRowAt:) in GamePickerViewController is executed. That’s the reason nothing has changed.

Automatically triggered segues are not meant to work hand-in-hand with actions when the order of execution is important. To fix this, you need to tell the view controller when to trigger the segue.

UIViewController has performSegue(withIdentifier:sender:) . Its job is to allow you to trigger a segue when you want it triggered.

To fix the issue, first remove the segue that the cell triggers on selection. Select the Exit icon in Choose Game Scene , and from the Connections inspector remove the action connected to the Selection outlet by clicking on the small x .

The segue needs to connect from the view controller itself so nothing else triggers it. To create a segue from the controller Control-drag from the View Controller icon to the Exit icon . Give this new segue the identifier unwind to reference it from the code.

Storyboards-2_28.gif

Open GamePickerViewController.swift and, at the end of tableView(_:didSelectRowAt:) , add the following:

performSegue(withIdentifier: "unwind", sender: cell)

Build and run and try to change the game. It works this time. :]

Storyboards-2_29.gif

Time to resume the task of saving a new player.

Saving the New Player

The last step is to actually save the new Player object when the user taps Done . These are the actions needed to finish this step:

  1. Only when the segue connected to the Done button is triggered, construct a new Player object and store it in the controller.
  2. When the unwind segue of the Done button is performed, append that stored object to the data source.

Open Main.storyboard and, under Add Player Scene , select the unwind segue triggered by Done . Its name is Unwind segue to savePlayerDetail: in the Document Outline . Give it the identifier SavePlayerDetail .

Open PlayerDetailsViewController.swift . Add this property at the top of the class:

var player: Player?

Then, add the following at the beginning of prepare(for:sender:) in the same file:

if segue.identifier == "SavePlayerDetail",
   let playerName = nameTextField.text, 
   let gameName = detailLabel.text {
  player = Player(name: playerName, game: gameName, rating: 1)
}

This creates a new Player if the form is complete.

In PlayersViewController.swift , add the following as the implementation of savePlayerDetail(_:)

guard 
  let playerDetailsViewController = segue.source as? PlayerDetailsViewController,
  let player = playerDetailsViewController.player 
  else {
    return
}
playersDataSource.append(player: player, to: tableView)

Build and run, then add a new player in your app. It works like a charm! :]

Storyboards-2_30.gif

First Responders

A nice improvement is to have the text field for the player’s name automatically selected and just ready to type, instead of tapping the field each time you want to add a player.

In PlayerDetailsViewController.swift , add this line in viewDidLoad()

nameTextField.becomeFirstResponder()

This will give the text field focus just as if you tapped on it yourself.

Build and run and start adding a player. The screen will start with the keyboard open.

Note : Press Command-K to toggle the software keyboard on & off in the simulator.

Storyboards-2_31-281x500.png

Performance With Storyboards

Storyboards don’t have any impact on your app’s performance. Although you designed all the view controllers in a single file, it doesn’t mean that they will all load in memory together. Only the view controller you’re showing will be loaded, and it will be released when you dismiss it.

In PlayerDetailsViewController.swift , add the following after the properties:

required init?(coder aDecoder: NSCoder) {
  print("init PlayerDetailsViewController")
  super.init(coder: aDecoder)
}

deinit {
  print("deinit PlayerDetailsViewController")
}

This is to track when a PlayerDetailsViewController instance initializes and deinitializes by printing some text in the log.

Build and run again. Open and close the add player scene. You’ll see the first log message when you open the screen and the second when you close it.

Storyboards-2_32-480x125.png

Storyboard References

In bigger projects, the storyboard file can get crowded with many scenes. When working in a team, you’ll quickly learn that storyboard files can generate conflicts when more than one person is working on them.

But fear not, there is a remedy for that: Storyboard references . These allow you to break a storyboard into multiple files without losing any of the features illustrated so far.

As an example, you’re going to separate the flow of listing and adding the player.

Open Main.storyboard and zoom out so you can see all the scenes. Then drag a selection rectangle to select all the player-related scenes. Make sure you don’t select any additional scenes.

Storyboards-2_33.gif

Then, from the Xcode’s menu bar, choose Editor ▸ Refactor to Storyboard . Name the new file Player.storyboard . Xcode will create a new .storyboard file and will put the scenes you selected into it. In Main.storyboard , you’ll find a new item created.

Storyboards-2_34-480x190.png

This item is referring to another set of scenes in another storyboard file. You can also insert a Storyboard Reference from the Object library if you prefer to link scenes yourself. If you build and run, you’ll that your app functions exactly as it did before.

Where to Go From Here?

In this tutorial, you only scratched the surface of what table views can do. To learn more about them you can watch the Beginning Table Views course.

The use of animations can also add a lot of value to your applications with a small amount of effort. It’s worth checking out the Beginning iOS Animations course. Animations work well with storyboards :]

Both courses are part of the iOS & Swift Beginner Path if you would like to learn more topics.

If you have any questions or comments, please don’t hesitate to join the forum discussion below.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK