관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. iOS 스러운, storyboard 활용 방법 (dynamic prototypes cell, unwind segue, storyboard reference) 본문

HIG(Human Interface Guidelines)/HIG - UI

[iOS - swift] 2. iOS 스러운, storyboard 활용 방법 (dynamic prototypes cell, unwind segue, storyboard reference)

jake-kim 2021. 5. 5. 18:51

1. iOS 스러운, storyboard 활용 방법 (static prototype cell, segue, gesture)

2. iOS 스러운, storyboard 활용 방법 (dynamic prototype cell, unwind segue, storybaord reference)

플레이어 목록 DataSource 추가

  • Player 데이터 추가

struct Player {
  var name: String?
  var game: String?
  var rating: Int
}
  • PlayersDataSource 추가

  • PlayersDataSource
import UIKit

class PlayersDataSource {
  // MARK: - Properties
  var players: [Player]

  static func generatePlayersData() -> [Player] {
    return [
      Player(name: "Bill Evans", game: "Tic-Tac-Toe", rating: 4),
      Player(name: "Oscar Peterson", game: "Spin the Bottle", rating: 5),
      Player(name: "Dave Brubeck", game: "Texas Hold 'em Poker", rating: 2)
    ]
  }

  // MARK: - Initializers
  init() {
    players = PlayersDataSource.generatePlayersData()
  }

  // MARK: - Datasource Methods
  func numberOfPlayers() -> Int {
    players.count
  }

  func append(player: Player, to tableView: UITableView) {
    players.append(player)
    tableView.insertRows(at: [IndexPath(row: players.count-1, section: 0)], with: .automatic)
  }

  func player(at indexPath: IndexPath) -> Player {
    players[indexPath.row]
  }
}

 

PlayersViewController 추가

  • property 추가
class PlayersViewController: UITableViewController {
  var playersDataSource = PlayersDataSource()
}

Main.storyboard에서도 클래스 지정

Data를 담는 Cell 추가

  • Cell의 높이 설정 - tableView에서 Row Height 설정

  • PlayersViewController의 TableView를 Static Cells에서 Dynamic Prototypes로 변경

  • PlayerCell 추가: 주의할 점은 Cell이 Models에 속하는게 아닌 Views 그룹에 속하는 것

import UIKit

class PlayerCell: UITableViewCell {

    static let identifier = "PlayerCell"

}
  • XIB 연결
    - class 연결
    - identifier 설정

Class 연결
Identifier 설정

  • Storyboard에서 드래그하여 IBOutlet 설정
class PlayerCell: UITableViewCell {

    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var gameLabel: UILabel!
    @IBOutlet weak var ratingImageView: UIImageView!
}
  • IBOutlet 설정 주의
    - storyboard View에서 뷰 컴포넌트 자체를 끌어다가 바로 Outlet 작성하지 않고
    - 컴포넌트에 마우스를 대고 ctrl + 왼쪽 클릭하여 아래처럼 Referncing Outlets에서 동그런 원을 끌어다가 연결할 것 (Outlet이 중복으로 생기는 것을 방지하고 더욱 간편한 방법)

  • 데이터 binding 추가
class PlayerCell: UITableViewCell {

    static let identifier = "PlayerCell"

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

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

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

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

PlayersViewController에서 cell 사용

  • DataSource delegate 구현
// MARK: - UITableViewDataSource
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.identifier) as! PlayerCell
    cell.player = playersDataSource.player(at: indexPath)
    return cell
  }
}

 

PlayerDetailsViewController 추가

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

  • 초기화 코드 추가
class PlayerDetailsViewController: UITableViewController {

  @IBOutlet weak var nameTextField: UITextField!
  @IBOutlet weak var detailLabel: UILabel!

  var player: Player?

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

  // MARK: - View Lifecycle
  override func viewDidLoad() {
    game = "Chess"
    nameTextField.becomeFirstResponder()
  }
}

완성된 화면

unwind segue

  • 생성된 segue를 되돌리는 기능
  • unwind 작성 방법: unwind를 진행하는 화면이 아닌, unwind 결과로 나오는 화면에 @IBAction코드 작성
  • cancel버튼, Done버튼에 관한 @IBAction Code 작성
// PlayersViewController

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

  @IBAction  func  savePlayerDetail ( _  segue : UIStoryboardSegue ) {
  }
}
  • unwind segue 연결

오른쪽 버튼 오타: Item -> Done
생성된 Unwind segue

  • Done 버튼의 Unwind segue 동작 시 데이터 추가하는 기능은 뒤에서 추가

새 플레이어 만들기 - GamePickerViewController 추가

  • GamesDataSource 추가

import UIKit

class GamesDataSource {
  // MARK: - Properties
  var games = [
    "Angry Birds",
    "Chess",
    "Russian Roulette",
    "Spin the Bottle",
    "Texas Hold'em Poker",
    "Tic-Tac-Toe"
  ]

  var selectedGame: String? {
    didSet {
      if let selectedGame = selectedGame,
        let index = games.firstIndex(of: selectedGame) {
        selectedGameIndex = index
      }
    }
  }

  var selectedGameIndex: Int?

  // MARK: - Datasource Methods
  func selectGame(at indexPath: IndexPath) {
    selectedGame = games[indexPath.row]
  }

  func numberOfGames() -> Int {
    games.count
  }

  func gameName(at indexPath: IndexPath) -> String {
    games[indexPath.row]
  }
}

  • GamePickerViewController 추가
class GamePickerViewController: UITableViewController {
  let gamesDataSource = GamesDataSource()
}
  • XIB에 연결

  • Cell 설정: Static Prototypes -> Dynamic Prototypes

  • Cell나머지 삭제, Identifier설정

  • 따로 CustomCell.swift하여 사용하지 않을것이기 때문에, cell style을 설정해주지 않으면 겹쳐보이는 버그 발생
    Custom -> Basic으로 변경

  • DataSource delegate 구현
    - cell.accessoryType으로 checkmark속성 부여
    - dequeueReusableCell(withIdentifier:for:)이 함수는 optional로 반환하지 않는 장점 존재
// GamePickerViewController.swift

// MARK: - UITableViewDataSource
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)

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

    return cell
  }
}

 

cell을 탭할 때 unwind segue

  • 뒤로가기 시 나오는 화면인 PlayerDetailsViewController에 unwind segue 데이터를 받는 코드 추가
extension PlayerDetailsViewController {
  @IBAction func unwindWithSelectedGame(segue: UIStoryboardSegue) {
    if let gamePickerViewController = segue.source as? GamePickerViewController,
       let selectedGame = gamePickerViewController.gamesDataSource.selectedGame {
      game = selectedGame
    }
  }
}
  • Cell 선택 > Exit으로 Ctrl+드래그 > UnwindWithSelectedGameWithSegue 선택

Segue로 매개변수 전달

  • PlayerDetailsViewController -> GamePickerViewController: 선택한 game이 체크박스로 표현 기능
  • segue를 참조하기 위해 "PickGame" Identifier 지정

  • prepare(for:) 함수를 통해 segue 참조 가능 - PlayerDetailsViewController에 추가
// PlayerDetailsViewController.swift

  override func prepare(for segue: UIStoryboardSegue, sender: Any?)  {
    if segue.identifier == "PickGame",
       let gamePickerViewController = segue.destination as? GamePickerViewController {
      gamePickerViewController.gamesDataSource.selectedGame = game
    }
  }
}
  • GamePicker의 데이터 소스 delegate 구현
// GamePickerViewController.swift

// MARK: - UITableViewDelegate
extension GamePickerViewController {
  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // 셀을 선택할 경우 나타나는 회색 배경 제거
    tableView.deselectRow(at: indexPath, animated: true)

    // 이전에 선택한 cell의 checkmark UI 제거
    if let index = gamesDataSource.selectedGameIndex {
      let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0))
      cell?.accessoryType = .none
    }

    // dataSource에서 새 게임 선택
    gamesDataSource.selectGame(at: indexPath)

    // checkmark UI 표출
    let cell = tableView.cellForRow(at: indexPath)
    cell?.accessoryType = .checkmark
  }
}
  • unwind 수정: 기존의 unwind는 cell를 선택할 때 위 구현부 전에 실행되어서 데이터가 업데이트 안되는 현상이 존재
    - 기존의 unwind를 제거한 후, 코드에서 unwind를 호출
  • identifier 정보 추가: 위 cell?.accessory = .checkmark 바로 밑에 작성
    - 해당 id는 storyboard에서 참조될 값
extension GamePickerViewController {
  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    ...
    
    performSegue(withIdentifier: "unwind", sender: cell)
  }
}
  • 기존의 GameCell에 연결되어 있던 unwind 제거 후 다시 연결

  • 추가된 segue의 identifier입력

Done을 탭한 경우, 새 플레이어 저장

  • PlayerDetailsViewController의 prepare(for: sender:)에 Done버튼이 눌린 경우 위에서 선언한 property에 저장하는 로직 추가
    - 본 화면이 Done버튼을 클릭해서 뒤로 갈때 "SavePlayerDetail" segue가 동작
// PlayerDetailsViewController.swift

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  
  if segue.identifer == "PickGame", ... {
    ...
  }
  
  if segue.identifier == "SavePlayerDetail",
    let playerName = nameTextField.text,
    let gameName = detailLabel.text {
    player = Player(name: playerName, game: gameName, rating: 1)
  }
}
  • PlayerViewsViewController에서 unwind segue로 온 데이터 획득한 후 처리
    - savePlayerDetails 부분 내용 추가 (storyboard에서 segue 부분의 identifier에도 기입 필요)
// PlayersViewController.swift

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

  @IBAction func savePlayerDetail(_ segue: UIStoryboardSegue) {
    guard
      let playerDetailsViewController = segue.source as? PlayerDetailsViewController,
      let player = playerDetailsViewController.player
      else {
        return
    }
    playersDataSource.append(player: player, to: tableView)
  }
}

Storyboard Reference

  • 하나의 storyboard reference로 만들 ViewController들을 드래그하여 선택

  • Editor -> Refactor to Storyboard... 선택

  • 이름 설정
    - path 설정 주의: default는 Target바로 하위로 이동

  • 결과: reference로 생성되었으며, Player.storyboard라는 별도의 파일로 생성

  • reference 객체의 속성을 보면, Player.storyboard로 지정되어 있는 것을 확인

* segue 정리

  • A -> B, A <- B 단순 화면전환: segue, unwindSegue
  • A -> B 데이터 전달: prepare(for:sender:) 사용
  • A -> B cell 선택 시 데이터 전달
    • tableView cell선택  A -> B 전달: segue cell 아닌 ViewController에서 다음화면에 drag하여 segue생성 (id입력)
    • 이블뷰 didSelectRowAt 델리게이트에서 dataSource selectedIndex 대입
    • prepare(for:sender:)에서 dataSource에 있는 selectedItem으로 값을 가져와서, B화면에 전달
  • A <- B 데이터 전달
    • viewController에서 exit으로 드래그하여 segue 생성, id 입력
    • performSegue를 통해 호출
    •  쪽에서 정의했던 unwindSegue함수에서, segue.source as? B 통해 객체 얻어서 사용

* 참고

- 개념: www.raywenderlich.com/5055396-ios-storyboards-segues-and-more

- 소스코드: https://github.com/JK0369/ratings_example

Comments