Publisher 활용해서 하위 View에 정보 전달하기 (feat: SwiftUI 언제 화면을 다시 그릴까?)

SteadySlower·2022년 8월 11일
0

🪲 고쳐야 할 버그

이전에 랜덤으로 단어들을 랜덤으로 섞어주는 기능을 개발했었는데요. 사용자들이 이 기능을 사용하는 이유는 같은 단어를 랜덤한 순서로 다시 한번 테스트하기 위함입니다. 따라서 단어들은 랜덤으로 섞이는 순간 다시 앞면(한글)이 보여야 합니다.

하지만 아래 캡쳐에서 볼 수 있듯이 랜덤을 눌러도 이미 뒷면(가나)으로 되어있는 단어들은 뒷면인 상태로 유지가 되는 것을 볼 수 있습니다. 더 정확하게 이야기하면 단어에 상관 없이 뒷면으로 되어 있는 Cell들이 뒷면으로 유지되고 있습니다.

랜덤 버튼을 누르는 순간 viewModel.words가 셔플이 됩니다. View 부분은 아래와 같습니다.

LazyVStack(spacing: 32) {
	ForEach(0..<viewModel.words.count, id: \.self) { index in
	    WordCell(wordBook: viewModel.wordBook, word: $viewModel.words[index], delegate: viewModel)
	        .frame(width: deviceWidth * 0.9, height: viewModel.words[index].hasImage ? 200 : 100)
		}
}

원인

뷰에서 뷰모델에 있는 단어를 받아서 각각의 WordCell을 init하는 구조로 되어 있습니다. 단어를 shuffle하게 되면 @Published로 선언된 배열이 바뀌기 때문에 당연히 ForEach 안에 선언되어 있는 View도 re-render 되고 re-initialized 됩니다. 당연히 re-initialized 된다면 Cell은 초기값인 앞면으로 돌아와야 합니다.

하지만 지금 같은 버그가 발생하는 것은 SwiftUI 내부적인 작동 방식의 차이 때문입니다. SwiftUI의 작동방식은 우리의 상식(?)과는 약간 다릅니다. SwiftUI의 입장에서 생각하는 방식을 알기 위해 몇가지 실험을 한번 해봤습니다.

🧪 실험 1: 상위의 @State를 변경할 때

간단한 프로그램입니다. 상위 View인 Table에 있는 버튼을 누르면 @State로 선언된 list가 shuffle 되면서 순서가 바뀝니다. 결과적으로 list 안의 이름을 순서대로 연결한 Text 뿐만 아니라 ForEach 안에 있는 View들이 re-render 되면서 목록의 순서가 바뀌는 것으로 예상할 수 있을 것입니다.

import SwiftUI

struct Table: View {
    
    @State var list = ["Kim", "Lee", "Park", "Choi"]
    
    var body: some View {
        Text("list = \(list.joined(separator: ", "))")
        VStack {
            ForEach(0..<list.count, id: \.self) { index in
                Cell(list[index])
            }
            Button("Shuffle") {
                list.shuffle()
            }
        }
    }
}

struct Cell: View {
    @State var string: String
    
    init(_ string: String) {
        self.string = string
        print("re-init Cell of \(string)")
    }
    
    var body: some View {
        Text(string)
    }
}

하지만 셔플 버튼을 누르면 예상대로 Cell 객체가 다시 init 되지만 Cell 들의 순서에는 아무런 변화가 없습니다.

이는 SwiftUI가 최대한 경제적으로 동작해서 View를 최대한 덜 re-render하려고 하기 때문입니다.

먼저 VStack의 맨 위에 있는 Text는 같은 객체에 선언된 @State 변수가 바뀌었으니 당연히 바뀌게 됩니다.

상위 View인 Table 입장에서는 @State 변수가 바뀌었으니 View가 re-render되는 것은 당연한 일입니다. 따라서 상위 View 안의 ForEach 문 안에 선언된 객체들은 re-init되는 것이죠.

하지만 Cell 내부는 별개의 문제입니다. Cell 내부의 string 변수는 부모 View에서 데이터를 받아서 init이 되기는 하지만 init된 이후에는 부모뷰의 list와는 별개의 데이터입니다. 따라서 SwiftUI 입장에서 Cell 안의 @State로 선언된 데이터는 전혀 변하지 않았습니다. 따라서 Cell의 화면을 그리는 SwiftUI의 입장에서는 데이터가 바뀌지 않았으니 화면을 다시 그리지 않습니다.

이 실험을 통해서 우리가 코드로 메모리에 저장한 데이터가 실제 화면이 100% 일치하는 것이 아니라는 것을 알 수 있습니다. SwiftUI는 화면 랜더링을 효율적으로 하기 위한 고유의 로직을 가지고 있습니다.

🧪 실험 2: 하위 View에 Binding으로 전달할 때

그렇다면 이번에는 상위 View와 하위 View의 데이터를 Binding으로 연결 해보겠습니다.

import SwiftUI

struct Table: View {
    
    @State var list = ["Kim", "Lee", "Park", "Choi"]
    
    var body: some View {
        Text("list = \(list.joined(separator: ", "))")
        VStack {
            ForEach(0..<list.count, id: \.self) { index in
                Cell($list[index]) //👉 Binding으로 전달
            }
            Button("Shuffle") {
                list.shuffle()
            }
        }
    }
}

struct Cell: View {
    @Binding var string: String
    
    init(_ string: Binding<String>) {
        self._string = string
        print("re-init Cell of \(string.wrappedValue)")
    }
    
    var body: some View {
        Text(string)
    }
}

이렇게 하면 SwifitUI는 상위 View에 있는 @State 변수가 변경되었을 때 같은 참조를 가지는 하위 View의 @Binding 역시 변경되었다는 것을 감지합니다. 따라서 SwiftUI가 하위 View인 Cell의 화면을 다시 그리게 되는 것이죠.

⭐️ 결론적으로 하위 객체의 화면을 바꾸기 위해서는 하위 객체 안에 @State 혹은 @Binding으로 선언된 데이터를 바꿔주어야 합니다. 아무리 부모 View의 데이터가 변경되어 하위 객체 자체가 re-init 되었다고 해도 SwiftUI가 화면을 바꾸어 주지는 않습니다.

🧪 실험3: @Binding과 @State의 혼용

위의 실험코드를 통해 지금 우리가 마주친 버그를 구현해보겠습니다. 상위 View의 데이터를 하위 View에 @Binding으로 전달하는 것까지는 실험2와 동일합니다. 하지만 하위 View는 별도의 isTitle이라는 @State 변수를 가지고 있습니다. isTitle이 true일 때는 Text가 커지며 이는 tapGesture를 통해 toggle할 수 있도록 했습니다.

import SwiftUI

struct Table: View {
    
    @State var list = ["Kim", "Lee", "Park", "Choi"]
    
    var body: some View {
        Text("list = \(list.joined(separator: ", "))")
        VStack {
            ForEach(0..<list.count, id: \.self) { index in
                Cell($list[index])
            }
            Button("Shuffle") {
                list.shuffle()
            }
        }
    }
}

struct Cell: View {
    @Binding var string: String
    @State var isTitle = false
    
    init(_ string: Binding<String>) {
        self._string = string
				print("re-init Cell of \(string.wrappedValue)\nisTitle: \(isTitle)")
    }
    
    var body: some View {
        Text(string)
            .font(.system(isTitle ? .title : .body))
            .onTapGesture { isTitle.toggle() }
    }
}

우리는 실험 1, 2를 통해 상위 View의 데이터가 변경이 되어서 하위 View가 init이 되더라도 하위 View의 @State가 변경되지 않으면 SwiftUI는 화면을 다시 그려주지 않는다는 것을 배웠습니다.

SwiftUI 입장에서 보면 결과는 예측할 수 있습니다. SwiftUI가 변경되었다고 통보 받는 데이터는 list 뿐입니다. 따라서 list와 연결된 상위 View의 Text와 하위 Cell의 각각의 Text를 변경합니다. 하지만 isTitle가 명시적으로 변경되지는 않았으므로 isTitle에 연결된 글자 크기는 그대로 유지합니다.

비록 상위 View의 데이터가 바뀌어서 하위 View 객체가 다시 init되고 그 때문에 isTitle 값이 초기값인 false가 되더라도 SwiftUI는 절대 화면을 바꾸어주지 않습니다. SwiftUI에 연결된 @State 변수를 명시적으로 변경하지 않았기 때문입니다.

해결책

🧪 실험 4: isTitle을 상위 데이터로 이동

해결책 중에 첫번째는 isTitle 속성을 상위 View로 옮기는 것입니다. 이렇게 하면 list를 shuffle하는 타이밍에 isTitle 속성도 모두 false로 만들어 줍니다. @State가 변경되었으므로 SwiftUI는 isTitle 속성이 바뀐 것도 화면에 반영해 줄 것입니다.

import SwiftUI

struct Name {
    let name: String
    var isTitle = false
}

struct Table: View {
    
    @State var list = [
        Name(name: "Kim"),
        Name(name: "Lee"),
        Name(name: "Park"),
        Name(name: "Choi")
    ]
    
    var body: some View {
        Text("list = \(list.map{ $0.name }.joined(separator: ", "))")
        VStack {
            ForEach(0..<list.count, id: \.self) { index in
                Cell($list[index])
            }
            Button("Shuffle") {
                list.shuffle()
								(0..<list.count).forEach { list[$0].isTitle = false }
            }
        }
    }
}

struct Cell: View {
    @Binding var name: Name
    
    init(_ name: Binding<Name>) {
        self._name = name
        print("re-init Cell of \(name.name.wrappedValue)\nisTitle: \(name.isTitle.wrappedValue)")
    }
    
    var body: some View {
        Text(name.name)
            .font(.system(name.isTitle ? .title : .body))
            .onTapGesture { name.isTitle.toggle() }
    }
}

🚫  하지만 이 방법의 경우 하위 View의 속성을 상위 View가 가지고 있다는 점, 그리고 데이터 Model이 View가 가지고 있어야 할 글자크기라는 View 관련 속성을 가지고 있다는 점에서 그렇게 좋은 코드라고 생각하지 않습니다.

🧪 실험5: Publisher로 연결

만약에 isTitle 값을 하위 View에 그대로 두고 isTitle 값을 초기값인 false로 변경하고 싶다면 하위 View에서 직접 할당하는 방법 외에는 없습니다. 즉 상위 View에서 데이터를 shuffle할 때 하위 View의 isTitle에 false를 직접 할당해야 합니다. Combine을 활용해서 간단한 Publisher를 만들고 그 Publisher를 통해서 하위 View에 shuffle 될 때 isTitle을 false로 바꾸는 코드를 넣어봅시다.

import SwiftUI
import Combine

struct Table: View {
    
    @State var list = ["Kim", "Lee", "Park", "Choi"]
    let shufflePublisher = PassthroughSubject<Void, Never>()
    
    var body: some View {
        Text("list = \(list.joined(separator: ", "))")
        VStack {
            ForEach(0..<list.count, id: \.self) { index in
                Cell($list[index], publisher: shufflePublisher)
            }
            Button("Shuffle") {
                list.shuffle()
                shufflePublisher.send()
            }
        }
    }
}

struct Cell: View {
    @Binding var string: String
    @State var isTitle = false
    let shufflePublisher: PassthroughSubject<Void, Never>
    
    init(_ string: Binding<String>, publisher: PassthroughSubject<Void, Never>) {
        self._string = string
        self.shufflePublisher = publisher
        print("re-init Cell of \(string.wrappedValue)\nisTitle: \(isTitle)")
    }
    
    var body: some View {
        Text(string)
            .font(.system(isTitle ? .title : .body))
            .onTapGesture { isTitle.toggle() }
            .onReceive(shufflePublisher, perform: { print("published"); isTitle = false })
    }
}

상위 View가 데이터를 변환하는 타이밍에 publisher가 발행을 하고 하위 View에서는 그 publisher가 발행이 될 때 isTitle에 false를 할당합니다.

⭐️ SwiftUI 입장에서 한번 봅시다. SwiftUI가 변경되었다고 통보 받는 데이터는 list와 isTitle입니다. 따라서 list에 연결된 화면의 이름들이 shuffle될 뿐만 아니라 isTitle에 연결된 글자 크기 역시도 .body로 변경되는 것을 볼 수 있습니다.

버그 해결

위 실험에서 얻은 결과로 버그를 해결해보겠습니다. 사용할 방법은 실험 5에서 Combine을 사용한 방법과 동일합니다.

상위 View에 Publisher 선언하고 하위 View에 참조를 전달

// 상위 View의 ViewModel
var shufflePublisher = PassthroughSubject<Void, Never>()

// 셔플할 때 발행
@Published var words: [Word] = []

func shuffleWords() {
    words.shuffle()
    shufflePublisher.send()
}

// 하위 View인 Cell에 전달
ForEach(0..<viewModel.words.count, id: \.self) { index in
    WordCell(wordBook: viewModel.wordBook, word: $viewModel.words[index], shuffleProvider: viewModel.shufflePublisher)
        .frame(width: deviceWidth * 0.9, height: viewModel.words[index].hasImage ? 200 : 100)
}

하위 View에서 Publisher가 발행되면 단어의 앞면이 보이도록 함

@State private var isFront = true

.onReceive(shufflePublisher) {
    isFront = true
}

결과

랜덤을 누르면 상위 View의 @Published 변수인 words가 shuffle되면서 단어들의 순서가 바뀝니다. 그리고 publisher가 발행되면서 하위 View의 @State 변수인 isFront 또한 true로 변경되면서 모든 카드가 앞면으로 바뀝니다.

SwiftUI의 입장에서 볼까요? SwiftUI는 words와 isFront가 변경되었음을 알고 해당 화면인 단어의 순서와 단어의 앞면을 다시 그려줍니다.

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

0개의 댓글