GeometryReader in SwiftUI

SwiftUI를 공부하다 보면 자연스레 등장하는 개념이 바로 GeometryReader다. 어렴풋이 이해하고 있던 개념을 포스팅을 통해 정리해보려 한다.

What is GeometryReader

GeometryReader란 무엇인가?

SwiftUI에선 UIKit으로 레이아웃을 작성할 때와 달리 뷰 객체에 직접 접근할 수 없다. 뷰 객체에 직접 접근해 뷰 정보(size, position 등)에 알 수 있었던 것과 달리 SwiftUI에서 뷰 객체는 일시적인(transient) 객체로 프레임워크가 뷰를 그리고 난 후 객체는 사라지기 때문에 UIKit과 같은 방식으론 뷰에 관한 정보를 알 수 없다.

그렇다고 모든 뷰에 .frame(width:, height) 변경자를 사용해 직접 고정 값을 넣을 수도 없는 일이다. 이를 위해 등장한 개념이 GeometryReader로 상위 뷰의 Geometry 정보를 하위 뷰에 제공하는 역할을 한다.

보다 정확히는 상위 뷰가 제안한 Geometry 정보

1
2
3
4
5
6
7
struct SomeView: View { 
var body: some View {
GeometryReader { proxy in
// proxy를 통해 뷰 정보에 접근.
}
}
}

GeometryReader에 대해 더 자세히 살펴보기 전에 아직은 완성되지 않은 듯한 애플 공식 문서가 설명하고 있는 GeometryReader를 살펴보자.

A container view that defines its content as a function of its own size and coordinate space.

컨텐츠를 자신의 크기 및 좌표 공간의 함수로 정의하는 컨테이너 뷰

이 설명을 통해 자세히는 아니지만 몇 가지 키워드로 힌트는 얻을 수 있다.

  • 컨테이너 뷰
  • 자신의 크기 및 좌표 공간

Layout Process

GeometryReader를 이해하고 올바르게 사용하려면, SwiftUI에서 레이아웃을 그리는 세 단계에 대해 반드시 이해해야 할 필요가 있다.

이전 글을 참고하셔도 좋습니다.

이 세 단계를 간단히 요약하자면 아래와 같다.

  1. 부모 뷰가 자식 뷰의 사이즈를 제안한다.
  2. 자식 뷰는 제안받은 사이즈를 이용해 자신의 사이즈를 결정한다. ((언제나 제안받은 사이즈 == 자신의 사이즈) == false)
  3. 부모 뷰는 결정된 자식 뷰를 적절히 위치시킨다.

GeometryReader는 상위 뷰에서 제안한 사이즈를 자신의 사이즈로 사용 및 반환한다. 가능한한 최대한 확장한다고 생각하면 된다.

GeometryProxy

GeometryReader는 컨테이너 뷰의 한 종류이며, 다른 컨테이너 뷰의 ViewBuilder와는 다르게 인자를 하나 받는데 그것이 바로 GeometryProxy 객체다. 실제로 상위 뷰의 정보는 이 객체를 통해 접근이 가능하다. 기본적으로 위에서 살펴보았듯이 GeometryProxysize 프로퍼티를 갖고 있다.

이외에도 .frame(in: )라는 메서드가 있으며, 서브스크립트도 지원한다.

.frame(in:)

GeometryReader를 통해 size뿐만 아니라 .frame 메서드를 통해 CGRect에 접근해, 좌표값도 알 수 있다.

이 좌표값은 지정한 좌표평면 공간에 따라 달라질 수 있다.

  • .local - 자신이 속한 컨테이너 뷰 안에서의 좌표를 반환한다.
  • .global - 전체 스크린에서의 좌표를 반환한다.
  • .named(_:) - 지정한 좌표평면에서의 좌표를 반환한다.
    • .coordinateSpace(name: 을 통해 사용자 정의 좌표평면을 지정할 수 있다.

.frame(in:) 메서드를 통해 UIKit에서 convert 메서드를 사용해 좌표를 변환하던 작업이 보다 수월해졌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct ContentView: View {
var body: some View {
GeometryReader { proxy in
HStack(spacing: 0.0) {
...
ZStack(alignment: .topLeading) {
...
GeometryReader { innerProxy in
Rectangle()
.foregroundColor(Color.pink)
.onTapGesture {
let local = innerProxy.frame(in: .local)
let global = innerProxy.frame(in: .global)
let named = innerProxy.frame(in: .named("OuterGeometry"))

print("[local] minX : \(local.origin.x), minY : \(local.origin.y)")
print("[global] minX : \(global.origin.x), minY : \(global.origin.y)")
print("[named] minX : \(named.origin.x), minY : \(named.origin.y)")
}
}.frame(width: 50, height: 50)

}
...
}
}.coordinateSpace(name: "OuterGeometry")
}
}
1
2
3
4
[RESULT]
[local] minX : 0.0, minY : 0.0
[global] minX : 112.5, minY : 20.0
[named] minX : 112.5, minY : 0.0

subscript(Anchor) -> T

GeometryProxy의 서브스크립트를 이해하기 위해선 Anchor와 함께 Preferences에 대한 이해가 있어야 한다. 그러므로 이는 추후 Preferences에 대한 포스팅에서 함께 다뤄볼 예정이다.

PreferencesGeometryReader와 함께 SwiftUI에서 중요한 개념 중 하나로, 하위 뷰에서 상위 뷰로 정보를 전달하는데 사용된다.

Usage

그럼 우린 GeometryReader를 어떻게 사용할 수 있을까? 위에서 언급했듯이 기본적으로 상위 뷰와 비례해 하위 뷰의 사이즈를 조정할 때 사용할 수 있다.

추가로 위에서 언급했던 GeometryReader는 상위 뷰에서 제안한 사이즈를 자신의 사이즈로 사용 및 반환한다.” 특성을 이용하여 Preferences와 함께 사용될 때 하위 뷰의 geometry 정보를 상위 뷰에 전달할 때도 유용하게 사용할 수 있다.

위의 결과 화면은 VStack 내부의 Text들 중 가장 너비가 큰 값을 이용해 Text 각각의 backgroundCircle을 위치시킨 결과다. 그럼 Circle의 적절한 너비와 높이 값이 정해져야 하는데 이 정보는 어떻게 알 수 있으며 어떻게 지정해야 할까?

Preferences에 관한 포스팅에서도 더 자세히 살펴볼 코드지만 그중 일부만 살펴보자.

그전에 이 글을 참고하면 조금이나마 코드를 이해하는데 도움이 될 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
Text("Reset")
.padding()
.fixedSize()
.background(GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self,
value: [proxy.size])
})
.frame(width: size?.width, height: size?.width)
.background(
Circle()
.fill(Color.blue)
)

background안에 투명한 Color 뷰를 넣어 해당 Textsize 값을 알 수 있다.

그 이유를 이해하기 위해선 역시 Layout Process에 대한 이해가 필요한데, 기본적으로 background는 caller의 사이즈를 그대로 인자로 들어가는 뷰에 제안한다. 그리고 GeometryReader는 제안받은 사이즈를 자신의 사이즈로 반환하기 때문에 위의 코드에서 proxy의 사이즈가 곧 Textsize가 된다.

이렇게 알아낸 Text의 사이즈를 상위 뷰로 전달하고 상위 뷰는 이렇게 취합된 Text들의 사이즈 중 가장 큰 사이즈로 Text들의 너비를 조정해 파란색 Circle에 올바른 사이즈를 제안할 수 있게 된다.

Bugs🐛


사실 버그라고 해야 할지, 단순히 버전 차이에 따른 차이인지 정확하게는 알 수 없다. 하지만 iOS 13.x 버전과 iOS 14 버전에서의 GeometryReader의 동작 방식에는 차이가 있다.

바로 GeometryReader 하위 뷰들의 정렬 차이인데, 아래의 이미지를 통해 그 차이를 살펴보자.

코드의 구조는 대략 아래와 같다.

1
2
3
4
5
6
VStack { 
GeometryReader {
Text("…")
}
Text("…")
}

13.x 버전에선 GeometryReaderText를 기본적으로 가운데 위치시키지만, iOS 14 버전에선 Text를 왼쪽 상단 모서리에 위치시킨다. 구글링을 해보았지만, 딱히 명확한 이유를 알 수 없었다..😇

이에 대해 알고 있는 부분이 있으시다면 댓글 부탁드립니다

.

.

.

[는 하루만에 이유를 찾았다.] 😅

그 이유는 Xcode 12 beta 3 release note에서 찾을 수 있었다.

Resolved in Xcode 12 beta

  • Rebuilding against the iOS 14, macOS 11, watchOS 7, and tvOS 14 SDKs changes uses of GeometryReader to reliably top-leading align the views inside the GeometryReader. This was the previous behavior, except when it wasn’t possible to detect a single static view inside the GeometryReader. (59722992) (FB7597816)

top-leading 정렬이 정상 동작이며, GeometryReader가 단일 정적 뷰를 내부 뷰로 가져갈 경우 이를 감지 못하던 이슈(가운데 정렬시키던)가 해결되었다고 언급하고 있다.


참고 자료

SwiftUI under the hood

요즘 SwiftUI를 공부하면서 느낀 점은 마냥 쉽지만은 않다는 것이다. 흔히 SwiftUI를 소개할 때 매우 쉽게 UI를 그릴 수 있는 프레임워크로 소개하곤 하는데, 완전히 틀린 말은 아니지만 그렇다고 완전히 맞는 말도 아닌 것 같다. SwiftUI를 공부하면서 처음 iOS를 공부했을 때, 더 나아가 처음 프로그래밍을 공부했을 때가 종종 생각나곤 한다.

당시 처음으로 프로그래밍 언어를 가르쳐 주신 강사님으로부터 기본적인 C 언어 문법을 배우고 모든 걸 배운 것처럼 기세등등했던 적이 있다. 자신감이 한없이 하늘을 찌르고 있을 때 강사님은 다음과 같은 말씀을 해주셨다.

*”원래 처음 프로그래밍을 배우고 기본 문법을 마친 사람들이 프로그래밍을 매우 쉽다고 생각하고 자만하게 됩니다. 하지만 공부를 하면 할수록 어렵게 느껴지고 자신감이 떨어질 수 있는 공부가 프로그래밍입니다.”*

비단 프로그래밍에만 국한되는 이야기는 아닌 것 같다.

당시에는 이해가 가질 않았으나, 본격적으로 학부 공부를 시작하고 취업 준비를 하면서 저 말이 계속해서 떠올랐고 자신감이 자주 떨어지곤 했다. SwiftUI를 공부하면서도 마찬가지였다. 처음 VStack, List 등을 사용하면서 그 간편함에 놀라고 매우 쉽다고 생각했다. 하지만 데이터의 흐름과 레이아웃이 결정되는 방식 등 깊게 파고들면 파고들수록 머릿속은 여러 개념들이 완전하지 않은 채 뒤엉키게 되었다. 그렇게 혼란스러워하던 중 한 컨퍼런스에서 Chris Eidhof가 “SwiftUI under the hood”란 주제로 발표한 영상을 보고 복잡하고 산발되어 있던 개념들이 어느 정도 정리가 되면서 여러 개념들의 존재 이유와 목적에 대해 감을 잡을 수 있었다. 오늘은 그 영상의 내용을 요약 및 정리해보는 시간을 가지려 한다.

영상 : Chris Eidhof - SwiftUI under the hood

영상에서 다루는 큰 주제는 레이아웃 알고리즘이다. 즉 SwiftUI가 어떤 방식으로 레이아웃을 그리는지에 대해 다룬다. 이를 설명하면서 GeometryReader, Preference 등 중요한 개념들이 자연스럽게 등장하며 설명을 돕는다.

서론이 너무 길었다. 바로 시작해보자.


Layout Algorithm

파란색 배경의 원 안에 텍스트가 존재하는 뷰를 만든다고 상상해보자. 원이 아닌 다양한 도형이 될 수 있다. 즉 매우 흔한 상황이다. 우리가 원하는 결과물은 다음과 같을 것이다.

매우 쉬워 보인다! 그럼 코드로 이를 만들어보자.

1
2
3
4
5
Text("Reset")
.background(
Circle()
.fill(Color.blue)
)

기대하던 모습이 나올까? 그렇지 않다. 그 모습은 우리가 기대하던 모습과 거리가 멀다.

이유가 무엇일까? 그 이유를 알기 위해선 SwiftUI의 레이아웃 알고리즘에 대한 이해가 있어야 한다. 모든 레이아웃은 다음 4가지 단계를 통해 그려진다.

  1. 컨테이너 뷰(직계 상위 뷰)가 사이즈를 제안한다.
  2. 하위 뷰가 자신의 사이즈를 결정한다. (하위 뷰는 상위 뷰가 제안한 사이즈를 그대로 사용하거나, 본인이 자신의 사이즈를 결정하기도 한다.)
  3. 하위 뷰가 컨테이너 뷰에 자신의 사이즈를 알린다. (2단계에서 결정된 사이즈로 컨테이너 뷰의 사이즈가 정해진다.)
  4. 컨테이너 뷰가 하위 뷰를 가운데 정렬 시킨다. (alignment)

이는 아주 기본적인 절차이고 여러 값들의 재정의를 통해 변경될 수 있다. 그럼 이 알고리즘을 바탕으로 위의 코드를 분석해보자. 먼저 위의 코드로 그려진 뷰의 계층을 뷰 디버깅을 통해 살펴보고 아래 설명을 따라가면서 이해해보자.

  1. 먼저 루트 뷰(root view)가 화면 전체 사이즈를 background에게 제안한다. 그리고 background는 이를 다시 Text에게 전달한다.

    Modifier는 새로운 뷰를 만들고 이전 뷰를 감싸서 반환한다는 것을 기억한다면 backgroundText를 감싸고 있다는 것을 이해할 수 있다.

  2. 하지만 Text는 자신이 담고 있는 내용만큼만 필요하기 때문에 "Reset"만큼만 사용할 것을 backgroud에 알린다.

  3. 그럼 다시 background는 이를 하위 뷰인 ShapeView에 알리고 ShapeView는 이를 Circle에게 알린다. (ShapeViewText는 형제-자매(sibling) 관계다.) Circle는 받은 크기(Text의 크기)를 그대로 사용해서 그 크기에 딱 맞는 원을 그린다.

  4. 그리고 이를 background에게 전달하고 background는 다시 이 크기를 루트 뷰에 전달하고 루트 뷰는 해당 뷰를 가운데 정렬한다.

뷰 계층과 알고리즘을 따라가보면 왜 우리가 원하는 레이아웃이 나오지 않았는지에 대해 이해할 수 있다. 그럼 우리가 원하는 레이아웃을 그리기 위해선 어떻게 해야 할까?

가장 먼저 떠오르는 생각은 frame 변경자를 사용하는 것이다. 사용하기에 앞서 frame 변경자의 특징을 알아야 한다. frame 변경자는 상위 뷰에서 오는 크기 정보나, 하위 뷰에서 알려주는 크기 정보를 모두 무시하고 자신의 인자로 넘어온 크기 정보만 사용한다. 이 점을 기억하고 아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
Text("Reset")
//.frame(width: 75, height: 75) // --- 1
.background(
Circle()
.fill(Color.blue)
// .frame(width: 75, height: 75) // --- 2
)
//.frame(width: 75, height: 75) // --- 3

frame 연산자를 1, 2번 두 곳 중 한 곳에만 위치시켜도 원하는 레이아웃을 그릴 수 있다. 하지만 두 곳 모두 문제점을 갖고 있다.

  1. 위에서 언급했듯이 frame은 하위 뷰에서 알려준 크기 정보도 무시한다. 즉 Text가 정한 크기도 무시한다는 뜻이다. 그렇기 때문에 이곳에 frame을 위치시면 길어진 문자열 크기를 Text가 아무리 frame에 알려도 frame은 자신의 정보로 크기를 결정하기 때문에 문자열은 75x75 영역 안에서 벗어날 수 없고, 해당 영역 안에 표시되지 못한 부분은 말 줄임표(...)로 나타나게 된다.

  2. frame 영역은 Circle에만 영향을 주기 때문에 문자열이 길어지면 원의 영역을 벗어난다.

frame을 3번에 위치시키면 어떻게 될까? frame이 이 크기 정보(75x75)를 background에게 background가 다시 Text에 전달하지만 Text는 자신이 담고 있는 내용만큼만 사용한다고 background에 알리고 background는 이 정보를 다시 Circle에 전달하기 때문에 결과적으로 위에서 봤던 레이아웃과 동일한 것을 육안으로 확인할 수 있다.

육안으로 확인한 레이아웃이 같다고 실제로 그 둘이 같은 것은 아니다. 전체를 감싸고 있는 뷰의 크기 차이가 존재한다(파란 테두리가 감싸고 있는 영역). 그 이유는 위에서 언급했듯이 frame은 하위 뷰에서 보낸 정보도 무시하기 때문에 자신의 크기 정보(75x75)를 사용한다. 그렇기 때문에 전체를 감싸고 있는 뷰의 크기에 차이가 생기는 것이다.

문제를 해결하기 위해선 frame 안에 들어갈 값은 문자열의 길이에 따라 동적으로 변경되어야 한다. 정확히는 Text의 크기에 따라 변경되어야 한다. 그럼 Text의 크기는 어떻게 알 수 있을까? UIKit을 사용할 땐 객체에 직접 접근해 값을 가져올 수 있었다. 하지만 SwiftUI에선 불가능하다. 우리는 이 문제점을 GeometryReader를 사용해 해결해보려 한다.

GeometryReader

GeometryReader는 SwiftUI에서 중요한 개념이지만 이번 포스팅에선 현재 상황에서 GeometryReader가 해결할 수 있는 부분에 대해서만 간단하게 설명해보고자 한다.

GeometryReader는 컨테이너 뷰의 한 종류로 자신의 직계 상위 뷰의 기하학(Geometry) 정보(좌표, 크기 등)를 자신이 포함하는 자식 뷰에게 제공하는 역할을 한다. 그럼 바로 사용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
Text("Reset")
.padding()
.fixedSize()
.background(
GeometryReader { proxy in
// proxy.size.width == Text("Reset")'s width
}
)
.frame(width: 75, height: 75)
.background(
Circle()
.fill(Color.blue)
)

fixedSize 변경자는 Text에만 존재하는 변경자로 아무리 문자열이 길어져도 1줄에 보여주도록 강제한다. 이를 사용하면 위에서 보았던 이미지처럼 주어진 크기 안에서 문자열이 길어질 때 여러 줄과 함께 말 줄임표로 보여주는 것이 아니라 영역을 벗어나더라도 문자열을 1줄에 보여줄 수 있다.

단순히 직계 상위 뷰의 정보를 받아온다고 생각하지 말고 위에서 살펴보았던 레이아웃 결정 과정을 대입해서 생각해보자.

  1. background는 루트 뷰로부터 크기를 제안받는다.
  2. background는 그 크기를 Text에게 제안한다.
  3. Text는 자신이 포함하는 내용만을 담을 수 있는 크기를 사용하기로 결정하고 이를 background에게 알린다.
  4. background는 그 정보를 GeometryReader에게 알린다.

이런 순서로 GeometryReader는 직계 상위 뷰의 정보를 받아올 수 있는 것이다. 정확히 표현하자면 Text에 의해 결정된 크기를 사용하는 background의 크기 정보를 받아온 것이다. 그럼 우린 이 크기 정보를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@State private var width: CGFloat? = nil

Text("Reset")
.padding()
.fixedSize()
.background(
GeometryReader { proxy in
self.width = proxy.size.width
}
)
.frame(width: self.width, height: self.width)
.background(
Circle()
.fill(Color.blue)
)

위와 같이 코드를 작성할 수 있을 것 같지만 실제로 이렇게 작성하면 에러가 발생한다. 왜냐하면 GeometryReader는 생성자로 @ViewBuilder를 받기 때문에 self.width = proxy.size.width와 같은 코드는 @ViewBuilder 안에 작성할 수 없다.

우린 이 시점에서 프록시(GeometryProxy)에 담긴 크기 정보를 바로 width 프로퍼티에 할당할 수 없다. 우린 이 정보를 뷰 계층 위로 전달해야 한다. 이렇게 하위 뷰에서 상위 뷰로 정보를 전달하기 위해서 사용하는 것이 바로 Preference다.

Preference

Preference 역시 GeometryReader와 마찬가지로 굉장히 중요한 개념 중 하나이다. 하지만 마찬가지로 현재 상황에서 Preference가 해결할 수 있는 부분에 대해서만 간단하게 설명해보고자 한다.

Preference는 키-밸류 메커니즘으로 하위 뷰 정보를 상위 뷰에 전달할 수 있는 수단이다. 이를 위해선 먼저 PreferenceKey 프로토콜을 따르는 키를 정의해주어야 한다.

1
2
3
4
5
struct SizeKey: PreferenceKey {
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue()
}
}

간단하게 설명하자면 reduce 메소드는 SizeKey를 사용하는 하위 뷰들을 순회하면서 상위 뷰가 접근할 수 있는 값을 만들기 위해 이들의 값(SizeKey를 사용하는 하위 뷰의 값) 취합하는 역할을 한다.

이번 포스팅에선 단순히 PreferenceKey의 기능만을 소개하지만 추후 포스팅에서 더욱 자세히 다룰 예정이다. 하지만 당장 궁금하다면 이 글을 참고하면 좋을 것이다.

그리고 간단한 트릭(?)을 사용해서 우린 뷰 계층 위로 proxy 정보를 전달할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct ContentView: View {
@State private var width: CGFloat? = nil

var body: some View {
Text("Reset")
.padding()
.fixedSize()
.background(
GeometryReader { proxy in
Color.clear.preference(key: SizeKey.self, value: proxy.size.width) // --- 1
}
)
.frame(width: self.width, height: self.width)
.background(
Circle()
.fill(Color.blue)
)
.onPreferenceChange(SizeKey.self) { value in // --- 2
self.width = value
}
}
}
  1. ColorView 프로토콜을 따른다. 그렇기 때문에 사용자는 볼 수 없는 Color.clearpreference를 통해 proxy.size.width를 전달하고 있다.
  2. 상위 뷰(Text)에선 하위 뷰에서 전달한 정보를 onPreferenceChange를 통해 받을 수 있다.

이제 진짜 우리가 원하던 레이아웃을 확인할 수 있을 것이다.


정리하며

간단한 레이아웃(?)을 그려보면서 SwiftUI를 관통하는 여러 개념들을 자연스럽게 접해볼 수 있던 영상이었다. SwiftUI가 레이아웃을 그리는 알고리즘에 대해 이해하고 나니 왜 그렇게 그려지는지 이해가 가지 않았던 부분들이 어느정 도 머릿속에 정리가 되는 시간이었다.

영상 말미에 한 가지 과제(?)를 주는데 이 부분은 github에 올려놓았습니다.

SwiftUI에서 여러 타입의 뷰 반환하기

지난 포스팅에서 불투명 타입(Opaque Type)에 대해 공부했었다.

글 : Opaque Types in Swift

당시 우리는 불투명 타입을 반환할 땐 타입 정체성(Identity)를 잃지 않기 위해 한 가지 타입만 반환해야 한다고 공부했다. 하지만 개발을 하다 보면 여러 예외 상황을 마주하게 되는데 이는 불투명 타입도 마찬가지다.

먼저 SwiftUI로 프로젝트를 생성했을 때 마주하게 되는 기본 ContentView 코드를 살펴보자.

1
2
3
4
5
struct ContentView: View { 
var body: some View {
return Text("Hello World")
}
}

body 프로퍼티는 some View 타입이기 때문에 한 가지 타입만 반환할 수 있다. 그래서 뷰 계층을 작성할 때 우리는 아래와 같은 구조에 익숙하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView: View { 
var body: some View {
...View {
...View {
...View {
...View {

}
}
}
}
}
}

some View를 반환해야 하기 때문에 위와 같은 구조의 코드에 익숙하다. 하지만 조건에 따라 다른 타입의 뷰를 보여주어야 한다면 어떻게 해야 할까?

1
2
3
4
5
6
7
8
9
struct ContentView: View { 
var body: some View {
if isLogIn {
Image("User-Avatar")
}else {
Text("Please Login")
}
}
}

이렇게 코드를 작성하면 우리는 Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type와 같은 에러 메시지를 볼 수 있다.

하지만 언제나 방법은 있다! 게다가 두 가지씩이나! 그럼 그 두 가지를 살펴보자.

Group

Group은 어떠한 레이아웃 특성도 지니지 않는다. Group 안에서는 위와 같이 조건 분기를 통한 두 가지 타입을 반환할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View { 
var body: some View {
Group {
if isLogIn {
Image("User-Avatar")
}else {
Text("Please Login")
}
}
}
}

오늘의 주제와 별개로 Group의 사용 예를 한 가지 더 들어보자면 기본적으로 뷰 빌더 안에는 최대 10개 뷰만 들어갈 수 있다. 10개가 넘어가면 에러 메시지를 출력하는데, 이때 우리는 Group을 사용해 레이아웃을 해치지 않으면서 10개 이상의 뷰를 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct ContentView: View { 
var body: some View {
VStack {
Group {
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
}

Group {
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
Text("Text")
}
}
}
}

AnyView

Group 말고도 우린 AnyView를 통해 두 가지 타입의 뷰를 반환할 수 있다. 정확하게는 타입을 지워(Type erase) 하나의 타입이 반환되는 것처럼 보이게 하는 것이다.

1
2
3
4
5
6
7
8
9
struct ContentView: View { 
var body: some View {
if isLogIn {
return AnyView(Image("User-Avatar"))
}else {
return AnyView(Text("Please Login"))
}
}
}

하지만 AnyView는 성능 비용이 높기 때문에 사용하는 것을 지양한다. AnyView의 공식 문서에는 다음과 같은 내용이 있다.

Whenever the type of view used with an AnyView changes, the old hierarchy is destroyed and a new hierarchy is created for the new type

AnyView와 함께 사용된 뷰가 변할 때마다 이전 뷰 계층을 파괴하고 새로 만들기 때문에 이 부분에서 성능 비용이 높다고 설명하는 것 같다.

하지만 또 이 부분에 대해 Group과 성능 비교를 한 글도 확인할 수 있었다. 이 비교 실험 글에 따르면 사실상 큰 차이는 없다고 나온다. 또한 이 글에선 body에서 뷰 상태의 바인딩을 최소한으로 하는 것이 성능 향상에 더 도움이 된다고 언급하고 있다.

상태에 의존하는 뷰가 많을수록 rebuild 횟수가 증가할 가능성이 크기 때문에 이렇게 말하고 있는 것으로 생각된다.


오늘은 SwiftUI에서 여러 타입의 뷰를 반환하는 두 가지 방법에 대해 알아보았다.