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가 단일 정적 뷰를 내부 뷰로 가져갈 경우 이를 감지 못하던 이슈(가운데 정렬시키던)가 해결되었다고 언급하고 있다.


참고 자료

Comments