SwiftUI + Combine ํฅ์๋ณด๊ธฐ
SwiftUI + Combine ํฅ์๋ณด๊ธฐ ๊ด๋ จ
SwiftUI๋?
SwiftUI is a modern way to declare user interfaces for any Apple platform. Create beautiful, dynamic apps faster than ever before.
WWDC 2019์์ ๋ฐํ๋ SwiftUI๋ ๊ธฐ์กด์ Storyboard์ Autolayout์ ๋์ฒดํ ์ ์๋ UI ํ๋ ์์ํฌ ์ ๋๋ค.
๋ฐํ ํ ๊ฐ๋ฐ์๋ค์๊ฒ WWDC 2019 ์ค ๊ฐ์ฅ ํฐ ํํธ๋ฅผ ๋ค์๋ค๊ณ ํด๋ ๊ณผ์ธ์ด ์๋๋ฐ์.
๊ฐ์ธ์ ์ผ๋ก ํ์ ์ด๋ผ ๋ถ๋ฆด๋งํผ ๋งค์ฐ ๋๋๊ณ ๊ฐํธํ ํ๋ ์ ์ํฌ๋ผ๊ณ ์๊ฐํฉ๋๋ค.
์ผ๋จ UI ํ๋ ์์ํฌ๋ ์ฌ์ง์ผ๋ก ๋จผ์ ๋ง๋๋ณด๋๊ฒ ์ข๊ฒ ์ฃ (?)
SwiftUI ํฅ์๋ณด๊ธฐ
ํ์ฌ SwiftUI๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด์ macOS 10.15 beta ์ Xcode 11 beta ๋ฒ์ ์ด ํ์ํฉ๋๋ค.
SwiftUI ๊ธฐ๋ฐ์ ํ๋ก์ ํธ๋ฅผ ์์ฑํ๋๊ฑด ๋งค์ฐ ๊ฐ๋จํฉ๋๋ค.
Xcode 11์ ์คํํฉ๋๋ค.
๊ทธ๋ผ ์ง์~ ํ๊ณ ์ฒ์ ๋ณด๋ ํ๋ฉด์ด ๋ฐ๊ฒจ์ค๋๋ค!
๊ธฐ๋ณธ์ ์ผ๋ก ContentView
๊ฐ ์์ฑ๋์ด์๊ณ ๊ธฐ์กด์ ๋ณผ ์ ์์๋ SceneDelegate
ํ์ผ์ด ์๊ฒผ๋ค์.
๊ทธ ํ ์ด๋ ๊ฒ ํ๋ฆฌ๋ทฐ๊ฐ ํ์ฑํ๋๋ฉด์ Hello World๊ฐ ๋ ์๋ ๋ชฉ์ ์ ๋ณผ ์ ์์ต๋๋ค.
์์ฃผ ๊ฐ๋จํ์ฃ ?
ํด๋น ์ฌ์ง์ ๋ณด๋ฉด ๊ธฐ์กด UIKit์ UILabel
์ ๊ฐ์ ์ญํ ์ ํ๋ View
๊ฐ Text
๋ก ์ ์ธ๋์ด์๋๊ฒ์ ๋ณผ ์ ์์ต๋๋ค.
์๋ฌด๋ฐ ์ด๋ฏธ์ง๋ assets์ ์ถ๊ฐํ Cmd+Shift+l ๋จ์ถํค๋ฅผ ์ ๋ ฅํ๋ฉด
๋ค์๊ณผ ๊ฐ์ ์ฐฝ์ด ๋น๋๋ค.
๋ผ์ด๋ธ๋ฌ๋ฆฌ์์ ์ฝ๋๋ ํ๋ฆฌ๋ทฐ ํ๋ฉด์ผ๋ก Drag & Drop ํด์ View
๋ฅผ ์ถ๊ฐํ ์ ์์ต๋๋ค.
์ ๋ง ํธ๋ฆฌํ์ฃ ?
์ด๋ฏธ์ง๋ฅผ Text์๋์ Drag & Drop ํ๋๋ ๋ค์๊ณผ ๊ฐ์ด Image
View
๊ฐ ์์ฑ๋์๋ค์.
์ด๋ฏธ์ง๊ฐ ๋๋ฌด ํผ์ง๋งํ๋ ์ข์ธก ์ฝ๋์์ ๋ค์๊ณผ ๊ฐ์ด ์์ ํด๋ณด๊ฒ ์ต๋๋ค.
VStack {
Text("Hello World")
Image("IMG_5021")
.resizable()
.aspectRatio(contentMode: .fit)
.padding([.top, .bottom, .leading, .trailing], 16)
}
[๋์ถฉ "์ ํ... ์๋์ ์ธ ๊ฐ์ฌ..!" ํ๋ ์งค]
์คํ์ผ๋ง๋ ์ง์ ์ ๋ ฅํ์ง ์๊ณ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์์ Drag & Drop ์ผ๋ก ์ถ๊ฐ ํ ์ ์์ต๋๋ค.
์ฌ๊ธฐ์ ํด๋น View
๋ฅผ ๊ธฐ์กด UITableView
ํํ๋ก ๋ง๋๋ ๋ฒ์ ๋งค์ฐ ์ฝ์ต๋๋ค!
์ข์ธก ์ฝ๋๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ๋ฐ๊ฟ์ฃผ๊ธฐ๋งํ๋ฉด...
List(0 ..< 5) { _ in
VStack {
Text("Hello World")
Image("IMG_5021")
.resizable()
.aspectRatio(contentMode: .fit)
.padding([.top, .bottom, .leading, .trailing], 16)
}
}
๊ธฐ์กด์ DataSource
๋ Delegate
์ฒ๋ฆฌ ์์ด ๋ฐ๋ก ๋ฆฌ์คํธ ํํ์ ๋ทฐ๊ฐ ๋ง๋ค์ด ์ง๊ฑธ ๋ณผ ์ ์์ต๋๋ค.
ํ๋ฆฌ๋ทฐ ํ๋ฉด์ ํ๋ฆฌ๋ทฐ ๋ฒํผ์ ๋๋ฌ์ฃผ๋ฉด ํ๋ฆฌ๋ทฐ๊ฐ ํ์ฑํ๋์ด ์คํฌ๋กค๋ ํด๋ณผ ์ ์์ต๋๋ค.
๊ธฐ์กด์ ViewController
์ ๊ฐ๋
์ ์ฌ๋ผ์ง๊ณ View
์ View
๊ฐ์ ์ด๋ ๋ฐ ์ํธ์์ฉ ๋ฑ์ด ์ด๋ฃจ์ด ์ง๋ค๋๊ฒ์ ์ ์ ์์ต๋๋ค.
๋ ์์ธํ View
๋ ํผ๋ฐ์ค๋ ๊ณต์ ๋ฌธ์์์, ๊ทธ์ ์์ํ๋ UIKIt ๊ฐ์ฒด ๋น๊ต๋ ํด๋น git repo ์น์
(SimpleBoilerplates/SwiftUI-Cheat-Sheet
)์์ ํ์ธํด์ฃผ์ธ์.
์ ํ์์ ์ ๊ณตํ๋ Tutorials๋ ๋ณด๋ฉด ์ข์๋ฏํฉ๋๋ค.
Combine ์ด๋?
Customize handling of asynchronous events by combining event-processing operators.
์ด๋ ๊ฒ ๋ฉ์ง UI ํ๋ ์ ์ํฌ๋ฅผ ๋ง๋ค์๋๋ฐ ๋ฐ์ดํฐ ๋ฐ์ธ๋ฉ๋ ๊ธฐ์กด์ ๋ฐฉ์์ ๊ทธ๋๋ก ์ฌ์ฉํ์ง ์๊ฒ ์ฃ .
๊ทธ๋์ ์ ํ์์ Combine
์ด๋ผ๋ ์๋ก์ด ํ๋ ์์ํฌ๋ฅผ ๊ณต๊ฐํ์ต๋๋ค.
์ผ๋จ ๊ณต์ ๋ฌธ์๋ฅผ ํ์ธํด ๋ณด๋ฉด...
ํ ํฝ๋ค์ ์ดํด ๋ณด๋ฉด ์ด๋์ ๋ง์ด ๋ณธ ์น๊ตฌ ๊ฐ์ฃ ..?
๊ทธ๋ ์ต๋๋ค. ์ ํ์์ ๋น๋๊ธฐ ๋ฆฌ์กํฐ๋ธ ํ๋ก๊ทธ๋๋ฐ์ ์ํ ํ๋ ์์ํฌ๋ฅผ ์ ๊ณตํด์ฃผ์์ต๋๋ค.
๊ธฐ์กด์ ๋ง์ด ์ฌ์ฉํ๋ RxSwift
์ ๋งค์ฐ ์ ์ฌํ๊ฒ ๋ณด์ด๋๋ฐ์.
RxCocoa
์์ RxSwift
๊ฐ ์๊ณ SwiftUI
์์ Combine
์ด ์๋๊ฑฐ์ฃ !
RxSwift
๋ฅผ ๋ค๋ค๋ณด์
จ๋ค๋ฉด Combine
์ฌ์ฉ์ด ํธํ ๋ฏํฉ๋๋ค.
RxSwift์ Combine์ ๋น๊ต๋ ํด๋น ๋ธ๋ก๊ทธ๋ฅผ ํ์ธํด์ฃผ์ธ์.
Combine ํฅ์๋ณด๊ธฐ
์ด ์น๊ตฌ๋ ํฅ์๋ณด๊ธฐ์ ๋๋ฌด ํฐ ์น๊ตฌ์ด๊ธด ํ์ง๋ง...
Combine์ ๊ฐ๋จํ๊ฒ ํ๋ก์ ํธ์์ SwiftUI์ ํจ๊ป ์ด๋ป๊ฒ ์ฌ์ฉ๋๋์ง ํฌ์คํ ํด๋ณด๊ฒ ์ต๋๋ค.
github์ ์ฌ๋ผ์์๋ ๊ด๋ จ๋ ์ฌ๋ฌ repo๋ค์ ์ทจํฉํด์ ๊ฐ์ธ์ ์ผ๋ก ๊ฐ์ฅ ํจ์จ์ ์ด๋ผ๊ณ ์๊ฐํ๋ ๋ฐฉ์์ผ๋ก ์งํํ์์ต๋๋ค.
SwiftUI ๊ธฐ๋ฐ ํ๋ก์ ํธ๋ฅผ ํ๋ ์์ฑ ํด์ค ํ ์์ฑ๋ ContentView
์์ ๋ค์๊ณผ ๊ฐ์ body
์ฝ๋๋ฅผ ์์ฑํด์ค๋๋ค.
var body: some View {
NavigationView {
List(0..<5) { index in
HStack {
Image(systemName: "star")
Text("Hello World")
}
}
.navigationBarTitle(Text("Sample App"))
}
}
๋ฒ์จ ์ด๋ ๊ฒ ์๋ฆ๋ค์ด(?) UI๊ฐ ์์ฑ๋์๋ค์.
์ด์ ์ฌ๊ธฐ์ View์์ ๋ฐ์ ์ก์ ์ ๋ํ ์๋จ์ ๋ค๋น ๋ฐ ํ์ดํ ๋ณ๊ฒฝ๊ณผ Alert ํ์ ์ ๋์ ๋ณด๊ฒ ์ต๋๋ค.
์ผ๋จ View์ ๋ก์ง์ ๊ด๋ฆฌํ ViewModel์ ๋ง๋ค์ด์ฃผ๊ฒ ์ต๋๋ค.
ContentViewModel
์ด๋ผ๋ ์ด๋ฆ์ swift ํ์ผ ์์ฑ ํ ๋ค์๊ณผ ๊ฐ์ด ์ฝ๋๋ฅผ ์์ฑํด์ค๋๋ค.
๊ฐ ๋ผ์ธ์ ์ค๋ช ์ ์ฝ๋๋ด์ ์ฃผ์์ผ๋ก ์ฒ๋ฆฌ ํ์์ต๋๋ค.
import Combine
import SwiftUI
class ContentViewModel: BindableObject { // BindableObject ํ๋กํ ์ฝ์ implement ํด์ค๋๋ค.
let didChange = PassthroughSubject<Void, Never>() // BindableOjbect ํ๋กํ ์ฝ์์ ์๋ ํ์ ํ๋กํผํฐ ์
๋๋ค. ๋ทฐ์ ์ ์ฉ๋์ด์ผํ ๊ฐ์ฒด๊ฐ ์
๋ฐ์ดํธ ๋ ๋ ํธ์ถ ํด์ฃผ๋ฉด ๋ฉ๋๋ค.
private var cancellables: [AnyCancellable] = [] // ๊ธฐ์กด RxSwift์ DisposeBag๊ณผ ๊ฐ์ ์ญํ ์ ํ๋ ๋
์์
๋๋ค. ํด๋น ๊ฐ์ฒด๊ฐ deInit๋ ๋ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ์ ์ฌ์ฉ๋ฉ๋๋ค.
// View์ Input Action ๊ด๋ จ ์ฝ๋์
๋๋ค.
enum Input {
case didTap(index: Int)
}
func apply(_ input: Input) {
switch input {
case .didTap(let index):
didTapIndexSubject.send(index)
}
}
//ViewModel ๋ด๋ถ์์ index๊ด๋ จ ์ด๋ฒคํธ๋ฅผ ์ฒ๋ฆฌํ๊ธฐ ์ํ Subject์
๋๋ค.
private let didTapIndexSubject = PassthroughSubject<Int, Never>()
// View์ ๋ฐ์ธ๋ฉ ํ Output๊ด๋ จ ์ฝ๋์
๋๋ค.
struct Output {
var isErrorShown = false
var labelText: String?
}
private(set) var output = Output() {
didSet {
didChange.send(()) //Output ๊ตฌ์กฐ์ฒด๋ฅผ ์ด๊ธฐํ ํด์ค ํ ๊ฐ์ด ๋ณ๊ฒฝ ๋ ๋ ๋ง๋ค didChange Subject์ ์
๋ฐ์ดํธ๋ฅผ ์๋ฆฝ๋๋ค.
}
}
init() {
bindOutputs()
}
// ๋ฐ์ธ๋ฉ ๊ด๋ จ ์์
์ ํด์ฃผ๋ ํจ์ ์
๋๋ค.
private func bindOutputs() {
let isError = didTapIndexSubject
.map { $0 == 4 }// map์ ์ฌ์ฉํ์ฌ ๊ธฐ์กด Intํ์ Boolํ์ผ๋ก ๋ณํํด์ค๋๋ค..
.share() // share์ ์ฌ์ฉํด์ ํด๋น Publisher๋ฅผ ๊ณต์ ํด์ค๋๋ค.
let showError = isError.assign(to: \.output.isErrorShown, on: self) //isError Pulisher๋ฅผ output.isErrorShown์ assign ํด์ค๋๋ค.
let showSucessedMessage = isError.filter { !$0 } // isError์ ์ด๋ฒคํธ ๊ฐ์ด true์ผ ๊ฒฝ์ฐ ๋ฌด์ํ๊ธฐ ์ํด์ filter๋ฅผ ๊ฑธ์ด์ค๋๋ค.
.zip(didTapIndexSubject.eraseToAnyPublisher()) // didTapIndexSubject๋ฅผ Publisher๋ก ๋ณํ ํ๋ค zip์ ์ฌ์ฉํ์ฌ isError์ ํฉ์ณ์ค๋๋ค.
.map { "Sucessed \($1)" } // map์ ์ฌ์ฉํ์ฌ ๊ธฐ์กด ๊ฐ์ Stringํ์ ๊ฐ์ผ๋ก ๋ณํํด์ค๋๋ค.
.assign(to: \.output.labelText, on: self) // ๋ณํ ๋ ๊ฐ์ output.labelText์ assign ํด์ค๋๋ค.
// ํด๋น AnyCancellable ํ์ ํ๋กํผํฐ๋ค์ cancellables์ ์ถ๊ฐํด์ค๋๋ค.
cancellables += [
showSucessedMessage,
showError
]
}
}
์ฌ๊ธฐ์ ๊ธฐ์กด ContentView
์ฝ๋๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์ค์ ํด์ค๋๋ค.
๊ฐ ๋ผ์ธ์ ์ค๋ช ์ ์ฝ๋๋ด์ ์ฃผ์์ผ๋ก ์ฒ๋ฆฌ ํ์์ต๋๋ค.
struct ContentView : View {
@ObjectBinding var viewModel: ContentViewModel //ObjectBiding ํ๋กํผํฐ Delegate๋ฅผ ์ฌ์ฉํ์ฌ viewModel ๋ฅผ ์์ฑํด์ค๋๋ค.
var body: some View {
NavigationView {
List(0..<5) { index in
HStack {
Image(systemName: "star")
Text("Hello World")
}
.tapAction { // List๋ด์ Row๊ฐ ์ ํ๋ ๋ ํธ์ถ๋ฉ๋๋ค. ๊ธฐ์กด UITableViewDelegate์ didSelect ์ ๊ฐ๋ค๊ณ ์๊ฐํ์๋ฉด๋ฉ๋๋ค.
self.viewModel.apply(.didTap(index: index)) //viewModel์ Input Action์ ๋ณด๋
๋๋ค.
}
}
.presentation($viewModel.output.isErrorShown) { () -> Alert in // viewModel.output.isErrorShown ๊ฐ์ด ๋ณ๊ฒฝ๋ ๋ true์ผ ๊ฒฝ์ฐ
Alert(title: Text("Error"), message: Text("Error")) // Alert View๋ฅผ NavigationView์์ present ํด์ค๋๋ค.
}
.navigationBarTitle(Text(viewModel.output.labelText ?? "Sample App")) // viewModel.output.labelText์ ์๋ String๊ฐ์ผ๋ก ๋ค๋น๊ฒ์ด์
๊ฐ์ ์ค์ ํด์ค๋๋ค.
}
}
}
์ด๋ ๊ฒ ์ฝ๋๋ฅผ ์์ฑํ ๋ค ๋น๋๋ฅผ ํด๋ณด๋ฉด
๋ค์๊ณผ ๊ฐ์ด index๊ฐ 0~3 ๊น์ง์ Row ์ ํ์ ๋ค๋น๊ฒ์ด์ ํ์ดํ์ด ๋ณ๊ฒฝ๋๋๊ฒ์ ๋ณผ ์ ์๊ณ
index๊ฐ 4 ์ธ Row์ ํ์ error Alert์ด ๋จ๋๊ฒ์ ๋ณผ ์ ์์ต๋๋ค.
์ต์ข ์ฝ๋๋ ์๋์์ ํ์ธ ํ์ค ์ ์์ต๋๋ค.
ContentView
์ต์ข
์ฝ๋
struct ContentView : View {
@ObjectBinding var viewModel: ContentViewModel
var body: some View {
NavigationView {
List(0..<5) { index in
HStack {
Image(systemName: "star")
Text("Hello World")
}
.tapAction {
self.viewModel.apply(.didTap(index: index))
}
}
.presentation($viewModel.output.isErrorShown) { () -> Alert in
Alert(title: Text("Error"), message: Text("Error"))
}
.navigationBarTitle(Text(viewModel.output.labelText ?? "Sample App"))
}
}
}
ContentViewModel
์ต์ข
์ฝ๋
class ContentViewModel: BindableObject {
let didChange = PassthroughSubject<Void, Never>()
private var cancellables: [AnyCancellable] = []
enum Input {
case didTap(index: Int)
}
func apply(_ input: Input) {
switch input {
case .didTap(let index):
didTapIndexSubject.send(index)
}
}
private let didTapIndexSubject = PassthroughSubject<Int, Never>()
struct Output {
var isErrorShown = false
var labelText: String?
}
private(set) var output = Output() {
didSet {
didChange.send(())
}
}
init() {
bindOutputs()
}
private func bindOutputs() {
let isError = didTapIndexSubject
.map { index -> Bool in
(index == 4)
}.share()
let showError = isError.assign(to: \.output.isErrorShown, on: self)
let showSucessedMessage = isError.filter { !$0 }
.zip(didTapIndexSubject.eraseToAnyPublisher())
.map { _, index in
"Sucessed \(index)"
}.assign(to: \.output.labelText, on: self)
cancellables += [
showSucessedMessage,
showError
]
}
}
๋ง์น๋ฉฐ...
์ด๋ ๊ฒ WWDC 2019์์ ๊ณต๊ฐ๋ SwiftUi์ Combine ํ๋ ์์ํฌ๋ฅผ ์ดํด ๋ณด์์ต๋๋ค.
์ ํ์์ Swift์ ์ด๋ ๊ฒ ๋ง์ ํ์ ์์์ฃผ๋ ๊ฒ๋ ์ ์ ์์ด ์ ๋ง ๊ธฐ๋ปค์ต๋๋ค.
์ด๋ ๊ฒ ๋ฉ์ง ํ๋ ์์ํฌ๋ค์ iOS 13 ์ด์์์๋ง ์ฌ์ฉํ ์ ์์ด ์ค์ ๋ฌด์๋ ์ ์ฉํ๊ธฐ์ ์์ง ๋ง์ด ์ด๋ฅธ๊ฐ์ด ์๋ค๋ ์ ์ด ์์ฝ์ต๋๋ค.
ํ์ง๋ง ์ปค๋ฎค๋ํฐ์์ ๋ ๋๊ณ ์๋ ์๋ฌธ์ผ๋ก๋ swift 5.1์์ ์ถ๊ฐ๋ return
์๋ต, ํ๋กํผํฐ Delegate
๋ฑ์ผ๋ก ์ธํด ๋ง์๋๊ฒ์ด๋ผ ์ ์ ๋ฒ์ ์ถ์์๋ iOS 13 ๋ฏธ๋ง์ ๋ฒ์ ์์๋ ์ฌ์ฉํ ์ ์๊ฒ ๋ ์๋ ์๋ค๋ ์๊ธฐ๋ ์์ต๋๋ค.
(๋ญ ์๋๋ฉด ๊ฑ 2๋ ๋์ ๊ฐ์ธ ํ๋ก์ ํธ์์๋ง ์จ์ผ์ฃ ..)
์๋ฌดํผ ์ ํ์ด ์ด์ ๊ฐ์ด ๋ฉ์ง First-party ํ๋ ์ ์ํฌ๋ฅผ ๋์ฑ ๋ง์ด ๋ง๋ค์ด์ฃผ๊ณ ๊ฐ์ ํด์ฃผ์์ผ๋ฉด ํ๋ ๋ง์์ ๋๋ค.