Great App 을 만들기 위한 가장 빠른 도구 SwiftUI
UI는 앱 제작에 있어서 빠질 수 없는 요소이다. UI에는 텍스트 필드, 버튼 뿐만 아니라 다크모드, 로컬라이제이션 지원 등등 다양한 요소가 들어가게된다. 이때 UI적인 요소를 크게 두 가지로 나눌 수 있는데 버튼, 텍스트필드, 라벨과 같은 이미 정형화된, 고정된 (Basic Feature) UI와 애니메이션 효과, 다이나믹 타입, 로컬라이제이션과 같은 커스터마이징적인(Custom Features) 요소가 있다. "훌륭한"앱일 수록 정형화된 UI요소보다 이러한 커스터마이징한 요소가 많아지게 된다.
애플은 개발자가 Custom Features UI에 집중할 수 있도록 선언형/반응형 도구인 SwiftUI를 만들어 개발자가 빠르고 쉽게 Basic Features를 만드는 것을 목표로 했다.
SwiftUI 가 View 를 그리는 원리
사용자가 보게되는 앱의 가장 윗단에는 언제나 View가 있다. macOS 의 AppKit 에는 NSView가 있고 iOS의 UIKit 에는 UIView가 있는 것처럼 SwiftUI에는 View 가 존재한다.
A View defines a piece of UI
View는 SwiftUI가 그리는 UI의 가장 기초적인 블록 인터페이스이다. 화면에 보이는 모든 픽셀은 뷰에 포함된다고 볼 수 있다.
이러한 뷰는 계층구조를 생성하며 합성(Compositioning)된다.
위 스유 코드를 보면 알 수 있듯이 코드가 들여쓰기에 따라 계층구조가 만들어지는 것을 볼 수 있다.
UIKit과 비교했을 때 addSubview() 메서드가 없다는 것을 확인할 수 있는데 이는 SwiftUI가 선언형 프레임워크이기 때문이다. 이 말의 의미에 대해 조금더 자세히 들여다보자
Imperative UI
아보카도 토스트를 예로 들어보자. 기존 UIKit과 같은 절차형(Imperative) 프레임워크는 아보카드 토스트를 만들기위해 아래와 같은 순서대로 진행했다.
1. 재료준비 하기: 아보카도, 빵, 버터, 소금, 후추 etc
2. 요리도구 준비하기: 토스트기계, 접시, 버터칼
3. 빵을 얇게 썰기
4. 접시에 빵을 놓기
5. 토스트 위에 버터를 얇게 펴기
6. 아보카도 썰기
7. 아보카도 씨앗 제거하기
...
똑같은 작업을 절차형(Imperative)이 아닌 선언형(Declarative)으로 아보카도 토스트를 만들어보자
1. "나 버터하고 소금하고 후추들어간 아보카드 토스트가 필요함~"
2. "아 그리고 토스트 대각선으로 잘라줘~"
3. "해 줘."
위와 같이 절차형은 주문사항을 아래서부터 꾸역꾸역 하나하나 준비해나가고 선언형은 이미 만들어진 작은 블록들을 이용해서 해줘-해줘-해줘의 연속으로 주문이 들어간다. 물론 선언형을 끝까지 파헤치면 반드시 절차형으로 구성되어 있겠지만 생산성을 고려하면 특정 상황이 아닌 이상 선언형 쓰는게 정신건강에 이롭다.
View Container Syntax
하지만 어떻게 선언형 UI를 구현했을까? VStack 을 예시로 알아보자
VStack 내부에서는 @ViewBuilder (Swift Attrubutes) 가 붙어있는 클로저를 통해 Content를 리턴한다.
@viewBuilder 란?
@viewBuilder는 @resultBuilder의 한 종류이다. @resultBuilder란 Swift 5.4에 추가된 Attribute이다
Attribute란 코드에서 @로 표시되며 코드에 대해 컴파일러에게 추가 정보나 메타데이터를 제공한다.
@resultBuilder 는 여러 결과물을 하나의 결과물로 조합해주는 역할을 수행한다.
@resultBuilder 는 나열된 데이터들에 문맥과 의미를 더해주고 해석하는 buildBlock() 메서드를 통해 수행한다.
다시말해 @resultBuilder를 가지고 있는 구조체는 반드시 buildBlock() 메서드를 가지고 있다.
@resultBuilder를 통해 여러가지 재료를 외부에서 미리 정해둔 규칙으로 깔끔하게 합치는 것이다.
다시 @viewBuilder로 돌아와, 여러가지 뷰(최대 10개)를 외부에서 지정한 규칙에 따라 하나의 뷰로 합치는 역할을 수행한다.
여담이지만 위와 같은 @viewBuilder를 이용하면 재사용 뷰를 만드는데 매우 유용하다. (feat. 정대리)
VStack 처럼 init에 @viewBuilder attribute를 받는 클로저를 넣어주면
재사용 뷰에 대해 추가적인 뷰를 소비자 객체에서 사용할 수 있다.
이때 주의할 점은 트레일링 클로저처럼 이름없이 (Content) 받을 때는
생성자 함수의 매개변수로 마지막에 넣어주어야 한다.
WWDC 내용으로 돌아오면, VStack은 @viewBuilder attribute를 받고있고 Content 타입을 리턴하는 클로저를 가지고 있다. 그렇다면 Content 타입은 무엇일까? Content 타입은 SwiftUI의 컨테이너에 표시되는 컨텐츠를 의미한다. 기본적으로 제너릭이기 때문에 어떤 컨텐츠든 올 수 있다.
@viewBuilder content: () -> Content
이 한 줄을 다시 한 번 해석해보자
@viewBuilder를 통해 여러가지 요소가 들어와 미리정한 규칙에의해 조합되어 하나의 결과물로 나온다. 여기서 "요소"는 Content 타입이라는 일종의 제너릭 타입으로 다양한 타입들이 올 수 있다. 또한 생성자 마지막에 선언되어 소비자가 이부분을 사용할 때는 트레일링 클로져 형태로 이름없는 함수로 선언된다. 이런 과정을 거쳐 ...
이와 같은 아름답고 간결한 코드로 UI를 구현한다.
하지만 위 코드를 보면 $달러 심볼이 있는 코드를 확인할 수 있다. 또 저건 무엇일까?
결론부터 말하자면 Property Wrapper와 바인딩하기 위해서는 $심볼이 필요하다. Property wrapper 는 구조체로 선언되고 내부에는 당연히 값이 있다. 프로퍼티 래퍼 내부의 값을 외부에서 바인딩하여 사용하고 싶을 때는 $ 심볼을 앞에서 써준다. @State 프로퍼티 래퍼의 값을 바인딩하여 데이터 플로우를 관리한다 더 자세한 설명은 추후 Property wrapper 를 소개하는 WWDC 세션에서 다루어보려한다.
Modifier
뷰를 짤 때는 기본적인 View 덩어리와 덩이리를 수정해나가는 과정을 통해 원하는 뷰를 만든다.
버튼, 텍스트필드, 라벨과 같은 기본덩어리를 Primitive View 라 부르고
Primitive View를 조금씩 수정해서 원하는 뷰로 만들게끔 도움을 주는 메서드를 Modifer라고 부른다.
먼저 Modifer에 대해서 알아보자
modifier 는 기본적으로 View 타입을 리턴하는 빌더패턴으로 구현되어있다. 따라서 뷰 계층을 이용해서 간결한 코드로 UI를 구현할 수 있다. 위 코드를 보면 알 수 있듯이 하위 계층 모두에게 opacity가 들어갈 경우 가장 상위 계층에서 한 번만 opacity를 선언해도 같은 결과가 나오게된다. 위 코드가 함의하는 바는 더이상 하위 뷰들은 상위의 공통적인 요소들을 신경쓰지 않고 오직 자신이 그릴 뷰의 역할에만 집중할 수 있다는 것이다.
Prefer smaller, single-purpose views
Build larger views using composition
하지만 modifier 를 넣을 때 주의할 점이 있다. 기본적을 빌더패턴을 사용해서 계산 후 View타입을 리턴하고 리턴한 View를 또 가져다 modify 하는 것이기 때문에 순서에 영향을 받는다. 위 코드에서 알 수 있듯이 백그라운드를 먼저 넣고 패딩을 주느냐, 패딩을 먼저 주고 백그라운드를 넣느냐에 따라 뷰가 다르게 나타난다.
Primitive View
UIKit의 UIView는 Class로 참조타입이기 때문에 상속의 개념을 사용해서 뷰를 그린다.
반면 SwiftUI는 Struct로 값타입이기 때문에 각 Primitive View와 Modifier는 새로운 뷰를 그려낸다.
이때 Primitive View와 Modifer는 그저 View Protocol을 채택하기만 하면된다.
그렇다면 새로운 View를 어떻게 그려내는지 보기위해서 View 프로토콜을 들여다볼 필요가 있다.
뷰 프로토콜을 통해 atomic building block 을 만든다 . 내부를 확인해보면 타입별칭을 사용했을 뿐 사실상 뷰안의 뷰를 만들어 계층구조를 만들어낸다는 것을 확인할 수 있다. 그렇다면 이런 생각도 들 수 있다. 뷰 프로토콜을 채택하면 계속해서 뷰의 뷰의 뷰의 뷰의... 무한한 재귀가 도는 것은 아닌가? 무한히 재귀를 도는 문제를 해결하는 가장 손쉬운 방법은 특정부분에서 끊어주는 것이다. 이 역할을 Primitive View가 수행한다.
@frozen public struct VStack<Content> : View where Content : View {
@inlinable public init(
alignment: HorizontalAlignment = .center,
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
)
public typealias Body = Never
}
Primitive View의 Body는 Never 로 선언되어 있다. 따라서 Primitive View를 수정하는 Modifier에서는 계속해서 some View를 리턴하지만 마지막 modifer 가 실행되고 다음 primitive View의 body로 와서 Never가 할당되어 이러한 재귀를 끊을 수 있다.
Never 타입이란?
Never 는 프로그램을 종료하고 정상적인 제어흐름이 더이상 존재하지 않음을 나타낸다
fatalError가 Never타입의 대표적인 예시이다.
참고 링크
WWDC 19 SwiftUI Essential: https://developer.apple.com/videos/play/wwdc2019/216/
What Are Result Builders?: https://www.youtube.com/watch?v=ZdK5B-tp2qE
취준생을 위한 스위프트 UI 앱만들기 컨테이너뷰: https://www.youtube.com/watch?v=7ZOzChjyk8Y
'Flutter' 카테고리의 다른 글
| Combine | 2. Operators and Subjects (0) | 2024.05.07 |
---|---|
ViewController의 생명주기 (0) | 2024.05.06 |
| Combine | 1. Getting Started (0) | 2024.05.05 |
UserDefaults 를 사용할 때 객체에 Codable 을 채택한 이유 (0) | 2024.05.05 |
| WWDC 19 | Introducing SwiftUI - Building Your First App (0) | 2024.04.23 |