SwiftUI

[SwiftUI] some View 때문에(?) 필요한 @ViewBuilder

하이D:) 2024. 11. 21. 17:13

 

@ViewBuilder에 대해서는 이전 블로그 글에서도 쓴 적이 있지만 활용 방법 위주로만 서술한 것 같아서 SwiftUI가 뷰를 리턴 받는 방식인 some View와도 연관되게 설명해보려 한다.

 

아래 글은 옛날에 썼던 글!

SwiftUI 재사용가능한 뷰를 만들기 위해 필요한 @ViewBuilder

 

 

 

 

📍ViewBuilder 공식문서

 

역시나 건너뛸 수 없는 공식문서의 정의를 봐보자!

A custom parameter attribute that constructs views from closures.

 

'클로저를 통해 뷰를 구성하는 매개변수에 대한 어트리뷰트이다.'라고 설명한다.

*어트리뷰트 : 컴파일러에게 추가적인 정보를 알려주는 역할을 하는 것

 

 

 

이게 무슨 뜻인지 모르겠다면 SwiftUI에서 뷰를 그릴 때 채택하는 View 프로토콜의 요구 사항인 body 연산 프로퍼티의 구현부를 한번 따라가 보자!신기하게도 이미 body프로퍼티는 ViewBuilder라는 어트리뷰트를 가지고 있다.

 

이 뜻은? body 자체가 '클로저를 통해 뷰를 구성하는 매개변수(constructs views from closures)'라는 뜻!

 

 

공식문서에서의 정의는 꼭 예시를 떠올려야 좀 이해가 되더라구요?

일단 지금은 우리가 뷰를 사용하기 위해 구성했던 body프로퍼티는 이미 ViewBuilder라는 어트리뷰트를 가지고 있구나 정도만 이해하고 넘어가 봅시다.

 

 

 

 

기술 블로그 완전 초반에 작성했던 글처럼 ViewBuilder를 설명하기 위해 공식문서에서도 'childView를 만들 때 전형적으로 사용될거야~~'(You typically use ViewBuilder as a parameter attribute for child view-producing closure parameters)라고 이야기하고 있다. 여러 종류의 자식 뷰를 제공할 수 있도록 하기 위해(allowing those closures to provide multiple child views) 자식뷰를 생성하는 클로저의 파라미터 어트리뷰트로서 @ViewBuilder를 사용한다. 

 

 

 

 

 

 

 

 

 

 

📍조건에 따라 다른 뷰를 반환하고 싶다면? @ViewBuilder 

 

일단 뷰가 그려지려면 body 연산 프로퍼티 안에 뷰에 대한 코드를 넣어야 하고 그 body가 some View 타입으로 정의되어 있어야 한다는 것은 SwiftUI로 뷰를 만들어본 사람이라면 누구나 알 것이다.

 

우리는 연산 프로퍼티, 메서드 등으로 뷰를 리턴하게 하여 커스텀 뷰를 만들어주곤 한다. 근데 이렇게 만들어준 커스텀 자식 뷰에서 조건부로 다른 타입의 뷰를 리턴한다고 할 때 어떤 일이 일어나는지 살펴보자.

 

 

 

 

▶️ any View를 리턴하게 한다면?

 

일단 직관적으로 any View를 반환하면 될수도? 라고 생각해서 any View를 리턴하는 커스텀 뷰를 body 안에 넣는다면 바아로 에러!

🚨Type 'any View' cannot conform to 'View'

 

body는 some View 타입인데 any View로 반환된 뷰의 요소가 있으니, SwiftUI 입장에선 body는 Opaque Type으로 하나의 구체 타입을 반환해야 하는데 any View라는 애매한 타입을 반환하기 때문에 컴파일러가 이를 최적화하거나 이해할 수 없어서 타입 안정성이 보장되지 못해서 에러가 나는 것 같다.

struct TestView : View {
    @State private var boolean : Bool = false
    
    var body: some View {
        VStack{
            content
            //🚨🚨Type 'any View' cannot conform to 'View'
        }

    }
    
    var content : any View {
        if boolean {
            Button {
                boolean.toggle()
            } label : {
                VStack {
                    Text("true입니다")
                }
                .background(.blue)
                .foregroundStyle(.white)
            }
        } else {
            Button {
                boolean.toggle()
            } label : {
                VStack {
                    Text("false입니다")
                    Text("true로 만드려면 클릭하세요")
                }
                .background(.red)
                .foregroundStyle(.white)
            }
        }
    }
}

 

 

 

 

▶️ some View를 리턴하는 커스텀뷰

그럼 some View를 리턴하는 커스텀뷰라면?

위에서 봤던 기존 에러는 없어지지만 또 다른 에러가 발생한다.

🚨Conflicting arguments to generic parameter 'τ_0_0' ('Button<some View>' vs. 'Button<some View>')

 

struct TestView : View {
    @State private var boolean : Bool = false
    
    var body: some View {
        VStack{
            content
        }

    }
    
    var content : some View { 
    //🚨🚨Conflicting arguments to generic parameter 'τ_0_0' ('Button<some View>' vs. 'Button<some View>')
        if boolean {
        
        //1
            Button {
                boolean.toggle()
            } label : {
                VStack {
                    Text("true입니다")
                }
                .background(.blue)
                .foregroundStyle(.white)
            }
        } else {
        
        //2
            Button {
                boolean.toggle()
            } label : {
                VStack {
                    Text("false입니다")
                    Text("true로 만드려면 클릭하세요")
                }
                .background(.red)
                .foregroundStyle(.white)
            }
        }
    }
}

 

 

 

아니 body는 some VIew 타입이고 이에 맞춰서 자식 뷰들도 some View로 타입을 맞춰줬는데 왜 에러가 나는거임..???

 

여기서 왜 에러가 나는지는 이전에 살펴본 Opaque Type의 특성을 통해 추론할 수 있는데, some View 즉, Opaque Type은 한 가지(정적) 구체 타입(underlying type)만 반환할 수 있고, 상황에 따라 여러 가지 구체타입(underlying type)을 반환하는 건 불가능하다. 이게 바로 앞선 글에서 설명했던 any와 some의 차이이기도 하다!

위 코드를 대충 봤을 때는 같은 뷰를 반환하고 있는 것 같지만 Button의 label을 보면, if 문의 버튼 라벨에는 Text가 하나만 들어있고 , else 문의 버튼 라벨에는 Text가 두 개 들어 있다. 컴파일러 입장에선 some View를 반환해야 하는데 조건에 따라 반환하는 두 개의 구체 타입이 각각 다른 타입이기 때문에 에러를 발생시키고 있는 것이다.

 

 

근데... 똑같은 some View 타입인 body 안에서는 조건에 따른 뷰 표현이 가능했던 것 같은데?? 이것에 대한 비밀이 바로 이 글에서 설명하려는 @ViewBuilder와 연관이 있는데 밑에서 자세히 알아보자!

 

 

 

 

 

▶️ 그럼 여러 가지 조건에 따른 뷰를 표현하고 싶다면? @ViewBuilder

 

위에서 잠깐 언급했던 것처럼 커스텀 자식 뷰가 body와 똑같이 some View 타입이어도, body는 조건별로 여러가지 뷰 반환이 가능하고 내가 만든 자식 뷰는 불가능한 이유는?이건 바로 body 연산프로퍼티가 View 프로토콜 내부에서 요구사항으로 정의되어 있을 때 @ViewBuilder 를 가지고 있었기 때문에 가능했던 것이다 !

 

 

 

 

그럼 우리도 some View를 반환하는 커스텀 자식 뷰에 @ViewBuilder를 사용해서 조건별로 다양한 뷰를 반환할 수 있도록 할 수 있겠다 라는 힌트를 얻을 수 있다. 위의 코드에서 @ViewBuilder 어트리뷰트를 더해주면 에러가 해결된다.

struct TestView : View {
    @State private var boolean : Bool = false
    
    var body: some View {
        VStack{
            content
        }

    }
    
    @ViewBuilder //⭐️
    var content : some View {
        if boolean {
            Button {
                boolean.toggle()
            } label : {
                VStack {
                    Text("true입니다")
                }
                .background(.blue)
                .foregroundStyle(.white)
            }
        } else {
            Button {
                boolean.toggle()
            } label : {
                VStack {
                    Text("false입니다")
                    Text("true로 만드려면 클릭하세요")
                }
                .background(.red)
                .foregroundStyle(.white)
            }
        }
    }
}

 

 

 

 

 

 

 

 

 

📍AnyView 구조체

근데 @ViewBuilder 말고도 해결할 수 있는 방법이 하나 더 있는데, AnyView라는 구조체로 뷰를 감싸는 것이다.

struct TestView : View {
    @State private var boolean : Bool = false
    
    var body: some View {
        VStack{
            content
        }

    }
    
    var content : some View {
        if boolean {
        //AnyView
            AnyView(
                Button {
                    boolean.toggle()
                } label : {
                    VStack {
                        Text("true입니다")
                    }
                    .background(.blue)
                    .foregroundStyle(.white)
                }
            )

        } else {
        //AnyView
            AnyView(
                Button {
                    boolean.toggle()
                } label : {
                    VStack {
                        Text("false입니다")
                        Text("true로 만드려면 클릭하세요")
                    }
                    .background(.red)
                    .foregroundStyle(.white)
                }
            )

        }
    }
}

 

 

 

하지만 이 WWDC2021 에서도 말하고 있지만 AnyView 말고 @ViewBuilder를 사용하자고 되어 있다.

https://developer.apple.com/videos/play/wwdc2021/10022/

 

Demystify SwiftUI - WWDC21 - Videos - Apple Developer

Peek behind the curtain into the core tenets of SwiftUI philosophy: Identity, Lifetime, and Dependencies. Find out about common patterns,...

developer.apple.com

 

 

왜 그럴까?

그 이유는,

 

 

1️⃣ type-erasing wrapper type

 

Unfortunately, this also means that SwiftUI can't see the conditional structure of my code. Instead, it just sees an AnyView as a return type of the function.

 

AnyView로 래핑 해서 조건별 다양한 뷰를 반환할 경우에는 SwiftUI가 조건별 코드의 구조를 볼 수 없다는 것을 의미한다. 단지 AnyView가 반환되는 구나~ 으로만 알고 있는 것이다. AnyView가 “type-erasing wrapper type” (직역하면 타입을 지워버리는 래퍼 타입) 으로 불리기 때문인데, 실제로 AnyView로 감싸게 되면, 래핑 된 뷰의 타입을 숨기게 된다.

 

AnyView 구현부에도 ”A tupe erased view”라고 명시되어 있다.

 

 

이게 왜 문제가 될까?

 

some View의 경우에는 some View를 반환할 때 구현부에서는 구체 타입을 알고 외부에서 사용할 때만 불투명 타입으로서 사용하는 것에 장점이 있다고 했는데, WWDC의 자료에서 보여주는 것처럼 AnyView로 감싸버리면 구현부에서 조차 구체타입을 인지하지 못하고 AnyView로만 알고 있기 때문에 SwiftUI에게 다양한 정보를 전달하지 못해 단점이 될 수 있다.

 

 

 

 

2️⃣ 직관적이지 않은 코드 

But perhaps more importantly, this code is also just really hard to read for us mere humans.

 

그리고 더욱 중요하게는 AnyView로 한 번 더 감싸졌기 때문에 읽기 어렵고 직관적이지 않은 코드일 수 있다고 말하고 있다.

 

 

 

 

 

📍 AnyView의 단점과 @ViewBuilder를 사용했을 때의 장점 

커스텀 뷰에서 조건 별 다양한 뷰의 반환이 필요하다고 했을 때 AnyView로 뷰를 감쌌을 때의 단점과 @ViewBuilder가 가지는 장점에 대해 정리해 보자면

 

AnyView의 단점

  • 직관적이지 않아 이해가 힘들고,
  • 컴파일 시점에 진단 불가할 수 있다
    • AnyView는 컴파일러에게 정적 유형 정보를 숨기므로 때로는 유용한 진단 오류와 경고가 코드에 표시되지 않을 수도 있다.
  • 퍼포먼스에 안 좋을 수 있다

 

 

 

 

@ViewBuilder 장점

  • 이러한 AnyView의 문제 때문에, @ViewBuilder라는 어트리뷰트를 사용함으로싸 코드를 단순화하여 가독성이 좋고, 목적성 바로 파악 가능하게 코드를 구성하는 것을 권장한다.
  • @ViewBuilder의 의경우 SwiftUI에게 뷰와 구성요소에 대해 훨씬 더 풍부한 정보를 제공한다

 

위에서 보았던 AnyView로 감싼 뷰를 반환한 것과 다르게 @ViewBuilder를 사용하면 아래 그림처럼 조건별로 어떤 콘텐츠의 계층이 사용되는지에 대해 SwiftUI가 보다 더 다양한 관점으로 뷰에 대한 정보를 파악할 수 있다.

 

 

 

 

 


 

 

📍 요약

 

  • SwiftUI가 뷰를 some View 타입으로 반환받아 처리하기 때문에 조건부로 다양한 뷰를 반환하고 싶다면 필요한 게 viewBuilder
  • some키워드로 Opaque Type이 반환되는 구조에서는 구현부에서 단일 구체 타입 반환만 허용되기 때문
  • body의 클로저 안에 조건 별 뷰 로드가 가능한 건 body라는 연산 프로퍼티가 ViewBuilder 어트리뷰트를 가지고 있기 때문
  • 커스텀으로 만들어주는 뷰에 대해서는 기본적으로 조건별로 다른 뷰가 반환될 수 없는데, 가능하게 하기 위해서 ViewBuilder어트리뷰트를 사용해 주면 된다.