Адаптивный UICollectionView

Недавно мне понадобилось сделать адаптивный лэйаут для UICollectionView для поддержки экранов всех iPhone и iPad, а так же новой функции SplitView. Старый метод, который я использовал требовал слишком много кода, в котором можно было легко запутаться.

Как я делал раньше

Класс контроллера я назначал делегатом UICollectionViewDelegateFlowLayout и использовал эти методы:

collectionView(_:layout:sizeForItemAtIndexPath:)
collectionView(_:layout:insetForSectionAtIndex:)
collectionView(_:layout:minimumLineSpacingForSectionAtIndex:)
collectionView(_:layout:minimumInteritemSpacingForSectionAtIndex:)

Пример. Мне нужно красиво уместить ячейки 100х100 pt  на экране с расстояниями в 4 pt между ними, я делал так:

    // настраиваем размер и расстояния. insetForSectionAtIndex задан в сториборде, так как менять его в коде нет необходимости
    let cellWidth:CGFloat = 100, spacing:CGFloat = 4

    // задаем размер ячейки в зависимости от ширины collection view
    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
        let columns:CGFloat = ceil(collectionView.bounds.size.width / cellWidth)
        let size = (collectionView.bounds.size.width - ((columns-1)*spacing)) / columns
        return CGSizeMake(size, size)
    }

    // возвращаем расстояния
    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAtIndex section: Int) -> CGFloat {
        return spacing
    }

    // здесь я обновлял высоту collection view при показе страницы и при изменении ориентации устройства (viewDidLoad: и viewWillTransitionToSize:
    func collectionViewUpdateLayout() {
        self.collectionView.performBatchUpdates(nil) { (_) -> Void in
            self.collectionViewHeight.constant = self.collectionView.contentSize.height
        }
    }

Пример 2. Если ширина UICollectionView больше 450 pt, используем две колонки квадратных ячеек. Расстояния между ними и границами UICollectionView – 4 pt. Если ширина меньше, то колонка одна, а расстояния – 0.

   // Задаем минимальную ширину для двух колонок. если ширина collection view станет меньше, у нас будет отображаться одна колонка ячеек. Так же задаем отступы между ячейками и границами collection view.
    let minimalCollectionViewWidthForDoubleColumnMode:CGFloat = 450,
    edgeInsets:CGFloat = 4,
    spacing:CGFloat = 4,
    lineSpacingForOneColumnMode:CGFloat = 0

    // считаем размер ячеек
    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
        let size = collectionView.bounds.size.width
        if size > minimalCollectionViewWidthForDoubleColumnMode {
            return CGSizeMake((size-edgeInsets*2-spacing)/2, (size-edgeInsets*2-spacing)/2)
        }
        else{
            return CGSizeMake(size, size)
        }
    }

    // возвращаем расстояния
    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAtIndex section: Int) -> CGFloat {
        return spacing
    }

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAtIndex section: Int) -> CGFloat {
        let size = collectionView.bounds.size.width
        if size > minimalCollectionViewWidthForDoubleColumnMode {
            return spacing
        }
        else {
            return lineSpacingForOneColumnMode
        }
    }

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAtIndex section: Int) -> UIEdgeInsets {
        if collectionView.bounds.size.width > minimalCollectionViewWidthForDoubleColumnMode {
            return UIEdgeInsetsMake(edgeInsets, edgeInsets, edgeInsets, edgeInsets)
        }
        else{
            return UIEdgeInsetsZero
        }
    }

(в коде выше могут быть серьёзные недочёты, но он служил мне верой и правдой в реальном приложении)

Мой новый способ

Я написал расширение для UICollectionViewLayout, которое позволяет задавать все эти значения в одну строчку кода, а если нужно — создавать сложные правила для разной ширины UICollectionView.

Улучшаем Пример 1.

     // Та самая функция, которую мы вызываем во viewDidLoad: и viewWillTransitionToSize: withTransitionCoordinator:
    func collectionViewUpdateLayout() {
        // Задаем правило
        collectionView.collectionViewLayout.makeAdaptive(withMinimumCellWidth: 100, cellAspectRatio: 1, spacing: CGSizeMake(4, 4), andEdgeInsets:UIEdgeInsetsMake(4, 4, 4, 4))
       // Обновляем высоту collection view
        self.collectionView.performBatchUpdates(nil) { (_) -> Void in
            self.collectionViewHeight.constant = self.collectionView.contentSize.height
        }
    }

Улучшаем Пример 2.

     // Та самая функция, которую мы вызываем во viewDidLoad: и viewWillTransitionToSize: withTransitionCoordinator:
     func collectionViewUpdateLayout() {
        // Создаём два правила для разных размеров экрана
        let rule1 = AdaptiveLayoutRule(columns: 1, width: 0, spacing: CGSizeZero, andInsets:UIEdgeInsetsZero)
        let rule2 = AdaptiveLayoutRule(columns: 2, width: 450, spacing: CGSizeMake(4, 4), andInsets:UIEdgeInsetsMake(4, 4, 4, 4))
       // Применяем правило
        self.collectionViewLayout.makeAdaptiveWithRules([rule1, rule2], aspectRatio: 1)
       // Обновляем высоту collection view
        self.collectionView.performBatchUpdates(nil) { (_) -> Void in
            self.collectionViewHeight.constant = self.collectionView.contentSize.height
        }
    }

 

Вот так

Если в вашем проекте есть необходимость использовать UICollectionView с простым и адаптивным лэйаутом, надеюсь, моё расширение вам пригодится.

Interface Builder хочет вас убить

Interface Builder — инструмент, входящий в состав Xcode. Он помогает создавать интерфейсы для приложений iOS, OS X и других платформ Apple. Последние разработки Apple, от начала и до конца доступны всем, остаётся лишь скачать последнюю версию.

Тут-то и начинаются проблемы…

The document “Main_iPhone.storyboard” could not be opened. The operation couldn’t be completed. (com.apple.InterfaceBuilder error -1.)

2014 год, загружен Xcode 6 beta 1. В одном из моих проектов главный файл интерфейса отказался напрочь открываться в Interface Builder. Как человек, надеющийся на корпорацию добра и пафоса, я решил подождать. Не может же ошибка быть у всех? Нельзя судить софт по бета версиям.

Проходит месяц, другой. Выходит Xcode 6 GM, а затем и релиз — без результатов. Как не открывался сториборд, так и не открывается.

Пользуясь старым Xcode 5 я беспощадно вырезал вью-контроллеры из сториборда, лишь бы открылся. После долгого мучения, я нашёл корень проблемы — заданные ранее .png изображения в UIImageView. Вот… Как так получилось? Удалил — всё работает.

Мой вопрос на Stack Overflow набрал 2.5 тысячи просмотров, а значит случай далеко не уникальный.

Проходит год. На дворе 2015-ый, загружен Xcode 7 GM. Прошлогоднюю проблему я не забыл и скрестив пальцы успешно открыл своё сториборд. Отлично! Однако, долго радоваться не пришлось — приложение намертво зависало при открытии некоторых вью-контроллеров.

Накопив опыт за год разработки я воспользовался Instruments, ещё одним полезным инструментом из набора Xcode и дал ответ на свой вопрос даже раньше, чем кто-то его прочитал. Углубившись в стэк вызовов, я заметил, что один из методов UITextView помогает поглощать 99% времени процессора. Не долго думая я очистил заданный текст в UITextView в зависавших вью-контроллерах, и угадайте что? Заработало!

Немногим позже. Xcode 7, релиз. Решил опробовать новый элемент StackView, который Apple представила нам в этом году. Расставил элементы, натянул автолэйаут и тут — Xcode повис. С моей манией жать cmd+C я долго не думал, завершил процесс и открыл проект заново. Но не тут-то было. При открытии сториборда Xcode упорно продолжал зависать, не давая мне входу.

Исходом этой проблемы был откат на старую версию файла без StackView, которые мне так и не удалось опробовать в бою. А продолжать я не стал, так как хотел вспомнил, что они доступны только с iOS 9.

Я не представляю, как такие странные ошибки возникают в столь закрытой экосистеме, их постоянство не может не удивлять. Но как бы больно ни было, я люблю Xcode, куда же я без него?