요즘 열심히 했더니 깃헙 잔디밭이 잘 자라고 있다.
연속으로 채워졌더니 중간에 비면 마음 아플거 같아서
더 하게 되는 좋은 효과가 나타나는 중!
이전 편 에서는 Component를 신경 쓸 필요 없는 단순한 화면 붙이기를 하였다.
이번 편에서는 화면 전환시 Component를 신경써야할 부분을 다룰 예정!
포인트
화면을 떼는것 (Detach)을 해보기
이전편 화면 전환은
Router는 중점으로 다뤘으니 이번엔Component에 초점을 맞추자.
결과 화면
전체 구조는 다음과 같다.

이전 LoggedOut 화면은 입력을 받기 위해 다음과 같이 구성된다.

이번에 추가될 OffGame 화면은 다음과 같다.

로직은
- 사용자가 두개의 플레이어 네임 입력후
Enter를 누른다. LoggedOut은Detach된다.OffGame은Attach된다.
여기서 우버 튜토리얼과 다른 점은 ViewLess 특징을 갖는 LoggedIn이 없다.
그 이유는 Component에 조금 더 초점을 맞추고 싶었기 때문이다.
그리고 ViewLess는 별도로 포스팅을 할 예정!
LoggedIn UI 구성
전에는 핑크 핑크 화면만 띄우고 끝났다.
이번엔 간단하게 TextField 2개, Button 1개 넣었다.
하지만, UI 그리는데에 시간 낭비를 막기 위해 뷰컨의 풀코드 공유
final class LoggedOutViewController: UIViewController, LoggedOutPresentable, LoggedOutViewControllable {
weak var listener: LoggedOutPresentableListener?
private let firstTextField: UITextField = {
let field = UITextField()
field.translatesAutoresizingMaskIntoConstraints = false
field.placeholder = "Player 1"
field.borderStyle = .roundedRect
field.font = .systemFont(ofSize: 20, weight: .semibold)
return field
}()
private let secondTextField: UITextField = {
let field = UITextField()
field.translatesAutoresizingMaskIntoConstraints = false
field.placeholder = "Player 2"
field.borderStyle = .roundedRect
field.font = .systemFont(ofSize: 20, weight: .semibold)
return field
}()
private lazy var enterButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.backgroundColor = .systemCyan
btn.setTitle("Enter", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 30, weight: .bold)
btn.addTarget(self, action: #selector(enterAction), for: .touchUpInside)
btn.setTitleColor(.white, for: .normal)
btn.layer.cornerRadius = 12
btn.layer.cornerCurve = .continuous
return btn
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.distribution = .equalSpacing
stack.alignment = .fill
stack.spacing = 20
return stack
}()
init() {
super.init(nibName: nil, bundle: nil)
setUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUI()
}
private func setUI() {
view.backgroundColor = .systemPink
view.addSubview(stackView)
stackView.addArrangedSubview(firstTextField)
stackView.addArrangedSubview(secondTextField)
stackView.addArrangedSubview(enterButton)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
firstTextField.heightAnchor.constraint(equalToConstant: 40),
secondTextField.heightAnchor.constraint(equalToConstant: 40),
enterButton.heightAnchor.constraint(equalToConstant: 60)
])
}
@objc
func enterAction() {
print("탭탭탭 엔터 탭")
}
}
따로 링크를 걸까도 했지만, 이게 나은 듯하다.
그럼 이제 버튼을 누르면 콘솔창에 프린터문 하나가 찍힐 것이다.
OffGame이 필요한 것
OffGame은 게임의 결과 화면이라고 보면 좋을 듯하다.
예상 구성은 다음과 같다.
- Player 1 & 2의 이름
- Player 1 & 2의 결과 점수
최대한 튜토리얼과 비슷하게 간다면,
이름과 점수의 데이터는 구분을 지어둔다.
이름은 한번 입력하고 나면 변하지 않는 고정 값이다.
하지만 점수는 게임에 따라 변동이 일어나는 값이다.
그렇다면 이름은 String이면 충분할 듯 하다.
점수는 변동이 있기 때문에 RxSwift의 BehaviorRelay가 적당할 듯 하다.
그렇다면, 우선 아래 사진처럼 만들어두자

필요한 것은 Component에 정의
위에서 말한 필요 정보들을 명시해줄 차례이다.
OffGameBuilder.swift에 가보면 OffGameComponent가 있다.
여기에 이 정보들을 적어준다.
final class OffGameComponent: Component<OffGameDependency> {
fileprivate var player1Name: String
fileprivate var player2Name: String
fileprivate var score: BehaviorRelay<GameScore>
init(
player1Name: String,
player2Name: String,
dependency: OffGameDependency
) {
self.player1Name = player1Name
self.player2Name = player2Name
self.score = .init(value: GameScore(player1Score: 0, player2Score: 0))
super.init(dependency: dependency)
}
}
struct GameScore {
var player1Score: Int
var player2Score: Int
}
위에서 말한것 처럼 고정 값 String 2개, 변동 값 BehaviorRelay 1개 선언 해주었다.
해당 프로퍼티들은 하위 리블렛이 참고할 일도 없다. 따라서 이 파일만 쓰이면 되므로
filePrivate접근제어자 사용
그러면 이제 아래 이미지처럼 Component를 생성하는 곳에서 에러가 하나 생겼을 것이다.

이 필요 값들은 OffGame이 생성되는 당시에 받아와서 초기화 해주는게 맞다.
따라서, build 메소드 관련 프로토콜과 구현부를 수정해주자
protocol OffGameBuildable: Buildable {
// 수정 전
func build(withListener listener: OffGameListener) -> OffGameRouting
// 수정 후
func build(withListener listener: OffGameListener, player1: String, player2: String) -> OffGameRouting
}
그리고 이를 따르는 구현체도 수정을 해준다.
func build(withListener listener: OffGameListener, player1: String, player2: String) -> OffGameRouting {
// 변경 전
let component = OffGameComponent(dependency: dependency)
// 변경 후
let component = OffGameComponent(dependency: dependency, player1Name: player1, player2Name: player2)
// 아래는 생략
}
그럼 이제 한가지 고민을 할 수 있다.
추가한 String 2개와 BehaviorRelay를 누가 가지고 있을 것인가?
사실, 죄다 Interactor에 넣고 Interactor에서 쓰던지
Interactor가 VC에게 넘기던지 할 수있다.
그치만, 하다보니 튜토리얼처럼 화면 고정 값 같은 것들은
ViewController에 바로 넘기는 것도 좋다고 생각이 되었다.
바뀔 일이 없다면, 굳이 Interactor 거쳐서 Presentor에 메소드를 추가해서 View로 전달 하느니
Builder에서 바로 View로 넘기는 것도 좋다 판단 되었다.
그래서 우선 OffGameViewController로 이동해서 초기화 구문과 UI셋팅 부분을 작성해주자.
final class OffGameViewController: UIViewController, OffGamePresentable, OffGameViewControllable {
weak var listener: OffGamePresentableListener?
private let player1Name: String
private let player2Name: String
init(
player1Name: String,
player2Name: String
) {
self.player1Name = player1Name
self.player2Name = player2Name
super.init(nibName: nil, bundle: nil)
setUI()
}
required init?(coder: NSCoder) {
fatalError()
}
private func setUI() {
view.backgroundColor = .systemOrange
}
}
그럼, 초기화 부분에 String을 2개 받게 해두었으니,
다시 Builder로 돌아가서 초기화할때 값을 할당해주자
// OffGameBuilder Class
// In build Method
// 변경 전
let viewController = OffGameViewController()
// 변경 후
let viewController = OffGameViewController(
player1Name: component.player1Name,
player2Name: component.player2Name
)
이제 다른 변수 였던 score를 생각해보자
BehaivorRelay 타입의 변동 가능한 값이고
score는 비즈니스 로직에 쓰일 수 있으니 Interactor가 적당해보인다.
따라서, OffGameInteractor의 init도 수정해주자
// 변경 전
override init(presenter: OffGamePresentable) {
super.init(presenter: presenter)
presenter.listener = self
}
// 변경 후
import RxRelay
private let score: BehaviorRelay<GameScore>
init(
presenter: OffGamePresentable,
score: BehaviorRelay<GameScore>
) {
self.score = score
super.init(presenter: presenter)
presenter.listener = self
}
사실 여기서는 구독만 하면 되기 때문에 Observable이면 충분하고
굳이 BehaivorRelay로 받을 필요는 없지만,,,
설명이 너무 길어지니….. Pass…
그럼 이제 빌드하면 에러가 나고 어디서 날지는 감이 점점 온다.
Builder로 가서 수정해주자
// In OffGameBuilder class
func build(withListener listener: OffGameListener, player1: String, player2: String) -> OffGameRouting {
let component = OffGameComponent(dependency: dependency, player1Name: player1, player2Name: player2)
// ... 중간 생략
// 변경 전
let interactor = OffGameInteractor(presenter: viewController)
// 변경 후
let interactor = OffGameInteractor(
presenter: viewController,
score: component.score
)
// .. 여기도 생략...
}
수정하고 나면 이제 에러는 안나지만 지금 여기까지 한김에 Interactor 넘어온 score를 구독해서
값 방출시 viewController에게 전달하는 코드도 짜놓자.
OffGameInteractor.swift 파일을 보면 OffGamePresentable가 있다.
이것은 OffGameViewController가 따르고 있다.
그러므로, 저 프로토콜에 메소드를 만든다면 뷰컨트롤러는 이를 지켜야한다.
이 점을 이용해서 방출된 값을 업데이트 해주도록 하자.
아래 코드를 추가
protocol OffGamePresentable: Presentable {
var listener: OffGamePresentableListener? { get set }
// 추가
func updateScore(playerScore1: String, playerScore2: String)
}
그리고 이제 Interactor가 활성화되면 구독을 할 수 있도록
didBecomeActive() 또한 수정해주자.
override func didBecomeActive() {
super.didBecomeActive()
score
.observe(on: MainScheduler.instance)
.subscribe(onNext: { score in
let score1 = String(score.player1Score)
let score2 = String(score.player2Score)
self.presenter.updateScore(playerScore1: score1, playerScore2: score2)
}).disposeOnDeactivate(interactor: self)
}
여기서 재밌는 점이있다.
disposeOnDeactivate라는 것을 볼 수 있다.
RxSwift를 쓰면 항상 DisposeBag을 만들어줬는데,
자체적으로 이미 다 구현이 되어있어서 저렇게 메소드를 이용하여
쉽게 처리할 수 있다.
그럼, OffGameViewController의 오류만 잡기 위해 우선 빈 메소드로 두자
// In OffGameViewController
func updateScore(playerScore1: String, playerScore2: String) {
// 이따가 채우기
}
UI는 안만들었지만, score 방출시 Interactor는 View에게 새로운 값을 넘겨주도록 완성 됐다.
이제 Root 하위에 있던 LoggedOut의 Enter버튼을 눌렀을때
LoggedOut을 제거하고 OffGame을 띄워보도록하자
LoggedOut Enter 버튼 처리
로직은 다음과 같다.
Ènter터치Interactor에게TextField두개의 입력된 값 전달
2번을 하기 위해서 Interactor에게 전달할 수 있도록
같은 파일 내에 존재하는 LoggedOutPresentableListener에 메소드를 추가하자.
protocol LoggedOutPresentableListener: AnyObject {
func didTapEnter(player1: String?, player2: String?)
}
이렇게 해두면 전달해줄 수 있다.
여담으로 직접적으로 Interactor라 안쓰고 Listener라 쓰는 것은
RIBs 가이드에 보면 아래 사진처럼 실제로는 View와 Interactor 사이에
Presenter가 존재하므로 PresentableListener라는 표현을 쓰는듯 하였다.

(이미지 출처: RIBs Github)
그럼 이제 실제 버튼 탭 되었을때 액션을 정의해주자
@objc
func enterAction() {
let player1Text = firstTextField.text
let player2Text = secondTextField.text
listener?.didTapEnter(player1: player1Text, player2: player2Text)
}
그리고 빌드를 해보면 에러가 날텐데
LoggedOutInteractor로 이동해서 프로토콜에 적었던 메소드를 구현해주자.
프로토콜은 이게 너무 좋다. 구현은 조금 나중에 하더라도
현재 화면에서는 코드를 계속 짤 수 있으니깐 !
//
// in LoggedOutInteractor of LoggedOutInteractor.swift
func didTapEnter(player1: String?, player2: String?) {
}
위의 메소드를 만들어주고 나면 이제 해야할 일은 무엇일까?
현재는 LoggedOutInteractor이다.
LoggedOut을 띄운 RootRouter한테 LoggedOutInteractor가 LoggedOut을 닫아달라고 해야할까?
아니다
닫아 달라고 명령하는 것은 Router의 역할이고
Router에게 명령을 하는 것은 Interactor가 할 일이다.
다른 리블렛끼리 상호작용을 하는 것은 Interactor의 몫이다.
LoggedOutInteractor는 RootÌnteractor에게
입력을 받았다고 정도만 알려주면 된다.
그럼 RootInteractor가 RootRouter에게
“오케이, 입력했으니깐 LoggedOut닫고 OffGame 띄워!”
라고 해야한다.
문서를 보니 다음과 같이 써있었다.
상위 리블렛 to 하위 리블렛: 스트림 이용,
하위 리블렛 to 상위 리블렛: 리스너의 인터페이스 이용
그럼 RootInteractor에게 액션을 전달 할 수 있게 다음과 같이 코드를 추가하자
// In LoggedOutInteractor.swift
protocol LoggedOutListener: AnyObject {
func loggedOutDidTapEnter(player1: String, player2: String)
}
네이밍은 몇가지 방식을 보았지만
위와 같이 어디서 + 무엇을 형식으로
위 코드와 같이 작명을 하면 보기가 더 좋았던 것 같다.
그러면 아까 공백이었던 메소드를 채워주자
// In LoggedOutInteractor Class
func didTapEnter(player1: String?, player2: String?) {
// 옵셔널 제거
let name1 = (player1 == nil || player1?.isEmpty == true) ? "Player 1" : player1!
let name2 = (player2 == nil || player2?.isEmpty == true) ? "Player 2" : player2!
listener?.loggedOutDidTapEnter(player1: name1, player2: name2)
}
이제 또 빌드 해주면 에러가 난다.
RootInteractor에 메소드를 구현 안해줬기 때문이다!
드디어 Root쪽으로 넘어간다..
Detach
RootInteractor에 에러를 없애기 위해 프로토콜에 적었던 메소드를 구현해주자.
func loggedOutDidTapEnter(player1: String, player2: String) {
}
여기엔 무슨 내용이 들어가야할까?
“붙였던 LoggedOut을 제거하고 OffGame을 붙여!”
하지만, 튜토리얼에 있는 메소드명(routeToLoggedIn)은 제거하라는 뉘앙스가 없다.
그래서 좀더 명확히 하기 위해, router에게 내리는 명령을 두가지로 나눠보자
protocol RootRouting: ViewableRouting {
// 기존에 있던 시그니처
func attachLoggedOut()
// 추가된 시그니처
func detachLoggedOut()
func attachOffGame(player1: String, player2: String)
}
그러면 아까 비워있던 메소드를 채워보자
func loggedOutDidTapEnter(player1: String, player2: String) {
router?.detachLoggedOut()
router?.attachOffGame(player1: player1, player2: player2)
}
이렇게 짜두면 이해가 조금 더 쉬울 것이라 예상된다.
메소드를 파악할때
메소드 명을 보고는 LoggedOut에서 엔터 눌렀을때 이름 정보를 받는 메소드임을 알 수 있고,
실행되는 코드는 LoggedOut을 제거하고 OffGame을 파라미터 넘겨주면서 띄우라는 것을 알 수 있다.
여기서 중요한건 의존성 주입이 이뤄지기 위해선
attach 하면서 이런식으로 진행이 되어야한다는 점을
다시 한번 상기하고 Router로 이동해 계속 해보자
RootRouter에 위의 메소드들을 만들어준다.
// In RootRouter Class
func detachLoggedOut() {
guard let router = loggedOutRouting else { return }
detachChild(router)
viewController.dismiss(completion: nil)
loggedOutRouting = nil
}
func attachOffGame(player1: String, player2: String) {
}
attach는 조금 후에 보고, detach의 코드는 위와 같다.
어려울 것 없다!
detach해주고 viewController 닫아 주었고, 저장해두었던 값을 날렸다.
이 상태로 앱 실행 후 Enter를 눌러보자.
그럼 핑크 화면이 닫히고 민트 화면이 나오는 것을 볼 수 있다.
Detach는 잘 되었다. 그럼 이제 attach차례!
Attach
지난편에 상세히 다뤘기 때문에 간단히 보면
buildable & routing 프로퍼티를 선언해주고
private let offGameBuildable: OffGameBuildable
private var offgameRouting: Routing?
초기화 구문 수정해주고
init(
interactor: RootInteractable,
viewController: RootViewControllable,
loggedOutBuildable: LoggedOutBuildable,
offGameBuildable: OffGameBuildable
) {
self.offGameBuildable = offGameBuildable
self.loggedOutBuildable = loggedOutBuildable
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
attach 메소드 수정해주고
func attachOffGame(player1: String, player2: String) {
guard offgameRouting == nil else { return }
// 현재는 withListener의 interactor를 넣으면 오류난다
let router = offGameBuildable.build(withListener: interactor, player1: player1, player2: player2)
offgameRouting = router
attachChild(router)
viewControllable.present(router.viewControllable, animated: true, completion: nil)
}
위의 주석처럼 현재는 interactor 넣으면 에러가 나기 때문에 아래처럼 RootInteractable을 수정해주면 에러는 사라진다.
// 변경전
protocol RootInteractable: Interactable, LoggedOutListener
// 변경후
protocol RootInteractable: Interactable, LoggedOutListener, OffGameListener
그리고 마지막으로 Router를 초기화 했던 Builder를 수정해준다.
func build() -> LaunchRouting {
// 위는 생략..
// component를 넣으면 현재는 오류난다.
let offGameBuilder = OffGameBuilder(dependency: component)
return RootRouter(
interactor: interactor,
viewController: viewController,
loggedOutBuildable: loggedOutBuilder,
offGameBuildable: offGameBuilder
)
}
주석처럼 component를 넣게되면 component가 OffGame의 Depedency를 준수하지 않기 때문에 오류가 난다.
그래서 아래처럼 수정
// 수정 전
final class RootComponent: Component<RootDependency>, LoggedOutDependency
// 수정 후
final class RootComponent: Component<RootDependency>, LoggedOutDependency, OffGameDependency
실행해보자

드디어…!
후……. ㅠ
끝이 보인다.
데이터들을 뿌려주기 위해 OffGameViewController의 UI를 짜보자.
긁어가기 쉽게 코드로 짰다.
final class OffGameViewController: UIViewController, OffGamePresentable, OffGameViewControllable {
weak var listener: OffGamePresentableListener?
private let player1Name: String
private let player2Name: String
private let player1Label: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .systemFont(ofSize: 40, weight: .semibold)
label.textColor = .white
label.textAlignment = .center
return label
}()
private let player2Label: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .systemFont(ofSize: 40, weight: .semibold)
label.textAlignment = .center
label.textColor = .white
return label
}()
private lazy var playButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("PLAY", for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 50, weight: .bold)
btn.backgroundColor = .systemCyan
btn.layer.cornerRadius = 12
btn.layer.cornerCurve = .continuous
btn.addTarget(self, action: #selector(playAction), for: .touchUpInside)
return btn
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.distribution = .equalSpacing
stack.alignment = .fill
stack.spacing = 20
return stack
}()
init(
player1Name: String,
player2Name: String
) {
self.player1Name = player1Name
self.player2Name = player2Name
super.init(nibName: nil, bundle: nil)
setUI()
}
required init?(coder: NSCoder) {
fatalError()
}
private func setUI() {
view.backgroundColor = .systemOrange
view.addSubview(stackView)
stackView.addArrangedSubview(player1Label)
stackView.addArrangedSubview(player2Label)
stackView.addArrangedSubview(playButton)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 100),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
player1Label.heightAnchor.constraint(equalToConstant: 50),
player2Label.heightAnchor.constraint(equalToConstant: 50),
])
}
func updateScore(playerScore1: String, playerScore2: String) {
let player1Text = "\(player1Name) : \(playerScore1)"
let player2Text = "\(player2Name) : \(playerScore2)"
player1Label.text = player1Text
player2Label.text = player2Text
}
@objc
private func playAction() {
print("플레이는 다음에!!")
}
}
그러면 다음과 같은 화면이 나올 것이다.

LoggedOut에서 받은 값을 이용해서 OffGame이라는 것을 만들었다.
글의 중점은 다시 한번 얘기히자면!
하위 리블렛을 붙이면서 Component를 이용하려 했다는 점을 잊지말자.
마무리
진짜 튜토리얼이 왜이렇게 생략을 많이 했는지 알 것 같았다…
하 중간에 생략을 나도 어느정도 할까 하다가
혹여나 튜토리얼 이해 안되는 개발자가 이 글을 읽었을 때,
똑같이 뭐야 왜 생략됐어 ㅠ
하고 다른 글을 찾아 가는 일을 방지하고자… 길지만 써봤다….