iOS application 개발을 시작하며 SwiftUI 를 선택하였습니다.
개인적으로 iOS도 Swift도 처음이며 이 글이 첫 글입니다.
저는 Frontend Application을 개발할 경우 사용할 언어의 Reactive programming을 위한 환경을 먼저 살펴봅니다.
정리를 해보니 한 번에 다 정리할 수가 없어 이번 글은 SwiftUI의 State and DataFlow 만 정리하겠습니다.
SwiftUI
SwiftUI는 Swift 기반의 User interface toolkit입니다.
Reactive Programming 환경
Reactive programming을 하기 위해서는 먼저 필요한것은 분리된 Model과 View간에 binding을 하는 방법입니다.
SwiftUI는 이를 위해 Combine framework을 제공하고 있습니다.
그리고 SwiftUI 내에서 State의 변화가 View에 어떻게 반영이 되는지 Data Flow를 알아야 합니다.
개발자 사이트의 State and Data Flow Document의 Data Flow 그림을 Reactive Programming 환경을 보기 위해 일부 수정하였습니다.

Data변경시 View Update하기
View는 State를 Rendering하여 UI를 표현합니다.
즉, State의 변경은 Action을 통해서 이루어지고 View에는 Immutable Data가 전달이 됩니다.
그래서 Data가 변경이 될때 View가 업데이트가 될 수 있도록 동기화 처리를 해주어야 하고 이 방법이 @State입니다.
그러므로 State를 변경해주지 않으면 변수의 값을 변경하더라도 화면은 Update되지 않게 되는데 SwiftUI에서는 다음 Error가 납니다.
'Cannot assign to property: 'self' is immutable'
아래의 예제 코드는 그림 2와 같이 디지털 시계를 구현하였습니다.
여기서 변경되는 Data는 초당 변하는 시간이 됩니다.
디지털 시계 만들기
import SwiftUI
@main
struct timerApp: App {
var body: some Scene {
WindowGroup {
ClockView()
}
}
}
struct ClockView: View{
@State private var clock = Date()
var formatter = DateFormatter()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
init(){
formatter.dateFormat = "HH:mm:ss"
}
var body: some View {
ZStack{
Text("\(clock, formatter: formatter)")
.onReceive(timer) { input in
clock = input
}
.font(.system(size:50))
}
}
}

Model을 분리하여 View Update 하기
그림 2의 디지털 시계는 Model과 View를 분리하지 않았습니다.
Swift는 View 외부의 Model과 View를 Async하게 연결하는 방법으로 Combine framework을 제공합니다.
Combine은 Publisher와 Subscriber로 구성되며 그림 1을 참조하시면 됩니다.
SwiftUI에서는 이를 좀 더 편하게 사용하기 위해서 다음과 같은 Protocol과 Type을 제공합니다.
ObservableObject
@Published
@ObservedObject
@StateObject
@EnvironmentObject
설명을 위해서 간단히 아래와 같이 그림을 그려봤습니다.
Publisher를 정의하기 위한 Model Class는 ObservableObject 여야 합니다.
그리고 동기화할 Property는 @Published type으로 지정해야 합니다.
View 내부에 Model과 binding을 할 instance는 다음 3가지 type으로 생성할 수 있습니다.
@ObservedObject – View에 instance를 생성해서 View에 바로 연결을 해줍니다.
@EnvironmentObject – View 외부에 정의되어 있는 Model Instance를 view에서 사용할 수 있도록 해줍니다.
@StateObject – View가 재생성이 되더라도 instance가 유지되도록 해주고 EnvironmentObject처럼 하위 View에 사용할 수 있도록 할 수 있습니다.

크기 조절 가능 디지털 시계 만들기
아래의 예제는 ClockFormat을 Model로 분리하고 Size를 Control UI로 조절할 수 있도록 구현하였습니다.
ClockFormat Class를 ObservableObject로 상속받고 View에서 사용할 Size property를 @Published Type으로 지정해줍니다.
- Model (ClockFormat.swift)
import Foundation
class ClockFormat: ObservableObject{
@Published var size = 50.0
let formatter = DateFormatter()
init(){
formatter.dateFormat = "HH:mm:ss"
}
func increaseClockSize(){ size += 5.0 }
func decreaseClockSize(){ if (size > 5.0) {size -= 5.0} }
}
- View
import SwiftUI
@main
struct timerApp: App {
var body: some Scene {
WindowGroup {
ClockView()
}
}
}
struct ClockView: View{
@State private var clock = Date()
@ObservedObject private var clockFormat = ClockFormat()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack{
Text("\(clock, formatter: clockFormat.formatter)")
.onReceive(timer) { input in
clock = input
}
.font(.system(size: clockFormat.size))
VStack{
Spacer()
HStack(spacing:20){
Button(action: {clockFormat.decreaseClockSize()}, label:{Text("-")})
.font(.largeTitle)
.frame(width:200, height:50)
Button(action: {clockFormat.increaseClockSize()}, label:{Text("+")})
.font(.largeTitle)
.frame(width:200, height:50)
}
}
}
}
}

Control View 분리하여 멀티 디지털 시계 만들기
이번에는 @EnvironmentObject를 설명하기 위해서 강제로 Control View를 분리해보겠습니다.
clockFormat을 View가 아닌 App에 instance를 만들었습니다.
ClockView와 ClockControlView가 동일한 instance가 연결되게 하려면 View 외부에 instance를 두고 View 내부에서는 @EnvironmentObject로 연결해줍니다.
- Model (ClockFormat.swift)
import Foundation
class ClockFormat: ObservableObject{
@Published var size = 50.0
let formatter = DateFormatter()
init(){
formatter.dateFormat = "HH:mm:ss"
}
func increaseClockSize(){ size += 5.0 }
func decreaseClockSize(){ if (size > 5.0) {size -= 5.0} }
}
- View
import SwiftUI
@main
struct timerApp: App {
var clockFormat = ClockFormat()
var body: some Scene {
WindowGroup {
VStack{
Spacer()
ClockView()
.environmentObject(clockFormat)
}
ClockControlView()
.environmentObject(clockFormat)
}
}
}
struct ClockView: View{
@State private var clock = Date()
@EnvironmentObject var clockFormat: ClockFormat
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text("\(clock, formatter: clockFormat.formatter)")
.onReceive(timer) { input in
clock = input
}
.font(.system(size: clockFormat.size))
}
}
struct ClockControlView: View {
@EnvironmentObject var clockFormat : ClockFormat
var body: some View {
VStack{
Spacer()
HStack(spacing:20){
Button(action: {clockFormat.decreaseClockSize()}, label:{Text("-")})
.font(.largeTitle)
.frame(width:200, height:50)
Button(action: {clockFormat.increaseClockSize()}, label:{Text("+")})
.font(.largeTitle)
.frame(width:200, height:50)
}
}
}
}
탭하면 컬러가 바뀌는 디지털 시계 만들기
이번 예제는 @ObservedObject와 @StateObject의 차이를 설명하고자 만들었습니다.
즉, @StateObject가 왜 필요한지를 설명하고자 억지로 만든 코드이긴 합니다. ^^
우선은 ClockTime을 model로 분리하였습니다.
다음 코드는 시계를 탭을 하면 컬러가 랜덤하게 변경되도록 하였습니다.
아래 코드에서 @State로 정의한 randomcolor 이 ClockView의 인자로 전달하도록 하였습니다.
이렇게 구현한 결과를 보시면 탭을 했을때 색깔이 변하지만 시계가 멈춰버리는 현상을 볼 수 있습니다.
즉, randomcolor가 변경되면서 ClockView instance가 새로 생성되버리면서 ObservedObject로 연결한 ClockTime과 ClockView간의 connection이 끊어져버리는 상태입니다.
- Model (ClockFormat.swift)
import Foundation
class ClockFormat: ObservableObject{
@Published var size = 50.0
let formatter = DateFormatter()
init(){
formatter.dateFormat = "HH:mm:ss"
}
func increaseClockSize(){ size += 5.0 }
func decreaseClockSize(){ if (size > 5.0) {size -= 5.0} }
}
- Model (ClockTime.swift)
import Foundation
import Combine
class ClockTime: ObservableObject{
@Published var clock = Date()
@Published var count = 0
private var subscription : Cancellable?
func start() {
subscription = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
.receive(on: DispatchQueue.main).sink{
timer in
self.count+=1
self.clock = timer
}
}
}
- View
import SwiftUI
@main
struct timerApp: App {
@State private var randomcolor = Color.black
var clockFormat = ClockFormat()
var body: some Scene {
WindowGroup {
VStack{
Spacer()
ClockView(color: randomcolor).environmentObject(clockFormat)
}
.gesture(
TapGesture()
.onEnded{ _ in
randomcolor = Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1))})
ClockControlView().environmentObject(clockFormat)
}
}
}
struct ClockView: View{
var color: Color
@ObservedObject private var clockTime = ClockTime()
@EnvironmentObject var clockFormat: ClockFormat
var body: some View {
VStack{
Text("\(clockTime.clock, formatter: clockFormat.formatter)")
.font(.system(size: clockFormat.size))
Text("Elapsed \(clockTime.count)" as String)
.font(.system(size: clockFormat.size/2))
}
.foregroundColor(color)
.onAppear {clockTime.start()}
}
}
struct ClockControlView: View {
@EnvironmentObject var clockFormat : ClockFormat
var body: some View {
VStack{
Spacer()
HStack(spacing:20){
Button(action: {clockFormat.decreaseClockSize()}, label:{Text("-")})
.font(.largeTitle)
.frame(width:200, height:50)
Button(action: {clockFormat.increaseClockSize()}, label:{Text("+")})
.font(.largeTitle)
.frame(width:200, height:50)
}
}
}
}

그럼 이 문제를 어떻게 해결하면 될까요?
아래와 같이 @ObservedObject를 @StateObject로 변경해주기만 하면 됩니다.
이유는 @ObservedObject는 View가 새로 생성될때 함께 다시 생성해버리기 때문입니다.
하지만 @StateObject는 View가 새로 생성되더라도 이전의 Instance를 유지해주기 때문에 시간도 계속 업데이트되고 Elapsed Count도 유지됩니다.
struct ClockView: View{
var color: Color
@StateObject private var clockTime = ClockTime()
@EnvironmentObject var clockFormat: ClockFormat
var body: some View {
VStack{
Text("\(clockTime.clock, formatter: clockFormat.formatter)")
.font(.system(size: clockFormat.size))
Text("Elapsed \(clockTime.count)" as String)
.font(.system(size: clockFormat.size/2))
}
.foregroundColor(color)
.onAppear {clockTime.start()}
}
}

마치며
SwiftUI로 Reactive Programming을 하는데 있어 가장 기본적인 State와 Data Flow및 Model과 View 간에 동기화 방법을 예제와 함께 알아봤습니다.
다음에는 좀 더 심화적인 Reactive Programming을 설명하고자 합니다.
Hits: 2