Określanie jednego wymiaru komórek w UICollectionView przy użyciu automatycznego układu

113

W iOS 8 UICollectionViewFlowLayoutobsługuje automatyczną zmianę rozmiaru komórek na podstawie ich własnego rozmiaru zawartości. Spowoduje to zmianę rozmiaru komórek pod względem szerokości i wysokości zgodnie z ich zawartością.

Czy można określić stałą wartość szerokości (lub wysokości) wszystkich komórek i zezwolić na zmianę rozmiaru innych wymiarów?

Dla prostego przykładu rozważmy wielokreskową etykietę w komórce z ograniczeniami umieszczającymi ją po bokach komórki. Wielowierszowa etykieta może być zmieniana na różne sposoby w celu dostosowania tekstu. Komórka powinna wypełnić szerokość widoku kolekcji i odpowiednio dostosować jej wysokość. Zamiast tego wielkość komórek jest przypadkowa i powoduje to nawet awarię, gdy rozmiar komórki jest większy niż wymiar, którego nie można przewijać w widoku kolekcji.


iOS 8 wprowadza metodę systemLayoutSizeFittingSize: withHorizontalFittingPriority: verticalFittingPriority:Dla każdej komórki w widoku kolekcji układ wywołuje tę metodę w komórce, przekazując szacowany rozmiar. Sensowne byłoby dla mnie zastąpienie tej metody w komórce, przekazanie podanego rozmiaru i ustawienie wiązania poziomego na wymagane oraz niskiego priorytetu ograniczenia pionowego. W ten sposób rozmiar poziomy jest ustalony na wartość ustawioną w układzie, a rozmiar pionowy może być elastyczny.

Coś takiego:

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

Rozmiary zwracane tą metodą są jednak zupełnie dziwne. Dokumentacja dotycząca tej metody jest dla mnie bardzo niejasna i wspomina o stosowaniu stałych, UILayoutFittingCompressedSize UILayoutFittingExpandedSizektóre reprezentują po prostu zerowy rozmiar i dość duży.

Czy sizeparametr tej metody jest rzeczywiście tylko sposobem przekazania dwóch stałych? Czy nie ma sposobu na osiągnięcie takiego zachowania, jakiego oczekuję od uzyskania odpowiedniej wysokości dla danego rozmiaru?


Alternatywne rozwiązania

1) Dodanie ograniczeń, które będą określać określoną szerokość komórki, zapewnia prawidłowy układ. Jest to słabe rozwiązanie, ponieważ to ograniczenie powinno być ustawione na rozmiar widoku kolekcji komórki, do którego nie ma bezpiecznego odniesienia. Wartość tego ograniczenia może zostać przekazana podczas konfigurowania komórki, ale wydaje się to również całkowicie sprzeczne z intuicją. Jest to również niewygodne, ponieważ dodawanie ograniczeń bezpośrednio do komórki lub jej widoku zawartości powoduje wiele problemów.

2) Użyj widoku tabeli. Widoki tabeli działają w ten sposób po wyjęciu z pudełka, ponieważ komórki mają stałą szerokość, ale nie byłoby to dostosowane do innych sytuacji, takich jak układ iPada z komórkami o stałej szerokości w wielu kolumnach.

Anthony Mattox
źródło

Odpowiedzi:

135

Wygląda na to, że pytasz o sposób użycia UICollectionView do utworzenia układu takiego jak UITableView. Jeśli naprawdę tego chcesz, właściwym sposobem na zrobienie tego jest niestandardowa podklasa UICollectionViewLayout (może coś w rodzaju SBTableLayout ).

Z drugiej strony, jeśli naprawdę pytasz, czy istnieje czysty sposób na zrobienie tego z domyślnym UICollectionViewFlowLayout, to uważam, że nie ma sposobu. Nawet w przypadku samoczynnych rozmiarów komórek iOS8 nie jest to proste. Podstawowym problemem, jak powiedziałeś, jest to, że mechanizm układu przepływu nie zapewnia możliwości ustalenia jednego wymiaru i umożliwienia odpowiedzi drugiemu. (Ponadto, nawet gdybyś mógł, dodatkowa złożoność wymagałaby dwóch przebiegów układu w celu ustalenia rozmiaru etykiet wielowierszowych. Może to nie pasować do sposobu, w jaki komórki samodopasowujące chcą obliczać wszystkie rozmiary za pomocą jednego wywołania systemLayoutSizeFittingSize).

Jeśli jednak nadal chcesz utworzyć układ podobny do widoku tabeli z układem przepływu, z komórkami, które określają swój własny rozmiar i naturalnie reagują na szerokość widoku kolekcji, oczywiście jest to możliwe. Nadal istnieje bałagan. Zrobiłem to za pomocą „komórki rozmiaru”, tj. Niewyświetlanego UICollectionViewCell, który kontroler przechowuje tylko do obliczania rozmiarów komórek.

To podejście składa się z dwóch części. Pierwsza część jest przeznaczona dla delegata widoku kolekcji do obliczenia prawidłowego rozmiaru komórki poprzez uwzględnienie szerokości widoku kolekcji i użycie komórki określającej rozmiar do obliczenia wysokości komórki.

W swoim UICollectionViewDelegateFlowLayout zaimplementujesz taką metodę:

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
}

Spowoduje to, że widok kolekcji ustawi szerokość komórki na podstawie otaczającego widoku kolekcji, ale następnie poprosi komórkę określającą rozmiar o obliczenie wysokości.

Druga część polega na tym, aby komórka określająca rozmiar używała własnych ograniczeń AL do obliczenia wysokości. Może to być trudniejsze niż powinno, ponieważ wielowierszowe UILabel wymagają efektywnego dwuetapowego procesu tworzenia układu. Praca wykonywana jest metodą preferredLayoutSizeFittingSize, która wygląda następująco:

 /*
 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
 }

(Ten kod jest zaadaptowany z przykładowego kodu Apple z przykładowego kodu sesji WWDC2014 w „Zaawansowanym widoku kolekcji”).

Kilka punktów, na które warto zwrócić uwagę. Wykorzystuje layoutIfNeeded () do wymuszenia układu całej komórki, aby obliczyć i ustawić szerokość etykiety. Ale to nie wystarczy. Uważam, że musisz również ustawić preferredMaxLayoutWidthtak, aby etykieta używała tej szerokości z automatycznym układem. I tylko wtedy możesz użyć systemLayoutSizeFittingSize, aby komórka obliczyła swoją wysokość z uwzględnieniem etykiety.

Czy podoba mi się to podejście? Nie!! Wydaje się zbyt skomplikowane, a układ dwukrotnie. Ale dopóki wydajność nie stanie się problemem, wolałbym wykonać układ dwa razy w czasie wykonywania, niż musieć dwukrotnie definiować go w kodzie, co wydaje się być jedyną alternatywą.

Mam nadzieję, że w końcu komórki samorozmiarowe będą działać inaczej, a to wszystko stanie się dużo prostsze.

Przykładowy projekt pokazujący to w pracy.

Ale dlaczego nie użyć po prostu samokalibrowych komórek?

Teoretycznie, nowe możliwości iOS8 dla "samokalibracji komórek" powinny uczynić to niepotrzebnym. Jeśli zdefiniowałeś komórkę za pomocą automatycznego układu (AL), widok kolekcji powinien być wystarczająco inteligentny, aby umożliwić jej rozmiar i prawidłowy układ. W praktyce nie widziałem żadnych przykładów, które sprawiłyby, że działałyby z etykietami wieloliniowymi. Myślę, że to po części ze względu na własny mechanizm doboru komórka jest nadal buggy.

Ale założę się, że dzieje się tak głównie ze względu na zwykłe zawiłości automatycznego układu i etykiet, które polega na tym, że etykiety UIL wymagają zasadniczo dwuetapowego procesu układania. Nie jest dla mnie jasne, w jaki sposób można wykonać oba kroki w przypadku ogniw samorozmiarowych.

I jak powiedziałem, to naprawdę praca dla innego układu. Istotą układu przepływu jest to, że umieszcza rzeczy, które mają rozmiar, zamiast ustalać szerokość i pozwalać im wybrać wysokość.

A co z preferowanymLayoutAttributesFittingAttributes:?

preferredLayoutAttributesFittingAttributes:Metoda jest czerwony śledź, myślę. To jest tylko do wykorzystania z nowym mechanizmem samokalibracji komórek. Więc to nie jest odpowiedź, dopóki ten mechanizm jest zawodny.

A co słychać w systemlayoutSizeFittingSize :?

Masz rację, doktorzy są mylący.

Dokumentacja systemLayoutSizeFittingSize:i systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:obie sugerują, że powinieneś przekazać tylko UILayoutFittingCompressedSizei UILayoutFittingExpandedSizejako targetSize. Jednak sama sygnatura metody, komentarze nagłówka i zachowanie funkcji wskazują, że odpowiadają one dokładnej wartości targetSizeparametru.

W rzeczywistości, jeśli ustawisz UICollectionViewFlowLayoutDelegate.estimatedItemSize, w celu włączenia nowego mechanizmu samoczynnego określania rozmiaru komórki, ta wartość wydaje się być przekazywana jako targetSize. I UILabel.systemLayoutSizeFittingSizewydaje się zwracać dokładnie takie same wartości jak UILabel.sizeThatFits. Jest to podejrzane, biorąc pod uwagę, że argument do systemLayoutSizeFittingSizema być przybliżonym celem, a argumentem do sizeThatFits:ma być maksymalny opisywany rozmiar.

Więcej zasobów

Chociaż smutne jest myślenie, że taki rutynowy wymóg wymaga „zasobów badawczych”, myślę, że tak. Dobre przykłady i dyskusje to:

glony
źródło
Dlaczego przywracasz ramkę do starej ramki?
vrwim
Robię to, aby funkcja mogła być używana tylko do określenia preferowanego rozmiaru układu widoku, bez wpływu na stan układu widoku, w którym ją wywołujesz. Praktycznie nie ma to znaczenia, jeśli trzymasz ten widok tylko w celu wykonywanie obliczeń układu. Poza tym to, co tutaj robię, jest niewystarczające. Przywrócenie samej ramki tak naprawdę nie przywraca jej do poprzedniego stanu, ponieważ wykonanie układu mogło zmodyfikować układy widoków podrzędnych.
glony
1
To taki bałagan jak na coś, co ludzie robili od czasu ios6. Apple zawsze wychodzi na jaw, ale nigdy nie jest do końca w porządku.
GregP
1
Możesz ręcznie utworzyć wystąpienie komórki określającej rozmiar w viewDidLoad i przytrzymać ją, aby wyświetlić właściwość niestandardową. To tylko jedna komórka, więc jest tania. Ponieważ używasz go tylko do zmiany rozmiaru i zarządzasz wszystkimi interakcjami z nim, nie musi on uczestniczyć w mechanizmie ponownego wykorzystania komórek widoku kolekcji, więc nie musisz go pobierać z samego widoku kolekcji.
glony
1
Jak, u licha, komórki samorozmiaru mogą być nadal niszczone ?!
Reuben Scratton
50

Jest na to czystszy sposób niż niektóre inne odpowiedzi tutaj i działa dobrze. Powinien być wydajny (widoki kolekcji ładują się szybko, bez niepotrzebnych automatycznych przebiegów układu itp.) I nie ma żadnych „magicznych liczb”, takich jak stała szerokość widoku kolekcji. Zmiana rozmiaru widoku kolekcji, np. Przy obracaniu, a następnie unieważnienie układu też powinno działać świetnie.

1. Utwórz następującą podklasę układu przepływu

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. Zarejestruj widok kolekcji w celu automatycznego doboru rozmiaru

// 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. Użyj wstępnie zdefiniowanej szerokości + niestandardowej wysokości w podklasie komórki

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    layoutAttributes.bounds.size.height = systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
    return layoutAttributes
}
Jordan Smith
źródło
8
To najlepsza odpowiedź, jaką znalazłem na temat samodoskonalenia. Dziękuję Jordan!
haik.ampardjian
1
Nie mogę tego uruchomić na iOS (9.3). Na 10 działa dobrze. Aplikacja ulega awarii na _updateVisibleCells (nieskończona rekurencja?). openradar.me/25303115
cristiano2lopes
@ cristiano2lopes działa dobrze dla mnie na iOS 9.3. Upewnij się, że podajesz rozsądne oszacowanie i upewnij się, że ograniczenia komórek są skonfigurowane tak, aby rozwiązywać je do prawidłowego rozmiaru.
Jordan Smith
To działa niesamowicie. Używam Xcode 8 i przetestowałem go na iOS 9.3.3 (urządzenie fizyczne) i nie ulega awarii! Działa świetnie. Tylko upewnij się, że oszacowanie jest prawidłowe!
hahmed
1
@JordanSmith To nie działa w iOS 10. Czy masz jakieś przykładowe demo. Próbowałem wielu rzeczy, aby zobaczyć wysokość komórki widoku kolekcji na podstawie jej zawartości, ale nic. Używam automatycznego układu w iOS 10 i używam swift 3.
Ekta Padaliya
6

Prosty sposób na zrobienie tego w iOS 9 w kilku linijkach kodów - przykład w poziomie (ustalenie wysokości do wysokości widoku kolekcji):

Zainicjuj układ przepływu widoku kolekcji za pomocą, estimatedItemSizeaby włączyć samodopasowanie komórki:

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

Zaimplementuj delegata układu widoku kolekcji (w kontrolerze widoku przez większość czasu) collectionView:layout:sizeForItemAtIndexPath:. Celem jest tutaj ustawienie stałej wysokości (lub szerokości) wymiaru widoku kolekcji. Wartość 10 może być dowolna, ale należy ustawić ją na wartość, która nie łamie ograniczeń:

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

Zastąp swoją niestandardową preferredLayoutAttributesFittingAttributes:metodę komórki , ta część faktycznie oblicza dynamiczną szerokość komórki na podstawie ograniczeń automatycznego układu i wysokości, którą właśnie ustawiłeś:

- (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;
}
Tanguy G.
źródło
Czy wiesz, czy na iOS9 ta prostsza metoda działa poprawnie, gdy komórka zawiera wiele linii UILabel, których łamanie linii wpływa na wewnętrzną wysokość komórki? Jest to powszechny przypadek, który wymagał wszystkich opisywanych przeze mnie backflipów. Zastanawiam się, czy iOS naprawił to od czasu mojej odpowiedzi.
glony
2
@algal Niestety, odpowiedź brzmi: nie.
jamesk
4

Spróbuj naprawić szerokość w preferowanych atrybutach układu:

- (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;
}

Oczywiście chcesz również upewnić się, że poprawnie skonfigurowałeś swój układ, aby:

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

Oto coś, co umieściłem na Githubie, który używa komórek o stałej szerokości i obsługuje typ dynamiczny, więc wysokość komórek jest aktualizowana wraz ze zmianą rozmiaru czcionki systemowej.

Daniel Galasko
źródło
W tym fragmencie wywołujesz systemLayoutSizeFittingSize :. Czy udało Ci się to uruchomić bez ustawiania gdzieś preferowanegoMaxLayoutWidth? Z mojego doświadczenia wynika, że ​​jeśli nie ustawiasz preferowanegoMaxLayoutWidth, musisz wykonać dodatkowy ręczny układ, np. Przez nadpisanie sizeThatFits: z obliczeniami, które odtwarzają logikę ograniczeń automatycznego układu zdefiniowanych w innym miejscu.
glony
@algal preferowaneMaxLayoutWidth to właściwość na UILabel? Nie do końca wiesz, jakie to ma znaczenie?
Daniel Galasko
Ups! Słuszna uwaga, nie jest! Nigdy nie radziłem sobie z tym za pomocą UITextView, więc nie zdawałem sobie sprawy, że ich interfejsy API różnią się tam. Wygląda na to, że UITextView.systemLayoutSizeFittingSize szanuje swoją ramkę, ponieważ nie ma intrinsicContentSize. Ale UILabel.systemLayoutSizeFittingSize ignoruje ramkę, ponieważ ma intrinsicContentSize, więc preferowanyMaxLayoutWidth jest potrzebny, aby uzyskać intrinsicContentSize w celu ujawnienia zawiniętej wysokości do AL. Nadal wydaje się, że UITextView będzie potrzebował dwóch przebiegów lub ręcznego układu, ale nie sądzę, żebym to wszystko w pełni rozumiał.
glony
@algal Zgadzam się, doświadczyłem też pewnych włochatych problemów podczas korzystania z UILabel. Ustawienia preferowanej szerokości rozwiązują problem, ale byłoby miło mieć bardziej dynamiczne podejście, ponieważ ta szerokość musi być skalowana wraz z jej nadzorem. Większość moich projektów nie używa preferowanej właściwości, generalnie używam jej jako szybkiego rozwiązania, ponieważ zawsze jest głębszy problem
Daniel Galasko
0

TAK, można to zrobić programowo za pomocą automatycznego układu i ustawiając ograniczenia w scenorysie lub xib. Musisz dodać ograniczenie, aby szerokość pozostała stała i ustawić wysokość większą lub równą.
http://www.thinkandbuild.it/learn-to-love-auto-layout-programmatically/

http://www.cocoanetics.com/2013/08/variable-sized-items-in-uicollectionview/
Mam nadzieję, że tak się stanie pomocne i rozwiąż problem.

Dhaivat Vyas
źródło
3
Powinno to zadziałać dla bezwzględnej wartości szerokości, ale jeśli chcesz, aby komórka zawsze miała pełną szerokość widoku kolekcji, nie zadziała.
Michael Waterfall
@MichaelWaterfall Udało mi się go uruchomić z pełną szerokością przy użyciu AutoLayout. Dodałem ograniczenie szerokości do widoku zawartości wewnątrz komórki. Następnie w cellForItemAtIndexPathzaktualizować ograniczenia: cell.constraintItemWidth.constant = UIScreen.main.bounds.width. Moja aplikacja działa tylko w trybie portait. Prawdopodobnie reloadDatapo zmianie orientacji chcesz zaktualizować wiązanie.
Flitskikker