Danny의 iOS 컨닝페이퍼
article thumbnail
반응형

[TIL #18] 2023 / 04 / 28

오늘은 CompositionalLayout에서 Cell의 크기를 동적으로 조절해 보려고 합니다.

 

기본 사용방법은 [Swift/TIL #13] CollectionView의 CompositionalLayout 를 참고하세요.

 

 

시작

이와 같이 더 보기 버튼이 있는 컬렉션뷰를 만들어 보려고 합니다.

 

일단 레이블의 numberOfLines는 3줄로 제한을 걸어두고,

 

더 보기를 버튼을 클릭하면 나머지 글 전부를 볼 수 있게 만들 예정입니다.

 

 

먼저 셀 코드를 확인해 봅시다.

 

레이블과 버튼이 있는 Cell을 만들어 줬습니다.

그리고 버튼이 동작 시 델리게이트로 넘겨줘서 Label을 바꿔줄 예정입니다.

protocol DynamicCustomCellDelegate: AnyObject {
    func showHideButtonTapped(_ cell: DynamicCustomCell, sender: UIButton)
}

class DynamicCustomCell: UICollectionViewCell {
    
    static let Identifier = "DynamicCustomCell"
    
    weak var delegate: DynamicCustomCellDelegate?
    
    let titleLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 3
        label.textAlignment = .center
        return label
    }()
    
    lazy var showHideButton: UIButton = {
        let button = UIButton()
        button.setTitle("더보기", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.addTarget(self, action: #selector(showHideButtonHandler), for: .touchUpInside)
        return button
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.layer.cornerRadius = 10
        self.clipsToBounds = true
        self.contentView.backgroundColor = .lightGray
        
        self.contentView.addSubview(titleLabel)
        titleLabel.snp.makeConstraints {
            $0.top.leading.trailing.bottom.equalToSuperview()
        }
        
        self.contentView.addSubview(showHideButton)
        showHideButton.snp.makeConstraints {
            $0.centerY.equalTo(titleLabel)
            $0.trailing.equalToSuperview().offset(-30)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    @objc func showHideButtonHandler(_ sender: UIButton) {
        delegate?.showHideButtonTapped(self, sender: sender)
    }
}

 

 

이제 UICollectionViewCompositionalLayout로

컬렉션뷰 레이아웃을 만들어 봅시다.

 

layoutSize를 설정할 때, 최종크기가 렌더링 시 정해지는 estimated를 사용해줘야 합니다.

 

estimated에 대한 설명은 [Swift/TIL #13] CollectionView의 CompositionalLayout 를 참고해 주세요.

 

코드는 이렇습니다. 여기선 단일 섹션으로 간단히 만들어 줬습니다.

private func createLayout() -> UICollectionViewLayout {
    
    // 👉 heightDimension - estimated
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                          heightDimension: .estimated(100))
    let itme = NSCollectionLayoutItem(layoutSize: itemSize)
    // estimated를 사용하게 되면 contentInsets 값은 무시가 됩니다.
    itme.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: .fixed(20), trailing: nil, bottom: nil)
    
    // 👉 heightDimension - estimated
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                           heightDimension: .estimated(100))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                   subitems: [itme])
    group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)

    let section = NSCollectionLayoutSection(group: group)
    
    let layout = UICollectionViewCompositionalLayout(section: section)
    
    return layout
}

 

여기서 알고 가야 할 포인트가 3가지 있습니다.

 

1. estimated는 렌더링 시 크기가 정해지므로 아무 값이나 넣어줘도 무방합니다.

다만, 실제 크기와 설정한 크기의 값이 적을수록 퍼포먼스가 좋아진다고 하네요.

 

2. 동적으로 크기를 변화시키려면, itemSize와 groupSize 두 쪽 모두 estimated로 설정해줘야 합니다.

 

왜냐하면, group 안쪽에 item이 위치해 있죠? 그러므로 한쪽만 사용하게 되면 아마도 엄청난 계산이 필요할 거예요...

위 코드에서는 높이(height)가 동적으로 변하기 때문에 heightDimension에 대한 설정 모두 estimated로 적용함.

 

3. estimated를 사용 시, 여백을 설정하는 contentInsets 값은 무시가 됩니다.

그러므로 inset 또는 공간을 만들어줘야 할 경우, edgeSpacing를 사용하여 여백을 만들어 줘야 합니다.

contentInsets 공식문서

 

 

여기서는 간단히 사용하기 위해 UICollectionViewDiffableDataSource 대신

dataSource를 채택하여 컬렉션 뷰를 만들어 줬습니다.

extension DynamicViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 1
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DynamicCustomCell.Identifier, for: indexPath) as? DynamicCustomCell else {
            fatalError("Faild to load CustomCell")
        }
        
        cell.delegate = self
        
        let text = "엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n엄청 긴 글 \n마지막"
        
        cell.titleLabel.text = text
        
        return cell
    }
}

 

 

이제 기본적인 컬렉션뷰 만들기는 끝났습니다.

만들어 둔 버튼에 로직을 추가하여 기능을 완성해 봅시다.

 

위에서 Cell의 크기를 동적으로 지정해 줬기 때문에 titleLabel의 줄 수만 조정 후, 컬렉션 뷰를 업데이트시켜 주면,

레이블에 길이에 따라 늘었다 줄었다 하는 더 보기 버튼 완성입니다.

extension DynamicViewController: DynamicCustomCellDelegate {
    func showHideButtonTapped(_ cell: DynamicCustomCell, sender: UIButton) {
        switch sender.currentTitle {
        case "더보기":
            sender.setTitle("숨기기", for: .normal)
            cell.titleLabel.numberOfLines = 0
            
            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }
            
        case "숨기기":
            sender.setTitle("더보기", for: .normal)
            cell.titleLabel.numberOfLines = 3
            
            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }
            
        default: break
        }
    }
}

 

 

전체 코드는 GitHub 를 참고하세요.

 

 

문제

여러 개의 Cell이 존재하면, 선택한 버튼의 Cell이 아니라 랜덤으로 아무 Cell의 크기가 변하더라고요.

 

이유는 아마도 컬렉션 뷰의 reloadData에서 문제가 발생한 것으로 보입니다.

 

간략히 문제를 정리해보면

1. Button이 위치한 Cell의 인덱스를 특정할 수 없다.

2. reloadData로 전체 셀을 다시 로드해서 순서가 변하게 된다.

 

이것들이 문제 같습니다.

 

그래서 조금 찾아보니 reloadData 말고도 셀을 업데이트하는 메서드가 3가지 더 있더라고요.

참고로 UICollectionViewDiffableDataSource에서도 마찬가지로 아래 메서드들이 존재합니다. (사용 가능)

func reloadSections(_ sections: IndexSet)
func reloadItems(at indexPaths: [IndexPath])
func reconfigureItems(at indexPaths: [IndexPath])

 

 

여기서 먼저 reloadItems를 사용해 봤는데, 이상하게도 버튼을 2번씩 눌러줘야만 동작이 되더라고요.

이건 이유를 도저히 모르겠네요...

 

그래서 iOS 15부터 사용가능한 reconfigureItems를 사용해 각 아이템을 업데이트 해봤습니다.

reconfigureItems는 새로운 데이터로 셀을 다시 구성할 때 사용한다고 합니다.

새롭게 뷰를 리로드하는게 아니라서 reloadItems보다 성능면에서 좋다고 하네요.

 

그리고 estimated 같이 자동으로 셀의 크기를 조절하게 되면, 먼저 셀을 재구성한 뒤 셀의 크기를 조절해 준다고 합니다.

extension DynamicViewController: DynamicCustomCellDelegate {
    func showHideButtonTapped(_ cell: DynamicCustomCell, sender: UIButton) {
        
        // 각 셀의 index를 알수 있게 불러와 줍시다.
        guard let indexPath = collectionView.indexPath(for: cell) else { return }
        
        switch sender.currentTitle {
        case "더보기":
            sender.setTitle("숨기기", for: .normal)
            cell.titleLabel.numberOfLines = 0

            DispatchQueue.main.async {
                self.collectionView.reconfigureItems(at: [indexPath])
            }
            
        case "숨기기":
            sender.setTitle("더보기", for: .normal)
            cell.titleLabel.numberOfLines = 3
            
            DispatchQueue.main.async {
                self.collectionView.reconfigureItems(at: [indexPath])
            }
            
        default: break
        }
    }
}

 

완성

 

전체 코드는 GitHub 를 참고하세요.

 

 

반응형
profile

Danny의 iOS 컨닝페이퍼

@Danny's iOS

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!