티스토리 뷰

Biz & Tech/IT & Business

Self-Sizing Cell 파악하기

SK(주) C&C 블로그 운영자 2015. 10. 26. 10:41

1. WWDC 2014에서 소개된 Self-Sizing Cell

애플은 WWDC 2014에서 iOS8 UITableView와 UICollectionView에 추가된 Self-sizing 메커니즘을 소개하였습니다. 기존에는 없던 새로운 메커니즘이었고 그 내용이 흥미로웠기에 이에 대해 공유해보고자 합니다. Collection View 는 크게 UICollectionVIew – UICollectionViewLayout – UICollectionViewCell 로 구성되고, UICollectionVIewLayout을 이용하여 각 셀들의 크기, 위치를 결정짓는 레이아웃을 수행합니다. UICollectionView는 결정된 레이아웃대로 보여주기만 할 뿐이기 때문에 레이아웃에 따라 보여지는 모습이 달라지게 됩니다.

iOS에서는 Collection View 의 Layout 으로 UICollectionViewFlowLayout 를 제공하고 있습니다. iOS8 이전까지는 UICollectionViewFlowLayout에서 셀의 크기를 결정하는 방식이 두 가지가 있었습니다.

하나는 모두 동일한 크기를 갖는 방식이고, 다른 하나는 각 셀마다 크기를 다르게 갖는 방식입니다. 모두 동일한 크기를 일괄 지정하는 방식은 UICollectionViewFlowLayout에 itemSize 프로퍼티 값을 설정하여 UICollectionView에서 해당 셀의 크기를 모두 같은 itemSize 프로퍼티 값으로 설정하는 식으로 동작합니다.

각 셀마다 크기를 각각 지정하는 방식은 UICollectionViewFlowLayout에서 UICollectionViewFlowLayoutDelegate를 통해서 indexPath의 셀의 크기를 얻어 와서 설정하는 식으로 동작합니다. 두 가지 방식 모두 미리 레이아웃이 모든 셀의 크기를 알게 됨으로써 위치를 계산할 수 있어서 UICollectionViewFlowLayout이 모든 셀을 미리 배치를 해놓고 UICollectionView에서 스크롤이 이동하면서 뷰 렉트(CGRect)가 변경됨에 따라 해당 뷰 렉트에 포함되는 셀들을 배치된 위치에 나타나게 하는 식으로 동작합니다.

2. UICollectionView의 Self-Sizing Cell

앞서 말한 것처럼 iOS 8에서 새로운 셀의 크기를 지정하는 방식을 도입했습니다. 동적으로 셀의 크기가 결정될 수 있게 하는 방식으로 iOS에서는 이를 Self-sizing cell 방식이라 명명하고 있습니다. 셀에 AutoLayout을 적용함으로써 셀 스스로 크기를 결정하고 이를 UICollectionViewLayout에 알려줘서 해당 셀의 크기를 갱신한 값으로 다시 레이아웃이 되게 합니다.

기존 UICollectionViewFlowLayout의 delegate를 통하여 셀의 사이즈를 결정하는 방식에서는 컨텐츠(이미지의 크기라던지, 문자열의 길이 등)를 다루는 로직이 delegate 내에서도 해야하기 때문에 캡슐화가 잘 되지 않고, 컨텐츠 영역과의 코드 커플링도 높아지게 됩니다.

셀의 컨텐츠를 다루는 내용은 셀 내부에서만 있어야 캡슐화가 잘 되고 코드 커플링을 낮출수 있는데 그렇지 못하기 때문입니다. 이는 추후에 내용에 변경이 있을 때 용이하지 못하게 되므로 유지보수가 힘들어 질 수 도 있음을 의미합니다. 이 새로운 Self-sizing cell 방식을 사용할 경우에는 캡슐화를 높일 수 있는 장점이 있습니다.

Self-sizing cell 방식을 통해서 셀의 크기를 정하는데 있어서 셀 내부에서만 처리하기 때문에 캡슐화에 유리하고, 또한 기존과 다르게 셀 자체가 크기를 정하는데 있어서 주도권을 가지게 되는 것입니다.

UICollectionVewFlowLayout에서 Self-sizing cell 방식을 적용하기 위해서 estimatedItemSize 프로퍼티를 사용합니다. 이 프로퍼티를 사용하여 대략적인 셀의 크기를 미리 결정해 주고 나중에 셀이 AutoLayout될때 셀의 크기가 변경이 되면 그 내용이 UICollectionVewFlowLayout에 다시 전달하고  UICollectionVewFlowLayout에서 해당 셀의 최종 사이즈가 결정되는 식으로 Self-sizing cell 방식이 동작됩니다.

3. UICollectionView + UICollectionViewFlowLayout Self-sizing cell 샘플 작성하기

직접 Self-sizing cell 방식으로 동작되도록 코드를 작성해보겠습니다.

UICollectionView의 collectionViewLayout에 UICollectionViewFlowLayout을 설정해주는데, 이 때 UICollectionViewFlowLayout에estimatedItemSize 프로퍼티를 설정해줍니다.

1
2
3
UICollectionViewFlowLayout* flowLayout = [[UICollectionViewFlowLayout alloc] init];
flowLayout.estimatedItemSize = CGSizeMake(320, 20);
self.collectionView.collectionViewLayout = flowLayout;

Self-sizing cell이 되도록 cell contentView에 constraint를 적용합니다. contentView에 UILabel만 추가하고 해당 UILabel이 텍스트가 길어지면 길어질수록 라인수가 증가하도록 numOfLines를 0으로 세팅합니다. contentView와 UILabel에 디폴트 오토 리사이징 마스크에 의한 constraints가 설정되지 않도록  translatesAutoresizingMaskIntoConstraints = NO로 설정해줍니다.

그리고 레이아웃을 구성하는데 필요한 constraints를 적용합니다. 이 때에 주의할 점이 있습니다. UILabel에 너비에 대한 constraint가 결정되어 있지 않으면, UILabel은 텍스트의 길이에 따라 한없이 너비가 넓어질 것이므로, 최대 너비에 대한 constraint를 적용하여야 원하는대로 텍스트 길이에 따라 라인수가 증가할수 있게 됩니다. constraint를 사용하는 방법 말고도 UILabel의 prefferedWidth 프로퍼티를 이용해도 상관없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-(instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if(self) {
        self.contentView.backgroundColor = [UIColor whiteColor];
        self.contentView.translatesAutoresizingMaskIntoConstraints = NO;
 
        _title = [[UILabel alloc] init];
        _title.font = [UIFont systemFontOfSize:17.0];
        _title.numberOfLines = 0;
        _title.translatesAutoresizingMaskIntoConstraints = NO;
 
        [self.contentView addSubview:_title];
 
        NSMutableArray *constraints = [[NSMutableArray alloc] init];
 
        [constraints addObjectsFromArray:
         [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-8-[_title(<=300)]-8-|"
               options:0
               metrics:nil
                 views:NSDictionaryOfVariableBindings(_title)]];
 
        [constraints addObjectsFromArray:
         [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-8-[_title]-8-|"
               options:0
               metrics:nil
                 views:NSDictionaryOfVariableBindings(_title)]];
 
        [self.contentView addConstraints:constraints];
    }
    return self;
}

위 처럼 셀의 contentView에서 constraints로 AutoLayout에 의해 셀의 크기가 Self-sizing이 되게 합니다. 실행시켜보면 셀마다 AutoLayout으로 결정된 크기로 잘 동작되는 것을 볼 수 있습니다.

4. Self-sizing UICollectionViewFlowLayout의 문제점

UICollectionViewFlowLayout의 Self-sizing cell이 잘 동작되는 것으로 보이지만 실제로는 그렇지 않습니다. Stackoverflow나 iOS UI 기술 관련 질문들을 보면UICollectionViewFlowLayout Self-sizing 기능에 대한 이슈들이 많이 올라와 있다는 것을 볼 수 있습니다.

문제되는 사항들은 다음과 같습니다.

첫 번째, estimatedItemSize 프로퍼티 설정에 관한 문제점입니다.  UICollectionViewFlowLayout에서는 estimatedItemSize 프로퍼티를 토대로 각 셀들을 임시 배치해 놓습니다. 아직 AutoLayout 이후 Self-sizing이 되지 않은 셀의 경우에는 이 임시 배치된 위치를 기준으로 뷰 렉트에 포함되는지 안 되는지를 판단하고 이후 디큐시키는 과정을 거치게 됩니다.

즉, UICollectionViewLayout collectionViewContentSize estimatedItemSize 프로퍼티의 크기로 계산해서 표현하게 되는 것 입니다. 물론 실제로 디큐 및 AutoLayout연산이 되어 크기가 변경되면 그에 따라 collectionViewContentSize를 갱신해주기는 하는데 초기값은 아직 디큐된 것이 없기 때문에 estimatedItemSize 프로퍼티 계산을 통해 collectionViewContentSize값을 가지게 됩니다.

따라서 estimatedItemSize값을 실제 표현될 셀의 크기보다 너무 작은 크기로 할 경우에는 UICollectionView에서 표현할 셀의 갯수와estimatedItemSize 프로퍼티 크기를 계산하여 초기 collectionViewContentSize값이 UICollectionView 사이즈보다 작다고 판단하면 스크롤이 가능하지 않다고 여깁니다.

실제 AutoLayout되어 collectionViewContentSize가 갱신됨으로써 UICollectionView 뷰 바운즈를 넘게 되어 스크롤이 가능해질 여력이 있다 하여도 초기 collectionViewContentSize값 자체는 작기 때문에 스크롤이 아예 불가능해집니다.

반대로 estimatedItemSize 프로퍼티를 실제 표현될 셀의 크기에 비해 너무 크게 할 경우에는 다른 문제가 생깁니다.

실제 셀의 크기가 더 작게되면 estimatedItemSize 프로퍼티에 의해서 임시로 배치된 위치에 비해 더 앞쪽으로 자리 잡히게 됩니다. 배치된 위치로 봤을 때 아직 해당 셀은 디큐잉 될 시점이 아니기 때문에 아직 실제 화면상에는 나타나지 않게 됩니다. 따라서 스크롤 시켜서 실제 위치에 도달할 때 그때서야 셀이 디큐잉 되어 나타나게 되고 그제서야 제대로 된 위치에 자리잡는 것을 볼 수 있게 됩니다. estimatedItemSize 프로퍼티와 실제 셀 크기 격차가 크게 되면 실제 위치와 임시 위치가 더 많이 벌어지게 되고, 격차에 따라 디큐잉 된 셀이 이미 지나간 뷰 포트에 자리를 잡게 되면서  아예 셀이 보이지 않게 될 수도 있습니다.

따라서 UICollectionViewFlowLayout에 Self-sizing cell을 적용하기 위해서 estimatedItemSize 프로퍼티를 설정하는데 있어서 주의가 필요합니다. estimatedItemSize 프로퍼티의 역할로 미루어볼때 설정되어야 하는 값은 실제로 표현될 셀의 크기의 최소값으로 작성해야 하는 것이 좋습니다. 하지만 이처럼 단순히 셀의 크기를 최소값으로 하게되면 (iOS 8.4기준 현재) collectionViewContentSize에 버그가 발생하는 이슈가 있었습니다.

estimatedItemSize 프로퍼티로는 한줄에 여러 셀이 레이아웃된다고 판단하여 가상의 배치를 해두었는데, 만약  AutoLayout에 의해 셀의 크기가 바뀌면서 줄바꿈이 발생할 경우 이 변경된 내용에 맞게 collectionViewContentSize를 갱신해서 크기를 키워야 스크롤이 정상적으로 동작하게 될 것입니다.

하지만 UICollectionViewFlowLayout의 버그로 인해 collectionViewContentSize가 제대로 갱신되지 않아서 스크롤 범위가 정상적으로 되지 않는 현상이 보이고 있습니다. 따라서 UICollectionViewFlowLayout에 Self-Sizing cell을 적용하기 위해 estimatedItemSize프로퍼티를 설정할때 실제 셀의 크기와 차이로 인해 줄바꿈이 발생하지 않도록 유의하여야 합니다.

이렇게 estimatedItemSize 프로퍼티는 아무 임의의 값을 설정하는 것이 아니고 셀의 컨텐츠와 연관을 둬서 설정해야 합니다. 이는 셀의 컨텐츠 로직이 완전히 독립적이게 되지 않는다는 의미가 됩니다.

두 번째 문제는 scrollTo 메서드가 정상동작 하지 않는다는 것입니다.

UICollectionViewFlowLayout는 디큐된 셀이 다음 순서의 셀의 위치값(x,y)에 영향을 주게 됩니다. 즉, 연속되는 indexPath 순서대로 셀의 위치가 결정되어야만 정상적인 위치 값을 가질 수 있게 된다는 것 입니다.

따라서 아직 뷰 포트가 이동하지 않아서 정상적으로 디큐 되지 않은 셀의 위치로 scrollTo를 하는 거라면 셀이 정상적으로 자리를 잡지 못하게 됩니다. 그래서 화면 밖으로 셀이 자리잡기도 하여 아예 아무것도 보이지 않게 됩니다.

하지만 scrollTo animated:YES로 할 경우에는 한번에 이동하지 않고 스크롤 플링(Scroll fling : Smooth scroll)이 되면서 동작하게 됩니다. 따라서 이 경우에 그 셀 위치까지 이동을 하면서 디큐 과정을 거치게 됩니다. 물론 scrollTo하는 범위가 많은 indexPath를 건너뛰게 되는거라면 스크롤 플링이 빠른 속도로 진행될 것이고 마찬가지로 indexPath를 건너뛰면서 디큐잉이 됩니다. 따라서 이 경우에도 셀이 정상적으로 자리를 잡지 못하여 셀이 보이지 않게 되는 현상이 발생합니다.

5. UITableView의 Self-sizing cell 방식

위처럼 UICollectionViewFlowLayout에서 Self-sizing에는 이슈들이 있어서 현재 (iOS8.4 기준) 잘 동작한다고 볼 수 없습니다.

그렇다면 UITableView에서는 어떨까요?

UITableView는 UICollectionView와 다르게 레이아웃팅 과정도 UITableView가 합니다. UICollectionViewLayout에게 셀들의 레이아웃팅 역할을 맡기던 UICollectionView와 다르게 직접 레이아웃팅에 관여하는 것입니다.

iOS8 이전의 UITableView에서는 rowHeight 프로퍼티를 이용하여 모든 cell들이 동일한 높이 값을 갖도록 하는 방식과UITableViewDelegate를 통하여 각 셀들이 각자의 높이 값을 갖도록 하는 방식 두 가지로 사용되었습니다.

UICollectionView와 마찬가지로 iOS8부터 UITableView에 동적으로 높이가 결정되도록 하는 새로운 방식인 Self-sizing cell 방식이 추가되었습니다.

UITableView은 Self-sizing cell 방식이 UICollectionView의 방식과 유사합니다. estimatedRowHeight 프로퍼티를 사용하여 임시 배치하고, 셀이 AutoLayout에 의해 Self-sizing이 되면서 실제 위치로 자리잡게 합니다.

실제 샘플 코드를 작성해보겠습니다.

UITableView에 estimatedRowHeight 프로퍼티를 설정하고 기존에 사용하는 rowHeight 프로퍼티에는 Self-sizing으로써 동적으로 셀의 높이값이 설정될 것을 UITableView에 알리기 위해 UITableViewAutomaticDimension으로 세팅합니다.

1
2
_tableView.estimatedRowHeight = 20;
_tableView.rowHeight = UITableViewAutomaticDimension;

셀의 내용은 UICollectionViewCell 와 동일하게 하나의 UILabel을 넣고 텍스트 길이에 따라 셀의 크기가 결정되도록 합니다. 또한 셀의 contentView에 constraint를 적용함으로써 AutoLayout으로 셀의 레이아웃팅이 잡히도록 합니다.

UICollectionViewCell과 다른 점은 너비에 대한 constraint를 적용할 필요가 없다는 것입니다. TableViewCell은 이미 UITableVIew에 의해 너비가 결정되기 때문입니다. 따라서 셀의 contentView에 translatesAutoresizingMaskIntoConstraints 프로퍼티를 NO로 세팅할 필요도 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
-(instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
 
    if(self) {
        self.contentView.backgroundColor = [UIColor whiteColor];
 
        _title = [[UILabel alloc] initWithFrame:CGRectInset(self.bounds, 8, 8)];
        _title.font = [UIFont systemFontOfSize:17.0];
        _title.numberOfLines = 0;
        _title.translatesAutoresizingMaskIntoConstraints = NO;
 
        [self.contentView addSubview:_title];
 
        NSMutableArray *constraints = [[NSMutableArray alloc] init];
 
        [constraints addObjectsFromArray:
         [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-8-[_title]-8-|"
                                options:0
                                metrics:nil
                                views:NSDictionaryOfVariableBindings(_title)]];
 
        [constraints addObjectsFromArray:
         [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-8-[_title]-8-|"
                                options:0
                                metrics:nil
                                views:NSDictionaryOfVariableBindings(_title)]];
 
        [self.contentView addConstraints:constraints];
 
    }
    return self;
}

실행시켜 보면 정상적으로 텍스트 길이에 따라 셀의 크기가 변하는 것으로 동작하는 것을 볼 수 있습니다.

그렇다면 앞서 UICollectionView에서 나오는 문제에 대해서 확인해보겠습니다.

estimatedRowHeight 프로퍼티를 작은 값으로 해서 한 온-스크린 내에 표현되도록 할 경우에 UICollectinView처럼 스크롤러블 하지 않게 되는지 확인해봅니다. 문제없이 정상적으로 스크롤이 가능한 것을 볼 수 있습니다.

그럼 반대로 estimatedRowHeight 프로터티 값을 실제 표현될 셀의 높이 값보다 크게 할 경우 스크롤 시마다  셀이 뒤늦게 디큐되서 자리잡는지 확인해보겠습니다. 이 경우에도 문제없이 contentSize 값이 제대로 갱신되어 정상적으로 동작하는 것을 볼 수 있습니다.

그럼 scrollTo의 경우는 어떨까요?

UICollectionView와 다르게 셀이 스크린 밖으로 사라지는 현상이 없는 것을 볼 수 있습니다. 다만 앞서 설명한 UICollectionView와 동일하게 많은 indexPath를 건너뛰는 스크롤 플링(Scroll fling – Smooth scroll)를 할 경우 중간을 건너 뛰면서 디큐잉 되면서 원하는 위치의 셀이 나타나 있지 않다는 것을 알 수 있습니다.

하지만 UICollectionView처럼 셀이 스크린 밖으로 사라지지 않으며 스크롤을 다시 위로 올리면 뒤늦게 셀이 디큐잉 되면서 아래 있는 셀들이 새로운 자리로 갱신되는 것을 볼 수 있습니다. 물론 스크롤과 함께 셀들이 갱신되면서 중간중간 프레임이 튀는 듯한 느낌을 받을 수는 있는데, 스크롤 중이었기 때문에 비교적 자연스럽게 넘어가는 것을 볼 수 있습니다.

이처럼 UITableView는 Self-sizing하는데 있어서 문제없이 정상적으로 동작하는 것을 볼 수 있습니다. scrollTo 메서드 호출 시에  셀이 스크린 밖으로 사라지는 이슈도 없으며 estimatedRowHeight 프로퍼티에 따른 문제점도 발생하지 않습니다.

새로운 셀이 AutoLayout으로 Self-sizing이 되면 그에 따라서 아래의 셀들을 위치를 갱신해주는 것 입니다.

이렇게 UICollectionView에 비해 UITableView가 정상 동작할 수 있었던 것은 UITableView의 특성 때문이라고 추측됩니다.

UITableView의 한 라인에 하나의 셀만 자리잡게 하는 고정 레이아웃을 사용합니다. 셀의 높이값이 동적으로 변경된다면 그것은 다른 셀의 y값만 변화하는 것이며, estimatedRowHeight 프로퍼티에 비해 실제 적용된 셀의 높이값의 오프셋 만큼 컨텐츠 사이즈 높이값을 갱신 시켜주기만 하면 되는 것 입니다. 그렇기 때문에 UITableView는 정상적으로 Self-sizing을 처리 할 수 있었던 것으로 보입니다.

6. Self-sizing 가능한 커스텀 UICollectionViewLayout 만들기

UITableView처럼 동적으로 Self-sizing cell 방식이 잘 동작할 수 있도록 리스트 형식의 UICollectionViewLayout을 구현해보겠습니다. 세로 스크롤이 되도록 순서대로 셀들을 아래로 배치하며, 한 줄에 한 셀만 표현되도록 합니다. 각 셀간의 간격은 상수로 고정 값을 설정하도록 합니다.

 prepareLayout 에서 indexPath 루프를 돌면서 layoutAttribute를 생성합니다. x값은 중앙에 자리 잡도록 하고, y값은 앞 순서 셀의 바닥 위치 값에 간격 상수값을 더한 위치에 잡게 합니다. UICollectionViewContentSize 에서 사용할 컨텐츠 사이즈를 저장해둡니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-(void)prepareLayout
{
    const NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
    const CGFloat collectionViewWidth = self.collectionView.bounds.size.width;
 
    CGFloat yPos = 0;
    for(NSInteger item = 0; item    {
        UICollectionViewLayoutAttributes *attr;
        CGSize itemSize;
 
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:item inSection:0];
        attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
        itemSize = _estimatedItemSize;
 
        const CGFloat x = (collectionViewWidth - itemSize.width) * 0.5;
 
        attr.frame = CGRectMake(x, yPos, itemSize.width, itemSize.height);
        _layoutAttributes[item] = attr;
 
        yPos += (itemSize.height + _spacing);
    }
 
    _contentSize = CGSizeMake(collectionViewWidth, yPos-_spacing);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-(CGSize)collectionViewContentSize
{
    return _contentSize;
}
 
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
    [_elementsInRect removeAllObjects];
 
    const NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
    for(NSInteger item = 0; item    {
        UICollectionViewLayoutAttributes* attr = _layoutAttributes[item];
        if( CGRectIntersectsRect(attr.frame, rect) ) {
            [_elementsInRect addObject:attr];
        }
    }
 
    return _elementsInRect;
}

이렇게 하면 각 셀들의 크기는 estimatedSize 프로퍼티로 세팅된 크기로 임시 배치 되는 상황입니다. 이제 여기에 Self-sizing cell 메커니즘을 추가해보겠습니다. 셀이 Self-sizing에 의해 layoutAttribute 가 변경되었을 경우 invalidatateLayout을 할지를 물어보는 로직이 iOS8부터 새로 추가 되었습니다.

1
- (BOOL)shouldInvalidateLayoutForPreferredLayoutAttributes:(UICollectionViewLayoutAttributes *)preferredAttributes withOriginalAttributes:(UICollectionViewLayoutAttributes *)originalAttributes

이 메서드를 통해 어느 indexPath 셀에서 layoutAttribute 변경 요청이 있는지를 알 수 있습니다. 따라서 이 시점에 변경할 layoutAttributes의 값을 적용하고 invalidateLayout한 뒤에, 다음 레이아웃 루틴에서 변경된 layoutAttributes기반으로 새로 레이아웃팅을 하면 Self-sizing cell 메커니즘이 동작되는 것입니다.

해당 구조에 맞게 구현을 해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-(BOOL)shouldInvalidateLayoutForPreferredLayoutAttributes:(UICollectionViewLayoutAttributes *)preferredAttributes withOriginalAttributes:(UICollectionViewLayoutAttributes *)originalAttributes
{
    const CGSize preferredSize = preferredAttributes.frame.size;
    const CGSize originSize = originalAttributes.frame.size;
    if( !CGSizeEqualToSize(preferredSize, originSize) )
    {
        const NSInteger item = preferredAttributes.indexPath.item;
        UICollectionViewLayoutAttributes* attr = _layoutAttributes[item];
 
        attr.frame = CGRectMake(attr.frame.origin.x, attr.frame.origin.y, preferredSize.width, preferredSize.height);
 
        return YES;
    }
    else {
        return NO;
    }
}

사이즈가 변경된 것이 있을 경우 기존 저장한 layoutAttributes 프레임 프로퍼티를 변경합니다. 그리고 YES를 리턴 해주면 invaldiateLayout되어 새로 레이아웃 루틴을 타게 될 것이고 다시 한번 prepareLayout이 호출될 것 입니다. 그럼 이 때에 변경된 사이즈 내용으로 새로 자리배치 시키도록 prepareLayout 구현내용을 수정합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-(void)prepareLayout
{
    const NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
    const CGFloat collectionViewWidth = self.collectionView.bounds.size.width;
 
    CGFloat yPos = 0;
    for(NSInteger item = 0; item    {
        UICollectionViewLayoutAttributes *attr;
        CGSize itemSize;
 
        if(_needToInitLayoutAttributes) {
            NSIndexPath *indexPath = [NSIndexPath indexPathForRow:item inSection:0];
            attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
            itemSize = _estimatedItemSize;
 
        }else {
            attr = _layoutAttributes[item];
            itemSize = attr.frame.size;
        }
 
        const CGFloat x = (collectionViewWidth - itemSize.width) * 0.5;
 
        attr.frame = CGRectMake(x, yPos, itemSize.width, itemSize.height);
        _layoutAttributes[item] = attr;
 
        yPos += (itemSize.height + _spacing);
    }
 
    _contentSize = CGSizeMake(collectionViewWidth, yPos-_spacing);
 
    _needToInitLayoutAttributes = NO;
}

실행시키면 다음처럼 정상적으로 동작하는 것을 볼 수 있습니다.

로직 상 scrollTo도 UITableView처럼 동작을 합니다.  scrollTo는 estimatedSize를 기준으로 자리 잡힌 곳으로 이동을 하므로 정상적으로 해당 셀이 보이게 됩니다. 이후 scroll 을 이동시키면 건너뛰었던 셀이 뒤늦게 새로 디큐되면서 크기 값이 바뀌게 될 테고, 그에 따라 다시 레이아웃 루틴에 의해 prepareLayout에서 contentSIze가 갱신되므로 점차 점차 cell들이 제대로 갱신됩니다. UITableView와 동작이 똑같다고 볼 수 있습니다.

이 커스텀 UICollectionViewLayout에서는 스크롤 성능이 고려되어 있지 않습니다. 셀이 Self-sizing이 되어 크기가 바뀌게 되면 shouldInvalidateLayoutForPreferredLayoutAttributes 리턴값 YES에 의해 invaldiateLayout이 발생하므로, 그때 레이아웃 루틴을 다시 타게 됩니다. 그럼 prepareLayout에서 모든 셀의 위치를 다시 계산합니다.

스크롤되어 새로운 셀이 디큐될 때마다 모든 셀의 위치를 갱신하기 때문에 성능이 떨어지는 것입니다. 또한 스크롤되어 레이아웃 루틴 시에 layoutAttributesForElementsInRect:rect가 호출되는데 이 또한 모든 셀들을 체크하는 루틴으로 짜여져 있기에 이 또한 스크롤 성능을 저하시키는 요인이 됩니다.

스크롤 성능을 개선하기 위해서는 문제 해결 방향은 명확합니다.

스크롤 된다고 항상 모든 셀의 위치를 갱신하지 않고 더티 셀(Dirty Cell)만 갱신하는 것. 물론 앞선 셀이 invalid되어 더티 셀이 되면 이후 indexPath의 셀들은 모두 더티 셀로 간주해야 합니다. 이렇게 invalidate가 필요한 셀만 다시 레이아웃 시키는 식으로 구조를 개선하면 스크롤 성능도 향상시킬 수 있습니다. 이를 위해 invalidataionContext 로직을 참고하시면 되겠습니다.

invalidationContext 로직을 구현하는데에 많은 주의가 필요합니다. 아마도 UICollectionViewFlowLayout Self-sizing을 구현하면서 레거시 내용과 충돌되지 않는 선에서 invalidationContext을 구현하다가 위에서 짚었던 문제점들이 발생한 것으로 추측됩니다. 또한 layoutAttributesForElementsInRect 로직에서도 개선이 필요합니다.

모든 셀을 서칭하여 겹치는 셀인지를 체크하지 말고 필요한 녀석들만 서칭하는 식으로 구조를 바꾸면 스크롤 성능을 개선시킬 수 있습니다. 샘플소스는 (링크)에서 다운 받으실 수 있습니다.

7. 맺음말

지금까지 iOS8에서 UICollectionView와 UITableView에 신규 적용된 Self-sizing 기법과 구조, 적용방법 과 문제점, 그리고 커스텀 UICollectionViewLayout을 만드는 것까지 살펴보았습니다.

UICollectionView의 UICollectionViewFlowLayout에 Self-sizing을 적용하는데 있어서 많은 제약사항과 까다로움이 있는 것을 알 수 있었습니다.

따라서 자신의 collectionView에 Self-sizing cell을 적용하고 싶다면,  estimatedItemSize값을 제약사항을 두고 셀의 크기를 설정하거나 아니면 커스텀 UICollectionViewLayout을 만들어서 사용하는 것을 추천하는 바 입니다.

※ 컨텐츠  출처 : SK플래닛 기술 블로그 (http://readme.skplanet.com/)