[UIKit] UICollectionView: Cell Accessibility

Junyoung Park·2022년 12월 4일
0

UIKit

목록 보기
109/142
post-thumbnail

UICollectionView From Scratch [2] - Flow Layout | Self-Sizing Cells | Data Source

UICollectionView: Cell Accessibility

구현 목표

  • 유저가 설정한 텍스트 사이즈 등 액세서빌리티를 컬렉션 뷰에 적용

구현 태스크

  • 컬렉션 뷰 UI 적용
  • systemLayoutSizeFitting 적용
  • 액세서빌리티 변경 비 컬렉션 뷰 레이아웃 업데이트
  • 뷰 모델 바인딩
  • 세그멘트 컨트롤 바인딩, 모델 내 데이터 소스 조정

핵심 코드

override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
        let padding: CGFloat = 8
        let noOfItems = traitCollection.horizontalSizeClass == .compact ? 4 : 8
        let itemWidth = (UIScreen.main.bounds.width - (padding * 2)) / CGFloat(noOfItems)
        return super.systemLayoutSizeFitting(.init(width: itemWidth, height: UIView.layoutFittingExpandedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
    }
  • self-sizing의 핵심, 현재 사이즈에 따라 주어진 크기 변경
  • 현재 셀이 알고 있는 아이템의 너비를 알아낸 뒤, 높이는 UIView.layoutFittingExpandedSize.height를 통해 동적으로 얻어내기
label.adjustsFontForContentSizeCategory = true
  • 액세서빌리티 변경에 따라 해당 폰트 크기를 변경하기 위한 프로퍼티
let imageHeightConstraint = imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor)
        imageHeightConstraint.priority = .init(999)
  • 셀 사이즈를 조정할 때 다른 제약 조건과 충돌하는 경우가 있을 수 있기 때문에 해당 제약 조건의 우선순위를 가장 높게 999로 설정
layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
  • 커스텀 컬렉션 뷰 내에서 해당 레이아웃 함수를 오버라이드한 뒤 플로우 레이아웃의 프로퍼티 중 estimatedItemSize를 통해 자동 사이즈로 리사이징 가능하도록 설정

소스 코드

import UIKit
import Combine

class SingleSectionCharactersViewController: UIViewController {
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.sectionInset = .init(top: 0, left: 8, bottom: 0, right: 8)
        layout.minimumLineSpacing = 0
        layout.minimumInteritemSpacing = 0
        layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(CharacterCell.self, forCellWithReuseIdentifier: CharacterCell.identifier)
        collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderView.identifier)
        return collectionView
    }()
    private lazy var segmentedControl: UISegmentedControl = {
        let control = UISegmentedControl(items: Universe.allCases.map({ $0.title }))
        control.selectedSegmentIndex = 0
        control.addTarget(self, action: #selector(didTapControl), for: .valueChanged)
        return control
    }()
    private let input: PassthroughSubject<SingleSectionCharactersViewModel.Input, Never> = .init()
    private var cancellables = Set<AnyCancellable>()
    private let viewModel = SingleSectionCharactersViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView.frame = view.bounds
    }
    
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        collectionView.collectionViewLayout.invalidateLayout()
    }
    
    private func setUI() {
        view.addSubview(collectionView)
        view.backgroundColor = .systemBackground
        navigationItem.titleView = segmentedControl
    }
    
    private func bind() {
        viewModel.transform(input: input.eraseToAnyPublisher())
        viewModel
            .univsere
            .sink { [weak self] _ in
                self?.collectionView.reloadData()
            }
            .store(in: &cancellables)
    }
    
    @objc private func didTapControl() {
        input.send(.controlDidTap(segmentedControl.selectedUniverse))
    }
}
  • 컬렉션 뷰와 세그멘트 컨트롤로 구현된 간단한 UI
  • traitCollectionDidChange를 오버라이드, 유저 설정한 액세서빌리티가 변경될 때 자동으로 컬렉션 뷰 레이아웃을 invalidate하기
  • 뷰 모델 바인딩을 통해 세그먼트 컨트롤 값이 변경될 때마다 새로운 뷰를 그리기
extension SingleSectionCharactersViewController: UICollectionViewDelegate {
    
}

extension SingleSectionCharactersViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return viewModel.univsere.value.stubs.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CharacterCell.identifier, for: indexPath) as? CharacterCell else { fatalError() }
        let model = viewModel.univsere.value.stubs[indexPath.row]
        cell.configure(with: model)
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: HeaderView.identifier, for: indexPath) as? HeaderView else { fatalError() }
        header.configure(with: viewModel.univsere.value.title)
        return header
    }
}

extension SingleSectionCharactersViewController: UICollectionViewDelegateFlowLayout {
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        let header = HeaderView()
        header.configure(with: viewModel.univsere.value.title)
        return header.systemLayoutSizeFitting(.init(width: collectionView.bounds.width, height: UIView.layoutFittingExpandedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
    }
}
  • 컬렉션 뷰 데이터 소스 및 플로우 레이아웃 함수 적용
  • 컬렉션 뷰 셀 내부에 선언된 인스턴스 메소드 systemLayoutSizeFitting을 헤더 뷰를 리턴할 때 적용하기 위해 referenceSizeForHeaderInSection 함수에서 해당 헤더 뷰의 사이즈를 그때 계산해서 리턴
import UIKit
import Combine

class SingleSectionCharactersViewModel {
    enum Input {
        case controlDidTap(Universe)
    }
    
    let univsere: CurrentValueSubject<Universe, Never> = .init(.ff7r)
    private var cancellables = Set<AnyCancellable>()
    
    func transform(input: AnyPublisher<Input, Never>) {
        input
            .sink { [weak self] result in
                switch result {
                case .controlDidTap(let control): self?.univsere.send(control)
                }
            }
            .store(in: &cancellables)
    }
}
  • 컴바인 스타일로 구현한 뷰 모델
  • 뷰 컨트롤러의 인풋은 현재 시점에서는 세그멘트 컨트롤이 유일하기 때문에 해당 인풋을 받아들여 뷰 컨트롤러가 구독 중인 데이터 소스를 변경

구현 화면

profile
JUST DO IT

0개의 댓글