1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

<Swift>RxDataSourcesでヘッダー・フッター付きのUICollectionViewを実装

Last updated at Posted at 2020-07-25

完成イメージ

パターン1(全てのセクションで同じ名前、同じデータ形式を表示する場合)

TodolistDetailViewModel.swift
import Foundation
import RxSwift
import RxCocoa
import RxDataSources

struct SectionOfTodolist {
    // headerはSectionModelTypeに設定されない。
    // 任意の文字列を設定しておき、ヘッダーセルを作るときに参照するために定義。
    // 参照の仕方:dataSource.sectionModels[indexPath.section].header
    var header: String
    var items: [Item]
}

extension SectionOfTodolist: SectionModelType {
    typealias Item = Todo
    init(original: SectionOfTodolist, items: [SectionOfTodolist.Item]) {
        self = original
        self.items = items
    }
}

class TodolistDetailViewModel {
    let items = BehaviorRelay<[SectionOfTodolist]>(value: [])

    func updateItems() {
        let todolist = getTodolist()
        let sections = convertToSectionModels(srcArray: todolist.todoArray)
        items.accept(sections)
    }
    
    private func getTodolist() -> Todolist {
        // ダミーデータ
        var todoArray: [Todo] = []
        todoArray.append(Todo(title: "テストTODO1", expireDate: Date()))
        todoArray.append(Todo(title: "テストTODO2", expireDate: Date()))
        todoArray.append(Todo(title: "テストTODO3", expireDate: Date()))
        let ret = Todolist(name: "ダミーTODOリスト", color: .blue, todoArray: todoArray)
        return ret
    }
    
    private func convertToSectionModels(srcArray: [Todo]) -> [SectionOfTodolist] {
        let section =
            SectionOfTodolist(header: "test header"
                , items: srcArray)
        let section2 =
                    SectionOfTodolist(header: "test header2"
                        , items: srcArray)
        return [section, section2]
    }
}
TodolistDetailViewController.swift
import UIKit
import RxSwift
import RxDataSources

class TodolistDetailViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!
    
    private lazy var dataSource = RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>(
    configureCell: configureCell
    ,configureSupplementaryView: titleForHeaderInSection)

    // カスタムセルを設定
    private lazy var configureCell: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureCell = { [weak self] (_, collectionView, indexPath, todo) in
        guard let strongSelf = self else { return UICollectionViewCell() }
            return strongSelf.todolistCell(indexPath: indexPath, todo: todo)
    }
    
    // セクションヘッダータイトルを設定
    private lazy var titleForHeaderInSection: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureSupplementaryView = { [weak self] (dataSource, collectionView, kind, indexPath) in
        guard let strongSelf = self else { return UICollectionReusableView() }
        return strongSelf.headerCell(indexPath: indexPath, kind: kind)
    }

    private var viewModel: TodolistDetailViewModel!
    
    private let todolistId: Int
    
    private let disposeBag = DisposeBag()
    
    init(todolistId: Int) {
        self.todolistId = todolistId
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViewController()
        setupCollectionView()
        setupViewModel()
    }
}

extension TodolistDetailViewController {
    private func setupViewController() {
        self.navigationItem.title = "ダミーTODOリスト"
        self.view.backgroundColor = .systemBlue
    }

    private func setupCollectionView() {
        collectionView.contentInset.top = TodolistDetailCollectionViewCell.cellMargin
        collectionView.register(UINib(nibName: "TodolistDetailCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: "TodolistDetailCollectionViewCell")
        collectionView.register(
            UICollectionReusableView.self
            , forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader
            , withReuseIdentifier: "Section")
        collectionView.rx.setDelegate(self).disposed(by: disposeBag)
        
        // ヘッダサイズを指定
        let flowLayout = UICollectionViewFlowLayout()
        flowLayout.headerReferenceSize = CGSize(width: self.collectionView.frame.width, height: 40)
        collectionView.collectionViewLayout = flowLayout
        
        // タップ時のイベント
        collectionView.rx.itemSelected
            .map { [weak self] indexPath -> Todo? in
                return self?.dataSource[indexPath]
            }
            .subscribe(onNext: { [weak self] item in
                guard let item = item else { return }
                    self?.presentTodoDetaillViewController()
            })
            .disposed(by: disposeBag)
    }

    private func setupViewModel() {
        viewModel = TodolistDetailViewModel()
        viewModel.items
            .asDriver()
            .drive(collectionView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
        viewModel.updateItems()
    }

    private func todolistCell(indexPath: IndexPath, todo: Todo) -> UICollectionViewCell {
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TodolistDetailCollectionViewCell", for: indexPath ) as? TodolistDetailCollectionViewCell{
            cell.update(todoTitle: todo.title, todoExpireDate: todo.expireDate)
                        
            // チェックボックスタップ時
            let checkButtonObservable = cell.checkButton.rx.tap.asObservable()
            checkButtonObservable.subscribe(
                onNext: { [weak self] in
                    cell.isCheck = !cell.isCheck
                    let image = cell.isCheck ? UIImage(systemName: "checkmark.square") : UIImage(systemName: "square")
                    cell.checkButton.setImage(image, for: .normal)
                }
            ).disposed(by: cell.disposeBag)
            
            return cell
        }
        return UICollectionViewCell()
    }

    private func headerCell(indexPath: IndexPath, kind: String) -> UICollectionReusableView {
        let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Section", for: indexPath)
        headerView.backgroundColor = .yellow
        let label = UILabel(frame: CGRect(x:0, y:0, width:self.collectionView.frame.width, height:20))
        label.textColor = UIColor.black
        label.textAlignment = .center
        label.text = dataSource.sectionModels[indexPath.section].header
        headerView.addSubview(label)
        return headerView
    }

    private func presentTodoDetaillViewController() {
        let todoId = 0
        let vc = TodoViewController(todoId: todoId)
        self.navigationController?.pushViewController(vc, animated: true)
    }
}

extension TodolistDetailViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        let item = dataSource[section]
            return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let item = dataSource[indexPath]
            let width = collectionView.bounds.width - (TodolistDetailCollectionViewCell.cellMargin * 2)
            return CGSize(width: width, height: TodolistDetailCollectionViewCell.cellHeight)
    }
}

■参考にしたサイト
http://m.hangge.com/news/cache/detail_2005.html
https://www.jianshu.com/p/8b9cb8d57edd

パターン2(セルに表示するデータ形式をセクションによって変える場合)

※例:(パターン1からの差分のみ記載)

TodolistDetailViewModel.swift
extension SectionOfTodolist: SectionModelType {
    // <変更>
    typealias Item = TodolistItem
    ...省略
}

// <追加>
enum TodolistItem {
    case todo(todo: Todo)
    case memo(memo: Memo)
}

class TodolistDetailViewModel {
    ...省略
    func updateItems() {
        let todolist = getTodolist()
        // <変更>
        let sections1 = convertTodoToSectionModels(srcArray: todolist.todoArray)
        let sections2 = convertMemoToSectionModels(srcArray: memolist.memoArray)
        items.accept([sections1, sections2])
    }
    
    // <追加>
    private func convertTodoToSectionModel(srcArray: [Todo]) -> SectionOfTodolist {
        let items = srcArray.map {
            TodolistItem.todo(todo: $0)
        }
        let section =
            SectionOfTodolist(header: "TODOカテゴリー"
                , items: items)
        
        return section
    }
    
    // <追加>
    private func convertMemoToSectionModel(srcArray: [Memo]) -> SectionOfTodolist {
        let items = srcArray.map {
            TodolistItem.memo(memo: $0)
        }
        let section =
            SectionOfTodolist(header: "MEMOカテゴリー"
                , items: items)
        return section
    }
}
TodolistDetailViewController.swift
class TodolistDetailViewController: UIViewController {
...省略...
    // カスタムセルを設定
    private lazy var configureCell: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureCell = { [weak self] (_, collectionView, indexPath, item) in
        guard let strongSelf = self else { return UICollectionViewCell() }
        switch item {
        // <変更>
        case .todo(let todo):
            return strongSelf.todolistCell(indexPath: indexPath, todo: todo)
        case .memo(let memo):
            return strongSelf.todolistCell(indexPath: indexPath, todo: memo)
        }
    }
...省略
    private func setupCollectionView() {
...省略
        // タップ時のイベント
        collectionView.rx.itemSelected
            // <変更>
            .map { [weak self] indexPath -> TodolistItem? in
                return self?.dataSource[indexPath]
            }
            .subscribe(onNext: { [weak self] item in
                guard let item = item else { return }
                switch item {
                // <変更>
                case .todo(let todo):
                    self?.presentTodoDetaillViewController()
                case .memo(let memo):
                    self?.presentTodoDetaillViewController()
                }
            })
            .disposed(by: disposeBag)
    }
...省略
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let item = dataSource[indexPath]
        switch item {
        // <変更>
        case .todo:
            let width = collectionView.bounds.width - (TodolistDetailCollectionViewCell.cellMargin * 2)
            return CGSize(width: width, height: TodolistDetailCollectionViewCell.cellHeight)
        case .memo:
            let width = collectionView.bounds.width - (TodolistDetailCollectionViewCell.cellMargin * 2)
            return CGSize(width: width, height: TodolistDetailCollectionViewCell.cellHeight)
        }
    }
}

パターン3(セルに表示するデータ形式をセクションによって変える + セクションによってヘッダのタイトルを変える場合)

※例(パターン2からの差分のみ記載)

TodolistDetailViewModel.swift

struct SectionOfTodolist {
    // 変更
    var model: TodolistSection
    var items: [Item]
}

// 追加
enum TodolistSection {
    case category(name: String)
}

class TodolistDetailViewModel {
    ...省略
    private func convertTodoToSectionModel(srcArray: [Todo]) -> SectionOfTodolist {
        let items = srcArray.map {
            TodolistItem.todo(todo: $0)
        }
        // <変更>
        let section = SectionOfTodolist(model: .category(name: "TODOカテゴリー"), items: items)
        return section
    }

    private func convertMemoToSectionModel(srcArray: [Memo]) -> SectionOfTodolist {
        let items = srcArray.map {
            TodolistItem.memo(memo: $0)
        }
        // <変更>
        let section = SectionOfTodolist(model: .category(name: "MEMOカテゴリー"), items: items)
        return section
    }
}
TodolistDetailViewController.swift

class TodolistDetailViewController: UIViewController {
...省略...
    // セクションヘッダータイトルを設定
    private lazy var titleForHeaderInSection: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureSupplementaryView = { [weak self] (dataSource, collectionView, kind, indexPath) in
        
        guard let strongSelf = self else { return UICollectionReusableView() }
        // <変更>
        let sectionOfTodolist = dataSource[indexPath.section]
        switch sectionOfTodolist.model {
        case .category:
            return strongSelf.headerCell(indexPath: indexPath, kind: kind)
        }
    }

    private func headerCell(indexPath: IndexPath, kind: String) -> UICollectionReusableView {
        
        let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath)
        headerView.backgroundColor = .yellow
        let label = UILabel(frame: CGRect(x:0, y:0, width:self.collectionView.frame.width, height:20))
        label.textColor = UIColor.black
        label.textAlignment = .center
        
        // <変更>
        let sectionOfTodolist = dataSource[indexPath.section]
        switch sectionOfTodolist.model {
        case .category(let name):
            label.text = name
        }
        headerView.addSubview(label)
        return headerView
    }
}

extension TodolistDetailViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        // <変更>
        let sectionOfTodolist = dataSource[section]
        switch sectionOfTodolist.model {
        case .category:
            return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
        }
    }
}

パターン4(セルに表示するデータ形式をセクションによって変える + セクションによってヘッダのタイトルを変える + フッターも表示する場合)

※例(パターン2からの差分のみ記載)

TodolistDetailViewController.swift
class TodolistDetailViewController: UIViewController {

    // <変更>
    private lazy var dataSource = RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>(
    configureCell: configureCell
    ,configureSupplementaryView: collectionReusableView)

    ...省略

    // <変更>
    // セクションヘッダーとフッターセルを設定
    private lazy var collectionReusableView: RxCollectionViewSectionedReloadDataSource<SectionOfTodolist>.ConfigureSupplementaryView = { [weak self] (dataSource, collectionView, kind, indexPath) in
        ...省略
        case .category:
            // <変更>
            return strongSelf.headerFooterCell(indexPath: indexPath, kind: kind)
        }
    }

    private func setupCollectionView() {
        ...省略
        // <変更>
        collectionView.register(
        UICollectionReusableView.self
        , forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader
        , withReuseIdentifier: "Header")
        // <追加>
        collectionView.register(
            UICollectionReusableView.self
            , forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter
            , withReuseIdentifier: "Footer")
        ...省略
        // <追加>
        // フッターサイズを指定
        flowLayout.footerReferenceSize = CGSize(width: self.collectionView.frame.width, height: 40)
        ...省略
    }

    private func headerFooterCell(indexPath: IndexPath, kind: String) -> UICollectionReusableView {
        // <追加>
        if (kind == "UICollectionElementKindSectionHeader") {
        ...省略
        // <追加>
        } else {
            let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath)
            footerView.backgroundColor = .yellow
            let sectionOfTodolist = dataSource[indexPath.section]
            switch sectionOfTodolist.model {
            case .category(let name):
                let button = UIButton(frame: CGRect(x:0, y:10, width:self.collectionView.frame.width, height:20))
                button.setTitle("TODOを追加", for: .normal)
                button.setTitleColor(.gray, for: .normal)
                footerView.addSubview(button)
                button.rx.tap.asObservable().subscribe(onNext: {
                    [weak self] in
                    self?.presentTodoDetaillViewController()
                }).disposed(by: disposeBag)
            }
            return footerView
        }
    }
    ...省略
}

パターン5(パターン4をSectionModelでやる場合)

※例(パターン3からの差分のみ記載)

TodolistDetailViewModel.swift
// 削除
//struct SectionOfTodolist {
//    var header: String
//    var items: [Item]
//}
//
//extension SectionOfTodolist: SectionModelType {
//    typealias Item = TodolistItem
//
//    init(original: SectionOfTodolist, items: [SectionOfTodolist.Item]) {
//        self = original
//        self.items = items
//    }
//}

// 追加
typealias SectionOfTodolist = SectionModel<TodolistSection, TodolistItem>

補足:SectionModelTypeとSectionModelの違い

RxDataSources/TodolistDetailViewModel.swift
public protocol SectionModelType {
    associatedtype Item

    var items: [Item] { get }

    init(original: Self, items: [Item])
}
RxDataSources/SectionModel.swift
public struct SectionModel<Section, ItemType> {
    public var model: Section
    public var items: [Item]

    public init(model: Section, items: [Item]) {
        self.model = model
        self.items = items
    }
}

extension SectionModel
    : SectionModelType {
    public typealias Identity = Section
    public typealias Item = ItemType
    
    public var identity: Section {
        return model
    }
}

extension SectionModel
    : CustomStringConvertible {

    public var description: String {
        return "\(self.model) > \(items)"
    }
}

extension SectionModel {
    public init(original: SectionModel<Section, Item>, items: [Item]) {
        self.model = original.model
        self.items = items
    }
}
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?