Environment 와 Transaction

지난번에 포스팅 하지 못했던 UIViewRepresentable 관련 내용을 정리하고자 합니다.
개발 및 테스팅 환경은 지난 포스팅과 동일합니다.


Environment

Context 객채에서 아래와 같이 접근할 수 있는 environment 에 대해서 먼저 알아보도록 하겠습니다.

정의부터 살펴보도록 하겠습니다.

A collection of environment values propagated through a view hierarchy.

뷰 하이어라키를 통해 전달되는 environment value 의 컬렉션 입니다.
여기서 설명하는 environment value 란, SwiftUI (시스템) 가 자동적으로 세팅 및 업데이트 해주는 pixelLength, scenePhase, locale 등이 존재합니다.
위와 같은 value 들을 environment(_:_:) 메소드를 통해 아래와 같이 override 나 set 해줄 수 있습니다.

MyView()
    .environment(\.lineLimit, 2)

몇몇 value 에 대해서는 위 코드보다 더 읽기 쉬운 직접적인 메소드를 사용하여 값을 적용해 줄 수 있습니다.

MyView()
    .lineLimit(2)

시스템이 자동으로 제공하는 값 이 외에도 EnvironmentKey 프로토콜을 준수하는 타입을 만들어 커스텀한 Environment Value 를 생성 할 수 있습니다.

EnvironmentKey Protocol

Environment key 는 아래와 같이 코드에 정의되어 있습니다.

애플 예제를 참고하여 직접 CustomEnvironment Value 를 만들어 보도록 하겠습니다.

먼저, static 변수인 defaultValue 를 작성해 주어야 합니다.

private struct WeatherEnvironmentValue: EnvironmentKey {
    static let defaultValue: String = "Sunny!"
}

defaultValue 을 작성할 때 타입을 명시 한다면 따로 이를 통해 컴파일러가 자동으로 관련값을 유추해내기 때문에 typealias Value = ... 와 같은 코드를 작성하지 않아도 됩니다.

다음으로 생성한 EnvironmentKey 에 대응되는 EnvironmentValues property 를 생성해 주어야 합니다.

extension EnvironmentValues {
    var weather: String {
        get { self[WeatherEnvironmentValue.self] }
        set { self[WeatherEnvironmentValue.self] = newValue }
    }
}

EnvironmentValues 는 key - value 형태로 저장되어 있기 때문에, 위와 같이 접근하여 값을 얻거나 세팅해 줄 수 있습니다.

생성한 EnvironmentValue 를 사용하기 위해 위에서 소개했던 environment(_:_:) 메소드를 이용하거나, View 의 Extension 을 통해 새로운 메소드를 생성하여 사용해 줄 수 있습니다.
또 하나의 방법으로 @Environment 를 이용하여 readonly 변수로 사용할 수 있습니다.

extension View {
    func weather(_ weatherValue: String) -> some View {
        environment(\.weather, weatherValue)
    }
}

Text("plain text")
	.weather("Cloudy :(") // way 1
Text("plain text")
	.environment(\.weather, "Cloudy :(") // way 2
@Environment(\.weather) var weather: String // way 3 -> read only!!

어디에 어떻게 쓰게 될지 아직 정확히 감은 안오지만, 위와 같이 Custom 하게 만든 EnvironmentValues 를 사용한 예제를 만들어 보았습니다.

struct ContentView: View {
    @State var weatherString: String = "" // [1]
    
    var body: some View {
        VStack {
            LottieView()
                .frame(width: 200, height: 200, alignment: .center)
                .weather(weatherString) // [2]
            Text("지금 날씨는 ??")
            HStack {
                Button("맑음") {
                    weatherString = "sunny" // [1]
                }
                Button("흐림") {
                    weatherString = "cloudy" // [1]
                }
            }
        }
    }
}

struct LottieView: UIViewRepresentable {
    typealias UIViewType = UIView
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        if context.environment.weather.count > 0 {
            let animation = AnimationView(animation: .named(context.environment.weather)) // [3]
            
            uiView.addSubview(animation)
            animation.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                animation.widthAnchor.constraint(equalTo: uiView.widthAnchor),
                animation.heightAnchor.constraint(equalTo: uiView.heightAnchor)
            ])
            
            animation.play { _ in
                animation.removeFromSuperview()
            }
        }
    }
}

버튼을 통해 weather environment value 를 update 하여 해당 weather environment value 에 맞는 Lottie 를 재생시키는 예제입니다.

[1] 버튼에 따라 weather environment 에 지정할 string value 를 지정합니다.
[2] 아까 작성하였던 weather 메소드로 environment value 를 수정합니다.
[3] environment update 에 따른 updateUIView 의 호출에서
지정된 environment value 에 접근해 해당 value 에 맞는 Lottie 를 재생합니다.

실행 결과는 아래와 같습니다.

이 예제가 Environment 를 올바르게 사용한 건지 잘은 모르겠으나, 위와 같이 사용할 수 있습니다.

여기서 드는 궁금증으로,

설정한 Environment 값이 다른 View 에서도 같은 값으로 전달 받게 될까?

라는 점이 예제를 만드는 도중 해당 부분이 궁금해서 테스트 해봤는데,
로티 재생 없이 environment value 를 print 해본 결과 아래와 같습니다.


흐림 버튼을 눌렀을 때 weather 메소드로 environment value 를 지정해준 view 1 은 "cloudy" 로 표시되었지만, weather 사용이 없는 view 2 에서는 기본값인 "Sunny!" 가 그대로 사용되고 있는 것을 확인하였습니다.

결론은 writeable 한 environment value 라면 각 View 마다 다를 수 있습니다.

그렇다면 굳이 왜 view 2 의 updateUIView 가 호출되었는지는 궁금증으로 남긴 하네요.


Transaction

우선 정의부터 살펴보겠습니다.

The context of the current state-processing update.

현재 State 변경에 따른 처리에 대한 컨텍스트 단위 입니다.
Transaction 을 사용하여 뷰 하이어라키 내 뷰 간의 애니메이션을 전달할 수 있습니다.
State 변경 시에 생성되며 해당 State 의 Update 시 발생할 수 있는 애니메이션을 조작할 수 있습니다.

아래 두 방법을 통해 Transaction (Animation) 을 구성하여 View 에 전달 해 줄 수 있습니다.
1. withAnimation 사용
2. withTransaction 사용

해당 글에서 참고한 예제를 통해 살펴보도록 하겠습니다.

struct ContentView: View {
	@State private var scaleModify: Bool = false
    var body: some View {
        Circle()
            .foregroundColor(.brown)
            .frame(width: 200, height: 200, alignment: .center)
            .scaleEffect(scaleModify ? 1.0 : 0.5)
        Circle()
            .foregroundColor(.blue)
            .frame(width: 200, height: 200, alignment: .center)
            .scaleEffect(scaleModify ? 1.0 : 0.5)
            .transaction { transaction in // [2]
                transaction.animation = .none
            }
        
        Button("크기 조절") {
            let transaction = Transaction(animation: .easeInOut)
            withTransaction(transaction) { // [1]
                scaleModify.toggle()
            }
        }
    }
}

버튼을 터치할 시 Circle 의 scale 을 바꾸는 간단한 예제입니다.
첫 번째 Circle 은 정상적으로 scale 조정 애니메이션이 실행되나, 두 번째 Circle 은 애니메이션이 실행되지 않도록 Transaction 을 수정합니다.

withTransaction 메소드를 통해 애니메이션이 포함 된 State 의 update 를 지정해 줍니다. [1]

withAnimation 을 사용하여도 결과는 동일합니다.

2번째 Circle 의 경우 transaction 메소드를 통해 전달받게 될 Transaction 을 미리 캡쳐하여 지정된 애니메이션을 none 으로 바꿉니다. [2]

실행 결과는 아래와 같습니다.

위 코드에서 Circle View 를 UIViewRepresentable 을 준수하는 Custom View 로 수정하고 updateUIView 에서 Transaction 을 확인해 보았습니다.

코드는 아래와 같습니다.

struct ContentView: View {
    @State private var scaleModify: Bool = false
    var body: some View {
        AnimationView(dummy: $scaleModify)
            .background(.brown)
            .frame(width: 200, height: 200, alignment: .center)
            .scaleEffect(scaleModify ? 1.0 : 0.5)
        AnimationView(dummy: $scaleModify)
            .background(.blue)
            .frame(width: 200, height: 200, alignment: .center)
            .scaleEffect(scaleModify ? 1.0 : 0.5)
            .transaction { transaction in
                transaction.animation = .none
            }
        
        Button("크기 조절") {
            let transaction = Transaction(animation: .easeInOut)
            withTransaction(transaction) {
                scaleModify.toggle()
            }
        }
    }
}

struct AnimationView: UIViewRepresentable {
    typealias UIViewType = UIView
    @Binding var dummy:Bool
    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        let transaction = context.transaction
        
        print(transaction.animation) // [1]
    }
}

AnimationView 는 다른 추가사항 없이 transaction 만을 확인하기 위해 위와 같이 구성하였습니다.

State 변경을 통해 updateUIView 콜백을 받기 위해 dummy Binding을 추가하였습니다.

[1] 의 결과를 확인해보면 아래와 같습니다.

애니메이션이 적용된 첫 번째 뷰에서는 정상적으로 작동된 animation 의 값을 확인할 수 있었고, 두 번째 뷰는 transaction 메소드를 통해 수정한 대로 애니메이션이 적용되지 않음을 확인할 수 있었습니다.

이와 같이 UIViewRepresentable context 내에서도 Transaction 을 전달 받을 수 있습니다.

아직 이 개념을 어디다 써야 할 지는 잘 감이 오지 않네요.

저도 공부 하면서 정리한 내용이라 잘못된 내용이 존재하리라 생각합니다.
잘못된 내용이 있어도 너그러이 이해해 주시고 피드백 부탁드리겠습니다.

읽어주셔서 감사합니다.


<참고>
EnvironmentValues
EnvironmentKey
로티 이미지 출처 1
로티 이미지 출처 2
Transaction
Transactions in SwiftUI
withAnimation(::)
withTransaction(::)
How to override animations with transactions

좋은 웹페이지 즐겨찾기