써니쿠키의 IOS 개발일기

[iOS] CollectionView Layout Custom, 부채꼴 만들기(1/2) 본문

swift, Ios

[iOS] CollectionView Layout Custom, 부채꼴 만들기(1/2)

sunnyCookie 2023. 5. 4. 23:34

안녕하세요 써니쿠키입니다 :)!!!

 

오늘은 UiKit을 이용해서

CollectionView의 Layout을 커스텀해서

부채꼴 모양으로 만들려고합니다.

 

완성되면 아래와같이 될 예정~~!

저는 코데코학습자료를 활용했습니다.

 

 


 (사전지식1)

 이론부터..! 

수학 기억을 조금 꺼내야합니다

노란색 영역이 iPhone 화면이고,

민트색 사각형이 CollectionView의 Cell입니다.

이렇게 커스텀 하려면 염두해야할게 아래 3가지입니다

 

1. 원의 반지름 (radius)

2. 셀 사이 각도 = (anglePerItem)

3. 셀 각도 위치

 

각도계산을 일반화하려고 첫번째 셀(왼쪽 위)을 x°로 둔다면, i번째 Cell의 각도는 아래와 같이 일반화 할 수 있습니다.

0번 cell = x°

1번 cell = x° + 1 * anglePerItem

2번 cell = x° + 2 * anglePerItem

...

i번 cell =  x° + i * anglePerItem ✅

 

각도 좌표계는 아래와 같습니다.

0°는 중심을 가리키고

오른쪽은 +(양의각도)

왼쪽으론 -(음의각도)로 표시됩니다.

따라서 0°의 cell은 수직 중앙에 놓이게 될겁니다.

 

 

 


(사전지식2)

 UICollectionView 와 Layout Process 

layout을 커스텀하기전에

collectionView 와 layout의 프로세스를 알아야 합니다.

 

아래 그림을 천천히 보면 UICollectionView 타입과,

UICollectionViewLayout 타입의 상호작용을 알 수 있습니다.

여기있는 상호작용을 이해하면서 

UICollectionViewLayout 의 subClass를 만들어 레이아웃속성들을 변경(override)해줄 예정입니다!

 

 ✅ UICollectionViewLayout 타입의 item들은 Visual attributes 를 명시합니다.

✅ 이 attribute 들은 UICollectionViewLayoutAttribute의 인스턴스들 입니다.

(frame, transform같은 각 아이템의 프로퍼티를 포함합니다)

 

즉, UICollectionViewLayout이 명시하는 attribute에는

item들의 사이즈가 담겨있고, 이것들을 계산해서 가지고 있어야합니다!

 

 

 

  UICollectionViewLayout 에서 필수적으로 구현해야하는 프로퍼티 및 메서드  

정리는 해놓지만 뒤에서 보여줄 레이아웃 커스텀 코드들을 쭉 읽고 이걸 읽으면 이해가 더 편하실 것 같습니다 ㅎㅎ

 

var collectionViewContentSize

컬렉션뷰 contents 의 width,height를 반환합니다.

visible content가 아닌 전체 content size입니다.

컬렉션뷰는 이 정보를 scroll view의 content size로써 내부적으로 사용합니다. 

 

func prepare()

레이아웃기능이 발생하려고할때, UIKit은 이 메서드를 호출합니다.

여기서 컬렉션뷰의사이즈, 각 아이템들의 위치들을 결정하는데 필요한 계산들을 하기위한 prepare, perform 기회가됩니다.

 

func layoutAttributesForElements(in: )

모든 아이템들에 대한 레이아웃 attributes를 반환합니다.

UIColelctionViewLayoutAttributes타입의 Array로 collectionView에 반환합니다.

 

func layoutAttributesForItem(at:)

어떤 특정한 필요한 레이아웃의 정보를 컬렉션뷰에 전달합니다.

indexPath로 요청된 해당 아이템의 레이아웃 attributes를 반환합니다.

 

 

 


 부채꼴 CollectionView Layout 만들기 

우선 기본적인 UICollectionView를 하나 만들어주고(cell마다 랜덤색상 보이도록 구현했습니다) layout을 변경해보도록 합시다

(기본 구현 전체코드는 접은글 확인)

더보기

1. ViewController.swift

final class ViewController: UIViewController {

    //MARK: - DiffableDataSource Section
    enum Section {
        case main
    }

    //MARK: - DataSource (diffableDataSource 사용)
    typealias DataSource = UICollectionViewDiffableDataSource<Section, Int>
    private var datasource: DataSource?

    //MARK: - UI요소
    private let collectionView: UICollectionView = {
        let flowLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
        flowLayout.itemSize = CGSize(width: 100, height: 100)

        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false

        return collectionView
    }()

    //MARK: - Logic
    override func viewDidLoad() {
        super.viewDidLoad()
        configureLayout()
        configureDataSource()
        snapShot()
        //collectionView.collectionViewLayout = CircularLayout()
    }

    func configureLayout() {
        self.view.addSubview(collectionView)

        let safeArea = self.view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            collectionView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
            collectionView.topAnchor.constraint(equalTo: safeArea.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor),
        ])
    }

    func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<NumberCell, Int> { cell, _, number in
            cell.configureCell(with: number)
        }

        datasource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        }
    }

    private func snapShot() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
        snapshot.deleteAllItems()
        snapshot.appendSections([.main])
        snapshot.appendItems(Array(0...30))
        datasource?.apply(snapshot)
    }
}

 

2. NumberCell.swift

final class NumberCell: UICollectionViewCell {

    private let label: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        label.backgroundColor = UIColor(red: CGFloat(drand48()), green: CGFloat(drand48()), blue: CGFloat(drand48()), alpha: 1.0)

        return label
    }()

    func configureCell(with item: Int) {
        configureLayout()
        self.label.text = String(item)
    }

    func configureLayout() {
        contentView.addSubview(label)

        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            label.topAnchor.constraint(equalTo: contentView.topAnchor),
            label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }
}

 

 

1️⃣ UICollectionViewLayout의 SubClass생성

UICollectionViewLayout 은 item들은 Visual attributes 를 명시한다고 했쬬오!

 

필요속성

 

1. itemSize

Cell Size가 됩니다

 

 

2. radius

반경이 변경되면 모두 다시 계산하므로

didSet에 CollectionView가 Layout을 업데이트하도록 명시하는

invalidateLayout()을 호출합니다

 

3. anglePerItem

atan 메서드는 탄젠트값을 받아 절대각을 -π/2 ~ π/2(-90 ~ 90도)로 반환합니다

Rx는 반지름이되고, Ry는 Cell width로 만들었고, 수정 가능합니다

 

(override) collectionViewContentSize

컬렉션뷰의 컨텐츠 크기가 더 커지도록 변경

너비: item갯수 * item 너비

높이: 컬렉션뷰 높이와 동일

class CircularLayout: UICollectionViewLayout {
    let itemSize = CGSize(width: 133, height: 173)

    var radius: CGFloat = 500 { 
      didSet {
        invalidateLayout() 
      }
    }

    var anglePerItem: CGFloat {
      return atan(itemSize.width / radius) 
    }

    var attributesList = [CircularLayoutAttributes]()

    override var collectionViewContentSize: CGSize {
        // 높이는 컬렉션뷰와 같지만 너비는 itemSize.width * numberOfItems
        return CGSize(width: CGFloat(collectionView!.numberOfItems(inSection: 0)) * itemSize.width,
                      height: CGRectGetHeight(collectionView!.bounds))
    }
}

 

 

 

2️⃣ UICollectionViewLayoutAttributes 의 SubClass생성

UICollectionViewLayout 에서 명시하는 attributes 들이

UICollectionViewLayoutAttributes의 인스턴스라고 했쬬오!!

 

필요속성

 

1. anchorPoint

회전이 한 점을 기준으로 회전하므로 필요합니다.

 

2. angle

각도를 설정하면서 내부적으로 transformangle 라디안의 값과 동일하게 설정해줍니다.

 

또한 오른쪽에 있는 셀이 왼쪽에 있는 셀과 겹치기를 원하므로 zIndex를 각도가 증가하는 함수로 설정합니다.

각도는 라디안으로 표시되므로 인접한 값이 zIndex(Int타입) 와 같은 값으로 반올림되지 않도록 1,000,000배 해줍니다.

 

3. (override) copyWithZone(zone: NSZone)

UICollectionViewLayoutAttributes의 하위 클래스는 collectionView가 레이아웃을 수행할 때 attributes의 객체가 내부적으로 복사되어있기 때문에 NSCopying 프로토콜을 준수해야 합니다.

객체를 복사할 때 anchorPoint 및 angle 속성이 모두 설정되도록 overriding합니다.

class CircularLayoutAttributes: UICollectionViewLayoutAttributes {
  var anchorPoint = CGPoint(x: 0.5, y: 0.5)
  var angle: CGFloat = 0 {
    didSet {
      zIndex = Int(angle * 1000000)
      transform = CGAffineTransformMakeRotation(angle)
    }
  }

    override func copy(with zone: NSZone? = nil) -> Any {
        let copiedAttributes: CircularLayoutAttributes = super.copy(with: zone) as! CircularLayoutAttributes
        copiedAttributes.anchorPoint = self.anchorPoint
        copiedAttributes.angle = self.angle
        return copiedAttributes
    }
}

 

 

 

3️⃣ 만든 CircularLayoutAttributes 주입하기

이제 CircularLayout으로 돌아가서

layoutAttributesClass()를 ovrriding해서 만들어둔

CircularLayoutAttributes 를 사용할 수 있습니다.

 

이는 collectionView 레이아웃 속성들에 기존의

UICollectionViewLayoutAttributes대신CircularLayoutAttributes를 사용할 것임을 알려줍니다.

그리고 모든 아이템들에 대해 이 특정 attributes를 적용하기 위해서

뒤에서 사용해야해서 attributesList 라는 변수에 Array로 담아둡니다.

class CircularLayout: UICollectionViewLayout {
  ...

    var attributesList = [CircularLayoutAttributes]()

    //컬렉션뷰 레이아웃 속성에 UICollectionViewLayoutAttributes가 아니라 CircularLayoutAttributes를 사용할 것임을 알려준다.
    override class var layoutAttributesClass: AnyClass {     
      return CircularLayoutAttributes.self
     }

    ...
}

 

 

 

4️⃣ Layout 준비

컬렉션 뷰가 화면에 처음 나타날 때 UICollectionViewLayoutprepareLayout()가 호출됩니다.

(이 메서드는 레이아웃이 무효화될 때마다 호출되기도 합니다.)

여기서는 layout attibutes 들을 만들고 저장하는 메서드기 때문에 레이아웃 프로세스에서 가장 중요한 것 중 하나입니다.

class CircularLayout: UICollectionViewLayout {
        ...

    override func prepare() {
        super.prepare()

        let centerX = collectionView!.contentOffset.x + (CGRectGetWidth(collectionView!.bounds) / 2.0)

        attributesList = (0..<collectionView!.numberOfItems(inSection: 0).map { (i) -> CircularLayoutAttributes in
            // 1
            let attributes = CircularLayoutAttributes(forCellWith: IndexPath(item: i, section: 0))
            attributes.size = self.itemSize
            // 2 각항목 중앙배치
            attributes.center = CGPoint(x: centerX, y: CGRectGetMidY(self.collectionView!.bounds))
            // 3 라디안 만큼 항목 회전
            attributes.angle = self.angle + (self.anglePerItem * CGFloat(i))

            return attributes
        }
    }

    ...
}

collectionView에서 각 item마다 클로저를 실행해줍니다.

1. 각 indexPath마다 CircularLayoutAttributes의 객체를 생성하고 size를 설정합니다.

2. 각 항목을 화면 중앙에 배치합니다.

3. 각 항목을 anglePerItem * i(라디안 단위)만큼 회전합니다.

 

그리고 적절하게 UICollectionViewLayout을 적절하게

우리가 만든 CircularLayout로 하위클래스화 하려면 다음과 같은 메서드들도 overriding해아합니다.

이 메서드들은 레이아웃 프로세스 전체에서와 사용자가 스크롤 할 때도 여러번 호출되는 것들입니다

class CircularLayout: UICollectionViewLayout {
        ...
      // attributes의 전체 배열 반환
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return attributesList
     }

        // 어떤 index의 item의 attributes를 반환
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return attributesList[indexPath.row]
     }

        ...
}

 

 

여기까지하면 아래와 같이 나타날겁니다

 

여기까지 했으면….

이제 회전축이되는 AnchorPoint를 이동해주면 돼요!!!

너무 길어져서 2부로 나누어서 업로드합니다..!

반응형
Comments