SwiftUI의 데이터 흐름

  • SwiftUI에서의 데이터 흐름과 라이프사이클을 알아보고자 합니다.
  • SwiftUI 데이터 흐름에 대해서는 애플의 WWDC19WWDC20 영상이 있습니다.
  • WWDC23 이후 완전히 개편된 간편해진 데이터 사용 방법에 공식 문서도 있습니다.

SwiftUI 라이프사이클

  • SwiftUI에는 View의 상태를 나타내는 라이프사이클이 아래와 같이 단 두가지 밖에 없습니다.
  • 대신 상태를 나타내는 다양한 Property Wrapper가 존재해 Data 흐름에 대한 여러 상태에 대응할 수 있습니다.
.onAppear {
    print("View appeared")
}

.onDisappear {
    print("View disappeared")
}

WWDC 23 이후

Xcode 15와 Swift 5.9 이후, Observation의 Observable Macro를 통해 더 쉽게 SwiftUI를 다룰 수 있게 되었습니다

  • Observable 프로토콜을 준수하는 대신, @Observable Macro를 표시합니다.
  • 이로 인해, @Published 프로퍼티 래퍼도 이제 필요하지 않게 되었습니다.
// BEFORE
class Library: ObservableObject {
    @Published var books: [Book] = [Book(), Book(), Book()]
}

// AFTER
@Observable class Library {
    var books: [Book] = [Book(), Book(), Book()]
}
  • @State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject와 같은 프로퍼티 래퍼들이 있었습니다.
  • 그러나 이제는 훨씬 간단하게, 값 타입과 참조 타입 모두 @State 프로퍼티 래퍼만을 사용하면 됩니다.
// BEFORE
@main
struct BookReaderApp: App {
    @StateObject private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environmentObject(library)
        }
    }
}

// AFTER
@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}
  • @EnvironmentObject 역시, @Environment로 통일할수 있게 되었습니다.
// BEFORE
struct LibraryView: View {
    @EnvironmentObject var library: Library

    var body: some View {
        List(library.books) { book in
            BookView(book: book)
        }
    }
}

// AFTER
struct LibraryView: View {
    @Environment(Library.self) private var library
    
    var body: some View {
        List(library.books) { book in
            BookView(book: book)
        }
    }
}
  • @ObservedObject 역시, 필요가 없어졌습니다.
// BEFORE
struct BookView: View {
    @ObservedObject var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

// AFTER
struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}
  • Observable을 준수하는 오브젝트에 대해 @Binding이 필요한 경우에만 @Bindable을 사용하면 됩니다.
// BEFORE
struct BookEditView: View {
    @ObservedObject var book: Book
    
    var body: some View {
        TextField("Title", text: $book.title)
    }
}

// AFTER
struct BookEditView: View {
    @Bindable var book: Book
    
    var body: some View {
        TextField("Title", text: $book.title)
    }
}

WWDC 23 이전

@State

  • State 프로퍼티 래퍼는 크게 2가지 역할을 합니다.
  • View를 준수하는 struct 변수의 값을 변경할수 있게합니다.
  • 변수의 값이 변경되면 View의 body가 다시 계산될수 있게합니다.
  • State 변수는 주로 Source of Truth 역할을 하기에 private으로 선언됩니다.
  • 다른 view와 이를 공유하고 싶다면, 아래에서 소개될 @Binding이나 @ObservedObject를 사용합니다.
struct ContentView: View {
  @State private var number = 0
}

@Binding

  • Binding 프로퍼티 래퍼는 부모 view의 State 변수와 같은 값을 양방향으로 연결되도록 합니다.
  • 아래 코드에서 isPresentedshowAddView를 바인딩 시켜줘서 값을 변경합니다.
struct ContentView: View {
  @State private var showAddView = false

  var body: some View {
    VStack {
      Button("Trigger") {
        showAddView = true
      }
    }
    .sheet(isPresented: $showAddView) {
      AddView(isPresented: self.$showAddView)
    }
  }
}

struct AddView: View {
  @Binding var isPresented: Bool

  var body: some View {
    Button("Dismiss") {
      self.isPresented = false
    }
  }
}

ObservableObject

  • ObservableObject Protocol은 Combine 프레임워크의 일부로, 객체를 옵저빙 할 수 있게 도와줍니다.
  • 클래스가 ObservableObject Protocol을 준수하도록 해주고, Published 프로퍼티 래퍼를 사용하면 됩니다.
  • Published 변수를 사용하면 변수의 값이 변경 되었다는 것을 View가 알 수 있게 해줍니다.
class MyViewModel: ObservableObject {
  @Published var dataSource: MyModel
  
  init(dataSource: MyModel) {
    self.dataSource = dataSource
  }
}

@StateObject

  • StateObject 프로퍼티 래퍼를 WWDC 2020에서 애플이 추가로 공개했습니다.
  • ObservedObject 프로퍼티 래퍼와 비슷한 방식으로 작동하지만, View가 다시 랜더링 될 때 릴리즈 되는것을 방지해줍니다.
  • State와 마찬가지로, 일반적으로 Source of Truth 역할을 하기에 주로 private으로 선언됩니다.
class User: ObservableObject {
  @Published var name = "Hohyeon Moon"
}

struct ContentView: View {
  @StateObject private var user = User()
}

@ObservedObject

  • ObservedObject 프로퍼티 래퍼는 view가 객체를 옵저빙 할 수 있게합니다.
  • 아래 코드에서 User class는 ObservableObject를 준수하고 @Published 변수를 갖고 있습니다.
  • @ObservedObject user 변수는 이러한 User class 객체를 담고 있습니다.
  • SwiftUI는 이러한 user 객체의 @Published 변수 값이 변경될 때 view를 refresh합니다.
struct ContentView: View {
  @StateObject private var user = User()

  var body: some View {
    ChildView(user: user)
  }
}

struct ChildView: View {
  @ObservedObject var user: User

  var body: some View {
    Text(user.name)
  }
}

@EnvironmentObject

  • @EnvironmentObject는 보통 앱 전반에 걸쳐 공유되는 데이터에 사용됩니다.
  • @EnvironmentObject.environmentObject()를 통해 값을 전달할 수 있습니다.
  • 전달하는 object는 ObservableObject 프로토콜을 준수해야 합니다.
  • 아래 코드와 같이 root view를 제공하면, 어떠한 view에서도 사용이 가능합니다.
class Settings: ObservableObject {
  @Published var version = "0"
}

struct ContentView: View {
  @StateObject var settings = Settings()

  var body: some View {
    MainView()
      .environmentObject(settings)
  }
}

struct MainView: View {
  @EnvironmentObject var settings: Settings

  var body: some View {
    Text(settings.version)
  }
}

Property Wrapper

  • DynamicProperty를 준수하면, 값이 변경됐을때 View의 body가 다시 계산됩니다.
  • State, Binding, StateObject, ObservedObject, EnvironmentObject 등은 DynamicProperty를 준수합니다.
  • SwiftUI에는 이와 같이 여러가지 프로퍼티 래퍼가 있고 이를 접근하는 방식이 여러가지 존재합니다.
  • 아래와 같이 State 변수 number는 number, _number, $number로 접근 가능합니다.
@State private var number = 0

_number // Binding<Int>
number // Int (= _number.wrappedValue)
$number // Binding<Int>: (= _number.projectedValue)

총 정리

  • 이렇게 해서 SwiftUI에서는 라이프사이클과 데이터 흐름을 어떻게 처리하는지 알아봤습니다.
  • swiftuipropertywrappers.com의 이미지를 빌려와 총 정리를 해보면 이렇습니다.
swiftui-data-flow

참고

본문에 링크되어 있는 링크는 제외했습니다