SwiftUI View: @ViewBuilder

Doldamul·2022년 8월 29일
0

개념

@resultBuilder struct ViewBuilder

View를 선언적으로 생성하는 resultBuilder DSL(SwiftUI View 생성 전용 언어). 복수의 하위 View를 생성하는 클로저 매개변수에 @ViewBuilder 특성(Attribute)을 사용하는 것이 일반적이다.

  • swift는 사용자가 내장 기능을 편리하게 사용할 수 있도록 다양한 특성(Attribute) 키워드를 제공한다. ViewBuilder는 선언적 언어를 쉽게 제작할 수 있는 resultBuilder 특성을 사용하여 구현된 특성이다.

    	```swift
    	// 다양한 특성 키워드들...
    	@objc
    	@available
    	@Published
    	@IBAction
    	@resultBuilder
    	...
    	```

resultBuilder와 DSL에 대해 자세히 알아보고 싶다면 이전 글을 참고하자: Swift Attributes: @resultBuilder

적용 예시

일례로, SwiftUI 라이브러리에 정의된 Button View의 생성자는 클로저 매개변수 label@ViewBuilder 특성이 붙어 있으므로 여러 View들이 생성되는 클로저를 전달받을 수 있다.

Button.init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)

Button View 이외에도 HStack, List, TabView 등의 컨테이너 View들은 모두 @ViewBuilder 특성을 통해 사용자가 클로저에 복수의 View를 선언할 수 있도록 지원하고 있다.

struct ContentView: View {
    var body: some View {
        HStack { // HStack 생성자의 클로저 매개변수는 @ViewBuilder 특성을 가지고 있다.
            Text("1")
            Text("2")
            Text("3")
        }
    }
}

View 프로토콜의 body 프로퍼티 선언에도 @ViewBuilder 특성이 붙어있기 때문에, View를 상속받는 모든 사용자 정의 View의 body에는 @ViewBuilder를 붙이지 않아도 클로저 내에서 복수의 View 선언이 가능하다.

protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }
// 프로토콜 명세에 @ViewBuilder 특성이 정의되어 있다.
}
struct ContentView: View {
    var body: some View { // body는 @ViewBuilder 특성을 상속받는다.
        Text("1")
        Text("2")
        Text("3")
    }
}

사용자 정의 컨테이너 View

ViewBuilder를 활용하여 사용자 정의된 컨테이너 View를 만들기도 한다. 기본값이 변형된 HStack을 만들어보자. HStack의 생성자는 다음과 같이 정의되어있다:

// HStack 정의에서...
init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)

위 정의에서 spacing 기본값을 20으로 설정한 변형버전 HStack을 구현해볼 수 있다.

struct SpacedHStack<Content: View>: View {
    var alignment: VerticalAlignment
    var spacing: CGFloat?
    var content: Content
    
// 1. ViewBuilder를 사용해 클로저를 인자로 받는다.
    init(alignment: VerticalAlignment = .bottom, spacing: CGFloat? = 20.0, @ViewBuilder content: () -> Content) {
        self.alignment = alignment
        self.spacing = spacing

// 2. 클로저를 View로 변환한다.
        self.content = content()
    }
    
    var body: some View {
        HStack(alignment: alignment, spacing: spacing) {
// 3. 변환된 View를 사용한다.
            content
        }
    }
}

이렇게 구현한 View는 다른 곳에서 자유롭게 사용할 수 있다.

struct ContentView: View {
    var body: some View {
        SpacedHStack {
            Text("1")
            Text("2")
            Text("3")
        }
    }
}

특징

다음은 resultBuilder로부터 제공받은 명세에 맞추어 ViewBuilder에 구현된 함수들이다. 아래 친절한 설명이 나와있으니, 겁먹지 말고 빠르게 넘어가자.

// buildBlock 오버로딩 타입 메소드 11개
static func buildBlock() -> EmptyView
static func buildBlock<Content>(Content) -> Content
static func buildBlock<C0, C1>(C0, C1) -> TupleView<(C0, C1)>
static func buildBlock<C0, C1, C2>(C0, C1, C2) -> TupleView<(C0, C1, C2)>
static func buildBlock<C0, C1, C2, C3>(C0, C1, C2, C3) -> TupleView<(C0, C1, C2, C3)>
static func buildBlock<C0, C1, C2, C3, C4>(C0, C1, C2, C3, C4) -> TupleView<(C0, C1, C2, C3, C4)>
static func buildBlock<C0, C1, C2, C3, C4, C5>(C0, C1, C2, C3, C4, C5) -> TupleView<(C0, C1, C2, C3, C4, C5)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6>(C0, C1, C2, C3, C4, C5, C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7>(C0, C1, C2, C3, C4, C5, C6, C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(C0, C1, C2, C3, C4, C5, C6, C7, C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)>
static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)>

// if-else문 및 switch문 지원
static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent>
static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent>

// 단일 if문 지원
// buildOptional과 동일한 함수이다. 왜 이름이 2개인 것인지는 잘 모르겠다.
static func buildIf<Content>(Content?) -> Content?

// if-#available문 지원
static func buildLimitedAvailability<Content>(Content) -> AnyView

위 함수들은 클로저 내의 선언적 구문을 일반적인 swift 구문으로 번역하기 위해 사용된다. 특히 buildBlockresultBuilder 특성에서 필수적으로 요구하는 함수로, 클로저 내에서 나열된 View 표현식을 읽어들인 다음 하나의 View로 묶어 반환한다. 따라서,

  • 클로저 내에 나열된 View가 없을 경우 EmptyView를 반환한다.
  • 클로저 내에 나열된 View가 1개일 경우 해당 View를 그대로 반환한다.
  • 클로저 내에 나열된 View가 2개 ~ 10개일 경우 TupleView를 반환한다.
  • 클로저 내에 나열된 View가 11개 이상일 경우는 지원하지 않는다. (컴파일 에러)

우리가 EmptyViewTupleView를 직접 만들어 사용할 일은 없겠지만, SwiftUI가 자동으로 이러한 View를 만들어냄은 기억하자. 또한 클로저 내에서 11개 이상의 View를 일차원적으로 나열할 수 없는 이유도 알 수 있다. 11개 이상의 인자를 받는 TupleViewbuildBlock을 구현하지 않았기 때문이다.

buildEither 함수와 buildIf 함수, buildLimitedAvailability 함수가 정의되어 있으므로 우리는 클로저 내에서 다음을 사용할 수 있다.

  • 단일 if
  • if-else
  • switch
  • if #available 구문

if #available 구문을 사용한 예시는 다음과 같다. 다음 예제에서는 기본적으로 LazyVStack을 사용하지만, LazyVStsck을 사용할 수 없는 구버전에서는 VStack으로 대체할 수 있도록 지원하고 있다.

import SwiftUI

@available(macOS 10.15, iOS 13.0, *)
struct ContentView: View {
    var body: some View {
        ScrollView {
            if #available(macOS 11.0, iOS 14.0, *) { // 버전별로 사용할 View를 나눌 수 있다.
                LazyVStack {
                    ForEach(1...1000, id: \.self) { value in
                        Text("Row \(value)")
                    }
                }
            } else {
                VStack {
                    ForEach(1...1000, id: \.self) { value in
                        Text("Row \(value)")
                    }
                }
            }
        }
    }
}

buildEither문은 if문과 else문의 View 타입 정보를 모두 반환하기 때문에, buildLimitedAvailability 함수는 구 OS 버전에서의 컴파일 에러를 피하기 위해 LazyVStack 타입을 AnyView 타입으로 래핑해서 감춰버린다.

참고자료

profile
덕질은 삶의 활력소다. 내가 애플을 좋아하는 이유. 재밌거덩

0개의 댓글