Collection View / 1.1. Представления-коллекции



Только после победы соперник будет уязвим для атаки

Цурин Арктус

В этом уроке


Представления коллекции

Мы очень кратко коснулись их при разработке приложения для macOS. Теперь же мы расширим наш опыт, создав приложение с любыми местами отдыха наших милых дам - владельцев!


Скелет

  1. Перенесите в ассеты картинки нашего приложения
  2. Создайте группы и перенесите в них файлы:
    • View Controller - главный контроллер представления
    • Resources - сторибоарды приложения и бандл ассетов
    • Cells - в которой мы будем создавать ячейки
    • Data - где будем хранить модели данных. Сюда перенесите файл places.plist. Он содержит заранее подготовленные данные про девочек с их любимыми местами.
  3. На холсте замените главный контроллер на Collection View Controller - он содержит несколько полезных нам вещей, что сократит сложность разработки в разы.
  4. Замените родительский класс у ViewController на UICollectionViewController
  5. Установите его классом для нашего контроллера на раскадровке

Создадим модель данных для хранения мест:

  1. Место должно содержать название name
  2. Имя картинки imageName
  3. Конструктор для обоих свойств
  4. Соответствие протоколу Codable
class Place: Codable {
    let name: String
    let imageName: String

    init(name: String, imageName: String) {
        self.name = name
        self.imageName = imageName
    }
}

На его основе теперь мы можем создать модель девочки Girl:

  1. Имя name
  2. Набор мест places в виде опционального массива
  3. Соответствующий конструктор
class Girl: Codable {
    let name: String
    var places: [Place]?

    init(name: String) {
        self.name = name
    }
}

Теперь мы создадим источник данных, который будет отвечать за их извлечение из plist-файла.

Обратите внимание, что древо файлов целиком соответствует структуре классов. Это даст нам возможность извлечь объекты с помощью обычного метода декодинга.

Начнем наше творчество:

class DataSource {
    private init() {

    }

    static let shared = DataSource()
}

Особенность того, что мы сделали - у класса единственный приватный конструктор, а значит его объекты не могут быть созданы ниоткуда, кроме из него самого. А так же у него есть член shared, который даёт доступ к единственному экземпляру класса. Вот мы и реализовали дизайн-паттерн синглтон.

Теперь создадим свойство для извлечения массива девочек:

var girls: [Girl]? {

}

Сперва мы получим ссылку на файл с ними, иначе вернём ничто:

guard let url = Bundle.main.url(forResource: "places", withExtension: "plist") else {
    return nil
}

Теперь попробуем извлечь байт-данные из него:

guard let data = try? Data(contentsOf: url) else {
    return nil
}

И наконец выполним декодинг и вернём результат:

return try? PropertyListDecoder().decode([Girl].self, from: data)

Ячейки коллекции

  1. Вернитесь на холст
  2. Выберите представление-коллекцию
  3. Задайте ей задний фон чёрного цвета
  4. В инспекторе размеров
    • задайте значение 200 для ширины и высоты ячейки
    • А также обнулите Min Spacing, чтобы убрать минимальный отступ для ячеек

Учтите, что финальный размер ячеек будет отличаться. Здесь он нужен лишь для создания дизайна

  1. Добавьте на ячейку обыкновенное представление UIView c нулевыми отступами от границ
  2. На нём разместите картинку:
    • Нулевые отступы от представления
    • Aspect Fill
    • Clip To Bounds
  3. Поверх картинки разместите метку:
    • Текст Title
    • Центрирование текста
    • Белый цвет
    • Шрифт - системный, SemiBold, 12
    • Отступ метки от границ на 8 точек
    • Отступ от границ представления - 8 точек
    • Число линий - 3
  4. Добавьте ячейке реиспользуемый идентификатор Cell
  5. Теперь создайте класс CollectionViewCell, унаследовав его от UICollectionViewCell и разместив в группе Cells.
  6. От картинки в ячейке проведите аутлет imageView
  7. А от метки - titleLabel
  8. Задайте новый класс нашей ячейке

Теперь мы добавим небольшой код, который позволит ячейке принимать в себя место, а на его основе инициализировать себя:

var place: Place? {
    didSet {
        if let place = place {
            titleLabel.text = place.name
            imageView.image = UIImage(named: place.imageName)!
        }
    }
}

По-моему просто чудесно. А Вы ещё не купили нашу подписку? Спешите успеть по скидке!

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

override func prepareForReuse() {
    super.prepareForReuse()

    titleLabel.text = nil
    imageView.image = nil
}

В данном приложении Вы не заметите отличий от его использования или неиспользования, но запомните его на будущее - правильным будет очищать любое переиспользуемое представление, чтобы им можно было воспользоваться новь.


Делегат и источник данных

Так как мы использовать контроллер коллекции, то он уже реализует методы источники и делегата. Осталось перезаписать их.

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

var girls = DataSource.shared.girls!

Так как наше представление будет делить любимые места девочек по ним самим, то у нас будет столько же секций, сколько девочек:

extension ViewController {
    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return girls.count
    }
}

А в каждой секции будет столько вещей, сколько там мест:

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return girls[section].places?.count ?? 0
}

Так как любимых мест может не быть, то мы можем получить ничего, аз значит вернуть 0.

Схоже ведь с табличными представлениями, isn't it?

И похожим методом создаётся ячейка:

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! CollectionViewCell
    //Сперва она извлекается из очереди
    cell.place = girls[indexPath.section].places![indexPath.item]
    //Мы передаём в неё место и возвращаем
    return cell
}

Здесь интерес для нас также имеет тот факт, что мы получили не строку, а вещь из индекс-пути. Мы могли обратиться и к строке - она будет иметь то же значение, но для представлений-коллекций это немного неуместно.

Запустите приложение и Вы получите чёрный экран:

Дело в том, что представление коллекции не знает, как ему быть с ячейками - в отличие от табличного представления, представление-коллекция чуточку глупее и ему нужен специальный объект разметки - layout. По-умолчанию выбран Flow Layout. Который решает большую часть задач, но ему нужно немного нашей помощи, чтобы узнать размеры ячеек. Добавьте во viewDidLoad:

let width = view.frame.width / 3
//Получили ширину кадра
let layout = collectionViewLayout as! UICollectionViewFlowLayout
//Извлекли layout и сделали его нужного типа
layout.itemSize = CGSize(width: width, height: width)
//Задали размер ячейки

Запустите приложение и Вы увидите -фоточки азиаток- любимые места наших милых дам!


Градиентное представление

Всё здорово, но текст слишком сливается с фоном, существует графический эффект, когда на часть объекта накладывается прозрачный градиент, получающий на конце часть белого цвета.

Создадим сам класс, унаследовав его от простого представления:

class GradientView: UIView {

}

Создадим в нём ленивое свойство градиентного уровня:

lazy fileprivate var gradientLayer: CAGradientLayer = {
    let layer = CAGradientLayer()
    layer.colors = [#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0).cgColor, #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.75).cgColor]
    //Цвета в градиенте
    layer.locations = [0, 1]
    //Точки для цветов градиента
    return layer
}()
  1. Градиент будет изменяться от чистого цвета до чистого черного с 25% прозрачностью.
  2. Мы извлекаем cgColor, так как этот массив принимает любой объект, но работает только с цветами Core Graphics

Помимо задания промежуточных позиций можно создать линейный градиент через начальное и конечное значения

Сделайте так, чтобы при пронуждении из файла интерфейса:

  1. Его цвет становился прозрачным
  2. Градиентный слой добавлялся в подслои
override func awakeFromNib() {
    super.awakeFromNib()
    backgroundColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0)
    layer.addSublayer(gradientLayer)
}

Теперь реализуем метод, который при изменении подвидов будет менять и размер слоя:

override func layoutSubviews() {
    super.layoutSubviews()

    CATransaction.begin()
    //Начинаем анимацию
    CATransaction.setDisableActions(true)
    //Отключаем действия
    gradientLayer.frame = bounds
    //Приравниваем подслой к границам
    CATransaction.commit()
    //Подтверждаем анимацию
}

Нечто новенькое для нас - транзакция Core Animation - на самом деле неявно они срабатывают постоянно при работе программы. В данном случае это работает как блок - все действия по анимациями между вызовом begin и commit накапливаются, а затем выполняются вместе.

Теперь доработаем наш холст:

  1. Удалите все ограничения с метки в ячейке
  2. Добавьте картинку:
    • Картинка - check-circle-blank
    • Ограничения на ширину и высоту в 22 точки
  3. Объедините их вместе с меткой в горизонтальный стэк с пробелом в 8 точек
  4. Задайте стеку отступы от вехе сторон кроме верхней в 8 точек
  5. Скройте картинку с кругляшком
  6. Добавьте UIView с классом градиентного представления в ячейку таким образом, чтобы её отступы от границ кроме верхней были равны 0, а между верхней его стороной и верхней стороной стэка было -8 точек.

Вы можете создать ограничение на совпадение верхних сторон и затем изменить константу.

Вот и результат.


Секции

Теперь мы создадим секции, что позволит разделить любимые места по девочкам явно:

  1. Выберите коллекцию
  2. Поставьте галочку в инспекторе атрибутов у Section Headers - это создаст нам заголовки секций
  3. В иерархии документов появится Collection Reusable View

Этот объект позволит нам создать наши заголовки. В табличных представлениях мы могли воспользоваться готовой системой, здесь придётся слегка замарать свои -крабовые клешни- лапки.

Задайте этому представлению цвет #EB6046

Поместите в это представление:

Объедините их все в стэк:

  1. Расположение и распределение - Fill
  2. Spacing - 8 точек
  3. Центрирование по вертикали
  4. Отступы от боковых граней без учета марджинов - 8

Теперь нам нужно решить проблемы сплющивания контента - задайте у Count в инспекторе атрибутов Content Hugging Priority в 252.

Что же это за свойство? У объектов, у которых оно выше, стэк старается сохранить их Intrinsic Size или же действительные размеры. У картинки ширина фиксированная, задав больший приоритет сохранности метки для числа, мы позволяем имени расширяться.

Теперь создадим новый класс, который будет отвечать за это представление:

import UIKit

class SectionHeaderView: UICollectionReusableView {

}

Установите его классом для нашего реиспользуемого заголовка и задайте ему идентификатор SectionHeader.

Теперь создайте следующие аутлеты:

А теперь по аналогии с ячейкой создайте хранимое свойство:

var girl: Girl? {
    didSet {
        if let girl = girl {
            imageView.image = UIImage(named: girl.name)
            nameLabel.text = girl.name
            countLabel.text = String(girl.places?.count ?? 0)
        }
    }
}


Помните, что хорошим тоном будет создать метод очищения для реиспользуемого вида.

Теперь добавим в контроллер последний метод:

override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
    let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "SectionHeader", for: indexPath) as! SectionHeaderView
    //Извлекаем реиспользуемое представление
    view.girl = girls[indexPath.section]
    //Добавлям в секцию девочку
    return view
}

Этот метод очень схож с методом получения ячейки, отличие лишь в том, что извлекаются не основные, а вспомогательные виды.

Запустите программу:

Последним штрихом добавим прикрепление заголовка секции к экрану, пока идёт эта секция. Очень похоже на приложение Фото.

Для этого во viewDidLoad добавьте следующую строку:

layout.sectionHeadersPinToVisibleBounds = true

Управление коллекцией

Давайте добавим всякие возможности для коллекции. Начнем мы с добавления контроллера навигации:

  1. Заключите имеющийся контроллер в контроллер навигации
  2. Добавьте бару навигации большие заголовки
  3. Цвет заднего фона (Bar Tint) устнаовите в #EB6046
  4. А цвет текста и большого текста в белый
  5. И с помощью настроек проекта и Info.plist сделайте статус-бар светлым
  6. Название контроллера задайте в Swift World Places
  7. Tint-цвет задайте в белый

Вы получите следующее:

Прежде, чем двигаться дальше, попробуйте реализовать протокол NSCopying для класса Place:

extension Place: NSCopying {
    func copy(with zone: NSZone? = nil) -> Any {
        return Place(name: name, imageName: imageName)
    }
}

Также измените квалификатор имени с константы на переменную.

В правую часть добавьте Bar Button Item с системным видом Add.

Теперь создайте от неё действие addItem:

  1. Оно должно извлекать случайную девочку (с помощью GKRandomDistribution) и её номер
  2. Извлекать копию случайного места для этой девочки
  3. Присваивать ему название Новое место
  4. Добавлять в массив мест девочки
  5. И добавлять в представление-коллекцию
@IBAction  func addItem(_ sender: Any?) {
    let randomGirlNumber = GKRandomDistribution(lowestValue: 0, highestValue: 2).nextInt()
    let girl = girls[randomGirlNumber]
    let place = girl.places![GKRandomDistribution(lowestValue: 0, highestValue: girl.places!.count - 1).nextInt()].copy() as! Place
    place.name = "Новое место"
    girl.places?.append(place)

    collectionView?.insertItems(at: [IndexPath(item: girl.places!.count - 1, section: randomGirlNumber)])
}

Теперь можно добавлять случайные новые места: