(WWDC19 Building Custom Views with SwiftUI를 정리한 글입니다. 스압 주의)
위 화면에는 사실 3가지 View가 존재.
View 계층구조 최하단에 Text("Hello, World!") 가 있고,
항상 body, Text와 동일한 경계를 갖는 ContentView가 있음.
그리고 디바이스의 SafeAreaInset을 제외한 RootView가 존재.
여기에서 body를 갖는 최상위View = ContentView는 항상 '레이아웃 중립'이라고 불리는 레이어.
해당 bounds는 body의 bounds에 의해 정의됨. ContentView의 bounds = body의 bounds.
그래서 실제로 우리가 관심을 가져야할 View는 RootView와 body내부의 View(Text)만 있음.
레이아웃 프로세스
레이아웃 프로세스는 세단계로 이루어짐.
1. 부모View가 자식View(Text)에게 제안된 사이즈를 제공
이 경우엔 부모View가 RootView이기 때문에 SafeArea의 전체 사이즈를 제공.
2. 자식View가 부모View가 제안한 사이즈를 고려해서 자신의 사이즈를 결정
SwiftUI는 자식View의 사이즈를 강제할 방법은 없음. 부모View는 자식View가 정한 사이즈를 존중.
3. 부모View는 자식View가 정한 사이즈를 고려해서 자신의 좌표위에 배치
모든 부모View, 자식View간에는 위와 같은 상호작용을 통해서 전체 레이아웃을 그려냄.
이 두번째 단계가 중요. View의 크기 조정 동작이 있음을 의미.
모든 View는 자신의 크기를 제어하므로 View를 만들 때 크기 조정 방법과 시기를 결정할 수 있음.
아래 같은 경우엔 Image의 크기를 50x10으로 고정크기로 결정했고,
아래의 경우에는 크기가 자유로운 대신, 높이와 너비 비율이 동일하도록(aspectRatio) 제한을 둠.
이처럼 View의 크기 조정은 View정의에 캡슐화됨.
그래서 처음 예시인 Text View의 bounds는 표시된 줄의 높이와 너비 이상으로 늘어나지 않음.
여기에서 SwiftUI가 알아서 처리하기 때문에 몰라도 되는 단계가 하나 더 있는데,
View의 모서리를 가장 가까운 Pixel로 반올림처리를 해서 아래같은 안티앨리어싱 대신에
아래와 같은 항상 선명하고 깨끗한 모서리로 그려질 수 있게 됨.
좀 더 자세한 예시( + background + padding)
아래와 같이 Text를 "Avocado Toast"로 수정하고, .background 수정자로 배경색을 주면
Color View를 Secondary 자식으로 갖는 BackgroundView로 Text를 감싸게 됨.
Color.green이 Text의 bounds와 딱 맞게 그려짐(background를 주면 정확한 경계를 알기 좋음).
여기에 추가로 Color.green 가장자리 Text 주위에 padding을 추가.
(SwiftUI는 플랫폼, dynamicType Size, 환경에 적합한 padding 크기를 선택함. 매개변수를 넣지 않으면 컨텍스트에 따라 적응형 padding을 가짐)
위 케이스에서 레이아웃을 그리는 과정.
1. 먼저 RootView가 전체크기를 background View에게 제안.
2. Toast View와 마찬가지로 background View도 레이아웃 중립적이기 때문에 그대로 padding View에 크기 제안을 전달.
3. padding View는 자식 View의 테두리에 10픽셀을 추가할 것을 알고 있기 때문에,
자식 View(Text)에게 그만큼 더 작은 bounds를 제안하고,
4. Text는 크기를 필요한 만큼의 크기를 padding View에게 전달함.
5. padding View는 Text가 전달한 크기보다 테두리가 10픽셀만큼 큰 좌표공간에 Text를 적절하게 배치함.
6. background View가 레이아웃 중립적이기 때문에 해당 크기를 위쪽(RootView)에 알려주겠지만,
그 전에 먼저 background View의 secondary 자식인 Color에게 해당 크기를 제공.
7. 크기를 제공받은 Color가 레이아웃에 딱 맞춰 그려짐. 그래서 Color의 크기가 padding의 크기와 동일하게 됨.
8. 마지막으로 background가 크기를 Root View에게 전달하고, Root View가 적절하게 중앙에 배치시켜줌.
또 다른 예시(frame은 제약사항이 아닌 또 다른 View)
아래 예시는 body가 20x20으로 고정된 Image.
SwiftUI는 Asset Catalog나 코드 상으로 resizable로 표시하지 않으면 크기가 고정됨.
크기 조정을 위해 Image에 frame 수정자로 30x30으로 적용.
하지만 Image는 resizable이 적용되지 않았기 때문에 크기는 아직 변하지 않음.
현재 Image 주변의 30x30 크기가 Avocado View의 body 크기.
💡여기서 중요한 점은 frame이 UIKit에서처럼 해당 View의 제약사항이 아니라, Image처럼 View일 뿐임을 인식하는 것!
frame View는 자식View(Image)에게 고정된 크기를 제안하지만, 이전 예시처럼 Image는 자신이 필요한 만큼의 크기를 선택함.
Stack(HStack, VStack)
Basic
Stack은 자식View들을 행(HStack)이나 열(VStack)로 배열시킴.
최상위에는 2개의 자식View(VStack)을 가지고 있는 HStack이 있고,
첫번째 자식View는 별점이 있는 VStack.
두번째 자식View는 HStack과 Text를 가지고 leading정렬 하고있는 VStack.
두 자식 VStack 사이에는 적응형 spacing이 적용되어 약간의 공간이 있음.
자식 HStack의 자식View는 Text와 flexible한 Spacer와 Image를 가지고 있음.
💡SwiftUI에서 자식 Stack들끼리 절대 충돌시키지 않음.
인접한 Text의 baseline과 baseline 사이의 간격은 HIG와 정확히 일치하도록 배치됨.
이러한 규칙들을 SwiftUI 레이아웃 시스템에 인코딩해놓아서 간단한 코드로도 이쁜 결과물이 만들어지도록 함.
아랍어와 같은 현지화가 필요한 경우 수평 좌표를 뒤집어서 레이아웃을 다시 작성할 필요가 없음.
왼쪽, 오른쪽 정렬으로 하지 않고 선행(leading), 후행(trailing) 정렬로 명칭한 이유가 여기에 있음.
Stack은 자식View들을 동등한 입장에 두고 경쟁을 해야 함.
예시에서는 Text의 lineLimt을 1줄로 제한을 두었는데,
만약 HStack이 더 작은 크기를 제안하게 되면, Text가 그에 맞춰서 줄어들게 됨.
부모View가 제안한 크기가 충분한 경우
우선 부모 View(HStack)이 제공하는 크기가 충분한 경우,
HStack은 spacing을 먼저 파악하고 제안된 너비에서 이를 빼고 나머지 크기를 제공함.
나머지크기를 자식View의 갯수(3)만큼 나누고, 유연성이 가장 없는 자식View(Image)의 크기로 제안함.
유연성이 없는 Image가 자신의 너비(20)를 HStack에게 알려주고,
HStack은 나머지 제안된 너비에서 해당 너비만큼을 제외시킴.
나머지 제안된 크기를 다시 남은 자식View 갯수(2)만큼 나눠서, 유연성이 가장 낮은 자식View에게 제공됨.
첫번째 자식 Text(Delecious)는 그보다 작은 너비를 차지한다고 HStack에게 알리고,
두번째 자식 Text(Avocado Toast) 또한 이를 뺀 공간보다 작은 너비임을 HStack에게 알려주게 되면,
이제 HStack이 모든 자식View들의 크기를 알게 되어 spacing에 맞춰 다시 정렬해주게 됨. 기본값인 center로 정렬됨.
마지막으로 HStack은 자식View들을 모두 포함한 자신의 크기를 결정함.
부모View가 제안한 크기가 충분하지 않은 경우(layoutPriority)
만약 HStack이 제안한 크기가 Text들이 필요한 공간보다 작았을 경우,
좀더 중요한 Text(Avocado Toast)가 모두 보이고, 덜 중요한 Text(Delicious)가 줄어들도록 하고 싶을 수 있음.
이럴 땐 layoutPriority 수정자를 0 -> 1로 높여서 원하는 우선순위대로 그려줄 수 있음.
하위View들이 다른 layoutPriority를 가질 경우
낮은 우선순위를 가진 자식View들의 최소너비(...만큼의 너비)를 설정하고,
남은 공간을 가장 높은 우선순위의 View 갯수(1)만큼 나눠서 크기를 제안함.
alignments
위 Stack을 bottom으로 정렬하면 아래와 같이 그려짐.
만약 'Delecious' Text를 더 작은 글꼴로 바꾸면 아래와 같이 보여질 것.
정렬을 lastTextBaseline으로 맞추면 아래와 같이 정렬이 되는데,
Image의 경우 View의 최하단으로 맞춰짐. 하지만 디자이너는 baseline을 상단 87.4%까지로 하고 싶어할 수 있음.
이럴 때는 lastTextBaseline을 계산하는 방법을 클로저로 SwiftUI에게 알려줄 수 있음.
alignments 심화 과정
별점 예제로 돌아가서,
디자이너가 별(★★★★★)의 중심과, 제목 Text(Avocado Toast)의 중심을 맞추고 싶어할 수도 있음.
이 두 Text View들은 서로 다른 Stack에 속해 있음.
이 서로 다른 Stack들은 center로 정렬되어 있는 상위의 HStack에 포함되어 있는데,
이는 VerticalAlignment의 extension을 구현해서 해결할 수 있음.
AlignmentID를 따르는 enum(MidStarAndTitle)을 정의하고,
requirement인 defaultValue(in:)을 구현해서 SwiftUI에게 defaultValue를 계산하는 방법을 알려줌.
그리고 static 인스턴스를 정의해줌.
정렬을 원하는 View(별점, 타이틀)에 명시적으로 구현한 정렬을 적용시켜주면,
중첩된 Stack의 두 레이어를 통해 외부의 HStack이 내부의 View를 정렬할 수 있도록 해줌.
Graphics in SwiftUI
아래와 같은 그라데이션이 적용된 Circle 뷰를 그리는 방법.
우선 간단한 View부터 시작.
Circle을 그리고 Color.red로 fill 적용.
이 때 별도로 위치 크기를 지정하지는 않음.
Shape(Circle) 또한 레이아웃 시스템에 의해서 그려짐.
레이아웃, 애니메이션, 필터 효과 등 모든 수정자들은 레이아웃 시스템이 적용됨.
Drawing Model
기본 패턴은 모양(Shape)과 스타일(색상 등) 두가지의 조합이 하나의 View를 만들어 냄.
Circle 모양에 Color.red를 채워 넣을 수도 있고,
Capsule에 Color.red로 채워진 20너비의 윤곽선(stroke)를 적용할 수도 있고,
Ellipse(타원)에 Color.red로 채워진 점선 테두리(strokeBorder)를 적용할 수도 있음.
Shape Styles
Shape의 Style에는 여러가지를 제공.
- Colors
- Tiled Images
- Gradiants: Linear, radial, angular(원뿔)
Gradiant의 예시.
아래와 같이 여러가지 색상을 했을 경우. 각 갯수만큼 균등하게 공간을 나누어 그려짐.
AngularGradient(원뿔)로 적용했을 때는 아래와 같이 그려짐.
center와 angle을 지정해줄 수 있음.
Circle에 위 스타일이 적용된 strokeBorder를 반환하도록 하면 아래와 같은 그라디언트가 적용된 링을 얻을 수 있음.
조금 더 복잡해 보이는 Shape 예시(ZStack)
아래와 같은 인터랙티브 파이차트를 그릴 예정.
Wedge 중 하나를 떼어보면 아래와 같은 형태.
이 경우엔 Shape 프로토콜을 따름.
Shape 프로토콜은 path(in:) 을 구현하는 requirement만 있음.
빈 Path를 만들고 각 4개의 라인을 그리는 과정.
여기에 애니메이션을 주고 싶다면 AnimatableData라는 커스텀 Shape에 추가 프로퍼티를 추가해서,
시스템이 사용할 부동소수점 숫자 벡터값을 제공하도록 함.
위 작업을 활용해 ZStack을 이용해서 그리기.
ForEach를 통해 각 id를 WedgeView로 매핑.
데이터모델이 업데이트되면 ZStack이 삽입, 제거 전환을 처리해 뷰가 업데이트 됨. id를 기반으로 깔끔하게 fadeIn, fadeOut될 것.
tapAction 수정자를 통해 해당 id의 데이터를 삭제하도록 해주면 뷰가 업데이트 됨.
transition 수정자에 커스텀 transition인 scaleAndFade를 줄 수 있음.
커스텀 transition(with ViewModifier)
뷰가 삽입될 때는 커지면서 fadeIn되고, 제거될 때는 줄어들면서 fadeOut되길 원함.
단순화하면 두가지 상태가 존재함.
정의해야할 상태를 알았으니 ViewModifier로 만들 수 있음.
여기에 추가로 시스템에 위 ViewModifier의 두가지 상태, isActive가 true, false인 상태를 제공해야 SwiftUI가 이를 단일 전환으로 패키징한 다음 항목이 추가, 제거될 때 패키지화할 수 있음.
SwiftUI가 제공하는 Graphic Effects
- Opacity
- Geometry (scales, rotations, etc.)
- Color effects (contrast, brightness, hue rotate, monochrome, etc.)
- Blur effect
- Drop shadow effect
- Clipping, masking
- Compositing, groups, blend modes
SwiftUI의 통합 모델
- Layout
- Graphics
- Animation and transitions
- Interaction