SwiftUI에서 View Refresh없이 값 수정하기

SwiftUI View와 값 타입

값 타입은 속도가 빠르고 기본적으로 불변성이 있어 예측이 쉽다는 장점이 있다. SwiftUI의 View는 값 타입으로만 구현할 수 있고, Equatable(==) 연산을 통해 뷰의 변경사항을 추적하여 변경된 부분에서만 효율적으로 뷰를 그린다.
이 때 변경사항을 추적해 뷰를 다시 그리기 위한 플래그로 뷰 하위 프로퍼티에 @State나 @StateObject와 같은 어노테이션을 사용해 해당하는 값이 변경되었음을 뷰에 알릴 수 있다.

struct MyView: View {
    @State var hasLoaded = false
    
    var body: some View {
        Button {
            if !hasLoaded {
                self.load()
            } else {
                
            }
        } label: {
            Text("Button")
        }
    }
    
    func load() {
        print("Networking-")
        self.hasLoaded = true // 이때 뷰가 리프레쉬
    }
}

뷰 리프레쉬 없이 값 업데이트하기

그런데 개발하다 보면 View를 리프레쉬 시키지 않고 따로 저장할 로직에 필요한 값을 저장할 필요가 있을 수 있다. 그런데 @State 프로퍼티가 아니면 mutating을 할 수 없기 때문에 View의 하위 값을 변경할 수 없다.
로직을 담을 법한 ViewModel이나 Usecase와 같이 다른 객체가 있다면 담당시킬 수 있겠지만, 그렇지 않은 경우에는 뷰에서 변경시키려 하면 값타입을 mutating할 수 없다는 에러를 마주하게 된다.

struct MyView: View {
    var hasLoaded = false
    
    var body: some View {
        Button {
            if !hasLoaded {
                self.load() // Cannot use mutating member on immutable value: 'self' is immutable
            } else {
                // no-op
            }
        } label: {
            Text("Button")
        }
    }
    
    mutating func load() {
        print("Networking-")
        self.hasLoaded = true
    }
}

NonState...?

결과적으로 class와 같이 레퍼런스 타입을 이용해 실질적인 mutating이 이루어지지 않는 값으로 래핑해야 한다. 보통 이럴 경우가 많이 없지만 필요한 경우 class 안에 래핑해서 넣어줄 텐데 이렇게 계속 래핑하기도 번거로워 propertyWraper로 만들었다.(근데 아무리 생각해도 이름이 영 별로다)

@propertyWrapper
/// @State에서 View가 Refresh되지 않고 mutating이 가능한 참조타입 래핑
public final class NonState<T> { // 레퍼런스 타입이 될것
    /// 내부 값타입
    private var value: T
    
    /// 참조타입처럼 보여질 값
    public var wrappedValue: T {
        get { self.value }
        set { self.value = newValue }
    }

    /// 값타입으로 초기화
    public init(wrappedValue: T) {
        self.value = wrappedValue
    }
}

이제 값타입 프로퍼티를 레퍼런스 타입처럼 리프레쉬 없이 mutating할 수 있게 되었다.

struct MyView: View {
    @NonState var hasLoaded = false
    
    var body: some View {
        Button {
            if !hasLoaded {
                self.load()
            } else {
                // no-op
            }
        } label: {
            Text("Button")
        }
    
    func load() {
        print("Networking-")
        self.hasLoaded = true
    }
}