CollectionView Custom Layout
CollectionView๋ฅผ ์ฌ์ฉํ๋ค ๋ณด๋ฉด, Flow๋ Compositional๋ก๋ ๊ตฌํํ๊ธฐ ๋ณต์กํ ๋ ์ด์์์ ๋ง๋ค์ด์ผ ํ๊ฑฐ๋,
ํน์ ์ฌ๋ฌ ๋ฐฉํฅ์ผ๋ก ์คํฌ๋กคํด์ผ ํ๋ ๋ ์ด์์์ ๋ง๋ค์ด์ผ ํ ๋๋ ์์ต๋๋ค.
์ด๋ด ๋๋ Layout์ Custom ํ๋ ๊ฒ ๋ ์ฝ๊ฑฐ๋ ์ ์ผํ ๋ฐฉ๋ฒ์ธ๋ฐ์,
UICollectionViewLayout์ Subclassingํด์ ๊ตฌํํ ์ ์์ต๋๋ค.
๊ทธ๋ฌ๋ฉด ์ฐ๋ฆฌ๋ ์ด ์์์ ๋๊ฐ์ง ์์ ์ ํด์ฃผ๋ฉด ๋ฉ๋๋ค.
1. ์คํฌ๋กค ๊ฐ๋ฅํ content ์์ญ ์ง์ ํ๊ธฐ
2. ์ ๊ณผ ๋ทฐ๋ค์ ๋ํ attribute objects ์ ๊ณตํ๊ธฐ
attribute object๋ ์ ์ด๋ ๋ณด์ถฉ ๋ทฐ์ ๋ ์ด์์์ ์ฒ๋ฆฌํ ๋ ํ์ํ ์์ฑ๋ค์ ๊ฐ์ง๊ณ ์์ต๋๋ค.
frame, bounds, center, alpha ๋ฑ
์ด๋ฅผ ์ด์ฉํด์ ์ ์ด๋ ๋ณด์ถฉ ๋ทฐ๋ฅผ ๊ทธ๋ฆฌ๋ ๊ฒ์ด์ฃ .
๊ทธ๋ฌ๋ฉด ์ด๋ฐ ์์ ๋ค์ ์ด๋ป๊ฒ ํ ์ ์์๊น์?
ํน์ ๋ฉ์๋๋ค์ overrideํด์ ๊ตฌํํ ์ ์์ต๋๋ค.
1. prepare()
์ฌ๊ธฐ์ ๋ ์ด์์ ์ ๋ณด๋ฅผ ์ ๊ณตํ๋๋ฐ ํ์ํ ์ ํ ๊ณ์ฐ์ ์ํํ ์ ์์ต๋๋ค.
2. collectionViewContentSize
์คํฌ๋กค ๊ฐ๋ฅํ ์์ญ์ return ํ๋ฉด ๋ฉ๋๋ค. ์ prepare์์ ์ํํด์ฃผ๋ฉด ๋ฉ๋๋ค.
3. layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
์ฌ๊ธฐ์ ํน์ rect์์ ์์ด์ผ ํ๋ ์ ๋ค์ด๋ ๋ณด์ถฉ ๋ทฐ์ ๋ํ attributes๋ฅผ ๋๊ฒจ์ฃผ๋ฉด ๋ฉ๋๋ค.
์ด rect๊ฐ ๊ผญ ํ๋ฉด์ ๋ณด์ด๋ rect๋ ์๋๋๋ค.
CollectionView๊ฐ ๋ ์ด์์ ์์ ์ ํ ๋ ์ ๋ฉ์๋๋ค์ ํธ์ถํ๊ณ ์์๋ ์ง์ผ์ ์คํ๋ฉ๋๋ค.
๊ทธ๋ฆผ์ผ๋ก ๋ณด๋ฉด ์๋์ ๊ฐ์ต๋๋ค.

์ด๋ ๊ฒ๋ง ๋ณด๋ ์ ๊ฐ์ด ์ ์กํ๋ ๊ฒ ๊ฐ๋ค์.
์ง์ ๋ ์ด์์์ ๋ง๋ค๋ฉด์ ์ดํดํด ๋ณด๊ฒ ์ต๋๋ค.
๋ณต์กํฉ ๋ ์ด์์์ ๋ง๋ค๊ธฐ ์ํด ์ฌ์ฉํ๋ ๊ฒ์ด์ง๋ง, ์ดํด๋ฅผ ์ํด ์ฌ์ด ๋ ์ด์์์ ์ฌ์ฉํ๊ฒ ์ต๋๋ค.

์ฝ๋๋ Layout๊ณผ DataSource์ ์ง์คํ๊ฒ ์ต๋๋ค.
์ฐ์ DataSource๋ฅผ ๋ง๋ค๊ฒ ์ต๋๋ค.
final class CustomDataSource: NSObject, UICollectionViewDataSource {
// ๋ฐ์ดํฐ
private var messages: [Message] = []
// ๋ฐ์ดํฐ๊ฐ ์์นํ offset๋ค
private(set) var offsets: [CGFloat] = []
// ๊ฐ ์
์ด ๊ฐ์ง ๋์ด, ๋ฐ์ดํฐ๋ก๋ถํฐ ๋ฏธ๋ฆฌ ๊ณ์ฐ
var heights: [CGFloat] {
return messages.map { $0.height }
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return messages.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let message = messages[indexPath.item]
return MessageCell.dequeue(from: collectionView, at: indexPath, with: message)
}
// ๋ฐ์ดํฐ ์ถ๊ฐ, offset๋ค์ ๊ณ์ฐํด์ค๋ค.
func append(_ data: [Message]) {
messages += data
for message in data {
offsets.append((offsets.last ?? 0) + message.height)
}
}
}
์ฌ๊ธฐ์๋ ๊ธฐ๋ณธ์ ์ผ๋ก UICollectionViewDataSource๋ฅผ ์ฑํํ๊ณ
Layout์ ํ์ํ ์ ๋ณด๋ค์ ๊ฐ์ง๊ณ ์์ต๋๋ค.
๊ณต์๋ฌธ์์ ๋ณด๋ฉด ์ด๋ ๊ฒ ๋์์๋ค์
The layout object uses information provided by its data source to create the collection viewโs layout. Your layout communicates with the data source by calling methods on the collectionView property, which is accessible in all of the layoutโs methods.
๋ฒ์ญํ๋ฉด ๋ ์ด์์ ์ค๋ธ์ ํธ๋ data source๋ก๋ถํฐ ์ ๊ณต๋ฐ์ ์ ๋ณด๋ฅผ ์ด์ฉํด ๋ ์ด์์์ ๋ง๋ ๋ค.
๋ ์ด์์์ collectionView ํ๋กํผํฐ๋ฅผ ํตํด data source์ ์ ๊ทผํ ์ ์๋ค.
์ ์ฝ๋์์๋ ์ ์ด ์์นํ offset๊ณผ ๋์ด๋ฅผ ์ ๊ณตํด์ฃผ๊ณ ์์ต๋๋ค.
์ด์ ๋ ์ด์์์ ๋ง๋ค๊ฒ ์ต๋๋ค.
์ฐ์ prepare๋ถํฐ ๊ตฌํํด๋ณด๊ฒ ์ต๋๋ค.
final class CustomLayout: UICollectionViewLayout {
// ์
Attributes ์บ์ฑ
private var cellAttributes = [UICollectionViewLayoutAttributes]()
private var computedContentSize: CGSize = .zero
override var collectionViewContentSize: CGSize {
// prepare์์ ๊ณ์ฐํ ํฌ๊ธฐ ๋ฆฌํด
return computedContentSize
}
override func prepare() {
guard let collectionView, let dataSource = collectionView.dataSource as? CustomDataSource else { return }
// data source๋ก ๋ถํฐ ํ์ํ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
let heights = dataSource.heights
let yOffsets = dataSource.offsets
// ์
๋ฐฐ์น
for section in 0..<collectionView.numberOfSections {
for item in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(item: item, section: section)
let cellHeight = heights[item]
let cellWidth = collectionView.bounds.size.width
let cellOffset = yOffsets[item] - cellHeight
let itemFrame = CGRect(x: 0, y: cellOffset, width: cellWidth, height: cellHeight)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = itemFrame
// attributes ์ ์ฅ
cellAttributes.append(attributes)
}
}
// contentSize ๊ณ์ฐ
let contentWidth = collectionView.bounds.size.width
let contentHeight = max(collectionView.bounds.size.height, dataSource.offsets.last ?? 0)
computedContentSize = CGSize(width: contentWidth, height: contentHeight)
}
}
์์์ ๋ง๋ data source๋ก๋ถํฐ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ ์ ๋ค์ ๋ฐฐ์น์ํค๊ณ ํฌ๊ธฐ๋ฅผ ์ ํ๋ ์ฝ๋์ ๋๋ค.
prepare๋ ๊ณต์๋ฌธ์๋ฅผ ๋ณด๋ฉด
Layout updates occur the first time the collection view presents its content and whenever the layout is invalidated explicitly or implicitly because of a change to the view. During each layout update, the collection view calls this method first to give your layout object a chance to prepare for the upcoming layout operation.
๋ผ๊ณ ํ๋๋ฐ์, ๋ฒ์ญํ๋ฉด
์ฒ์ present ๋ ๋, layout์ด invalidate ๋ ๋ ๋ถ๋ฆฐ๋ค.
์ฌ๊ธฐ์ ์์ผ๋ก ๋ ์ด์์ ์์ ์ ํ์ํ ์ ํ ๊ณ์ฐ์ ํ ์ ์๋ค.๋ผ๊ณ ํฉ๋๋ค.
๊ทธ๋ฌ๋๊น ์ฐ๋ฆฌ๊ฐ ๋ง์ด ์ฐ๋ reloadData()๋ invalidateLayout()๋ฑ์ ํธ์ถํ๋ฉด prepare ํ๋ค๋ ๊ฒ์ธ๋ฐ์,
์ฌ๊ธฐ์ ํ๋ ์์ ์ ๋ฌด๊ฒ์ ๋ฐ๋ผ reloadData()๊ฐ ๊ต์ฅํ ๋นํจ์จ์ ์ธ ๋์์ด ๋ ์๋ ์๊ฒ ๋ค์.
์ ์ปค์คํ ํด์ ์ฌ์ฉํด์ผ๊ฒ ์ต๋๋ค.
์ด์ layoutAttributesForElements๋ฅผ ๊ตฌํํ๊ฒ ์ต๋๋ค.
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributeList = [UICollectionViewLayoutAttributes]()
let index: Int? = binarySearch(cellAttributes) { (attribute) -> ComparisonResult in
if attribute.frame.intersects(rect) {
return .orderedSame
}
if attribute.frame.minY > rect.maxY {
return .orderedDescending
}
return .orderedAscending
}
guard let index else { return [] }
for attributes in cellAttributes[..<index].reversed() {
guard attributes.frame.maxY >= rect.minY else { break }
attributeList.append(attributes)
}
for attributes in cellAttributes[index...] {
guard attributes.frame.minY <= rect.maxY else { break }
attributeList.append(attributes)
}
return attributeList
}
private func binarySearch<T: Any>(_ a: [T], where compare: ((T)-> ComparisonResult)) -> Int? {
var lowerBound = 0
var upperBound = a.count
while lowerBound < upperBound {
let midIndex = lowerBound + (upperBound - lowerBound) / 2
if compare(a[midIndex]) == .orderedSame {
return midIndex
} else if compare(a[midIndex]) == .orderedAscending {
lowerBound = midIndex + 1
} else {
upperBound = midIndex
}
}
return nil
}
rect์์ ํ์ํ attribute๋ฅผ ์ฐพ์ ๋๊ฒจ์ฃผ๋ ์ฝ๋์ ๋๋ค.
์ด ํจ์๋ ๊ฒฝ์ฐ์ ๋ฐ๋ผ ๋งค์ฐ ์์ฃผ ํธ์ถ๋ ์ ์๋๋ฐ์, ์ง๊ธ ์ํฉ์์๋ ์คํฌ๋กค์ ํด์
๋ณด์ด๋ ํ๋ฉด์ด ๋ฐ๋๋ฉด ๊ณ์ ํธ์ถ๋๊ฒ ๋ฉ๋๋ค.
๊ทธ๋์ rect์ ์๋ง์ ์ ์ด๋ ๋ทฐ๋ฅผ ๋น ๋ฅด๊ฒ ์ฐพ์์ ๋๊ธฐ๋ ๊ฒ์ด ์ค์ํ๋ฐ์,
์์์๋ ์ด์ง ํ์์ ์ฌ์ฉํ์ต๋๋ค.
์ด์ ํ์ํ ํจ์๋ ๋ชจ๋ override ํ์ต๋๋ค.
๊ทธ๋ฐ๋ฐ ์์์ ๋ณด๋ฉด prepareํจ์๋ ๊ผญ ์์ด๋ ๋์ง ์๋?๋ผ๋ ์๊ฐ์ด ๋ค ์ ์์ต๋๋ค.
์ด์ฐจํผ layoutAttributesForElements์์ ์ฐพ์์ ๋๊ฒจ์ฃผ๋ฉด ๋๋๊น ๋ง์ด์ฃ .
๊ทธ๋์ ๊ณต์๋ฌธ์์์๋ prepare๋ ๊ผญ ์ํ๋์ด์ผ ํ๋ ๊ฒ์ ์๋๋ผ๊ณ ํฉ๋๋ค.
ํ์ง๋ง ์์์๋ ๋งํ๋ฏ์ด layoutAttributesForElements์์๋ ์๋๊ฐ ์ค์ํ๋ฏ๋ก
prepare์์ ๋ฏธ๋ฆฌ ๊ณ์ฐํด ์บ์ฑ์ ์งํํ ๊ฒ์ ๋๋ค.
๋ง์ฝ ์บ์ฑ์ ์งํํ๋ ๊ฒ์ด ๋น์ฉ์ด ๋ ํฌ๊ฑฐ๋ ํฐ ์ด๋์ด ์๋ค๋ฉด,
์ฌ์ฉํ์ง ์์๋ ๋ฉ๋๋ค!
์ ์ฒด์ฝ๋
https://github.com/12251145/CustomCollectionViewLayout
GitHub - 12251145/CustomCollectionViewLayout
Contribute to 12251145/CustomCollectionViewLayout development by creating an account on GitHub.
github.com
Ref.
Apple Developer Documentation
developer.apple.com
https://engineering.linecorp.com/ko/blog/ios-refactoring-uicollectionview-1/
UICollectionView๋ฅผ ์ด์ฉํ LINE iOS ๋ํ๋ฐฉ ๋ฆฌํฉํ ๋ง - 1
๋ค์ด๊ฐ๋ฉฐ LINE์ ๋ํ๋ฐฉ ํ๋ฉด์ ์ฌ์ฉ์๊ฐ ๊ฐ์ฅ ๋ง์ด ์ฌ์ฉํ๋ ํ๋ฉด ์ค ํ๋์ด๋ฉฐ ์๋ก์ด ๊ธฐ๋ฅ์ด ๊ณ์ ์ถ๊ฐ๋๊ณ ์์ต๋๋ค. ๊ทธ์ ๋ฐ๋ผ ์ฝ๋๊ฐ ์ ์ ๋ณต์กํด์ง๋ฉด์ ์ต๊ทผ์ ๋ฆฌํฉํ ๋ง์ ์งํํ๊ณ , ๊ทธ
engineering.linecorp.com
https://itnext.io/infinite-grid-using-uicollectionview-155801e4f7f4
Creating an infinite grid on iOS โ Using UICollectionView
As a follow-up to my previous tutorial โCreating an infinite grid on iOSโ: https://itnext.io/creating-an-infinite-grid-on-ios-2bd6db28c581โฆ
itnext.io
https://www.kodeco.com/4829472-uicollectionview-custom-layout-tutorial-pinterest
UICollectionView Custom Layout Tutorial: Pinterest
Build a UICollectionView custom layout inspired by the Pinterest app, and learn how to cache attributes and dynamically size cells.
www.kodeco.com
How to increase performances on custom UICollectionsViewLayout
GIPHY Engineering blog.
engineering.giphy.com