티스토리 뷰

[Swift&iOS/iOS] - [iOS] SwiftUI 학습 순서 (feat. Tutorial & WWDC)에서 SwiftUI Essentials(WWDC 2019)과 Building Custom Views with SwiftUI(WWDC 2019) 세션을 보는 걸 추천한다고 했었는데, 이 포스팅은 이 세션들에 대한 정리 내용입니다 ㅎㅎ (일부 WWDC에 나오지 않는 내용들도 포함 되어 있습니다.)

세션 링크

https://developer.apple.com/videos/play/wwdc2019/216

 

SwiftUI Essentials - WWDC19 - Videos - Apple Developer

Take your first deep-dive into building an app with SwiftUI. Learn about Views and how they work. From basic controls to sophisticated...

developer.apple.com

https://developer.apple.com/videos/play/wwdc2019/237

 

Building Custom Views with SwiftUI - WWDC19 - Videos - Apple Developer

Learn how to build custom views and controls in SwiftUI with advanced composition, layout, graphics, and animation. See a demo of a high...

developer.apple.com

 

우리가 보는 앱의 화면은 모두 뷰로 구성되어 있습니다. 사용하는 프레임워크에 따라 다르겠지만 (UIKit은 UIView, AppKit은 NSView, SwiftUI는 View) 어쨌든, 모든 view는 piece of UI입니다.

그럼 UIKit과 SwiftUI의 차이점은 뭘까요? 

두 프레임워크를 사용한 코드를 봤을 때 가장 먼저 눈에 띄는 건, UIKit의 경우 명시적으로(imperatively 하게) 동작한다는 점이고 SwiftUI의 경우 선언적으로(declaratively 하게) 동작한다는점 입니다.

View의 입장에서 보면 어떨까요?

UIKit의 경우 커스텀 뷰를 만들려면, UIView를 상속받아야 합니다. 그러다 보니 필요하지도 않은(사용하지 않을) UIView의 common property들에 대한 Storage를 갖게 됩니다.

반면, SwiftUI View는 프로토콜 타입이고, 커스텀 뷰를 만들기 위해 상속을 받을 필요가 없습니다. 뷰에 적용하고자 하는 특성을 modifier를 사용해 적용하고 modifier는 고유한 뷰를 반환합니다. 그렇기 때문에 뷰의 고유 특성에 대해서만 storage를 갖게 되고, 그 결과 매우 가볍고 효율적입니다. (UIView 상속처럼 공통 탬플릿을 제공할 필요가 없음)

그럼, View Protocol의 내부를 살펴볼까요?

연산 프로퍼티 body를 보면 자기 자신인 View를 반환하는 것을 볼 수 있습니다. 어?, View의 바디가 또 자기 자신인 View를 반환 하니까 reculsive(재귀) 아니야?..

맞습니다만..! 우리도 프로그램 짤 때 재귀를 사용할 경우 재귀를 종료할 수 있는 조건을 넣어 놓잖아요?

SwiftUI View 역시 마찬가지입니다. SwiftUI의 Primitive Views라 불리는 뷰들은 Body 타입이 View가 아닌 Never 타입이고 이 Never를 만나게 되면 재귀가 종료 되게 됩니다. Primitive Views에는 아래와 같이 Text, Image, Color 등이 있습니다.

Primitive Views 중 하나인 Text의 body

extension Text : View {
    public typealias Body = Never
}

 

이제 SwiftUI Layout System에 대해 알아볼 텐데요. SwiftUI Layout System을 이해하기 전에 알아야 할 지식들이 있습니다.

  1. 모든 레이아웃의 기본 정렬은 Center
  2. Root View는 기본적으로 SafeArea를 제외한 영역
  3. 거의 대부분의 modifier, View들은 layout neutral 하다.
  4. layout neutral? -> 레이아웃에 대한 결정권이 없기 때문에 하위 뷰의 bounds를 따라감
  5. Text, Image 등 몇몇 뷰는 고유한 크기를 갖는다. (텍스트 및 이미지에 맞춰 고유한 크기를 갖게 됨, UIKit에서도 AutoLayout 제약조건을 설정할 때 label 같은 특정 뷰들의 경우 너비와 높이에 대한 제약 조건을 주지 않고 위치만 잡아주어도 문제 없었죠? 그 이유는 label 텍스트의 고유한 사이즈에 그 크기가 맞춰지기 때문입니다. SwiftUI에서 역시 Text, Image처럼 고유한 크기를 갖고 있는 뷰는 그 크기에 맞춰 사이즈가 설정됩니다.)

위 지식을 토대로 간단한 예제를 살펴보면서 레이아웃 시스템이 어떻게 동작 하나 알아봅시다!

"Hello World"라는 Text 뷰의 경우 레이아웃의 계층구조를 아래 이미지와 같이 나타낼 수 있는데요.

여기서 Root View, ContentView, Text의 사이즈는 각각 어떻게 될까요? 

레이아웃 프로세스는 아래와 같이 세 단계로 나타낼 수 있습니다.

  1. 부모 뷰가 자식 뷰에게 사이즈를 제안
  2. 자식 뷰는 부모 뷰가 제안한 사이즈를 받아들이거나 자신만의 사이즈를 결정 
  3. 부모 뷰는 자식 뷰를 자신의 좌표 공간 내에 자식 뷰를 배치

ContentView의 경우 body와 동일한 bounds를 갖기 때문에 단순히 RootView와 Text만을 놓고 생각해 본다면 

RootView는 레이아웃 프로세스에 근거해, SafeArea만큼의 영역을 Text에게 제안할 겁니다. 

하지만 Text는 자신만의 사이즈를 갖고 있는 애죠?, Root View에 제안한 사이즈를 사용하지 않고 내 사이즈를 사용하겠다고 전달합니다.

Root View는 자식 뷰의 사이즈를 존중하여 받아들이고 해당 뷰를 자신의 좌표 공간 내에 배치합니다. (기본적인 alignment는 center이기 때문에 가운데에 배치합니다.)

즉, Text와 ContentView의 사이즈는 같고, 이 ContentView가 SafeArea 영역 가운데에 배치되는 것이죠.

조~~금 더 복잡한 레이아웃을 살펴보죠 

이제 Text에 padding과 background modifier가 추가된 뷰입니다.

레이아웃 계층의 경우 위와 같이 나타낼 수 있는데요. 마찬가지로 Root View는 아래와 같이 Background에게 SafeArea만큼의 영역을 제안합니다. 

Background의 경우 Layout neutral 하기 때문에 전달받은 사이즈를 그대로 Padding 뷰에 전달합니다.

Padding 뷰는 자식 뷰 side에 10 point씩 더할 것을 알고 있기 때문에, 상하좌우 10포인트씩 적은 영역을 Text에 전달합니다. (이렇게 줄어든 영역을 전달하는 이유는, 줄어든 영역을 기준으로 레이아웃이 잡혀야 패딩을 넣을 공간이 생기기 때문입니다. 만약 Avocado Toast 텍스트가 10 point씩 줄어든 레이아웃이 아닌 RootView 레이아웃 기준으로 그려지고 그 너비가 같다면 패딩을 넣을 공간이 없잖아요? 즉, 애초에 패딩 넣을 공간을 남겨두고 영역을 전달한다고 보시면 됩니다.)

참고) 만약 패딩을 극단적으로 크게 잡으면 어떻게 될까요? -> 자식에게 전달해줄 영역이 없기 때문에 Text는 보이지 않고 아래처럼 패딩만 남게 됩니다.

아무튼~~ Text는 자신만의 Size를 갖고 있는 녀석이기 때문에, 해당 사이즈를 Padding View에 전달하고, Padding View는 전달받은 사이즈를 기준으로 상하좌우 10 point씩 더 큰 크기를 Background View에 전달합니다.

Background View의 경우 Layout Neutral 하기 때문에 자식 뷰의 크기를 그대로 따라가고 Secondary child인 Color View에도 해당 크기를 제안합니다. Color 역시 Layout Neutral 하기 때문에 제안받은 사이즈를 그대로 사용합니다. (즉 Text 사이즈에 패딩 10 point씩 들어간 크기를 채택)

Background View는 Text 사이즈에 패딩이 상하좌우 10 Point 씩 들어간 크기를 Root View로 전달하고, Root View는 이 뷰를 SafeArea영역 가운데에 배치하게 됩니다.

이제 SwiftUI Layout System이 어떻게 동작하는지 조금 감이 오시나요..?

frame의 경우는 어떨까요? frame modifier도 결국 뷰입니다. AutoLayout에서의 제약조건이 아닌 액자의 개념으로 생각해야 하는데요.

만약 20x20 사이즈의 아보카도 이미지가 있고, 30x30의 frame을 주었다면, 아래와 같이 30x30 액자 안에 20x20 아보카도 이미지가 들어있는 모습을 생각해야 합니다. (resizable modifier를 사용하지 않았을 경우)

Text 역시 frame과 별개로 자신만의 크기를 갖는 것을 확인할 수 있습니다. (SwiftUI Layout System을 이해하셨다면 왜 아래처럼 UI가 그려지는지 이해할 수 있을 거예요)

UIKit AutoLayout에는 Layout Priority를 설정할 수 있었는데, SwiftUI도 그런게 있을까요? -> 네, 있습니다.

Text, Image 등은 자신만의 고유한 사이즈를 갖고 있는 애들인데요, 만약 이 뷰들을 Horizontal 하게 여러 개 배치한다 가정했을 때 어떻게 표현을 해야 할까요? 분명 특정 뷰는 줄어들어야 할 텐데요.. 이때 사용할 수 있는게 layoutPriority 입니다.

아래처럼, layoutPriority가 높을수록 부모 뷰가 자식에게 공간을 할당하는 데 있어 우선권을 갖게 되고, layoutPriority가 같은 뷰들은 남은 공간을 서로 나눠 갖게 됩니다. (균등 분할하지만, 특정 뷰가 균등 분할한 공간보다 더 적게 사용하면, 남는 영역만큼 다른 뷰에게 줄 수 있습니다.)

alignment(정렬)에서도 단순히 center, top, bottom 뿐만이 아니라 여러 옵션을 제공해주는데요. HStack을 기준으로는 firstTextBaseLine, lastTextBaseLine 등을 제공해줍니다. (alignmentGuide를 통해 세세한 조정도 가능)

마지막으로 frame에서의 idealSize(idealWidth & idealHeight)는 도대체 뭐고, fixedSize modifier의 역할은 뭘까요??

idealSize는 UIKit에서 Intrinsic Size와 같은 개념으로 볼 수 있습니다. 즉 본질적인 크기를 의미합니다. (UIKit에서 UILabel의 경우 높이 너비 등에 대한 레이아웃 제약을 따로 설정해주지 않으면 Text 크기(본질적인 크기)를 따라가는 것처럼) 즉, 부모 뷰의 공간과 관계없이 자신의 이상적인 크기를  의미합니다. 의미 그대로 이상적인 크기이기 때문에 ideal Size는 보통 fixedSize와 함께 씁니다. 

fixedSize는 애플 문서에도 나와있듯이, idealSize를 고정하는 데 사용되는 modifier입니다.

만약 frame에서 idealSize만 설정한다면, 아래처럼 전혀 frame 적용이 안되는 것을 볼 수 있는데, 

fixedSize()를 사용하면, 제대로 frame이 적용되는 것 을 볼 수 있죠 ㅎㅎ

Text 역시 마찬가지입니다. 부모 뷰의 공간과 관계없이 자신의 이상적인 크기를 모두 표현하려면 fixedSize() modifier를 사용해야 하죠

조금 길어졌는데, 여기까지가 SwiftUI Layout System에 대한 내용입니다. 회사 내에서 발표했던 내용을 기반으로 글을 쓰는 거라 생각보다 빨리 쓸 줄 알았는데 엄청 오래 걸리네요 ㅜ 아무튼.. 끝까지 읽으신 분들에게 많은 도움이 되셨길 바랍니다. 감사합니다 :) 

댓글
링크
최근에 올라온 글
최근에 달린 댓글