klioop for iOS

SwiftUI AlignmentGuide 1 본문

SwiftUI

SwiftUI AlignmentGuide 1

klioop2@gmail.com 2021. 5. 27. 09:04

안녕하세요

 

이번 포스팅부터 SwiftUI 레이아웃에서 핵심 중 하나인 AlignmentGuide 를 알아보겠습니다. 

공부할 때 헛갈리는 부분이 많았는데 나중에도 참고할 수 있도록 정리하겠습니다.

 

우선 왜 필요한지 생각해볼까요?

 

AlignmentGuide 는 WWDC 2019 Building Custom Views with SwiftUI 에서 일부분 소개되는데요.

컨테이너뷰인 Stack 안에서 뷰들이 컨테이너 정렬 파라미터에 기준으로 정렬되는데,

거기서 만족하지 않고 디테일하게 뷰를 정렬하는데 필요합니다.

 

하나하나 필요한 개념들을 살펴볼게요.

먼저 익숙한 VStack 예시입니다.

 

struct DayView: View {
    
    var body: some View {
        
        VStack {
            Day(label: "월요일")
            Day(label: "화요일")
            Day(label: "수요일")
            Day(label: "목요일")
            Day(label: "금요일")
            Day(label: "토요일")
            Day(label: "일요일")
            
        }
        .frame(width: 200)
        .padding(.vertical)
        .border(Color.gray)
    }
}

struct Day: View {
    
    let label: String
    
    var body: some View {
        
        Text(label)
            .padding(10)
            .background(RoundedRectangle(cornerRadius: 8)
                            .fill(Color.red.opacity(0.9))
            )
            .foregroundColor(.white)
        
    }
    
}

 

 

 

 

VStack 에 뷰들을 넣으면 알아서 가운데 정렬을 하는 건 매우 익숙합니다. 

(주의: frame 의 가운데 정렬을 하는건 VStack 이 아니라 frame 의 alignment 의 기본 값이 Alignment.center 이기 때문입니다)

 

VStack(alignment:, spacing:, content: ) 은 이런 파라미터를 가지는데,

alignment 파라미터를 정해주지 않으면default 값이 .center 이기 때문에 그렇습니다.

정확히는 HorizontalAlignment.center 값이 기본 값 입니다.

 

이 VStack 의 alignment 파라미터를 컨테이너 정렬(Container Alignment) 이라고 합니다.

VStack 의 alignment 타입은 HorizontalAlignment 에요. 

문서를 보면

 

 속성으로 center, leading, trailing 3 개를 갖는 것을 알 수 있습니다.

 

HStack 의 alignment 타입은 VerticalAlignment 로 문서를 보면 5 개의 속성을 갖습니다.

 

 

ZStack 의 alignment 는 Alignment 타입으로 HorizontalAlgnment 와 VerticalAlignment 을 둘 다 가집니다. 

이 포스팅에서는 VStack 위주로 설명하겠습니다.

 

우선 HorizontalAlignment 위주로 뷰의 정렬을 조금 더 자세히 살펴볼게요.

 

출처: https://swiftui-lab.com/alignment-guides/

 

HorizontalAlignment 는 y 축을 기준으로 뷰들을 정렬합니다.

이름에 수평정렬을 암시하고 VStack 은 수직 컨테이너라 처음에 조금 헛갈릴 수 있는데, 

VStack 은 y 축을 기준으로 자식 뷰를 수평정렬 시킨다고 이해하면 됩니다.

 

위 이미지를 보면 뷰 3 개가 수평정렬되는 것을 볼 수 있는데요. 

x 값들은 각 뷰의 시작점으로부터 x 포인트를 나타냅니다. 

 

저는 처음에 이 부분이 너무 헛갈렸어요. 

SwiftUI 좌표공간에서 오른쪽으로 갈수록 x 값이 증가한다고 알 고 있는데, 

위 이미지에서는 x 값이 커지면서 뷰가 HorizontalAlignment 기준으로 왼쪽으로 더 움직이니까요.

 

여기서 중요한 점은 위 이미지의 x 값은 각 뷰의 시작점이 기준입니다.

 

그래서 만약에 정렬선이 뷰의  x 값 -10 을 지나간다면

뷰의 시작점에서 -10 이기 때문에 뷰 자체는 정렬선으로부터 오른쪽으로 10 만큼 멀어집니다. 

 

뷰의 시작점으로부터의 x 값이 감소한 지점을 정렬선이 통과하고 그것을 기준으로 뷰가 정렬되기 때문에

뷰는 오른쪽으로 움직이게 되는 겁니다. 

저처럼 Swift 좌표공간 x 좌표와 헛갈리마세요 😓

alignmentGuide 를 사용할 때 매우 중요하기 때문에 확실히 알아둬야 합니다.

 

 

그러면 alignmentGuide 를 이용해서

처음 예시에서 월요일 뷰만 수평정렬선(HorizontalAlignment)이 10 을 통과하고 나머지는 가운데 정렬 시켜볼게요.

 

struct DayView: View {
    
    var body: some View {
        
        VStack(alignment: HorizontalAlignment.center) {
            Day(label: "월요일")
                .alignmentGuide(HorizontalAlignment.center, computeValue: { dimension in
                    10
                })                 
            Day(label: "화요일")
            Day(label: "수요일")
            Day(label: "목요일")
            Day(label: "금요일")
            Day(label: "토요일")
            Day(label: "일요일")
            
        }
        .frame(width: 200)
        .padding(.vertical)
        .border(Color.gray)
    }
}

 

먼저 결과부터 살펴보면

월요일 뷰의 시작점으로부터 10 만큼 떨어진 지점에서 정렬선이 통과되고 나머지 뷰들의 경우

각 뷰들의 가운데 지점으로 수평정렬선이 통과되고 있습니다.

(alignmentGuide 가 적용된 뷰가 먼저 정렬되고 그렇지 않은 뷰들이 정렬되는지 아니면 그 반대인지는

잘 모르겠습니다. 알 수 있는 방법이 없을까요 🤔 )

 

이제 코드를 설명해보겠습니다.

 

alignmentGuide modifier 는 Guide 와 computeValue 2 개의 인자를 받습니다.

Guide 는 컨테이너 정렬(Containter Alignment) 에 따라서 달라집니다. 

VStack 의 경우 HorizontalAligment 로 정렬하기 때문에 Guide, g 가 HorizontalAlignment 속성 값을 갖습니다.

 

 

뷰 modifer 이기 때문에 당연히 뷰를 반환하는 것을 알 수 있습니다.

 

여기서 중요한 점은 Guide(g) 는 항상 컨테이너 정렬과 같아야 합니다. 

그렇지 않는 alignmentGuide 는 무시됩니다.

 

struct DayView: View {
    
    var body: some View {
        
        VStack(alignment: HorizontalAlignment.center) {
            Day(label: "월요일")
                .alignmentGuide(HorizontalAlignment.center, computeValue: { dimension in
                    10
                })
                 .alignmentGuide(HorizontalAlignment.leading, computeValue: { dimension in
                    50
                })
            Day(label: "화요일")
            Day(label: "수요일")
            Day(label: "목요일")
            Day(label: "금요일")
            Day(label: "토요일")
            Day(label: "일요일")
            
        }
        .frame(width: 200)
        .padding(.vertical)
        .border(Color.gray)
    }
}

월요일 뷰에 컨테이너 정렬과 Guide 가 다른 alignmentGuide 가 하나 추가됐지만, 동일한 결과가 나오는 것이 보이시나요?

컨테이너 뷰의 Alignment 와 Guide Alignment 가 다르기 때문에 무시됩니다.

 

alignmentGuide 의 두 번째 인자인 computedValue 는 CGFloat 을 반환하는 클로져 입니다.

CGFloat 값은 이 뷰의 정렬선이 통과하는 지점을 나타냅니다.

 

그리고 이 클로져의 인자 dimension: ViewDimension 은 중요합니다.  

alignmentGuide 가 꾸미는 뷰의 레이아웃 정보를 ViewDimension 을 이용해서 가져올 수 있기 때문이에요.

 

HorizontalAlignment 의 경우 너비, 높이, .leading, .center, .trailing 을 가져올 수 있습니다.

VerticalAlginment 경우 너비, 높이, .top, .center, .bottom, .firstTextBaseLine, .lastTextBaseLine 을 가져올 수 있습니다.

 

 

예를 들어, let leadingPosition = dimension[HorizontalAlignment.leading] 처럼

HorizontalAlignment.leading 값을 subscript 를 이용해서 가져오면 

leadingPosition 은 값으로 0 을 가집니다.

 

HorizontalAlignment 가 .leading, .center, .trailing 3 개의 속성을 가지는 것을 기억하시나요

.leading 은 0, center 는 width / 2, 그리고 trailing 은 width 크기 값을 가집니다.

0 을 뷰의 시작 x 점으로 생각하면 됩니다.

 

이를 이용해서 정렬을 상수 값이 아닌 뷰와 연관시킨 값으로 할 수 있게 됩니다!!

정렬선이 뷰 너비의 1 / 3 지점을 지나도록 하거나

뷰의 중간에서 10 만큼 더 간 지점을 통과시킬 수 있는거죠.

 

정렬선이 화요일 뷰의 중간지점에서 20 만큼 더 지난 지점을 통과시켜보도록 하겠습니다. 

결과를 보기전에 어떻게 될 지 예상해볼까요?

 

우선 월요일 뷰를 제외하고

수 ~ 일요일 뷰까지는 가운데 정렬이 그대로 유지될 건 확실합니다.

화요일 뷰의 가운데 지점에서 오른쪽으로 20 만큼 더 지난 지점을 정렬선이 통과할 것이므로

화요일 뷰는 수요일 뷰에 대비해서 왼쪽으로 이동하겠네요.

그림이 그려지시나요? 저는 익숙해지는데 좀 걸렸습니다 😅

 

결과를 봐 볼까요

 

struct DaysView: View {
    
    var body: some View {
        
        VStack(alignment: HorizontalAlignment.center) {
            Day(label: "월요일")
                .alignmentGuide(HorizontalAlignment.center, computeValue: { dimension in
                    10
                })
            Day(label: "화요일")
                .alignmentGuide(HorizontalAlignment.center) { d in d[HorizontalAlignment.center] + 20}
            Day(label: "수요일")
            Day(label: "목요일")
            Day(label: "금요일")
            Day(label: "토요일")
            Day(label: "일요일")
            
        }
        .frame(width: 200)
        .padding(.vertical)
        .border(Color.gray)
    }
}

 

예상한대로 정렬됩니다!

 

정말 중요한 점은 Stack 안 모든 뷰들은 모두 각자 alignmentGuide 를 갖는다는 사실입니다.

 

월요일 뷰와 화요일 뷰에만 명시적으로 alignmentGuide 가 적용되었고

나머지 뷰들은 암묵적(implicit)으로 alignmentGuide 가 적용되고 있습니다.

즉, alignmentGuide modifier 뷰가 명시되지 않았으면

Stack 의 자식 뷰는 Stack 의 alignment 를 자신의 alignmentGuide 로 암묵적으로 갖습니다.

 

 

얘는 뭘까요

외부 파라미터 이름으로 explicit 이 생기고 optional 값을 반환하네요?

 

정확한 개념은 모르겠는데

개발자가 alignmentGuide 로 정렬선을 명시적으로 정의하고 난 후 

Guide 정렬선이 지나는 지점을 나타내는 것 같아요. 

그래서 alignmentGuide 를 한 번이라도 사용해야 nil 값을 반환하지 않아요. 

다음의 예시를 보면

 

Day(label: "화요일")
                .alignmentGuide(HorizontalAlignment.center) { d in d[HorizontalAlignment.center] + 20 }
                .alignmentGuide(HorizontalAlignment.center){ d in
                    let explicit = d[explicit: HorizontalAlignment.center] ?? 0
                    print("explicit - \(explicit)")
                    return explicit
                }

 

화요일 뷰에서 처음 alignmentGuide 를 이용해서 정렬선이 중간 + 20 지점을 통과하도록 만들었잖아요.

그러면 이 Guide, HorizontalAlignment.center 정렬선이 명시적으로 정의된 거고

그 값은 width / 2+ 20 이 돼요.

여기에서는 52.25 가 나오네요.

 

 

VStack, HStack 보다는 ZStack 에서 더 많이 쓰는 것 같아요.

https://stackoverflow.com/questions/61186217/understanding-the-explicit-alignment-of-swiftui

explicit 에 관해서는 이 링크를 참고하면 좋을 것 같아요 😀

 

 

이 포스팅에서 한 가지만 잊지 말아야 할 점이 있다면,

Stack 의 모든 자식 뷰들은 각자 명시적으로든, 암묵적으로든 alignmentGuide 를 갖는다는 것 입니다.

 

마지막으로 https://swiftui-lab.com/alignment-guides/ 에서 소개된

alignmentGuide 를 이용한 간단한 Animation 코드를 소개하면서 1 편은 마치겠습니다.

2 편은  Custom AlignmentGuide 를 다루겠습니다.

 

 

 

struct AlignmentAnimation: View {
    
    @State private var position = 0
    
    var body: some View {
        
        VStack {
            
            Spacer()
            
            ZStack {
                
                Hello()
                    .background(RoundedRectangle(cornerRadius: 8)
                                    .fill(Color.green)
                                    .opacity(0.5))
                    .alignmentGuide(HorizontalAlignment.center, computeValue: { d in
                        self.helloHorizontalAlignment(d)
                    })
                    .alignmentGuide(VerticalAlignment.center) { d in 
                    self.helloVerticalAlignment(d) 
                    }
                
                World()
                    .background(RoundedRectangle(cornerRadius: 8)
                                    .fill(Color.yellow)
                                    .opacity(0.5))
                    .alignmentGuide(HorizontalAlignment.center) { d in
                        self.worldHorizontalAlignment(d)
                    }
                    .alignmentGuide(VerticalAlignment.center) { d in
                        self.worldVerticalAlignment(d)
                    }
                
            }
            
            Spacer()
            
            HStack {
                
                Button(action: {
                    withAnimation(.easeOut(duration: 1)) {
                        self.position = 0
                    }
                }) {
                    Rectangle()
                        .fill(Color.red.opacity(0.9))
                        .frame(width: 50, height: 50)
                        .overlay(Text("H W").foregroundColor(.white))
                        
                }
                
                Button(action: {
                    withAnimation(.easeOut(duration: 1)) {
                        self.position = 1
                    }
                }) {
                    Rectangle()
                        .fill(Color.red.opacity(0.9))
                        .frame(width: 50, height: 50)
                        .overlay(Text("H \nW").foregroundColor(.white))
                        
                }
                
                Button(action: {
                    withAnimation(.easeOut(duration: 1)) {
                        self.position = 2
                    }
                }) {
                    Rectangle()
                        .fill(Color.red.opacity(0.9))
                        .frame(width: 50, height: 50)
                        .overlay(Text("W H").foregroundColor(.white))
                        
                }
                
                Button(action: {
                    withAnimation(.easeOut(duration: 1)) {
                        self.position = 3
                    }
                }) {
                    Rectangle()
                        .fill(Color.red.opacity(0.9))
                        .frame(width: 50, height: 50)
                        .overlay(Text("W \nH").foregroundColor(.white))
                        
                }
                
                
            }
            
        }
        
    }
    
    func helloHorizontalAlignment(_ d: ViewDimensions) -> CGFloat {
        
        switch position {
        case 0:
            return 0
        case 1:
            return 0
        case 2:
            return d[.leading] - 10
        default:
            return 0
        }
    }
    
    func helloVerticalAlignment(_ d: ViewDimensions) -> CGFloat {
        
        switch position {
        case 0:
            return 0
        case 1:
            return d[.bottom] + 10
        case 2:
            return 0
        default:
            return d[.top] - 10
        }
    }
    
    func worldHorizontalAlignment(_ d: ViewDimensions) -> CGFloat {
        
        switch position {
        case 0:
            return 0
        case 1:
            return 0
        case 2:
            return d[.trailing] + 10
        default:
            return 0
        }
    }
    
    func worldVerticalAlignment(_ d: ViewDimensions) -> CGFloat {
        
        switch position {
        case 0:
            return 0
        case 1:
            return d[.top] - 10
        case 2:
            return 0
        default:
            return d[.bottom] + 10
        }
        
    }
    
    
}

struct AlignmentAnimation_Previews: PreviewProvider {
    static var previews: some View {
        AlignmentAnimation()
    }
}

struct Hello: View {
    
    var body: some View {
        
        Group{
            
            Text("안녕").foregroundColor(.black) + Text(" 세상아").foregroundColor(.clear)
            
        }
        .padding()
        
    }
    
}