본문 바로가기

TIL

[TIL] Pinterest UI 구현

오늘은 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://parkjju.github.io/vue-TIL/trash/231123-34.html#%E1%84%91%E1%85%B5%E1%86%AB%E1%84%90%E1%85%A5%E1%84%85%E1%85%A6%E1%84%89%E1%85%B3%E1%84%90%E1%85%B3-%E1%84%83%E1%85%A6%E1%86%AF%E1%84%85%E1%85%B5%E1%84%80%E1%85%A6%E1%84%8B%E1%85%B5%E1%84%90%E1%85%B3 (핀터레스트 UI 구현 매커니즘에 대한 설명 1)

https://linux-studying.tistory.com/23#Custom%--CollectionViewFlowLayout (핀터레스트 UI 구현 매커니즘에 대한 설명 2)

https://nsios.tistory.com/154 (캐싱을 고려한 이미지 리사이징법 그 외 방법)