UICollectionView всему голова: Изменение представления на лету

Привет, Хабр! Представляю вашему вниманию перевод статьи "UICollectionView Tutorial: Changing presentation on the fly".

В данной статье мы рассмотрим использование различный способов отображение элементов, а также их переиспользование и динамическое изменение. Здесь мы не будем рассматривать основы работы с коллекциями и autolayout.

В результате мы получим такой пример:


При разработке мобильных приложений часто встречаются ситуации, когда табличного представления недостаточно и нужно показывать список элементов более интересно и уникально. Кроме того, возможность изменить способ отображения элементов может стать “фишкой” вашего приложения.

Все вышеперечисленные возможности достаточно просто реализовать при помощи UICollectionView и различных реализаций протокола UICollectionViewDelegateFlowLayout.

Полный код проекта.

Что нам потребуется в первую очередь для реализации:

  • class FruitsViewController: UICollectionViewController.
  • Модель данных Fruit

    struct Fruit {
        let name: String
        let icon: UIImage
    }
  • class FruitCollectionViewCell: UICollectionViewCell

Ячейка с UIImageView и UILabel для отображения фруктов


Ячейку мы создадим в отдельном файле с xib ом для возможности переиспользования.

По дизайну мы видим, что возможны 2 варианта ячейки — с текстом снизу и текстом справа от изображения.



Могут быть абсолютно отличающиеся виды ячеек, в таком случае нужно создавать 2 отдельных класса и использовать нужный. В нашем случае такой необходимости нет и достаточно 1 ячейки с UIStackView.



Шаги создания интерфейса для ячейки:

  1. Добавляем UIView
  2. Внутрь нее добавляем UIStackView (horizontal)
  3. Далее добавляем UIImageView и UILabel в UIStackView.
  4. Для UILabel устанавливаем значения Content Compression Resistance Priority = 1000 для горизонтали и для вертикали.
  5. Добавляем для UIImageView Aspect Ratio 1:1 и изменяем приоритет на 750.

Это нужно для корректного отображения в горизонтальном режиме.

Далее напишем логику отображения нашей ячейки как в горизонтальном, так и в вертикальном режиме.

Основным критерием отображения по горизонтали будем считать размер самой ячейки. Т.е. если места достаточно — отображаем горизонтальный режим. Если нет — вертикальный. Будем считать что достаточно места — это когда ширина в 2 раза больше высоты, так как изображение должно быть квадратным.

Код ячейки:

class FruitCollectionViewCell: UICollectionViewCell {    
    static let reuseID = String(describing: FruitCollectionViewCell.self)
    static let nib = UINib(nibName: String(describing: FruitCollectionViewCell.self), bundle: nil)
    
    @IBOutlet private weak var stackView: UIStackView!
    
    @IBOutlet private weak var ibImageView: UIImageView!
    @IBOutlet private weak var ibLabel: UILabel!
    
    override func awakeFromNib() {
        super.awakeFromNib()
        backgroundColor = .white
        clipsToBounds = true
        layer.cornerRadius = 4
        ibLabel.font = UIFont.systemFont(ofSize: 18)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        updateContentStyle()
    }
    
    func update(title: String, image: UIImage) {
        ibImageView.image = image
        ibLabel.text = title
    }
    
    private func updateContentStyle() {
        let isHorizontalStyle = bounds.width > 2 * bounds.height
        let oldAxis = stackView.axis
        let newAxis: NSLayoutConstraint.Axis = isHorizontalStyle ? .horizontal : .vertical
        guard oldAxis != newAxis else { return }

        stackView.axis = newAxis
        stackView.spacing = isHorizontalStyle ? 16 : 4
        ibLabel.textAlignment = isHorizontalStyle ? .left : .center
        let fontTransform: CGAffineTransform = isHorizontalStyle ? .identity : CGAffineTransform(scaleX: 0.8, y: 0.8)
        
        UIView.animate(withDuration: 0.3) {
            self.ibLabel.transform = fontTransform
            self.layoutIfNeeded()
        }
    }
}

Перейдем к основной части — к контроллеру и логике отображения и переключения видов ячеек.

Для всех возможных состояний отображения создадим enum PresentationStyle.
Также добавим кнопку для переключения между состояниями в навигейшн бар.

class FruitsViewController: UICollectionViewController {
    private enum PresentationStyle: String, CaseIterable {
        case table
        case defaultGrid
        case customGrid
        
        var buttonImage: UIImage {
            switch self {
            case .table: return  imageLiteral(resourceName: "table")
            case .defaultGrid: return  imageLiteral(resourceName: "default_grid")
            case .customGrid: return  imageLiteral(resourceName: "custom_grid")
            }
        }
    }
    
    private var selectedStyle: PresentationStyle = .table {
        didSet { updatePresentationStyle() }
    }
        
    private var datasource: [Fruit] = FruitsProvider.get()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.collectionView.register(FruitCollectionViewCell.nib,
                                      forCellWithReuseIdentifier: FruitCollectionViewCell.reuseID)
        collectionView.contentInset = .zero
        updatePresentationStyle()
        
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: selectedStyle.buttonImage, style: .plain, target: self, action: #selector(changeContentLayout))
    }
    
    private func updatePresentationStyle() {
        navigationItem.rightBarButtonItem?.image = selectedStyle.buttonImage
    }
    
    @objc private func changeContentLayout() {
        let allCases = PresentationStyle.allCases
        guard let index = allCases.firstIndex(of: selectedStyle) else { return }
        let nextIndex = (index + 1) % allCases.count
        selectedStyle = allCases[nextIndex]
        
    }
}

// MARK: UICollectionViewDataSource & UICollectionViewDelegate
extension FruitsViewController {
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return datasource.count
    }
    
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: FruitCollectionViewCell.reuseID,
                                                            for: indexPath) as? FruitCollectionViewCell else {
            fatalError("Wrong cell")
        }
        let fruit = datasource[indexPath.item]
        cell.update(title: fruit.name, image: fruit.icon)
        return cell
    }
}

Все что касается способа отображения элементов в коллекции описано в протоколе UICollectionViewDelegateFlowLayout. Следовательно, чтобы убрать какие либо реализации из контроллера и создать независимые переиспользуемые элементы — создадим под каждый вид отображения отдельную реализацию этого протокола.

Однако, есть 2 нюанса:

  1. В этом протоколе также описан метод выбора ячейки (didSelectItemAt:)
  2. Некоторые методы и логика одинаковы для всех N методов отображения (в нашем случаем N = 3).

По этому, создадим протокол CollectionViewSelectableItemDelegate, расширять стандартный протокол UICollectionViewDelegateFlowLayout, в котором определим замыкание выбора ячейки и в случае необходимости, какие либо дополнительные свойства и методы (например, возвращение типа ячейки, если используются различные типы для представлений). Это решит первую проблему.

protocol CollectionViewSelectableItemDelegate: class, UICollectionViewDelegateFlowLayout {
    var didSelectItem: ((_ indexPath: IndexPath) -> Void)? { get set }
}

Для решения второй проблемы — с дублированием логики, создадим базовый класс со всей общей логикой:

class DefaultCollectionViewDelegate: NSObject, CollectionViewSelectableItemDelegate {
    var didSelectItem: ((_ indexPath: IndexPath) -> Void)?
    let sectionInsets = UIEdgeInsets(top: 16.0, left: 16.0, bottom: 20.0, right: 16.0)
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        didSelectItem?(indexPath)
    }
    
    func collectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) {
        let cell = collectionView.cellForItem(at: indexPath)
        cell?.backgroundColor = UIColor.clear
    }
    
    func collectionView(_ collectionView: UICollectionView, didUnhighlightItemAt indexPath: IndexPath) {
        let cell = collectionView.cellForItem(at: indexPath)
        cell?.backgroundColor = UIColor.white
    }

}

В нашем случае общей логикой является вызов замыкания при выборе ячейки, а также изменение фона ячейки при переходе в состояние highlighted.

Далее опишем 3 реализации представлений: табличная, по 3 элемента в каждом ряде и комбинация первых двух способов.

Табличная:

class TabledContentCollectionViewDelegate: DefaultCollectionViewDelegate {
    // MARK: - UICollectionViewDelegateFlowLayout
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let paddingSpace = sectionInsets.left + sectionInsets.right
        let widthPerItem = collectionView.bounds.width - paddingSpace
        return CGSize(width: widthPerItem, height: 112)
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInsets
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 10
    }
}

3 элемента в каждом ряде:

class DefaultGriddedContentCollectionViewDelegate: DefaultCollectionViewDelegate {
    private let itemsPerRow: CGFloat = 3
    private let minimumItemSpacing: CGFloat = 8
    
    // MARK: - UICollectionViewDelegateFlowLayout
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let paddingSpace = sectionInsets.left + sectionInsets.right + minimumItemSpacing * (itemsPerRow - 1)
        let availableWidth = collectionView.bounds.width - paddingSpace
        let widthPerItem = availableWidth / itemsPerRow
        return CGSize(width: widthPerItem, height: widthPerItem)
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInsets
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 20
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return minimumItemSpacing
    }
}

Комбинация табличной и 3х в ряд.

class CustomGriddedContentCollectionViewDelegate: DefaultCollectionViewDelegate {
    private let itemsPerRow: CGFloat = 3
    private let minimumItemSpacing: CGFloat = 8
    
    // MARK: - UICollectionViewDelegateFlowLayout
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let itemSize: CGSize
        if indexPath.item % 4 == 0 {
            let itemWidth = collectionView.bounds.width - (sectionInsets.left + sectionInsets.right)
            itemSize = CGSize(width: itemWidth, height: 112)
        } else {
            let paddingSpace = sectionInsets.left + sectionInsets.right + minimumItemSpacing * (itemsPerRow - 1)
            let availableWidth = collectionView.bounds.width - paddingSpace
            let widthPerItem = availableWidth / itemsPerRow
            itemSize = CGSize(width: widthPerItem, height: widthPerItem)
        }
        return itemSize
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInsets
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 20
    }
    
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return minimumItemSpacing
    }
}

Последний этап — это добавить данные представления в контроллер и устанавливать коллекции нужный делегат.

Важный момент: так как делегат коллекции weak, то необходимо иметь strong ссылку в контроллере на объект представления.

Создадим в контроллере словарь всех доступных представлений относительно типа:

private var styleDelegates: [PresentationStyle: CollectionViewSelectableItemDelegate] = {
        let result: [PresentationStyle: CollectionViewSelectableItemDelegate] = [
            .table: TabledContentCollectionViewDelegate(),
            .defaultGrid: DefaultGriddedContentCollectionViewDelegate(),
            .customGrid: CustomGriddedContentCollectionViewDelegate(),
        ]
        result.values.forEach {
            $0.didSelectItem = { _ in
                print("Item selected")
            }
        }
        return result
    }()

И в метод updatePresentationStyle() добавим анимированное изменение делегат коллекции:

  collectionView.delegate = styleDelegates[selectedStyle]
        collectionView.performBatchUpdates({
            collectionView.reloadData()
        }, completion: nil)

Вот и все что необходимо, чтобы наши элементы анимировано переходили от одного вида к другому :)


Таким образом, мы теперь можем на любом экране отображать элементы каким угодно способом, динамически переключаться между отображениями и самое главное — код получился независимый, переиспользуемый и масштабируемый.

Полный код проекта.
  • +10
  • 2,8k
  • 2
Поделиться публикацией

Комментарии 2

    0

    Простой и эффективный способ оказался, неплохо!


    Однако, я добавил элементов копированием существующих, менял layout проскролив в конец списка и уже анимация не выходила такой гладкой. (некоторые элементы становились на свои места поментально, а некоторые с анимацией, что сильно коробит глаз) Не знаю, можно ли сделать ее лучше.


    Есть артефакты при скроле обратно, после нескольких изменений layout'а. (Текст запоминает старые размеры и анимируется в новые, чего быть не должно).


    Если это как-то исправить, то выйдет очень хороший способ.


    А метод collectionView.setCollectionViewLayout(UICollectionViewLayout, animated: Bool)
    не трогали для подобного изменения представления?

      0

      Действительно, эту задачу правильнее решать через setCollectionViewLayout

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое