오늘은 Pinterest라는 앱에서 유래된 Pinterest UI라는 화면 구현 과정에 대해 포스팅해볼까 합니다.
Pinterest UI
Pinterest UI란 말 그대로 Pinterest라는 앱에서 구현된 UI에서 유래된 UI로 아래 사진처럼 이미지 고유의 비율값을 유지하면서 자유분방하게 배치된 레이아웃을 의미합니다.
아래 사진을 좀 더 자세히 살펴보면, 가로 길이는 디바이스 너비의 딱 절반을 차지하고, 세로 길이는 사진 고유의 가로/세로 비율에 따라 동적으로 배치되어 있는다는 사실을 알 수 있습니다. 이러한 사실을 기반으로 레이아웃 로직을 작성해 보겠습니다.
이미지 고유 사이즈 계산
위에서 Pinterest UI 레이아웃의 핵심은 이미지의 고유 가로/세로 비율을 유지하면서 이미지를 화면에 나타낸다는 것입니다.
그러기 위해선 우선 나타낼 이미지의 고유 크기와 이미지를 나타낼 디바이스에서 이미지의 크기 비율을 계산해보아야 합니다.
아래 코드는 이 비율을 계산하는 메서드입니다.
func resize(newWidth: CGFloat) -> UIImage {
// 이미지 고유 width와 디바이스에 나타낼 이미지 width 비율 계산
// 여기서 newWidth가 디바이스에 나타낼 이미지 width
// self.size.width가 이미지 고유의 width입니다.
let scale = newWidth / self.size.width
// 계산된 비율로 디바이스에서 이미지의 세로 길이 계산
let newHeight = self.size.height * scale
// 새로 계산된 가로/세로 길이로 이미지 랜더링
let size = CGSize(width: newWidth, height: newHeight)
let render = UIGraphicsImageRenderer(size: size)
let renderImage = render.image { context in
self.draw(in: CGRect(origin: .zero, size: size))
}
return renderImage
}
그런 다음 새로 그려진 이미지를 외부로 반환해 줍니다.
func newSizeImageWidthDownloadedResource(image: UIImage) -> UIImage {
let targetWidth = (UIScreen.main.bounds.width - 56) / 2
let newSizeImage = image.resize(newWidth: targetWidth)
return newSizeImage
}
UICollectionViewFlowLoyout의 동작 방식
위와 같이 이미지 크기를 설정해 주고 collectionView의 UICollectionViewFlowLayout 객체로 레이아웃을 잡아주면 아래와 같은 결과를 확인해 보실 수 있는데요.
아래 사진을 보시면 사진별로 동적인 영역을 차지하는 것이 아니라 중간중간 간격도 너무 띄워져 있고, 각 이미지도 Pinterest 앱처럼 레이아웃이 배치되어 있지 않습니다. 왜 이러한 현상이 발생하는 것일까요?
위와 같은 현상의 원인은 UICollectionViewFlowLayout 동작 방식에 있습니다.
아래 그림을 보시면 각 Cell이 한 라인(Line)을 중심으로 배치되어 있는 것을 확인해보실 수 있는데요.
선을 따라서 Cell을 지정하다가, 공간이 부족하면 새로운 라인(Line)을 추가하여 다시 레이아웃을 그리게 됩니다.
하지만 ItemForSize를 통해 각 Cell 마다 다른 크기를 지정했을 때 문제점이 발생합니다. 라인(Line) 따라 셀을 배치하기 때문에 가장 큰 Cell을 기준으로 다른 Cell 이 중앙 정렬을 하게 되어 제어할 수 없는 공백들이 생기게 됩니다.
따라서 각 Cell이 다른 크기를 가지고 있는 경우, UICollectionViewFlowLayout를 상속받은 새로운 클래스를 정의해주어야 합니다.
커스텀 레이아웃 구현
이제 그럼 커스텀 레이아웃을 구현해 보겠습니다.
커스텀 레이아웃은 아래 4가지 요소를 통해 구현할 수 있습니다.
// 1. 콜렉션 뷰의 콘텐츠 사이즈를 지정합니다.
var collectionViewContentSize: CGSize
// 2. 콜렉션 뷰가 처음 초기화되거나 뷰가 변경될 떄 실행됩니다. 이 메서드에서 레이아웃을
// 미리 계산하여 메모리에 적재하고, 필요할 때마다 효율적으로 접근할 수 있도록 구현해야 합니다.
func prepare()
// 3. 모든 셀과 보충 뷰의 레이아웃 정보를 리턴합니다. 화면 표시 영역 기반(Rect)의 요청이 들어올 때 사용합니다.
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
// 4. 모든 셀의 레이아웃 정보를 리턴합니다. IndexPath 로 요청이 들어올 때 이 메서드를 사용합니다.
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
이제 진짜 시작해 보겠습니다.
먼저 Cell 콘텐츠의 높이를 받아오는 Delegate를 정의해 줍니다.
protocol PinterestLayoutDelegate: AnyObject {
func collectionView(
_ collectionView: UICollectionView,
heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
}
그다음은 나중에 레이아웃 정보 제공을 위한 프로퍼티들을 정의해 줍니다.
// delegate에 대한 참조
weak var delegate: PinterestLayoutDelegate?
// 레이아웃 구성을 위한 두 가지 프로퍼티
private let numberOfColumns = 2
private let cellPadding: CGFloat = 6
// 레이아웃을 재계산할 필요가 없도록 메모리에 저장
private var cache: [UICollectionViewLayoutAttributes] = []
// 콘텐츠 크기를 저장하는 두 가지 프로퍼티
private var contentHeight: CGFloat = 0
private var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
// 컬렉션 뷰의 컨텐츠 크기를 반환
// 이전 단계의 contentWidth 및 contentHeight를 모두 사용하여 크기 계산
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
그 다음 prepare() 메서드를 통해 레이아웃의 모든 item에 대한 UICollectionViewLayoutAttributes의 인스턴스를 계산해 줍니다.
// 레이아웃의 모든 item에 대한 UICollectionViewLayoutAttributes의 인스턴스를 계산
override func prepare() {
// 캐시가 비어 있고 컬렉션 뷰가 존재하는 경우에만 레이아웃 속성을 계산
guard
cache.isEmpty,
let collectionView = collectionView
else {
return
}
let columnWidth = contentWidth / CGFloat(numberOfColumns)
// cell 의 x 위치를 나타내는 배열
var xOffset: [CGFloat] = []
for column in 0..<numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
// cell 의 y 위치를 나타내는 배열
var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
// 현재 행의 위치
var column = 0
for item in 0..<collectionView.numberOfItems(inSection: 0) {
// IndexPath 에 맞는 셀의 크기, 위치를 계산
let indexPath = IndexPath(item: item, section: 0)
let photoHeight = delegate?.collectionView(
collectionView,
heightForPhotoAtIndexPath: indexPath) ?? 180
let height = cellPadding * 2 + photoHeight
let frame = CGRect(x: xOffset[column],
y: yOffset[column],
width: columnWidth,
height: height)
let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
// 위에서 계산한 Frame 을 기반으로 cache 에 들어갈 레이아웃 정보를 추가
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// 콜렉션 뷰의 contentHeight 를 다시 지정
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
// 다른 이미지 크기로 인해서, 한쪽 column에만 이미지가 추가되는 것을 방지
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
주어진 직사각형에 어떤 item들을 표시할지 layoutAttributesForElements(in:) 메서드를 정의해 줍니다.
이 메서드는 prepare() 메서드 이후에 호출됩니다.
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
// 셀 frame 과 요청 rect 가 교차한다면, 리턴 값에 추가
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
마지막으로 요청된 indexPath에 해당하는 레이아웃 속성을 캐시된 속성 정보에서 검색하고 반환합니다.
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
그러면 아래 사진과 같은 결과를 얻을 수 있습니다.
전체 코드
코드 전체는 다음과 같습니다.
import UIKit
protocol PinterestLayoutDelegate: AnyObject {
func collectionView(
_ collectionView: UICollectionView,
heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
}
final class PinterestLayout: UICollectionViewFlowLayout {
// delegate에 대한 참조
weak var delegate: PinterestLayoutDelegate?
// 레이아웃 구성을 위한 두 가지 프로퍼티
private let numberOfColumns = 2
private let cellPadding: CGFloat = 6
// 레이아웃을 재계산할 필요가 없도록 메모리에 저장
private var cache: [UICollectionViewLayoutAttributes] = []
// 콘텐츠 크기를 저장하는 두 가지 프로퍼티
private var contentHeight: CGFloat = 0
private var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
// 컬렉션 뷰의 컨텐츠 크기를 반환
// 이전 단계의 contentWidth 및 contentHeight를 모두 사용하여 크기 계산
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
}
extension PinterestLayout {
// 레이아웃의 모든 item에 대한 UICollectionViewLayoutAttributes의 인스턴스를 계산
override func prepare() {
// 캐시가 비어 있고 컬렉션 뷰가 존재하는 경우에만 레이아웃 속성을 계산
guard
cache.isEmpty,
let collectionView = collectionView
else {
return
}
let columnWidth = contentWidth / CGFloat(numberOfColumns)
// cell 의 x 위치를 나타내는 배열
var xOffset: [CGFloat] = []
for column in 0..<numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
// cell 의 y 위치를 나타내는 배열
var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
// 현재 행의 위치
var column = 0
for item in 0..<collectionView.numberOfItems(inSection: 0) {
// IndexPath 에 맞는 셀의 크기, 위치를 계산
let indexPath = IndexPath(item: item, section: 0)
let photoHeight = delegate?.collectionView(
collectionView,
heightForPhotoAtIndexPath: indexPath) ?? 180
let height = cellPadding * 2 + photoHeight
let frame = CGRect(x: xOffset[column],
y: yOffset[column],
width: columnWidth,
height: height)
let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
// 위에서 계산한 Frame 을 기반으로 cache 에 들어갈 레이아웃 정보를 추가
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// 콜렉션 뷰의 contentHeight 를 다시 지정
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
// 다른 이미지 크기로 인해서, 한쪽 column에만 이미지가 추가되는 것을 방지
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
}
extension PinterestLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
// 셀 frame 과 요청 rect 가 교차한다면, 리턴 값에 추가
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
// 요청된 indexPath에 해당하는 레이아웃 속성을 캐시된 속성 정보에서 검색하고 반환
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
}
참고 사이트
https://www.kodeco.com/4829472-uicollectionview-custom-layout-tutorial-pinterest (핀터레스트 UI 코드)
https://linux-studying.tistory.com/23#Custom%--CollectionViewFlowLayout (핀터레스트 UI 구현 매커니즘에 대한 설명 2)
https://nsios.tistory.com/154 (캐싱을 고려한 이미지 리사이징법 그 외 방법)
'TIL' 카테고리의 다른 글
[TIL] Realm-Swift 라이브러리 SPM Build 오류 대응(Privacy Manifest) (0) | 2024.05.19 |
---|---|
[TIL] Resizable Image 만드는 법 (0) | 2024.05.16 |
[TIL] @propertyWrapper를 활용한 UserDefaults 리팩토링 (0) | 2024.05.14 |
[TIL] Socket 통신 구현 (0) | 2024.05.12 |
[TIL] Socket에 대한 간단 정리 (0) | 2024.05.12 |