하위 View에 @ObservedObject 전달할 때 주의점

SteadySlower·2022년 9월 4일
0

현재 발생하는 버그

단어장 목록에서 현재 단어장을 성공 / 실패 여부를 초기화하고 싶을 때 (하얀 배경) 더블 탭을 통해 초기화할 수 있습니다.

private var doubleTapGesture: some Gesture {
    TapGesture(count: 2)
        .onEnded { viewModel.updateStudyState(to: .undefined) }
}

그리고 이 결과는 바로 View에 반영되어야 합니다. 하지만 아래 캡쳐에서 보듯이 바로 반영되지 않는 버그가 발생했습니다. 더블탭을 해도 흰색으로 초기화 되지 않고 뒷면으로 넘기기 등 다른 View 요소를 바꾸어야 View에 반영되었습니다.

코드 분석

원인은 ViewModel을 하위에 전달하는 과정에 있었습니다. 코드와 함께 자세히 보겠습니다.

일단 ViewModel입니다. ViewModel 안에는 word가 @Published로 선언되어 있습니다. word가 바뀌면 View를 re-render하기 위함입니다.

final class ViewModel: ObservableObject {
    @Published var word: Word

		// ... 하략 ...
}

상위 View에는 @ObservedObject로 선언되어 있습니다. 해당 ViewModel을 하위 View에 전달합니다

// 상위 View
struct WordCell: View {
		//👉 @ObservedObject로 선언됨
    @ObservedObject private var viewModel: ViewModel
		@GestureState private var dragAmount = CGSize.zero
    @State private var isFront = true

    // MARK: Body
    var body: some View {
        ContentView(isFront: isFront, viewModel: viewModel, cellFaceOffset: dragAmount)
            .onReceive(viewModel.eventPublisher) { handleEvent($0) }
            .gesture(dragGesture)
            .gesture(doubleTapGesture)
            .gesture(tapGesture)
            .onLongPressGesture { }
    }
}

하위 View에서는 그 ViewModel을 받아서 @ObservedObject가 아닌 일반적으로 let으로 선언해서 가지고 있습니다. 다른 @State 변수들 (isFront와 cellFaceOffset) 역시도 그냥 let으로만 가지고 있습니다.

그런데 isFront와 cellFaceOffset이 바뀔 때는 멀쩡하게 View가 바로바로 업데이트 되지만 왜 viewModel.word.studyState의 값이 변경되었는데도 View가 업데이트 되지 않는 것일까요?

private struct ContentView: View {
    private let isFront: Bool
		//👉 @ObservedObject 아님
    private let viewModel: ViewModel
    private var cellFaceOffset: CGSize
    
    init(isFront: Bool, viewModel: ViewModel, cellFaceOffset: CGSize) {
        self.isFront = isFront
        self.viewModel = viewModel
        self.cellFaceOffset = cellFaceOffset
    }

    var body: some View {
        GeometryReader { proxy in
            ZStack {
                WordCellBackground(imageHeight: proxy.frame(in: .local).height * 0.8)
                ZStack {
                    CellColor(state: viewModel.word.studyState)
                    if isFront {
                        WordCellFace(text: viewModel.frontText, imageURLs: viewModel.frontImageURLs)
                    } else {
                        WordCellFace(text: viewModel.backText, imageURLs: viewModel.backImageURLs)
                    }
                }
                .offset(cellFaceOffset)
            }
        }
    }
}

private struct CellColor: View {
    private let state: StudyState
    
    init(state: StudyState) {
        self.state = state
    }
    
    var body: some View {
        switch state {
        case .undefined:
            Color.white
        case .success:
            Color(red: 207/256, green: 240/256, blue: 204/256)
        case .fail:
            Color(red: 253/256, green: 253/256, blue: 150/256)
        }
    }
}

버그의 원인

ObservedObject와 State의 차이

먼저 isFront와 dragAmount가 변경될 때 View가 업데이트되는 이유에 대해서 알아봅시다. 최상위 View (WordCell)에 @State로 선언된 두 변수의 값이 변경될 때 WordCell의 body에 해당 변수와 연결된 View를 업데이트합니다. 코드에서 보면 ContentView의 인자로 전달이 되므로 (= 연결되어 있으므로) ContentView를 re-render 해서 View를 업데이트합니다.

그렇다면 본론으로 돌아가서 viewModel 안의 @Published인 word가 변경되어도 ContentView가 re-render되지 않는 이유는 무엇일까요? 일단 ContentView에는 viewModel이 연결된 것이지 viewModel 안의 @Published 객체와 연결된 것이 아닙니다.

@ObservedObject는 해당 변수 자체가 변경될 때 View를 업데이트하는 것이 아니라 내부에 있는 @Published 변수가 변경될 때 View를 업데이트 된다는 의미입니다.

(참고로 애초에 class는 reference type이기 때문에 내부 property인 word를 변경되었다고 viewModel이 변경된 것으로 간주하지도 않습니다.)

그렇다면 하위 View에서는?

하위 View인 ContentView를 봅시다. body 부분을 보면 하위 View인 CellColor에 ObservableObject 안에 @Published로 선언되어 있는 word의 property를 전달합니다. 그렇다면 여기서 View의 업데이트가 이루어져야 할 텐데 왜 안될까요?

private struct ContentView: View {
	//👉 @ObservedObject 아님
  private let viewModel: ViewModel
}

이번에는 ContentView의 viewModel 객체가 @ObservedObject로 선언되어 있지 않습니다. 상위 View에서 @ObservedObject로 선언되었는지 여부는 하위 View가 알 수도 없고 알바도(?!) 아닙니다. 지금 당장 이 View에서 @ObservedObject로 선언되어 있지 않기 때문에 View의 업데이트는 일어나지 않는 것이죠.

해결책

원인을 알았으니 해결책은 간단합니다. ContentView 안에 있는 viewModel ObservedObject로 선언하는 것입니다. 이렇게 하면 word.state가 변경되었을 때 상위 View인 WordCell은 View를 업데이트하지 않지만 하위 View인 ContentView가 그 하위 View인 CellColor를 업데이트합니다.

private struct ContentView: View {
    private let isFront: Bool
		//👉 @ObservedObject로 선언
    @ObservedObject private var viewModel: ViewModel
    private var cellFaceOffset: CGSize
    
    init(isFront: Bool, viewModel: ViewModel, cellFaceOffset: CGSize) {
        self.isFront = isFront
        self.viewModel = viewModel
        self.cellFaceOffset = cellFaceOffset
    }

    var body: some View {
        GeometryReader { proxy in
            ZStack {
                WordCellBackground(imageHeight: proxy.frame(in: .local).height * 0.8)
                ZStack {
                    CellColor(state: viewModel.word.studyState)
                    if isFront {
                        WordCellFace(text: viewModel.frontText, imageURLs: viewModel.frontImageURLs)
                    } else {
                        WordCellFace(text: viewModel.backText, imageURLs: viewModel.backImageURLs)
                    }
                }
                .offset(cellFaceOffset)
            }
        }
    }
}

(참고) 좌우 드래그는 작동했던 원인

이 버그를 발견하는데 오랜 시간이 걸린 이유는 더블탭에 대해서만 해당 버그가 발생했기 때문입니다. 왜 드래그 제스쳐 또한 word.state를 변경시키는데 위와 같은 버그가 발생하지 않을까요?

원인은 드래그의 경우는 ContentView를 업데이트 하기 때문입니다. 드래그의 경우 @GestureState의 값을 변경 시키는데요. 이 변수는 ContentView을 업데이트 합니다. 따라서 ContentView의 하위 View인 CellColor 역시도 re-render 되고요. (= re-init) 그 과정에서 새로 할당된 viewModel.state 값을 다시 읽어올 수 있게 됩니다.

즉 viewModel.state의 변경으로 CellColor가 업데이트 되는 것이 아니라 상위 View가 업데이트 되는 과정에서 덩달아 업데이트 되는 것이죠.

아래 캡쳐를 보시면 드래그를 할 때마다 CellColor가 re-init 되는 것을 볼 수 있습니다.

추가: 하위 View에 ViewModel을 전달할 때 EnvironmentObject를 사용하는 법

위 처럼 viewModel을 일일히 전달해도 되지만 매번 initializer에 넣어서 구현하는 것은 복잡합니다. 이 포스팅을 참고해서 더 쉽게 하위 View에 ViewModel을 전달하는 법을 알아보세요!

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글