iyOmSd/Title: Swift

[Swift] Multi Line Tag View 그리기

냄수 2023. 9. 30. 00:05
반응형

SwiftUI만 하다가 UIKit을 하게될 일이 생겼는데

요구사항중하나가

뷰를 크기에 맞게 여러줄로 표현해주는 뷰에요

테그를 표현하는 뷰같은 곳에 많이 쓰이는 UI로 알고있어요 

결과물 부터 보시죠! 

네 이런뷰입니다..

SwiftUI로도 이뷰는 어떻게 만들어야할까 깊은 고뇌를 해봤습니다... 

스유는 레이아웃 건드는게 좀 어렵더라구요 🤯

 

우선 UIKit으로 개발해야하니깐 UIKit관점에서 쉬운방법으로 구현해봤습니다!

이런뷰를 구현할때 많은 방법이 존재하겠지만

단순한 방법으로

뷰의 frame을 계산해서 구현하는 방식을 선택했어요

 

전체코드는 아래와같아요!

class MultiLineTagView: UIView {
    private let horizontalSpacing: CGFloat
    private let verticalSpacing: CGFloat
    private let rowHeight: CGFloat
    private var intrinsicHeight: CGFloat = 0
    private let horizontalPadding: CGFloat
    
    private var labels: [UILabel] = []
    
    init(
        horizontalSpacing: CGFloat = 0,
        verticalSpacing: CGFloat = 0,
        rowHeight: CGFloat,
        horizontalPadding: CGFloat = 0
    ) {
        self.horizontalSpacing = horizontalSpacing
        self.verticalSpacing = verticalSpacing
        self.rowHeight = rowHeight
        self.horizontalPadding = horizontalPadding
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /// 상속해서 사용할 label의 속성을 지정해줍니다
    func applyAttribute(label: UILabel) { 
        label.textColor = .black
        label.layer.borderWidth = 1
        label.layer.borderColor = UIColor.gray.cgColor
        label.layer.cornerRadius = 10
    }
    
    final func setTag(words: [String]) {
        for word in words {
            let label = UILabel()
            label.text = word
            label.textAlignment = .center
            applyAttribute(label: label)
            addSubview(label)
            label.frame.size.width = label.intrinsicContentSize.width + horizontalPadding * 2
            label.frame.size.height = rowHeight
            labels.append(label)
        }
    }
    
    final private func setupLayout() {
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        
        self.labels.forEach { label in
            if currentX + label.frame.width > bounds.width {
                // 다음행으로 이동
                currentX = 0
                currentY += rowHeight + verticalSpacing
            }
            label.frame.origin = CGPoint(x: currentX, y: currentY)
            currentX += label.frame.width + horizontalSpacing
        }
        intrinsicHeight = currentY + rowHeight
        invalidateIntrinsicContentSize()
    }
    
    override var intrinsicContentSize: CGSize {
        var size = super.intrinsicContentSize
        size.height = intrinsicHeight
        return size
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        setupLayout()
    }
}

 

부분별로 살펴보면

 

    final func setTag(words: [String]) {
        for word in words {
            let label = UILabel()
            label.text = word
            label.textAlignment = .center
            applyAttribute(label: label)
            addSubview(label)
            label.frame.size.width = label.intrinsicContentSize.width + horizontalPadding * 2
            label.frame.size.height = rowHeight
            labels.append(label)
        }
    }

이부분은 원하는 단어를 넣으면 label을 생성해서 뷰에 추가하는 것까지의 로직이에요

 

    func applyAttribute(label: UILabel) { 
        label.textColor = .black
        label.layer.borderWidth = 1
        label.layer.borderColor = UIColor.gray.cgColor
        label.layer.cornerRadius = 10
    }

이부분은 상속해서사용하도록 구현해놨고

label의 속성을 정의해주면

만들어질때 적용돼서 원하는 디자인을 만들 수 있어요

 

    final private func setupLayout() {
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        
        self.labels.forEach { label in
            if currentX + label.frame.width > bounds.width {
                // 다음행으로 이동
                currentX = 0
                currentY += rowHeight + verticalSpacing
            }
            label.frame.origin = CGPoint(x: currentX, y: currentY)
            currentX += label.frame.width + horizontalSpacing
        }
        intrinsicHeight = currentY + rowHeight
        invalidateIntrinsicContentSize()
    }
    
    override var intrinsicContentSize: CGSize {
        var size = super.intrinsicContentSize
        size.height = intrinsicHeight
        return size
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        setupLayout()
    }

현재x, y좌표를 계산하는 로직이에요

label이 추가됬을때 현재뷰 width를 넘어간다

그러면 y축으로 height과 spacing 만큼 이동시켜서 그리도록 하고

intrinsicHeight를 재계산합니다

이 값은 뷰와 label이 쌓인 뷰의 높이가 같도록 intrinsicContentSize를 계산된 높이를 지정하는데 사용됩니다.

(= 이작업을통해 회색배경만큼의 뷰만 딱 그려집니다 즉, 슈퍼뷰의 크기가 정해집니다)

또한 viewDidLoad 시점이라던가 init시점에 레이아웃을 설정한다면

원치않는 사이즈와 UI가 그려지게돼요

layoutSubviews를 이용해서 레이아웃 설정작업을 해주도록 구현했어요

 

 

#Preview {
    let tagView = MultiLineTagView(
        horizontalSpacing: 8,
        verticalSpacing: 8,
        rowHeight: 30,
        horizontalPadding: 10
    )
    tagView.setTag(words: [
        "NSiOS", "Enes", "가나다라", "abcdefg",
        "안녕하세요", "오늘은 9월 30일 입니다.", "좋은 하루되세요~!"
    ])
    tagView.backgroundColor = .lightGray
    return tagView
}

사용은 위의 코드같이 간편하게 정의해서 사용할 수 있어요

또한 상속을 한다면

 

final class NSTagView: MultiLineTagView {
    init() {
        super.init(
            horizontalSpacing: 6,
            verticalSpacing: 6,
            rowHeight: 30,
            horizontalPadding: 10
        )
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func applyAttribute(label: UILabel) {
        label.textColor = .purple
        label.font = .systemFont(ofSize: 15, weight: .medium)
        label.layer.cornerRadius = 15
        label.layer.masksToBounds = true
        label.layer.borderColor = UIColor.purple.cgColor
        label.layer.borderWidth = 1
    }
}
#Preview {
    let tagView = NSTagView()
    tagView.setTag(words: [
        "NSiOS", "Enes", "가나다라", "abcdefg",
        "안녕하세요", "오늘은 9월 30일 입니다.", "좋은 하루되세요~!"
    ])
    tagView.backgroundColor = .lightGray
    return tagView
}

간편하게 attribute만 바꿔서 사용할 수 있어요!

 

😈: 가운데로 정렬시키고 싶은데 안돼나요?

🤯: ... ㅠ

 

가운데 정렬을 적용해봅시다!

어떤 로직을 적용해볼거냐면 기존 프레임계산을 통해 그리기때문에 동일하게 해볼거에요

 

기존엔 0부터 width를 더해가며

텍스트의 길이가 뷰의 너비보다 넓다면 행을 변경시켜서 프레임을 이동시키는 식으로 했었는데

가운데정렬이 이와 다른점은

시작지점이 0부터가아니라 뷰의 너비에서 현재줄의 텍스트의 총넓이를 뺀 수치의 반 만큼 이동시키면 

그 위치가 가운데정렬을 적용하기위한 시작인 위치가 돼요

아래의 그림을 같이 보면 이해가 쉬워요

이런 로직을 적용해서 위치시켜줄겁니다!

setupLayout코드는 아래와같이 변경했어요

    // 추가된 타입
    enum TagAlignment { 
        case leading
        case center
    }
    
    // 추가된 프로퍼티
    private let alignment: TagAlignment 
    
    final private func setupLayout() {
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        var rowfirstIndex: Int = 0
        
        self.labels.indices.forEach { index in
            let label = labels[index]
            // 다음행으로 이동
            if currentX + label.frame.width > bounds.width {
                let lastMaxX = currentX - horizontalSpacing
                // 이전 행 위치 재정렬
                setupAlignment(
                    lastX: lastMaxX,
                    firstIndex: rowfirstIndex,
                    lastIndex: index-1 // 새로운행이기때문에 이전 인덱스가 들어갑니다
                )
                
                // 다음줄 초기화
                currentX = 0
                currentY += rowHeight + verticalSpacing
                rowfirstIndex = index // 새로운 행의 시작은 현재 인덱스
            }
            label.frame.origin = CGPoint(x: currentX, y: currentY)
            currentX += label.frame.width + horizontalSpacing
        }
        // 마지막줄 재정렬
        let lastMaxX = currentX - horizontalSpacing
        setupAlignment(
            lastX: lastMaxX,
            firstIndex: rowfirstIndex,
            lastIndex: labels.count - 1
        )
        
        intrinsicHeight = currentY + rowHeight
        invalidateIntrinsicContentSize()
    }
    
    
    private func setupAlignment(lastX: CGFloat, firstIndex: Int, lastIndex: Int) {
        guard lastIndex >= 0 else { return }
        let startX: CGFloat = if self.alignment == .center {
            (bounds.width - lastX) / 2
        } else { 0 }
        
        for i in firstIndex...lastIndex {
            var newFrame = labels[i].frame
            newFrame.origin.x += startX
            labels[i].frame = newFrame
        }
    }

setupAlignment쪽에서

해당 줄에대한 Label들을 재정렬 해주면서 (view.width - 1줄 text의 총 넓이) / 2 만큼 위치를 이동시켜요

이때 alignment옵션에맞게 leading, center정렬을 받아서 알맞게 적용할 수 있어요

 

반응형