Identity

SwiftUI는 선언형 프레임워크다. 개발자는 무엇을 만들고 싶은지를 표현하고, SwiftUI는 그걸 바탕으로 실제 UI를 구성하고 업데이트한다. 보통은 이 방식이 자연스럽게 잘 동작하는데, 가끔 화면이 예상과 다르게 갱신되거나 애니메이션이 끊기거나 상태가 사라지는 것처럼 보일 때가 있다.

Demystify SwiftUI 세션은 이런 현상을 이해하기 위해 SwiftUI가 코드를 바라보는 세 가지 관점, Identity, Lifetime, Dependency를 설명한다.

첫 번째로는 Identity다. 두 대상이 같은 존재인가, 다른 존재인가? 라는 질문이 바로 Identity의 핵심이다.

Same identity = same element

Different identities = distinct elements

서로 다른 상태에 있는 View들을 하나의 존재로 연결해 주는 것이 바로 Identity고, 같은 Identity를 공유하는 View들은 하나의 UI 요소가 여러 상태를 표현하는 것으로 SwiftUI는 이해한다.

Identity의 종류

  • Explicit identity: 데이터나 개발자가 직접 지정한 식별자를 사용하는 방식
  • Structural identity: View의 타입과 View 계층에서의 위치를 이용해 Identity를 결정하는 방식

Explicit identity

이름이나 고유한 식별자를 직접 부여하는 것이 Explicit Identity다.

세션에서는 강아지 예시를 사용한다. 두 강아지의 모습이 비슷하더라도 이름이나 태그 ID가 다르다면 서로 다른 존재로 구분할 수 있다. 반대로 같은 이름이나 같은 고유 ID를 공유한다면 같은 존재로 판단할 수 있다.

Explicit Identity는 강력하고 유연하지만, 대신 그 식별자를 계속 관리해야 한다.

Pointer Identity

UIViewNSView는 모두 클래스다. 메모리 상에서 고유한 주소, 즉 포인터를 가진다. 이 포인터 자체가 자연스러운 Identity가 되어 두 View가 같은 포인터를 가지고 있다면 같은 View로 볼 수 있다.

SwiftUI의 View는 값 타입이기 때문에 이런 방식의 포인터 Identity를 사용하지 않는다. 대신 데이터나 개발자가 지정한 ID처럼 다른 형태의 Explicit Identity를 쓴다.

Rescue Dogs 예제

List {
    Section {
        ForEach (rescueDogs, id: \.dogTagID) { rescueDog in
            ProfileView (rescueDog)
        }
    }
Section("Status") {
    ForEach (adoptedDogs, id: \.dogTagID) { rescueDog in
        ProfileView (rescueDog, foundForeverHome: true)
        }
    }
}

여기서 id가 Explicit Identity다. 각 구조견이 가진 고유한 태그 ID를 이용해 각각의 뷰를 식별한다.

상태가 바뀌어 어떤 강아지가 다른 섹션으로 이동하더라도 같은 dogTagID를 유지한다면, SwiftUI는 그 대상을 완전히 새로운 View가 아니라 같은 Identity를 가진 요소로 추적할 수 있다.

ScrollViewReader 예제

ScrollViewReader { proxy in
    ScrollView {
        HeaderView (rescueDog)
            .id(headerID)
        Text(rescueDog.backstory)
        Button("Jump to Top") {
            withAnimation {
                proxy.scrollTo(headerID)
            }
        }
    }
}

id(_:)는 View에 명시적인 Identity를 부여하는 Modifier다.

모든 View에 ID를 줄 필요는 없다. 다른 곳에서 참조해야 하는 View만 명시적인 ID를 가지면 충분하다. 여기서도 ScrollViewReader, ScrollView, Text, Button 등은 별도의 ID가 필요하지 않다.

하지만 명시적인 ID가 없다고 해서 Identity 자체가 없는 것은 아니다. 모든 View는 어떤 방식으로든 Identity를 가진다. 명시적으로 부여하지 않은 경우에는 SwiftUI가 구조를 통해 Identity를 판단한다.

Structural Identity

생김새는 비슷하지만 이름을 모르는 강아지 두 마리가 있다고 하자.

이처럼 상대적인 위치와 구조를 이용해 대상을 구분하는 것이 Structural Identity다. SwiftUI는 API 전반에서 이 개념을 적극적으로 활용한다.

View 안의 if문 예제

var body: some View {
    if rescueDogs.isEmpty {
        AdoptionDirectory(selection: rescueDogs)
    } else {
        DogList (rescueDogs)
    }
}

AdoptionDirectory는 조건이 true일 때만 등장하고, DogList는 조건이 false일 때만 등장한다. 조건문의 구조 자체가 각각의 View를 구분하는 기준이 된다.

둘의 모양이 비슷하더라도 SwiftUI는 두 View를 구분할 수 있다. 다만 이게 가능하려면 두 View의 위치가 임의로 뒤바뀌지 않는다는 걸 컴파일 시점에 보장할 수 있어야 한다.

SwiftUI는 이를 위해 View 계층의 Type Structure를 분석한다. SwiftUI가 실제로 보는 것은 View의 Generic Type이다.

some View =
    _ConditionalContent<
        AdoptionDirectory,
        
        DogList
    >

if는 내부적으로 _ConditionalContent라는 타입으로 변환된다. 이 타입은 true일 때의 View와 false일 때의 View를 제네릭 타입으로 가진다. 이러한 변환은 ViewBuilder가 하는데, View 프로토콜은 body 프로퍼티를 암묵적으로 ViewBuilder로 감싸준다.

ViewBuilderbody 안에 작성한 조건문이나 반복문 같은 로직을 하나의 제네릭 View 타입으로 조합한다. SwiftUI의 선언형 문법이 하나의 DSL처럼 느껴지는 이유도 여기에 있다.

body의 리턴 타입인 some View는 정적 복합 타입(static composite type)을 나타내는 플레이스홀더다. 실제 타입은 매우 길고 복잡해질 수 있기 때문에 some View가 그 구체 타입을 숨겨준다. -> 이것이 Swift의 opaque return type이다!

이러한 제네릭 타입 덕분에 SwiftUI는 true일 때 항상 AdoptionDirectory이고, false일 때 항상 DogList라는 사실을 컴파일 시점에 알 수 있다. 그래서 SwiftUI는 각각의 분기에 암시적인 Identity를 부여할 수 있다.

good dog, bad dog 예제

VStack {
    if dog.isGood {
        PawView(tint: .green)
        Spacer()
    } else {
        Spacer()
        PawView(tint: .red)
    }
}

if를 사용하여 조건마다 서로 다른 View를 생성하면 SwiftUI는 이 둘을 서로 다른 Identity를 가진 별개의 View라고 이해한다. 그래서 상태가 바뀔 때 기존 View가 사라지고 새로운 View가 나타나는 전환을 수행한다.

PawView(tint: dog.isGood ? .green : .red)
    .frame(
        maxHeight: .infinity,
        alignment: dog.isGood ? .top : .bottom
    )

반대로 하나의 PawView만 두고 색상과 레이아웃만 변경하면 상태가 바뀌어도 View가 부드럽게 이동한다. Identity가 유지되는 하나의 View를 수정하고 있기 때문이다. 두 방법 모두 쓸 수 있지만, SwiftUI는 두 번째 방식을 권장하는 편이다. 더 자연스럽게 전환되기 때문이다.

View의 Lifetime과 State를 유지하는 데도 도움이 된다.

AnyView

앞선 if 예제에서 SwiftUI는 조건문이 만들어 내는 제네릭 타입 구조를 그대로 인식했다. 그렇다면 반대로 이 타입 구조를 숨겨버리면 어떤 일이 생길까?

강아지 품종에 따라 서로 다른 View를 반환하는 헬퍼 예제

func view (for dog: Dog) -> some View {
    var dogView: AnyView
    if dog.breed == .bulldog {
        dogView = AnyView (BulldogView())
    } else if dog.breed == .pomeranian {
        dogView = AnyView(PomeranianView())
    } else if dog.breed == .borderCollie {
        dogView = AnyView(BorderCollieView())
        if sheepNearby {
            dogView = AnyView(HStack {
                dogView
                SheepView()
            })
        }
    } else {
        dogView = AnyView(UnknownBreedView())
    }
    return dogView
}

각 분기에서 반환하는 View 타입이 서로 다르기 때문에 AnyView로 감싼다.

AnyView는 type-erasing wrapper type이다. 내부에 어떤 View가 들어 있는지 구체 타입 정보를 숨긴다.

그래서 SwiftUI는 코드의 조건문 구조를 충분히 활용하지 못하고, 그냥 AnyView 하나를 반환하는 함수처럼 보게 된다.

코드를 단순하게 하고 SwiftUI에 더 많은 구조 정보 보여주기

양이 근처에 있을 때, 즉 sheepNearbytrue일 때만 SheepView()를 보여준다고 하자. HStack 자체를 조건부로 만드는 대신, HStack 안에서 SheepView만 조건부로 추가할 수 있다.

dogView = AnyView(HStack {
    BorderCollieView()
    if sheepNearby {
        SheepView()
    }
})

이렇게 하면 조건에 따라 바뀌는 범위를 더 안쪽으로 좁힐 수 있다.

따라서 로컬 dogView도 더 이상 필요하지 않다. 각 분기에서 바로 View를 반환하면 된다.

func view (for dog: Dog) -> some View {
    if dog.breed == .bulldog {
        return AnyView(BulldogView())
    } else if dog.breed == .pomeranian {
        return AnyView(PomeranianView())
    } else if dog.breed == .borderCollie {
        return AnyView(HStack {
            BorderCollieView()
            if sheepNearby {
                SheepView()
            }
        })
    } else {
        return AnyView(UnknownBreedView())
    }
}

SwiftUI에서는 if문이 서로 다른 View 타입을 반환하는 것이 가능하다. 정확히는 body처럼 ViewBuilder가 적용되는 문맥에서는 서로 다른 분기를 하나의 정적인 View 타입으로 조합할 수 있다. 그런데 일반 함수에서 returnAnyView를 단순히 삭제하면 컴파일 오류가 발생한다.

그 이유는 bodyViewBuilder를 적용해 주기 때문에 특별하다. 조건문들을 하나의 제네릭 View 타입으로 조합해서 반환한다. 하지만 일반 함수에는 기본적으로 ViewBuilder가 적용되지 않는다. ViewBuilder를 적용시키고 싶으면, @ViewBuilder 어트리뷰트를 붙이면 된다.

@ViewBuilder
func view (for dog: Dog) -> some View {
    if dog.breed == .bulldog {
        BulldogView()
    } else if dog.breed == .pomeranian {
        PomeranianView()
    } else if dog.breed == .borderCollie {
        HStack {
            BorderCollieView()
            if sheepNearby {
                SheepView()
            }
        }
    } else {
        UnknownBreedView()
    }
}

@ViewBuilder가 붙은 함수에서는 각 분기의 표현식들이 Builder에 의해 조합되기 때문에 return 없이 View를 나열할 수 있다.

반환 타입

some View =
    _ConditionalContent<
        _ConditionalContent<
            BulldogView,
            PomeranianView
        >,
        _ConditionalContent<
            HStack<
                TupleView<(
                    BorderCollieView,
                    SheepView?
                )>
            >,
            UnknownBreedView
        >
    >

함수의 조건문 구조가 그대로 _ConditionalContent 트리로 표현된다.

switch를 쓰면 코드를 좀 더 깔끔하게 정리할 수 있다. switch는 문법만 다를 뿐, 생성되는 타입 구조의 핵심은 동일하다.

@ViewBuilder
func view(for dog: Dog) -> some View {
    switch dog.breed {
    case .bulldog:
        BulldogView()
    case .pomeranian:
        PomeranianView()
    case .borderCollie:
        HStack {
            BorderCollieView()
            if sheepNearby {
                SheepView()
            }
        }
    default:
        UnknownBreedView()
    }
}

AnyView는 View의 타입 정보를 숨겨버린다. 반면 ViewBuilder를 활용하면 타입 정보를 유지하면서도 같은 기능을 구현할 수 있다. 그래서 특별한 이유가 없다면 AnyView는 피하는 편이 좋다. 코드가 더 복잡해질 뿐 아니라, SwiftUI가 구조를 이해하는 데 필요한 정보도 줄어들 수 있다.

정리

  • Explicit Identity를 사용하면 데이터나 사용자 정의 ID를 View의 Identity와 연결할 수 있다.
  • Structural Identity를 사용하면 SwiftUI는 View의 타입과 계층 구조만으로 각각의 View를 식별할 수 있다.