본문 바로가기

프로그래밍/iOS,macOS

UITableView , UICollectionView

이블뷰

UITableViewController

테이블뷰의 특정 기능에 대한 지원이 추가된 UIViewController
테이블뷰가 이미 생성되어 있음
정적 테이블 구성으로 스토리보드나 IB에서 디자인이 수월하나 변경에 제약이 많음
커스텀이 필요한경우 UIViewController에 UITableView을 추가해 사용

 

 

주요클래스

UITableView
UITableViewCell
UITableViewDelegate
UITableViewDataSource

 

cell class

IB에서 클래스를 지정하고, Identity 를 지정한다.
해당 id는 datasource구현시 재사용큐에서 cell을 얻어올때 사용한다.

class CustomCell : UITableViewCell {

}

 

컨트롤러

class TestController : UIViewController {
   // 연결된 테이블 뷰
   @IBOutlet weak var tableView : UITableView!

   override func viewDidLoad() {
      super.viewDidLoad()

      // delegate, datasource 지정
      tableView.delegate = self
      tableView.dataSource = self

      // 동적인 테이블셀 구성
      // 테이블 셀의 예상 높이
      tableView.estimatedRowHeight = 50
      tableView.rowHeight = UITableView.automaticDimension
      
      // cell
      tableView.register(UINib(nibName: "XibCell", Bundle(for: TestController.self), forCellReuseIdentifier: "XibCell")
      tableView.register(MyCell.self, forCellReuseIndetifier: "MyCell")
      tableView.register(MyHeaderCell.self, forHeaderFooterViewReuseIdentifier: "Header") 
   }

   // 데이터 가져와 리스트 업데이트
   func fetchData() {
      // 데이터를 가져옴
      DispathQueue.main.async ( execute: {
         self.tableView.reloadData()
      })
   }
}

 

DataSource

extension TestController : UITableViewDataSource {
   // 섹션수
   func numberOfSections(in tableView: UITableView) -> Int {
      return 0
   }
   
   // 섹션별 셀의 갯수(row)
   func tableView(_ tableView: UITableView, numberOfRowInSection section: Int)->Int {
      if section == 0 {
         return 0
      } else {
         return 0
      }
   }

   // 셀
   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)->UITableViewCell {
      if indexPath.section == 0 {
         let cell = tableView.dequeReusableCell( withIdentifier: "CustomCell 클래스의 id", for: indexPath ) as! CustomCell
         // cell의 ui 설정
           .
           .
         return cell
      } else {
      
      }
   }
}

 

Delegate

셀 높이 계산
고정된 사이즈의 셀을 사용하는 경우 해당 값을 지정.
변경되는 사이즈의 경우 초기값과 automaticDimentsion  리턴 (delegate 가 아닌 table view 자체에 설정해도 된다.)
tableView.estimatedRowHeight = 80
tableView.rowHeight = UITableView.automaticDimension

extension TestController : UITableViewDelegate {
   func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath)->CGFloat {
      return 80
   }

   func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath)->CGFloat {
      return UITableView.automaticDimension
   }
   
   
   // 헤더 사이즈
   func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
   
   }
   
   // 푸터 사이즈
   func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFlaot {
   
   }
   
   // 헤더뷰
   func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) ->UIView? {
      guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: "MyHeaderCell") else { return nil }
      return view   
   }
   
   // rx 사용하는 경우 row 삭제시 disposeBag 초기화 필요
   func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
      if let cell = cell as? MyCustomCell {
         cell.disposeBag = .init()
      }
   }
}

 

 


컬렉션뷰

주요클래스

UICollectionView
UICollectionViewLayout
UICollectionViewCell
UICollectionViewDelegate, UICollectionViewDelegateFlowLayout
UICollectionViewDataSource

IB에서 UICollectionView를 추가하면 기본적으로 FlowLayout 기반의 컬렉션뷰가 생성되며,
레이아웃은 collectionView.collectionViewLayout 프로퍼티를 통해 접근가능.
레이아웃에서는 스크롤방향이나 셀의 크기, 여백이나 간격등 기본적인 내용 지정할 수 있고, 커스터마이징한 레이아웃을 구성하기 위해서는 UICollectionViewDelegateFlowLayout 을 상속받아 오버라이딩.

 

주요 메쏘드 호출 순서

사이즈 계산을 위해 아래 호출순서에 맞는 위치에서 사이즈를 참조해야 한다. 
오토레이아웃의 경우 viewDidLayoutSubviews 가 호출된 이후에는 변경된 사이즈를 얻어올 수 있음.

UIViewController.viewDidLoad
UIViewController.viewDidLayoutSubviews
UICollectionViewDataSource.collectionView(_, numberOfItemsInSection)
UICollectionViewDelegateFlowLayout.collectionView(_, layout, sizeForItemAt)
UICollectionViewCell.preferredLayoutAttributesFitting

preferredLayoutAttributesFitting() 메쏘드는 collection view layout의 estimatedItemSize 값이 설정되어야 메쏘드가 호출된다.

 

컨트롤러

컬렉션뷰 설정.
delegate 및 dataSource 를 설정.
사이즈 설정.

셀의 크기 계산을 위해 layout 의 itemSizem, estimatedItemSize를 설정하게 되는데,
고정된 크기인 경우 itemSize를 해당 크기로 설정하면 되고, 동적 변경되는 셀의 경우 estimatedItemSize 를 설정한다.
이 값들을 지정하지 않는 경우 UICollectionViewDelegateFlowLayout 에서 사이즈를 전달할 수 있고, 이마저도 정의하지 않으면 내부에 지정된 초기 기본 사이즈가 설정된다.

estimatedItemSize 는 셀이 아직 화면에 보이지 않거나 사라진 이후에 셀의 기본적인 크기를 예측하는데 사용하게 되며, UICollectionViewFlowLayout.automaticSize 와 같이 설정된 경우 delegate 에서 사이즈를 다시 지정해 주어야 한다.
estimatedItemSize 가 설정되면 delegate에서 한번, 다시 셀에서 한번 더 사이즈를 조절할 수 있도록 셀의 preferredLayoutAttributesFitting() 메쏘드를 호출하게 된다.

* 성능을 위해선 고정된 사이즈나 텍스트 크기를 계산해서 지정해 주는게 가장 좋다.

 

class TestController : UIViewController {
   @IBOutlet weak var collectionView : UICollectionView!

   override func viewDidLoad() {
      super.viewDidLoad()
      collectionView.delegate = self
      collectionView.dataSource = self
      
      // xib 로 별도 구성한 cell 등록
      let nib = UINib(nibName: "CustomCell", bundle: nil)
      collectionView.register(nib, forCellWithReuseIdentifier: "MyCell")
   }
   
   
   override func viewDidLayoutSubviews() {
      super.viewDidLayoutSubviews()
      
      // ios 10
      if let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
         // 기본 예상 크기 설정 시, 가로 크기는 고정하는 등의 특정 사이즈가 필요한 경우
         flowLayout.estimatedItemSize = CGSize(width: collectionView.frame.width, height: 50)
      
         // cell 에서 가로, 세로 모두 변경해 주는 경우
         flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
         
         // 고정 사이즈는 estimatedItemSize가 아닌 itemSize 사용
         flowLayout.itemSize = CGSize(width: collectionView.frame.width, height: 80)
      }

   }
}

 

위 처럼 collection view 혹은 다른 view의 크기를 참조로 할 경우 viewDidLayoutSubviews() 등에서 사이즈를 지정한다.
itemSize는 기본 cell 의 공통 사이즈이며, delegate 의 sizeForItemAt 을 구현하지 않으면 이 값이 사용된다.

텍스트만으로 구성되거나 이미지등 사이즈를 미리 계산할 수 있는 간단한 셀의 경우  flowlayout의 delegate 중 sizeForItemAt에서 사이즈를 계산해 주는게 가장 깔끔하다.
셀이 복잡한 뷰들을 포함하고 auto layout으로 구성되어 있다면 cell의 preferredLayoutAttributesFitting에서 사이즈를 조정해 주어야 한다.

 

UICollectionViewDataSource

셀의 갯수와 실제 cell 뷰를 얻어온다.

extension TestController : UICollectionViewDataSource {
   func collectionView(_ collectionView: UICollectionView,
          numberOfItemInSection section: Int)->Int {
      return 0
   }

   func collectionView(_ collectionView: UICollectionView,
                cellForItemAt indexPath: IndexPath)->UICollectionViewCell {
  
      let cell = collectionView.dequeReusableCell( withReuseIdentifier:"MyCell", indexPath) as! CustomCell
      
      // cell의 ui 설정
      cell.layoutIfNeed()
           .
           .
      return cell
   }
}

 

UICollectionViewDelegateFlowLayout

UICollectionViewDelegateFlowLayout 사용하며, UICollectionViewDelegate를 상속받은 클래스임

func collectionView(_ : layout: sizeForItemAt) 메쏘드를 오버라이드해 사이즈를 전달 할 수 있다.
이 메쏘드를 오버라이드 하지 않는 경우 flowlayout 의 itemSize 값이 사용된다. 
아래 예는 텍스트의 boundingRect을 직접 계산해 사이즈를 정의하는 예.
만약 사이즈를 셀 자체에서 처리하고자 하는 경우 이곳에는 기본 셀 사이즈만 리턴해 주면 되는데.. 스크롤 시 튀는 문제가 발생할 수 있으므로, 되도록이면 실제 사이즈와 비슷한 사이즈로 계산한 값을 넣겨 주는것이 좋다. 

* 이곳에서 사이즈 계산하게 되면 많은 아이템이 추가되는 경우 부하가 발생한다.
별도의 뷰모델 등에서 사이즈 계산을 진행한 뒤에 이곳에서는 해당값만 리턴해 주도록 해야 한다.

extension TestController : UICollectionViewDelegateFlowLayout {

   // 플로우 레이아웃에서 각 아이템의 사이즈를 얻기위해 호출되는 함수로 아이템별로 사이즈를 지정할 수 있음
   // 이 메쏘드는 collectionView.estimatedItemSize 와 같음.
   func collectionView(_ collectionView: UICollectionView,
            layout collectionViewLayout: UICollectionViewLayout,
                sizeForItemAt indexPath: IndexPath) -> CGSize {
                
 
      // index에 따른 너비 리턴 예제      
      // 문자열로 사이즈계산 : 폰트 크기를 사용한 방법
      if let message = message?[indexPath.item].text {
      	  let size = CGSize( width: collectionView.frame.width, height: .infinity)
          let options = NSStringDrawingOptions.usesFontLeading.union( .usesLineFragmentOrigin )
          let stringRect = NSString( string:message )
              .boundingRectWithSize( 
                  size, 
                  options:options, 
                  attributes: [NSAttributedString.Key.font: UIFont.systemFontOfSize(14)],
                  context: nil
              )
          return CGSize( width: self.view.frame.width, height: stringRect.height )
      }
      
      
      // 기본 사이즈 리턴 : layout에 sectionInset 등이 설정되어 있는 경우 해당 값 제외하고 리턴해야 함.
      return CGSize(width:self.view.frame.width, height:80 )
   }




   // 섹션별 셀간의 간격을 조정
   func collectionView(_ collectionView: UICollectionView,
            layout collectionViewLayout: UICollectionViewLayout,
            minimumInteritemSpacingForSectionAt section: Int ) -> CGFloat {
      return CGFloat(10)
   }
}

 

Cell

* 별도 xib로 cell 을 구성하는 경우 collection view cell 을 추가하고, custom class에 클래스 지정, identifier 지정. 
File's Owner는 NSObject로 변경하지 않음. 

class CustomCell : UICollectionViewCell {

}

 

각 셀에서 자신의 실제 크기 계산
delegate가 아닌 셀 자체에서 크기를 계산하는 경우, 셀이 자신이 포함된 컨텐츠뷰의 크기를 알지 못한다. 그래서, 이 크기는 estimatedItemSize나 delegate의 sizeForItemAt 메쏘드에서 결정하고, 그 값을 기준으로 자신의 사이즈를 설정하게 된다. 즉, preferredLayoutAttributesFitting 만 구현하는게 아닌 다른 size 관련 설정들이나 구현이 함께 이루어져야 한다.

셀의 루트뷰인 contentView의 constraint 구성에 따라 사이즈가 변경 되거나 constraints 오류가 나타날 수 있으니 constraint 설정 주의가 필요.
너비나 높이 중 특정 사이즈로 고정된 경우에는 값을 변경할 필요 없음.

override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes)
->UICollectionViewLayoutAttributes {
    // 사이즈 변경이 필요없는 경우에 대한 예외처리 추가 필요.
    // 프로퍼티 등으로 이미 계산된 경우에는 처리 하지 않도록 한다.
    // if isSizeCalculated {
    //    return layoutAttributes
    // }
    
    layoutIfNeeded()
    
    // 시스템의 size fitting 호출
    let size = contentView.systemLayoutSizeFitting(layoutAttributes.size)
    var frame = layoutAttributes.frame
    frame.size.width = ceil(size.width)
    frame.size.height = ceil(size.height)
    layoutAttributes.frame = frame
    
    //isSizeCalculated = true
    return layoutAttributes

}

오토레이아웃은 layoutSubview() 에서 실제 사이즈가 계산되므로, 매번 사이즈를 계산하게 되면 성능 저하의 원인이 된다.
셀 데이터나 프로퍼티로 사이즈 계산이 필요한 경우에만 사이즈 계산을 진행하도록 예외처리를 추가해야 한다.
위 예제처럼 별도 프로퍼티를 사용하거나 layoutAttributes.size 가 기본 값인 경우에만 사이즈를 계산하도록 한다.
(프로퍼티 사용시 셀은 재사용이 되므로 프로퍼티 설정 여부 주의, datasource나 인덱스별로 관리 필요)

 

preferredLayoutAttributesFitting 을 사용하는 경우 목록에 아이템이 추가될때 스크롤 포지션에 문제가 생기는 경우가 있다.
(채팅과 같이 동적으로 셀이 계속 추가되는 경우...)
특정 위치에서 아이템이 추가되면 스크롤 위치를 맞추게 되는데, 화면에 보이지 않는 셀의 height가 기본값을 기준으로 계산되어 스크롤 offset이 엉뚱한 곳을 가르키게 되고, 화면이 튀게 된다.
셀에 새로운 row 가 추가되면 각 indexPath에 대해 UICollectionViewDelegate(UICollectionViewDelegateFlowlayout) 의 sizeForItemAt 메쏘드가 다시 호출되는데, 이때는 기본 사이즈가 아닌 수정된 사이즈가 리턴되도록 해야 한다.

각 셀별 datasource 데이터에 height 값을 저장할 프로퍼티를 하나 두고, preferredLayoutAttributesFitting() 에서 크기가 변경된 값을 프로퍼티에 저장한다.
이후 delegate의 sizeForItemAt 이 호출될 때 기본 값이 아닌 셀 데이터의 프로퍼티 값을 기준으로 사이즈를 넘겨 주도록 하면, 스크롤이 튀는 증상은 사라진다. 

 

스크롤 이동

채팅이나 메신저 앱들의 경우 최신 메시지를 하단에 보여주게 되는데.. 이경우 스크롤을 하단에 맞춰 주어야 한다. 특정 index path로 이동하는 메쏘드 scrollToItem(at: at: namimated: )를 이용해도 되나 가끔 정확한 위치를 찾아가지 못하는 이슈가 있어 contentOffset 값을 통해 직접 위치를 조정한다.

 

현재 컨텐츠의 가장 최신 뷰가 하단에 보이도록 스크롤 하기.

func scrollDownToBottom() {
   guard dataSource.count > 0 { return }
   guard collectionView.contentSize.height > collectionView.bounds.height else { return }
   let offset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.contentInset.bottom
   collectionView.contentOffset.y = offsetY
}

 

키보드에 따라 스크롤 조정

func setBottomConstraint(userInfo: [AnyHashable:Any] ) {
    guard let animationDuration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber),
          let keyboardFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
          let rawAnimationCurve = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
          
          else {
            return
          }

        let keyboardEndFrame = view.convert(keyboardFrame, from: view.window)
        let height = view.bounds.maxY - keyboardEndFrame.minY

        var offset = collectionView.contentOffset
        if height > 0 {
            keyboardHeight = height
            offset.y += height

            if offset.y > 0 {
                collectionView.contentOffset = offset
            }
        } else {
            offset.y -= keyboardHeight
            if offset.y < 0 {
                offset.y = 0
            }
            collectionView.contentOffset = offset
        }

        UIView.animate( withDuration: TimeInterval(truncating: animationDuration),
                        delay: 0.0,
                        options: UIView.AnimationOptions(rawValue: rawAnimationCurve),
                        animations: { [weak self] in
                            self?.view.layoutIfNeeded()
                        }, completion: nil)
    }

 

 


 

커스텀 레이아웃

https://www.raywenderlich.com/392-uicollectionview-custom-layout-tutorial-pinterest

 

UICollectionView Custom Layout Tutorial: Pinterest

In this tutorial, you’ll build a UICollectionView custom layout inspired by the Pinterest app, including how to cache attributes and dynamically size cells.

www.raywenderlich.com

 

필수 구현해야하는 메쏘드

 

prepare()
레이아웃에 대한 속성 준비
기본 속성값 정의
뷰 갱신시 자주 호출되어지므로 성능적인 고려가 필요

 

collectionViewContentSize
컬렉션뷰의 전체 크기로 스크롤 영역을 설정하게됨
플로우 레이아웃을 사용하는 경우 구현 불필요

 

layoutAttributesForElementsInRect(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
각 사이즈에 해당하는 레이아웃 속성 배열을 제공

 

커스텀 플로우 레이아웃

사이즈 정도의 특정 부분만 커스터마이징 하는 경우 UICollectionViewDelegate 만 적용하나 전체 컬렉션뷰의 모양을 커스터마이진 하는 경우 레이아웃을 상속받아 수정한다.

 

class TestLayout : UICollectionViewLayout {
    // 레이아웃 속성들을 저장해둘 배열
    // 매번 레이아웃 속성을 만들지 않고, 원하는 사이즈의 속성을 만들고 배열에 저장해 둔다
    fileprivate var layoutAttributes = [ UICollectionViewLayoutAttributes ]()
    
    
    // collection view의 전체 크기
    fileprivate var contentHeight:CGFloat =0
    fileprivate var contentWidth:CGFloat {
    	guard let collectionView = collectionView else {
        	return 0
        }
        return collectionView.bound.width
    }
    
    // 컬렉션뷰의 사이즈 전달
    override var collectionViewContentSize: CGSize {
    	return CGSize( width: contentWidth, height: contentHeight )
    }
    
    override func prepare() {
        // 구성할 컨텐츠의 사이즈들에 대한 레이아웃속성(UICollectionViewLayoutAttribute) 지정
        
        for item in 0..< collectionView.numberOfItems( inSection:  0 ) {
            let indexPath = IndexPath( item: item, section: 0 )
            
            // 기존 flowlayout 구조를 유지하려면 컨트롤러에서 할당한 delegate를 통해 사이즈를    얻어온다
            let size : CGSize = delegate.collectionView( collectionView, self, indexPath )
            var frame = CGRect(x:0, y:0, width: size.width, height: size.height)
            
            // 레이아웃 속성 생성
            let attrs = UICollectionViewLayoutAttributes( forCellWith: indexPath )
            attrs.frame = frame
            layoutAttribues.append( attrs )
            
            // 컬렉션뷰의 방향에 따른 사이즈 설정
            // vertical scrolling 이면 높이를 증가시키고, horizontal scrolling이면 width 증가
            contentHeight += size.height
        }
    }
	
    // 사각영역안에 포함되어 있는 레이아웃 속성을 배열에서 찾아서 리턴한다 
    override func layoutAttributesForElements( in rect: CGRect )->[UICollectionViewLayoutAttributes]? {
    	guard let layoutAttributes = layoutAttributes else { return super.layoutAttributesForElements(in:rect) }
        
        var attributes = [UICollectionViewLayoutAttributes]()
        
        for item in layoutAttributes {
            if item.frame.intersects( rect ) {
            	attributes.append( item )
            }
        }
        return attributes
    }
    
    
    // 해당 인덱스의 레이아웃 속성
    override func layoutAtrributesForItem( at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    	return layoutAttributes[indexPath.item]
    }
}