Skip to content

SwiftUI Combine으로 reactive programming

강병민 (Byungmin Kang) edited this page Dec 13, 2020 · 2 revisions

@State, @Binding

SwiftUI와 Combine을 이용하면서 가장 많이 보게될 Property Wrapper에 대해서 정리해보았습니다

예시를 들기 위해서 똑같은 View를 조금씩 다르게 구현해봤습니다

완성된 결과물은 정말 간단한 스위치입니다 ( toggle 이 on일 때만 "Hello World"를 표시합니다)

가장 간단한 방법으로는 하나의 view 내에 모든 코드를 갖고있을떄입니다

struct SampleView: View {
    @State private var isOn = true

    var body: some View {
        VStack {
            Text("This is an Example")
            VStack {
                Toggle(isOn: $isOn) {
                    Text("Show welcome message")
                }.padding()

                if isOn {
                    Text("Hello World!")
                }
            }
        }
    }
}

이 View는 true/false가 매우 중요한 view인데 이 정보를

@State private var isOn = true에 담고 있습니다

Apple 공식 자료와 영상을 보면 이러한 값을 "source of truth"라고합니다

When that state changes, SwiftUI knows to automatically reload the view with the latest changes so it can reflect its new information.

즉 Toggle이 isOn의 값을 바꾸게되면 SwiftUI는 View의 "State"(상태)가 바뀌었다는것을 알아채서 View를 업데이트하게됩니다.

그러면 이번에는

이부분만 SubView로 따로 빼놓겠습니다

이때 Extract SubView를 쓰면 편하겠죠?

따로 SubView로 빼놨더니 이렇게 오류를 뜹니다

해당 View의 state를 표현해줄 정보가 없는것이죠

그러면 방금과 마찬가지로 @State private var isOn = true를 추가하면 될까요?

🙅‍♂️안됩니다🙅‍♂️

이렇게하면 parent View와 child View가 각각의 "source of truth"를 가지게 되어 따로 행동하게 됩니다.

이러한 상황에 쓰이는것이 @Binding입니다

This is exactly what @Binding is for: it lets us create a property in the add user view that says “this value will be provided from elsewhere, and will be shared between us and that other place.” That property literally means “I have a Boolean value called isOn, but it’s being stored elsewhere.” -HackingWithSwift

struct SampleSubView: View {
    @Binding var isOn: Bool
    var body: some View {
        VStack {
            Toggle(isOn: $isOn) {
                Text("Show welcome message")
            }.padding()
            
            if isOn {
                Text("Hello World!")
            }
        }
    }
}

그럼 이제 부모 View에서는 해당값을 넘겨줘야겠죠?

이때 주의해야할 점은 isOn이 아니라 $isOn을 넘기는것입니다.

struct SampleView: View {
    @State private var isOn = true

    var body: some View {
        VStack {
            Text("This is an Example")
            SampleSubView(isOn: $isOn)
        }
    }
}

물론 이렇게 간단한 View일때는 @State를 써도 지장없습니다

하지만 조금더 복잡한 View, 혹은 복잡한 외부의 Model을 쓰게될때는 어떻게 해야할까요?

@StateObject, @ObservedObject, @Published

이때는 @StateObject, @ObservedObject, @Published를 이용하면됩니다

이를 활용하기에 적합한 디자인패턴이 MVVM 패턴입니다

예시 앱으로 간단한 카운터 앱을 만들어보겠습니다.


먼저 Model과 ViewModel을 보여드리면

struct Counter {
    let text: String
    var count: Int
}

class CounterViewModel: ObservableObject {
    @Published var counter = Counter(text: "카운터", count: 0)
    func addCount() {
        counter.count = counter.count.advanced(by: 1)
    }
}

Counter 구조체는 여태껏 해왔듯 정말 단순한 모델입니다

CounterViewModel에서 combine을 이용하게되는데, ObservableObject 프로토콜을 체택합니다 @Published var counter 는 counter라는 변수가 바뀔때마다 알림을 보내겠다고 의미합니다

사실상

var counter = Counter(text: "카운터", count: 0) {
        didSet {
            objectWillChange.send()
        }
    }

와 똑같은 코드인데, 단순화시킨 것이 @Published property wrapper인것 같아요

이제는 해당 ViewModel을 가지는 View를 보겠습니다.

struct CountersView: View {
    @State private var showModal = false
    @StateObject var viewModel = CounterViewModel()
    
    var body: some View {
        
        VStack {
            Text("\(viewModel.counter.text)가 눌린 횟수 : \(viewModel.counter.count)")
            Button("Show Modal") {
                self.showModal.toggle()
            }.sheet(isPresented: $showModal) {
                ModalView(showModal: self.$showModal, viewModel: viewModel)
            }
        }
    }
}

struct ModalView: View {
    @Binding var showModal: Bool
    @ObservedObject var viewModel: CounterViewModel
    
    var body: some View {
        VStack(spacing: 20) {
            Text("\(viewModel.counter.text)가 눌린 횟수 : \(viewModel.counter.count)")
                .padding()
            Button("카운터 횟수 늘리기") {
                viewModel.addCount()
            }
            Button("Dismiss") {
                showModal.toggle()
            }
        }
    }
}

@StateObject var viewModel = CounterViewModel() 부분은 View의 state가 viewModel에 의존적이다를 말해줍니다. viewModel 에서 업데이트가 나타나면 SwiftUI가 그것을 알아차리고 view를 다시 만듭니다.

마찬가지로 ModalView에도 viewModel을 갖고있습니다

흐름을 생각하면

  • ModalView에서 "카운터 횟수 늘리기"버튼을 누르면 view model에게 model을 업데이트하게 함
  • model인 @Published var counter가 업데이트가 되면 viewmodel이 알릴 준비를 함
  • CountersView는 @StateObject, ModalView는 @ObservedObject에게 알려줌

그러면 @StateObject와 @ObservedObject는 뭐가 다를까요?

여러번 얘기 나온 "Source of truth"입니다. view에 @StateObject이 있으면 해당 객체의 주인이라는 뜻이고

@ObservedObject는 그 주인이 외부에있다 라는 뜻입니다.

@StateObject is used to store new instances of reference type data that conforms to the ObservableObject protocol. This owns its data. . @ObservedObject refers to an instance of an external class that conforms to the ObservableObject protocol. This does not own its data. .

잠깐... 그러면 showModal도 ViewModel에 넣어도 되지 않을까요?

class CounterViewModel: ObservableObject {
    **@Published var showModal = false**
    @Published var counter = Counter(text: "카운터", count: 0)
    func addCount() {
        counter.count = counter.count.advanced(by: 1)
    }
}

struct CountersView: View {
    @StateObject var viewModel = CounterViewModel()
    
    var body: some View {
        
        VStack {
            Text("\(viewModel.counter.text)가 눌린 횟수 : \(viewModel.counter.count)")
            Button("Show Modal") {
                viewModel.showModal.toggle()
            }.sheet(isPresented: $viewModel.showModal) {
                ModalView(viewModel: viewModel)
            }
        }
    }
}

struct ModalView: View {
    @ObservedObject var viewModel: CounterViewModel
    
    var body: some View {
        VStack(spacing: 20) {
            Text("\(viewModel.counter.text)가 눌린 횟수 : \(viewModel.counter.count)")
                .padding()
            Button("카운터 횟수 늘리기") {
                viewModel.addCount()
            }
            Button("Dismiss") {
                viewModel.showModal.toggle()
            }
        }
    }
}

네 됩니다.

사실 각 @State를 쓸지, @StateObject를 쓸지는 명확한 정답이 있는것 같지 않습니다.

제 생각에는 view와 관련되면 @State, 그리고 viewmodel/ model 과 관련되어있으면 @StateObject를 쓰는게 좋을 것 같네요

@EnvironmentObject

음악 플레이어 앱을 만들다보면

특정 노래를 touch 하면 "현재 재생목록"에 추가가 되야합니다

원하는 것!

그러면 "현재 재생목록"은 어디에 있어야하고, 어떻게 접근을 해야할까요?

@StateObject로 현재 재생을 관리할 viewmodel을 만들고

Part2에서 해왔듯, @ObservedObject를 통해서 서로가 공유하게 만들수도 있는데, 이렇게하면 viewmodel이 필요하지 않은곳에서 받으면서 넘겨주게 됩니다

이러한 상황을 대비해서 애플은 @EnvironmentObject을 만들었습니다

WWDC에서 소개한 영상을 빌려오자면 더 쉽게 이해할수 있을 것같네요

https://user-images.githubusercontent.com/64558078/100515680-35715100-31c1-11eb-9ed3-ae5543202c83.png

그럼 바로 코드로 가보겠습니다

struct TrackCellView: View {
    
    let track: Track
    var didToggleFavorite: (() -> Void)?
    
    var body: some View {
        HStack {
            Button(action: {
            }, label: {
                TrackInfoView(title: track.title, artist: track.artist)
            })

위의 코드는 노래 cellView하나에대한 내용입니다.

https://user-images.githubusercontent.com/64558078/100515694-4de16b80-31c1-11eb-801c-5b1f0159bc7a.png

위의 사진에서 파란색 테두리의 부분을 눌렀을때 노래를 재생하게 하고 싶습니다

해당 부분을 Button으로 만들어주고, action에서 현재 track을 재생큐

그러면 먼저 현재 재생 그리고 현재 재생 큐를 관리해줄 view model을 보여드리자면

ViewModel

class PlayerViewModel: ObservableObject {
    @Published var currentTrack = Track(id: 1, title: "Dynamite", artist: "방탄소년단", isFavorite: true)
    @Published var queue = [Track]()
    
    func updateWith(track: Track) {
        currentTrack = track
        if queue.contains(where: {$0.id == track.id}) {
            queue.removeAll(where:  {$0.id == track.id})
        }
        queue.append(track)
    }
}

그러면 다시 TrackCellView로 돌아가서 PlayerViewModel를 추가할건데, EnvironmentObject property wrapper를 쓰겠습니다.

struct TrackCellView: View {
    
    let track: Track
    '''
    @EnvironmentObject var playerViewModel: PlayerViewModel

그리고 Button의 action에서 viewmodel에게 현재의 track정보를 넘기면도비니다.

struct TrackCellView: View {
    '''
    var body: some View {
        HStack {
            Button(action: {
                playerViewModel.updateWith(track: track)
            }, label: {
                TrackInfoView(title: track.title, artist: track.artist)
            })

그러면 environment object는 어디에서 지정해주면될까요?

PlayerViewModel이 필요할 곳에게 모두 뿌려주고 싶을경우에는

    @StateObject var playerViewModel =  PlayerViewModel()

TabView {
            TodayView()
                .tabItem {
                    Image(systemName: "1.square.fill")
                    Text("First")
                }
                .overlay(NowPlayingView(viewModel: playerViewModel), alignment: .bottom)
        }
        .environmentObject(playerViewModel)

를 넣어서 적용하면 됩니다.

완성 화면

참고자료

https://www.hackingwithswift.com/quick-start/swiftui/all-swiftui-property-wrappers-explained-and-compared

https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-stateobject-property-wrapper

https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-observedobject-property-wrapper

https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

https://developer.apple.com/tutorials/swiftui/

Clone this wiki locally