iOS/SwiftUI

SwiftUI) @ViewBuilder를 왜 쓸까 (feat. Property Wrapper)

Dev.Andy 2023. 11. 23. 13:41

머리말

결론

(애플이 아닌) 직접 만든 코드에는 하위 뷰를 담을 수 있게 하는 로직이 들어가 있지 않기 때문에

- `View` 프로토콜의 `body` 프로퍼티와 SwiftUI에 내장된 구조체에는 이미 `@ViewBuilder`가 구현되어 있어서 아무런 의심 없이 굳이 `@ViewBuilder` 프로퍼티 래퍼를 감싸지 않아도 된다.
-  하지만 직접 프로퍼티를 만들거나 구조체를 만들어서 이를 child view로 넣고 싶다면, `@ViewBuilder`를 활용해야 한다.
- Custom 구조체를 구현할 때 이니셜라이저에 `@ViewBuilder`를 넣어야 한다.

왜 Custom Wrapper의 이니셜라이저에 `@ViewBuilder`가 들어가는가

강의 시간에 커스텀 Wrapper를 이용하여 SwiftUI의 Navigation에 대한 버전 대응을 외부화 하도록 하는 것을 배웠다. 하지만 맨 마지막에 `@ViewBuilder`를 넣는 이유가 이해가 잘 되지 않았다.

struct NavigationWrapper<T: View>: View {
    
    var content: T
    
    // 📌 `@ViewBuilder`가 왜 들어가야 하는가?
    init(@ViewBuilder content: () -> T) {
        self.content = content() // T에 클로저를 호출하여 그에 대한 반환 값을 할당
    }
    
    var body: some View {
        if #available(iOS 16.0, *) { // iOS 16 이상일 경우
            NavigationStack {
                content
            }
        } else {
            NavigationView { // iOS 16 미만일 경우
                content
            }
        }
    }
}

실제 SwiftUI View에 적용

잘 동작하는데 왜 굳이 `@ViewBuilder` 넣지?

import SwiftUI

struct StudyNavigationView: View {
    var body: some View {
        NavigationWrapper(content: { // 📌 init에 `@ViewBuilder`를 빼도 잘만 동작한다
            VStack(spacing: 30, content: {
                Text("Placeholder")
                    .font(.largeTitle)
                    .foregroundStyle(.black)
                sampleView
            })
        })
    }
    
    var sampleView: some View {
        isTest ? Text("Hi") : Text("Bye")
    }
}

본문

에러 발생

NavigationWrapper에 VStack이 아닌 child view로 `Text()`를 넣는다면?

VStack을 빼고 곧바로 자식 뷰로 `Text()`를 넣으면 "`View` 프로토콜을 준수하지 않는다"라는 에러를 발생시킨다.

struct NavigationWrapper<T: View>: View {
    
    var content: T
    
    init(content: () -> T) { // 📌 `@ViewBuilder`생략
        self.content = content()
    }
struct StudyNavigationView: View {
    
    var isTest = false
    
    var body: some View {
        NavigationWrapper(content: {
            Text("ABC") // 🔥 ERROR: Type '()' cannot conform to 'View'
        }
    }
}

SwiftUI에 구현한 `View` 프로토콜과 구조체에는 이미 `@ViewBuilder`가 들어가 있다.

이미 `View` 프로토콜의 `var body` 프로퍼티와, 해당 프로토콜을 채택한 SwiftUI의 구조체인 `VStack`, `HStack` 등에는 이미 `@ViewBuilder`가 구현되어 있다...! 

한번 살펴 보자.

`View` 프로토콜과 `var body`프로퍼티

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {
    associatedtype Body : View
    
    // 📌 여기
    @ViewBuilder @MainActor var body: Self.Body { get }
}

`VStack`

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct VStack<Content> : View where Content : View {

    @inlinable public init(
        alignment: HorizontalAlignment = .center,
        spacing: CGFloat? = nil,
        @ViewBuilder content: () -> Content // 📌 여기
    )

    public typealias Body = Never
}

`HStack`

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct HStack<Content> : View where Content : View {
    
    @inlinable public init(
        alignment: VerticalAlignment = .center,
        spacing: CGFloat? = nil,
        @ViewBuilder content: () -> Content // 📌 여기
    )

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required ``View/body-swift.property`` property.
    public typealias Body = Never
}

이처럼 이미 SwiftUI에서 구현한 것들은 `@ViewBuilder`가 있다.

 

이니셜라이저에 프로퍼티 래퍼 활용하기

struct NavigationWrapper<T: View>: View { /* ViewModifier가 아닌 View */
    
    var content: T
    
    /* 이니셜라이저 파라미터로 클로저를 이용 */
    /* `@ViewBuilder`를 이용해 child view 생성이 가능해짐 */
    init(@ViewBuilder content: () -> T) {
        self.content = content() // T에 클로저를 호출한 반환 값을 할당
    }
struct StudyNavigationView: View {
    
    var isTest = false
    
    var body: some View {
        NavigationWrapper(content: {
            Text("ABC") // init에 정의된 `@ViewBuilder`로 child view 생성 가능
            VStack(spacing: 30, content: {
            //...
            })
        })
    }