Указание одного измерения ячеек в UICollectionView с использованием автоматического макета

113

В iOS 8 UICollectionViewFlowLayoutподдерживается автоматическое изменение размера ячеек в зависимости от их собственного размера содержимого. Это изменяет размеры ячеек как по ширине, так и по высоте в соответствии с их содержимым.

Можно ли указать фиксированное значение для ширины (или высоты) всех ячеек и разрешить изменение размера других размеров?

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


iOS 8 представляет метод. systemLayoutSizeFittingSize: withHorizontalFittingPriority: verticalFittingPriority:Для каждой ячейки в представлении коллекции макет вызывает этот метод для ячейки, передавая предполагаемый размер. Для меня было бы разумным переопределить этот метод в ячейке, передать заданный размер и установить горизонтальное ограничение как необходимое и низкий приоритет для вертикального ограничения. Таким образом, горизонтальный размер фиксируется на значение, установленное в макете, а вертикальный размер может быть гибким.

Что-то вроде этого:

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes {
    UICollectionViewLayoutAttributes *attributes = [super preferredLayoutAttributesFittingAttributes:layoutAttributes];
    attributes.size = [self systemLayoutSizeFittingSize:layoutAttributes.size withHorizontalFittingPriority:UILayoutPriorityRequired verticalFittingPriority:UILayoutPriorityFittingSizeLevel];
    return attributes;
}

Однако размеры, возвращаемые этим методом, совершенно странны. Документация по этому методу для меня очень непонятна и упоминается использование констант, UILayoutFittingCompressedSize UILayoutFittingExpandedSizeкоторые просто представляют нулевой размер и довольно большой.

Действительно ли sizeпараметр этого метода - просто способ передать две константы? Нет ли способа добиться ожидаемого поведения от получения подходящей высоты для данного размера?


Альтернативные решения

1) Добавление ограничений, которые будут указывать конкретную ширину ячейки, обеспечивает правильный макет. Это плохое решение, потому что это ограничение должно быть установлено равным размеру представления коллекции ячейки, на которое оно не имеет надежной ссылки. Значение этого ограничения может быть передано при настройке ячейки, но это также кажется совершенно нелогичным. Это также неудобно, потому что добавление ограничений непосредственно в ячейку или ее содержимое вызывает множество проблем.

2) Используйте табличный вид. Табличные представления работают таким образом, поскольку ячейки имеют фиксированную ширину, но это не подходит для других ситуаций, таких как макет iPad с ячейками фиксированной ширины в нескольких столбцах.

Энтони Маттокс
источник

Ответы:

135

Похоже, что вы просите об использовании UICollectionView для создания макета, такого как UITableView. Если это действительно то, что вы хотите, правильный способ сделать это - использовать собственный подкласс UICollectionViewLayout (возможно, что-то вроде SBTableLayout ).

С другой стороны, если вы действительно спрашиваете, есть ли чистый способ сделать это с помощью UICollectionViewFlowLayout по умолчанию, тогда я считаю, что нет. Даже с самоизмеряющимися ячейками iOS8 это непросто. Как вы говорите, фундаментальная проблема заключается в том, что механизм схемы потока не дает возможности исправить одно измерение и позволить другому реагировать. (Кроме того, даже если бы вы могли, возникла бы дополнительная сложность, связанная с необходимостью двух проходов макета для определения размера многострочных меток. Это может не соответствовать тому, как ячейки с саморазмером хотят вычислять все размеры с помощью одного вызова systemLayoutSizeFittingSize.)

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

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

В вашем UICollectionViewDelegateFlowLayout вы реализуете такой метод:

func collectionView(collectionView: UICollectionView,
  layout collectionViewLayout: UICollectionViewLayout,
  sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
  // NOTE: here is where we say we want cells to use the width of the collection view
   let requiredWidth = collectionView.bounds.size.width

   // NOTE: here is where we ask our sizing cell to compute what height it needs
  let targetSize = CGSize(width: requiredWidth, height: 0)
  /// NOTE: populate the sizing cell's contents so it can compute accurately
  self.sizingCell.label.text = items[indexPath.row]
  let adequateSize = self.sizingCell.preferredLayoutSizeFittingSize(targetSize)
  return adequateSize
}

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

Вторая часть - заставить размерную ячейку использовать свои собственные ограничения AL для вычисления высоты. Это может быть сложнее, чем должно быть, из-за того, что многострочный UILabel фактически требует двухэтапного процесса компоновки. Работа выполняется в методике preferredLayoutSizeFittingSize, которая выглядит так:

 /*
 Computes the size the cell will need to be to fit within targetSize.

 targetSize should be used to pass in a width.

 the returned size will have the same width, and the height which is
 calculated by Auto Layout so that the contents of the cell (i.e., text in the label)
 can fit within that width.

 */
 func preferredLayoutSizeFittingSize(targetSize:CGSize) -> CGSize {

   // save original frame and preferredMaxLayoutWidth
   let originalFrame = self.frame
   let originalPreferredMaxLayoutWidth = self.label.preferredMaxLayoutWidth

   // assert: targetSize.width has the required width of the cell

   // step1: set the cell.frame to use that width
   var frame = self.frame
   frame.size = targetSize
   self.frame = frame

   // step2: layout the cell
   self.setNeedsLayout()
   self.layoutIfNeeded()
   self.label.preferredMaxLayoutWidth = self.label.bounds.size.width

   // assert: the label's bounds and preferredMaxLayoutWidth are set to the width required by the cell's width

   // step3: compute how tall the cell needs to be

   // this causes the cell to compute the height it needs, which it does by asking the 
   // label what height it needs to wrap within its current bounds (which we just set).
   let computedSize = self.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)

   // assert: computedSize has the needed height for the cell

   // Apple: "Only consider the height for cells, because the contentView isn't anchored correctly sometimes."
   let newSize = CGSize(width:targetSize.width,height:computedSize.height)

   // restore old frame and preferredMaxLayoutWidth
   self.frame = originalFrame
   self.label.preferredMaxLayoutWidth = originalPreferredMaxLayoutWidth

   return newSize
 }

(Этот код адаптирован из примера кода Apple из примера кода сеанса WWDC2014 в «Advanced Collection View».)

Пара замечаний. Он использует layoutIfNeeded () для принудительного макета всей ячейки, чтобы вычислить и установить ширину метки. Но этого мало. Я считаю, что вам также нужно настроить preferredMaxLayoutWidthтак, чтобы метка использовала эту ширину с Auto Layout. И только тогда вы можете использовать systemLayoutSizeFittingSize, чтобы ячейка вычисляла свою высоту с учетом метки.

Нравится ли мне такой подход? Нет !! Он кажется слишком сложным, и он выполняет макет дважды. Но пока производительность не становится проблемой, я бы предпочел выполнить макет дважды во время выполнения, чем дважды определять его в коде, что, по-видимому, является единственной альтернативой.

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

Пример проекта, показывающий это в работе.

Но почему бы просто не использовать саморазмерные ячейки?

Теоретически новые возможности iOS8 для «самоизмеривания ячеек» должны сделать это ненужным. Если вы определили ячейку с помощью Auto Layout (AL), то представление коллекции должно быть достаточно умным, чтобы позволить ей размер и правильную компоновку. На практике я не встречал примеров, в которых бы это работало с многострочными метками. Я думаю, это отчасти потому, что механизм самоизмерительной ячейки все еще не работает.

Но я готов поспорить, что это в основном из-за обычной сложности Auto Layout и меток, которая заключается в том, что UILabels требует в основном двухэтапного процесса макета. Мне непонятно, как можно выполнить оба шага с самоизмеряющимися ячейками.

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

А что насчет предпочтительныхLayoutAttributesFittingAttributes:?

preferredLayoutAttributesFittingAttributes:Я считаю, что этот метод - отвлекающий маневр. Это только то, что нужно использовать с новым механизмом самоизмерительной ячейки. Так что это не ответ, если этот механизм ненадежен.

А что случилось с systemlayoutSizeFittingSize :?

Вы правы, документы сбивают с толку.

В документации systemLayoutSizeFittingSize:и в systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:том, и в другом предполагается, что вы должны передавать только UILayoutFittingCompressedSizeи UILayoutFittingExpandedSizeв качестве targetSize. Однако сама подпись метода, комментарии заголовка и поведение функций указывают на то, что они реагируют на точное значение targetSizeпараметра.

Фактически, если вы установите UICollectionViewFlowLayoutDelegate.estimatedItemSize, чтобы включить новый механизм саморазмерной ячейки, это значение, похоже, будет передано как targetSize. И, UILabel.systemLayoutSizeFittingSizeпохоже, возвращает те же значения, что и UILabel.sizeThatFits. Это подозрительно, учитывая, что аргумент to systemLayoutSizeFittingSizeдолжен быть приблизительной целью, а аргумент sizeThatFits:должен быть максимальным ограничивающим размером.

Дополнительные ресурсы

Хотя грустно думать, что такое рутинное требование требует «исследовательских ресурсов», я думаю, что это так. Хорошие примеры и обсуждения:

водорослевые
источник
Почему вы устанавливаете раму обратно на старую?
vrwim
Я делаю это для того, чтобы функцию можно было использовать только для определения предпочтительного размера макета представления, не влияя на состояние макета представления, в котором вы его вызываете. На практике это не имеет значения, если вы удерживаете это представление только с целью делать расчеты планировки. Кроме того, того, что я делаю здесь, недостаточно. Одно только восстановление кадра на самом деле не восстанавливает его в предыдущее состояние, поскольку выполнение макета могло изменить макеты подвидов.
algal
1
Это такой беспорядок для того, чем люди занимаются со времен ios6. Apple всегда предлагает какой-то способ, но он никогда не бывает совсем верным.
GregP 05
1
Вы можете вручную создать экземпляр ячейки размера в viewDidLoad и удерживать в ней просмотр настраиваемого свойства. Это всего одна ячейка, так что это дешево. Поскольку вы используете его только для определения размера и управляете всем взаимодействием с ним, ему не нужно участвовать в механизме повторного использования ячеек представления коллекции, поэтому вам не нужно получать его из самого представления коллекции.
algal
1
Как вообще могут быть сломаны саморазмерные клетки ?!
Reuben Scratton 09
50

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

1. Создайте следующий подкласс потокового макета.

class HorizontallyFlushCollectionViewFlowLayout: UICollectionViewFlowLayout {

    // Don't forget to use this class in your storyboard (or code, .xib etc)

    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = super.layoutAttributesForItemAtIndexPath(indexPath)?.copy() as? UICollectionViewLayoutAttributes
        guard let collectionView = collectionView else { return attributes }
        attributes?.bounds.size.width = collectionView.bounds.width - sectionInset.left - sectionInset.right
        return attributes
    }

    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let allAttributes = super.layoutAttributesForElementsInRect(rect)
        return allAttributes?.flatMap { attributes in
            switch attributes.representedElementCategory {
            case .Cell: return layoutAttributesForItemAtIndexPath(attributes.indexPath)
            default: return attributes
            }
        }
    }
}

2. Зарегистрируйте представление коллекции для автоматического изменения размера.

// The provided size should be a plausible estimate of the actual
// size. You can set your item size in your storyboard
// to a good estimate and use the code below. Otherwise,
// you can provide it manually too, e.g. CGSize(width: 100, height: 100)

flowLayout.estimatedItemSize = flowLayout.itemSize

3. Используйте предопределенную ширину + произвольную высоту в подклассе ячейки.

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    layoutAttributes.bounds.size.height = systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
    return layoutAttributes
}
Джордан Смит
источник
8
Это лучший ответ, который я нашел на тему самостоятельного определения размера. Спасибо, Джордан!
haik.ampardjian
1
Не могу заставить это работать на iOS (9.3). На 10 работает нормально. Приложение вылетает на _updateVisibleCells (бесконечная рекурсия?). openradar.me/25303115
cristiano2lopes
@ cristiano2lopes у меня хорошо работает на iOS 9.3. Убедитесь, что вы предоставляете разумную оценку, и убедитесь, что ограничения ваших ячеек настроены для разрешения до правильного размера.
Jordan Smith
Это прекрасно работает. Я использую Xcode 8 и протестирован в iOS 9.3.3 (физическое устройство), и он не дает сбоев! Прекрасно работает. Просто убедитесь, что ваша оценка верна!
Хахмед
1
@JordanSmith Это не работает в iOS 10. Есть ли у вас демонстрационный пример? Я пробовал много вещей, чтобы сделать высоту ячейки представления коллекции на основе ее содержимого, но ничего. Я использую автоматический макет в iOS 10 и использую
Swift
6

Простой способ сделать это в iOS 9 с помощью нескольких строк кода - пример горизонтального способа (привязка его высоты к высоте представления коллекции):

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

self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
self.estimatedItemSize = CGSizeMake(1, 1);

Реализуйте делегат макета представления коллекции (большую часть времени в контроллере представления) collectionView:layout:sizeForItemAtIndexPath:,. Цель здесь - установить фиксированную высоту (или ширину) для измерения представления коллекции. Значение 10 может быть любым, но вы должны установить его значение, не нарушающее ограничений:

- (CGSize)collectionView:(UICollectionView *)collectionView
                  layout:(UICollectionViewLayout *)collectionViewLayout
  sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return CGSizeMake(10, CGRectGetHeight(collectionView.bounds));
}

Переопределите ваш собственный preferredLayoutAttributesFittingAttributes:метод ячейки , эта часть фактически вычислит вашу динамическую ширину ячейки на основе ваших ограничений Auto Layout и высоты, которую вы только что установили:

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
    UICollectionViewLayoutAttributes *attributes = [layoutAttributes copy];
    float desiredWidth = [self.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].width;
    CGRect frame = attributes.frame;
    frame.size.width = desiredWidth;
    attributes.frame = frame;
    return attributes;
}
Танги Г.
источник
Знаете ли вы, правильно ли работает этот более простой метод на iOS9, когда ячейка содержит многострочную строку UILabel, разрыв которой влияет на внутреннюю высоту ячейки? Это обычный случай, когда требовались все описанные мною сальто назад. Мне интересно, исправила ли iOS это после моего ответа.
algal
2
@algal К сожалению, ответ отрицательный.
jamesk 06
4

Попробуйте указать ширину в предпочтительных атрибутах макета:

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes {
    UICollectionViewLayoutAttributes *attributes = [[super preferredLayoutAttributesFittingAttributes:layoutAttributes] copy];
    CGSize newSize = [self systemLayoutSizeFittingSize:CGSizeMake(FIXED_WIDTH,layoutAttributes.size) withHorizontalFittingPriority:UILayoutPriorityRequired verticalFittingPriority:UILayoutPriorityFittingSizeLevel];
    CGRect newFrame = attr.frame;
    newFrame.size.height = size.height;
    attr.frame = newFrame;
    return attr;
}

Естественно, вы также хотите убедиться, что правильно настроили макет, чтобы:

UICollectionViewFlowLayout *flowLayout = (UICollectionViewFlowLayout *) self.collectionView.collectionViewLayout;
flowLayout.estimatedItemSize = CGSizeMake(FIXED_WIDTH, estimatedHeight)];

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

Даниэль Галаско
источник
В этом фрагменте вы вызываете systemLayoutSizeFittingSize :. Удалось ли вам заставить это работать, не устанавливая где-нибудь предпочтительныйMaxLayoutWidth? По моему опыту, если вы не устанавливаете предпочтительныйMaxLayoutWidth, вам необходимо выполнить дополнительную ручную компоновку, например, переопределив sizeThatFits: с вычислениями, которые воспроизводят логику ограничений автоматической компоновки, определенных в другом месте.
algal
@algal preferredMaxLayoutWidth - это свойство UILabel? Не совсем уверены, насколько это актуально?
Даниэль Галаско
К сожалению! Хорошая мысль, это не так! Я никогда не сталкивался с этим с помощью UITextView, поэтому не понимал, что их API-интерфейсы там отличаются. Похоже, что UITextView.systemLayoutSizeFittingSize уважает свой фрейм, поскольку у него нет intrinsicContentSize. Но UILabel.systemLayoutSizeFittingSize игнорирует фрейм, так как он имеет intrinsicContentSize, поэтому предпочтительныйMaxLayoutWidth необходим, чтобы получить intrinsicContentSize, чтобы раскрыть его обернутую высоту для AL. По-прежнему кажется, что UITextView потребуется два прохода или ручная разметка, но я не думаю, что я все это полностью понимаю.
algal
@algal Я согласен, я также столкнулся с некоторыми трудностями при использовании UILabel. Настройки его предпочтительной ширины решают проблему, но было бы неплохо иметь более динамичный подход, поскольку эта ширина должна масштабироваться с ее супервизором. Большинство моих проектов не используют предпочтительное свойство, я обычно использую это как быстрое решение, поскольку всегда есть более глубокая проблема
Даниэль Галаско
0

ДА, это можно сделать с помощью автоматического макета программно и путем установки ограничений в раскадровке или xib. Вам нужно добавить ограничение, чтобы размер ширины оставался постоянным, и установить высоту больше или равной.
http://www.thinkandbuild.it/learn-to-love-auto-layout-programmatically/

http://www.cocoanetics.com/2013/08/variable-sized-items-in-uicollectionview/
Надеюсь, это будет полезно и решит вашу проблему.

Дхайват Вьяс
источник
3
Это должно работать для абсолютного значения ширины, но если вы хотите, чтобы ячейка всегда была на всю ширину представления коллекции, это не сработает.
Майкл Уотерфолл,
@MichaelWaterfall Мне удалось заставить его работать на полную ширину с помощью AutoLayout. Я добавил ограничение ширины для представления содержимого внутри ячейки. Тогда в cellForItemAtIndexPathобновлении ограничения: cell.constraintItemWidth.constant = UIScreen.main.bounds.width. Мое приложение предназначено только для портала. Вы, вероятно, захотите reloadDataпосле изменения ориентации обновить ограничение.
Flitskikker