iyOmSd/Title: SwiftUI

[SwiftUI] ChipView(iOS16+, iOS16-) tag view 구현하기

냄수 2024. 3. 13. 21:40
반응형

2023.09.30 - [iyOmSd/Title: Swift] - [Swift] Multi Line Tag View 그리기

 

[Swift] Multi Line Tag View 그리기

SwiftUI만 하다가 UIKit을 하게될 일이 생겼는데 요구사항중하나가 뷰를 크기에 맞게 여러줄로 표현해주는 뷰에요 테그를 표현하는 뷰같은 곳에 많이 쓰이는 UI로 알고있어요 결과물 부터 보시죠!

nsios.tistory.com

UIKit을 사용하는경우는 여기서 확인가능합니다!!

저 글을 쓸땐 이 레이아웃을 chip이라고 부르는지 몰랐어요 ㅎ

 

간단하게 ChipView란?

균등한 너비를 가지는게아니라 컨텐츠의 크기에 fit하게 정렬되고 

그 사이즈를 넘어간다면 아래로 이동되는 그러한 뷰입니다! 아래처럼요 

이런 뷰가 ChipView입니다~~! 

 

이제는 SwiftUI를 사용해서 한번 그려볼게요!

검색하면 코드가 흔하게 있긴합니다!

하지만 저는 조금 다르게 해볼거에요

 

제가 필요한 요구사항은 2개의 ChipView를 그려야하는데 디자인이달라서

ChipView에 다른UI 넣어서 사용할 수 있도록 재사용성을 높여보려고해요

 

iOS16-와 iOS16+에 맞게 

2가지 방법으로 구현해볼겁니다 !

 

 

저희의 궁극적인 목표인 뷰입니다

 

 

 

 

1. iOS16미만 

View를 구현해줄겁니다

ChipView에서 사용할 모델의 프로토콜을 구현하고

해당 프로토콜을 채택하는 모델 배열을 넘기면 뷰에서 알아서 그려주도록 할거에요 

또한 alignmentGuide로 frame을 직접 계산하는 방식을 사용합니다

public protocol Chipable: Identifiable {
    var id: UUID { get }
    var text: String { get }
}

struct Model: Chipable {
    let id: UUID = UUID()
    let text: String
}

 

struct ChipLayoutView<ChipView: View>: View {
    @Binding var data: [any Chipable]
    let verticalSpacing: CGFloat
    let horizontalSpacing: CGFloat
    @ViewBuilder let chipView: (Int) -> ChipView
    
    var body: some View {
        var width: CGFloat = .zero
        var height: CGFloat = .zero
        return GeometryReader { geo in
            ZStack(alignment: .topLeading) {
                ForEach(data.indices, id: \.self) { index in
                    let chipsData = data[index]
                    chipView(index)
                        .alignmentGuide(.leading) { dimension in
                            if (abs(width - dimension.width) > geo.size.width) {
                                width = 0
                                height -= dimension.height
                                height -= verticalSpacing
                            }
                            
                            let result = width
                            if chipsData.id == data.last!.id {
                                width = 0
                            } else {
                                width -= horizontalSpacing
                                width -= dimension.width
                            }
                            return result
                        }
                        .alignmentGuide(.top) { dimension in
                            let result = height
                            if chipsData.id == data.last!.id {
                                height = 0
                            }
                            return result
                        }
                }
            }
        }
        .frame(maxWidth: .infinity)
    }
}

 

어려운 부분만 살짝볼게요

.alignmentGuide(.leading) { dimension in
    if (abs(width - dimension.width) > geo.size.width) {
        width = 0
        height -= dimension.height
        height -= verticalSpacing
    }
    
    let result = width
    if chipsData.id == data.last!.id {
        width = 0
    } else {
        width -= horizontalSpacing
        width -= dimension.width
    }
    return result
}

 

여기서 width는 현재 그려진 행의 넓이

dimension.width는 그려지는 현재뷰의 넓이에요

geo는 컨테이너뷰의 넓이죠 

따라서 width > dimension.width > geo.size.width 는

현재 그려진 넓이에 그릴 뷰의 넓이를 더햇을때 컨테이너 사이즈를 넘어간다면

다음행으로 height를 증가시켜서 (이떄 -를해야 아래로 이동합니다)

아래에 그려라 라는 로직입니다! 

 

그 아래코드는 width를 그려지는 뷰의 width만큼 더하고있네요 (-를 해야 오른쪽으로 이동입니다) 

이런식으로 UIKit에서의 ChipView 로직과 비슷하게 돌아가요

 

사용은 아래와같이합니다

struct ChipView: View {
    @State private var sample: [any Chipable] = [
        Model(text: "123"),
        Model(text: "123456"),
        Model(text: "abcdefg"),
        Model(text: "가나다라마바사"),
        Model(text: "안녕하세요"),
        Model(text: "1"),
        Model(text: "11234"),
        Model(text: "123 234"),
    ]
    var body: some View {
        ScrollView {
            ChipLayoutView(
                data: $sample,
                verticalSpacing: 8,
                horizontalSpacing: 8
            ) { index in
                let model = sample[index]
                Text(model.text)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 5)
                    .background(
                        Capsule().foregroundStyle(.blue)
                    )
            }
            .frame(height: 200)
        }
    }
}

데이터를 넣어주고 해당 데이터의 index가 클로져로 들어와서

이를 이용해서 뷰를 그리거나 원하는 처리를 할 수 있어요

 

1개만 그린다면 전혀 문제가 없습니다

하지만

다른 뷰와 함께 그려야하거나 혹은 스크롤 뷰안에 있다 

라면 문제가 생깁니다

 

내부 컨텐츠 크기를 인식하지 못하기 때문에 겹쳐지는 현상이 발생합니다!

이렇게요

컬러하나를추가하면 왼쪽처럼 나오기를 기대하겠지만

오른쪽처럼 나옵니다! 

 

이를 위한 해결책으로는 ChipView내에서 frame을 계산해주고 그 frame을 이용해서 직접 선언해주면 크기가 잘동작합니다!

 

 

 

 

2. iOS16+

iOS16부터는 좀더 우아하게 사용할 수 있어요

Layout 프로토콜을 준수해서 필요한 Custom Layout을 만들어서 사용할 수 있어요

뷰크기를 계산하고. padding등 적용되면 bounds에 적용되서 사이즈를 받을 수 있어요

모델을 맞추기위한 임의의 프로토콜도 필요없고 VStack사용하듯이 쓰면돼서

이 방식이 좀 더 유연한 방법이 될 수 있을 것 같아요

 

코드를 먼저 볼게요 

struct ChipLayout: Layout {
    var verticalSpacing: CGFloat = 0
    var horizontalSpacing: CGFloat = 0
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        return CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)
    }
    
    // proposal 제공 뷰크기
    // bounds 위치
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        print("bound: ", bounds)
        print("proposal: ", proposal)
        
        var sumX: CGFloat = bounds.minX
        var sumY: CGFloat = bounds.minY
       
        for index in subviews.indices {
            let view = subviews[index]
            let viewSize = view.sizeThatFits(.unspecified)
            guard let proposalWidth = proposal.width else { continue }
            
            // 가로 끝인경우 아래로 이동
            if (sumX + viewSize.width > proposalWidth) {
                sumX = bounds.minX
                sumY += viewSize.height
                sumY += verticalSpacing
            }
            
            let point = CGPoint(x: sumX, y: sumY)
            // anchor: point의 기준 적용지점
            // proposal: unspecified, infinity -> 넘어감, zero -> 사라짐, size -> 제안한크기 만큼 지정됨
            // size지정해줘야 텍스트긴경우 짤림
            view.place(at: point, anchor: .topLeading, proposal: proposal)
            sumX += viewSize.width
            sumX += horizontalSpacing
        }
    }
    
}

 

하나씩 떼서 볼까요

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        return CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)
    }

제일먼저 불리고 사이즈를 정의하는 부분이에요

이부분에서 뷰의 크기가 결정됩니다

 

SwiftUI에서 레이아웃 결정방식은

부모에서 크기를 제안 -> 자식이 결정

이런 방식이죠

 

proposal: 제안된 뷰사이즈

subviews: 포함된 모든 하위뷰들이 보여집니다

 

예를들어 

이렇게 검은테두리가 저희가만들 레이아웃이 될 녀석이고

proposal로 상위뷰의 크기를 받게되요 

하지만 ScrollView라면 nil이에요 주의해야합니다!

subviews로 그 레이아웃안에 하위뷰로 chip들이 8개 들어있다면 8개의 뷰에 접근할 수 있죠

 

 

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        print("bound: ", bounds)
        print("proposal: ", proposal)
        
        var sumX: CGFloat = bounds.minX
        var sumY: CGFloat = bounds.minY
       
        for index in subviews.indices {
            let view = subviews[index]
            let viewSize = view.sizeThatFits(.unspecified)
            guard let proposalWidth = proposal.width else { continue }
            
            // 가로 끝인경우 아래로 이동
            if (sumX + viewSize.width > proposalWidth) {
                sumX = bounds.minX
                sumY += viewSize.height
                sumY += verticalSpacing
            }
            
            let point = CGPoint(x: sumX, y: sumY)
            // anchor: point의 기준 적용지점
            // proposal: unspecified, infinity -> 넘어감, zero -> 사라짐, size -> 제안한크기 만큼 지정됨
            // size지정해줘야 텍스트긴경우 짤림
            view.place(at: point, anchor: .topLeading, proposal: proposal)
            sumX += viewSize.width
            sumX += horizontalSpacing
        }
    }

이 부분에서 하위뷰의 위치를 재정의할 수 있습니다

즉, 실질적인 레이아웃 커스텀이 이뤄지는 곳이에요

 

bounds: 현재 Layout의 bounds정보를 가지고있어요

proposal: 제안된 사이즈를 가지고있어요

subviews: 포함된 모든 뷰를 가지고있어요

cache: 캐싱할 정보를 정의 할 수있고 그 지정한 값이 들어와요

 

Chip뷰를 만드려면

위에서 한 방식처럼 레이아웃의 각 하위뷰에 접근해서 넓이계산을 하고

제안된 넓이가 넘어가면 아래로 이동시키는 로직을 구현해야해요 

 

sumX, sumY시작은 bounds의 값으로 시작합니다!

 

0으로 사용안하나요? 

-> padding이 20이 적용됬는데 0부터 시작하면 어색한 레이아웃이 완성되기 때문이에요

 

view.sizeThatFits(.unspecified)

를 통해서 뷰의 사이즈를 가져올 수 있어요

ProposedViewSize 옵션으로는 

뷰하나를 떼다가 찍어봤는데 이렇게 나오네요

unspecified = (97.66666666666666, 30.333333333333332)

zero = (24.0, 10.0)

infinity = (97.66666666666666, 30.333333333333332)

 

최소, 최대, 이상적크기로 지정해서 값을 받을 수 있어요

보통 unspecified를 사용하구요

 

여기서는 frame계산이므로 -가아니라 +로 x와 y값을 계산해줍니다

 

다음으로 중요한 부분은 이코드입니다

view.place(at: point, anchor: .topLeading, proposal: proposal)

뷰의 위치를 지정하는 코드에요

at에 x, y값을 지정해주고

anchor에 point의 기준점을 잡아줘요

at (0, 0) 위치에 anchor .topLeading라면

topLeading의 위치가 (0, 0)위치에 있게되는거에요

보통 쓰고있는 frame의 (0, 0)이 되는거죠

이게 저희가 원하는 프레임의 왼쪽위부터 차곡차곡 채우기위한 지점이 됩니다! 

 

struct ChipView: View {
    @State private var sample: [String] = [
        "123",
        "123456",
        "abcdefg",
        "가나다라마바사",
        "안녕하세요",
        "1",
        "11234",
        "123 234",
    ]
    var body: some View {
        ScrollView {
            ChipLayout(verticalSpacing: 8, horizontalSpacing: 8) {
                ForEach(sample.indices, id: \.self) { index in
                    let model = sample[index]
                    Text(model)
                        .padding(.horizontal, 12)
                        .padding(.vertical, 5)
                        .background(
                            Capsule().foregroundStyle(.blue)
                        )
                }
            }
            .border(.black)
        }
    }
}

 

사용은 이렇게합니다

보통 VStack처럼 간편하죠? 

 

이 레이아웃 또한 

ScrollView 안에 있다면 nil이 발생하는 문제때문에 

아래에 뷰를 그릴떄 크기가 겹쳐지는 문제가 발생합니다!

 

이를 해결하기위해선 위에서는 frame의 합산을 지정했는데

height을 더하는 로직을 Layout내부에 넣어도 동작하지않아요 (계속 0으로나오네요 ㅠ)

 

해결 방법으로는 

cache를 이용해서 뷰 리랜더시 캐시 값을 이용하도록 했어요

이때 뷰에 보여줄 데이터를 default 생성자로 넣으면 안돼고 뷰랜더이후 값변경을 통해 재계산 할 수 있도록해야해요

sizeThatFits가 다시 호출돼야 cache에 저장된 뷰의 넓이를 사용할 수 있구요

updateCache함수 미정의시 업데이트가 일어나지 않는점 주의하셔야해요

 

 

Cache 적용

위의코드에 Cache를 정의해봅시다!

struct ChipLayout: Layout {
    var verticalSpacing: CGFloat = 0
    var horizontalSpacing: CGFloat = 0
    
    // scrollView에서 height = nil
    // ✅ 변경된 부분 cache
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        print("--sizeThatFits--", cache)
        // ✅ 추가된 부분 
        return CGSize(width: proposal.width ?? 0, height: cache.height)
    }
    
    // proposal 제공 뷰크기
    // bounds 위치
    // ✅ 변경된 부분 cache
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
        print("--placeSubviews--")
        print("bound: ", bounds)
        print("proposal: ", proposal)
        
        var sumX: CGFloat = bounds.minX
        var sumY: CGFloat = bounds.minY
       
        for index in subviews.indices {
            let view = subviews[index]
            let viewSize = view.sizeThatFits(.unspecified)
            guard let proposalWidth = proposal.width else { continue }
            
            // 가로 끝인경우 아래로 이동
            if (sumX + viewSize.width > proposalWidth) {
                sumX = bounds.minX
                sumY += viewSize.height
                sumY += verticalSpacing
            }
            
            let point = CGPoint(x: sumX, y: sumY)
            // anchor: point의 기준 적용지점
            // proposal: unspecified, infinity -> 넘어감, zero -> 사라짐, size -> 제안한크기 만큼 지정됨
            view.place(at: point, anchor: .topLeading, proposal: proposal)
            sumX += viewSize.width
            sumX += horizontalSpacing
            

        }
        // ✅ 추가된 부분
        if let firstViewSize = subviews.first?.sizeThatFits(.unspecified) {
            // sumY는 topLeading 기준의 좌표이므로 height를 구하려면 
            // chip뷰의 height를 더해야 전체 높이값이 나옵니다.
            cache.height = sumY + firstViewSize.height
        }
    }
    
    // ✅ 추가된 부분 
    
    struct Cache {
        var height: CGFloat
    }
    
    func makeCache(subviews: Subviews) -> Cache {
        print("make cache")
        return Cache(height: 0)
    }
    
    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        print("update cache", cache)
    }
}

 

캐시 타입을 만들어주고

계산한 높이를 Cache타입의 height에 저장할거에요

 

Layout에 Cache associatedtype이 있어서

Cache타입을 정의하면 

makeCache를 구현하라고 경고를줍니다!

cache: inout() 엿던 기존함수들 파라미터도 각 타입으로 대치해줘야해요

 

그이후 캐시에 접근해서 원하는 값을 넣어주면돼요

위에서 언급했듯이

updateCache 를 구현해주지않으면 원하는 변경된 캐시값을 사용할 수 없는점 주의해야해요

이렇게 캐시를 이용해주면 외부에서 신경쓰지않아도 내부 넓이를 잘잡을 수 있습니다! 👏👏

 

하지만 이부분도 정확하게 레이아웃을 잡지못하는 이슈가있는데

처음캐시에는 0이들어가서 위치를 못잡고

그 이후부터 데이터 업데이트, 혹은 UI업데이트가 일어나면 (정적데이터를 넣어두고사용시 onAppear에서 변경시 잘그려집니다!)

레이아웃이 아주 잘 잡힙니다!

 

데이터변경 X상태에서 기본초기화 로그

make cache
--sizeThatFits--

 

 

데이터 변경시 

make cache
--sizeThatFits-- Cache(height: 0.0)

update Data!

update cache
--sizeThatFits-- Cache(height: 0.0)
--placeSubviews--
bound:  (0.0, 0.0, 353.0, 0.0)
proposal:  ProposedViewSize(width: Optional(353.0), height: nil)
update cache
--sizeThatFits-- Cache(height: 72.0)
--placeSubviews--
bound:  (0.0, 0.0, 353.0, 72.0)
proposal:  ProposedViewSize(width: Optional(353.0), height: nil)

이 로그를 간단하게 함수로만 정리하면

makeCache
sizeThatFits, height: 0

// << 데이터 변경 시점 >>

updateCache, height: 0
sizeThatFits, height: 0
placeSubviews
updateCache, height: 72  // 캐시변경 일어나는 시점

// 여기아래부터 레이아웃 제대로잡힘
sizeThatFits, height: 72
placeSubviews

 

placeSubviews함수는 뷰가 다시 그려질때 계속호출되요

 

이제 아래와같이

여러 디자인의 ChipView를 사용할 수 있는 재사용가능한 레이아웃을 만들었습니다!

 

간편하게  사용가능해요 !

struct ChipView: View {
    @State private var sample: [String] = []
    var body: some View {
        ScrollView {
            ChipLayout(verticalSpacing: 8, horizontalSpacing: 8) {
               blueChipView
            }
            .border(.black)
            VStack(alignment: .leading) {
                Text("Section 1")
                ChipLayout(verticalSpacing: 8, horizontalSpacing: 8) {
                    orangeChipView
                }
            }
            .border(.black)
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now()+1) {
                sample = [
                    "123",
                    "123456",
                    "abcdefg",
                    "abcdefg",
                    "abcdefg",
                ]
            }
        }
    }

 

 

반응형