[SwiftUI] 몰라서 당했다, @State

valse·2022년 11월 16일
11

Swift

목록 보기
7/7
post-thumbnail

프로퍼티 래퍼

Swift 5.1 버전에서 등장한 프로퍼티 래퍼(Property Wrapper)는 SwiftUI 와 막강한 시너지를 뽐내고 있다.
SwiftUI는 현재까지 4.0 버전을 거치며 애플의 지속적인 관리 아래에 있는데, Swift 의 문법 체계를 바탕으로 만들어진 프레임워크라 그런지 궁합이 발군이다.

구조체로 뷰를 그려내는 SwiftUI의 특성상 값의 변형을 조작하기가 꽤 까다로웠을 것이라 생각한다.
유저의 값이 내부 데이터에 변화를 줄 때마다 값을 새로 할당하는 로직에 프로퍼티 래퍼가 등장해서 고민의 많은 부분은 해결되었다.

그런데 아직 그 용법이나 내부 구조를 정확히 아는 것은 아니어서 따로 정리해보려 한다.
물론 내가 삽질했던 내용들과 공식 가이드 코드들을 함께 뜯어볼 예정.


프로퍼티 래퍼 등장배경

프로퍼티 래퍼 자체는 "너무 많이 반복되는 코드를 추상화해서 재사용할 건데, 그 재사용할 로직을 저장해 둔 로직 그 자체" 이다.
그래서 프로퍼티 래퍼로 선언하는 속성은 속성 그 자체가 추상화된 로직에 갇힌다고 볼 수 있다.
@ 어트리뷰트를 통해 속성을 선언하면서 프로퍼티 래퍼로 감싸줄 수가 있다.


프로퍼티 래퍼 정의와 설명

이를 통해 프로퍼티 래퍼에다가 초기값을 "전달"할 수 있고, 뷰 구조체 외부의 다른 구조체에서 보관된다.
프로퍼티 래퍼 자체는 "구조체"이기 때문에 그 생성자를 호출할 수 있을 것 같지만, 그렇게 쓰지 말라고 공식 문서 가이드에 나와있다.
그래서 프로퍼티 래퍼 뒤에 선언되는 이름의 변수는 사실 프로퍼티 래퍼 구조체의 인스턴스이다.

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    // state를 초기값과 함께 생성한다.
    // 직접 생성자를 호출하지 말고, 아래와 같이 @State 어트리뷰트로 선언하여 초기값을 전달한다.
    // @State private var isPlaying: Bool = false
    
    // - 파라미터로 받는 wrappedValue: state의 초기 "감싸진 값"을 받는다.
    public init(wrappedValue value: Value)

    // -> state을 "초기값" 으로 생성한다.
    // - Parameter value: state의 "초기값".
    public init(initialValue value: Value)

    // state의 변수를 통해 그 내부의 값이 "참조된다(referenced)"
    // 이 속성은 데이터에 접근할 수 있게 하지만, 우리가 wrappedValue에 직접 접근하는 것은 아니다.
    // State 어트리뷰트로 생성된 속성 변수를 참조(refer)하는 것이다.
    // 아래 예시에 따르면, `isPlaying`의 값에 따라 버튼의 라벨은 변하며,
    // 액션은 `isPlaying`의 값을 토글한다.
    // 이러한 접근들은 모두 암묵적으로 state 속성의 "감싸진 값"에 의존적이다.
    
    // struct PlayButton: View {
    //     @State private var isPlaying: Bool = false
    //
    //     var body: some View {
    //         Button(isPlaying ? "Pause" : "Play") {
    //             isPlaying.toggle()
    //         }
    //     }
    // } 

	// 위에서 계속 언급했던 "감싸진 값" 이다.
    // 조금 생소한 nonmutating 키워드가 있는데, 이것이 구조체이면서 값을 변형할 수 있는 로직의 핵심이다.
    // nonmutating setter 덕분에 wrappedValue 자체는 "변하지 않는다"
    // 표현 그대로 mutating하지 않기 때문에 새로운 값이 들어와도 값이 "변질되지 않는다"
    // 기저에 숨은 로직이 wrappedValue를 '참조스럽게' 작동하게 하고 값 자체에 할당되는 값으로는 wrappedValue가 set되지 않도록 한 것이다.
    // nonmutating set을 쓰는 이유는 View 프로토콜의 body가 mutating을 받지 않기 때문.
    public var wrappedValue: Value { get nonmutating set }

    // state에 바인딩하기
    //
    // 뷰 계층의 하위로 바인딩 값으로 전달하기 위해 투사된 값(projected value)을 사용할 수 있다.
    // (하위 뷰에서) 이 투사값을 얻고 싶다면 $ 전치 기호를 사용한다.
    // 아래 예시에서 State isPlaying은 PlayButton() 에 투사된 값을 전달하고 있는 것이다.
    // struct PlayerView: View {
    //     var episode: Episode
    //     @State private var isPlaying: Bool = false
    //
    //     var body: some View {
    //         VStack {
    //             Text(episode.title)
    //                 .foregroundStyle(isPlaying ? .primary : .secondary)
    //             PlayButton(isPlaying: $isPlaying)
    //         }
    //     }
    // }
    //
    public var projectedValue: Binding<Value> { get }
}

그 밑에선 무슨 일이

위의 설명이 코드와 각주가 지저분하게 얽혀있지만, State의 형태를 정리하면 아래와 같다.

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    public var wrappedValue: Value { get nonmutating set }
    public var projectedValue: Binding<Value> { get }
    
    public init(wrappedValue value: Value)
    public init(initialValue value: Value)
}

@State private var someValue = "name"
  • 위의 예시 하단에 선언된 someValue 는 선언되는 시점에 State 구조체에서 분리된다.
private var _someValue: State<String> = State(initialValue: "name")
private var $someValue: Binding<String> { return _someValue.projectedValue }
private var someValue: Bool {
    get { return _someValue.wrappedValue }
    nonmutating set { _someValue.wrappedValue = newValue }
}
  • 구조체가 갖고 있는 wrappedValue, projectedValue, 그리고 외부의 someValue 로 구성되는 총 3가지의 속성이 존재하게 된다.

  • 첫번째 속성은 제너릭 State에 초기값 "name"을 갖고 있는 속성(State 구조체의 인스턴스)이다.
    이 속성은 이름 앞에 언더바 _를 갖고 있으며, Binding할 때 자기 자신의 속성인 projectedValue 를 전달한다.
    아래의 2개 속성은 모두 이 State 구조체 인스턴스의 속성에 의해 값이 결정된다.

  • 두번째 속성은 $someValue 이다.
    하위 뷰가 상위 뷰의 투사값을 가져올 때의 타입이며, 실제 값을 갖고 있지 않다.
    그래서 바인딩된 projectedValue에 아무리 값을 할당해봤자 실제값이 담긴 wrappedValue는 조금도 변하지 않는다.

  • 세번째 속성은 someValue 이다. 이 속성은 호출될 때는 State 구조체 인스턴스의 wrappedValue를 가져온다.
    할당될 때에는 마치 참조체에서 그러하듯 newValue 를 "덮어씌운다".
    nonmutating 키워드는 값 타입을 set할 때, value의 값이 아닌 value의 메모리 주소를 oldValue에 덮어씌운다고 한다.
    모든 프로퍼티 래퍼에서 nonmutating set을 사용하는 것은 아니다.


Source of Truth

그래서 등장하는 개념이 바로 정보의 출처가 믿을만한지를 따져보는 Source of Truth이다.
Binding된 데이터는 projectedValue 이기 때문에 실제로 State 구조체 인스턴스 내부의 wrappedValue와는 하등 상관이 없다.
그래서 Binding된 데이터는 실제 데이터를 갖고 있지 않다고 판단하고, wrappedValue 를 갖는 State 구조체 인스턴스가 Source of Truth가 된다.

이 차이 때문에 엄한 곳에다가 데이터를 변형시켜놓고 "뭐야 왜 안 바뀌는건데" 하면 곤란하다.
내가 정말 너무 오랜 시간동안 그랬다.


결론

아직 SwiftUI API로 제공되는 수많은 프로퍼티 래퍼를 다 살펴보지 못했다...
이게 얼마나 긴 분량이 될지 모르겟다.
그런데 오늘 @State 정리해보니까 너무 유익해서 다음에도 이어서 할 듯 하다.

참조

개인정리내용
0258-property-wrappers.md
about nonmutating

profile
🦶🏻🦉(발새 아님)

2개의 댓글

comment-user-thumbnail
2022년 11월 18일

주석으로 처리된 부분이 눈에 잘 안들어왔던 것 같아요! 내용 자체는 너무 유익했습니다

1개의 답글