SwiftUI로 인스타그램 탐색창 구현하기 with custom layout

김가영·6일 전
0

swift

목록 보기
4/4

인스타그램 탐색창

이렇게 생긴 인스타그램 탐색창을 SwiftUI로! 만들어보려고 한다.

분석

간략화해보면 이런 느낌이다.
  • (1x1인 정사각형 네 개) + (1x2인 직사각형 하나) 로, 총 다섯개의 아이템으로 구성된 레이아웃이 반복된다.
  • 같은 레이아웃이 반복되어야 하기 때문에 wwdc21 Compose custom layouts with SwiftUI을 참고해서 custom layout을 만들었다.

custom layout에 들어가기 전에...

swiftUI layout system의 기본은 -
1. 상위뷰가 하위뷰에 (minimum/maximum/ideal인 경우에 대한) 사이즈를 물어보고 
2. 하위뷰가 직접 자기 사이즈를 결정해서 상위뷰에 알려주면
3. 하위뷰들의 답변을 종합하여 각 뷰에 얼만큼을 배당할지를 결정한 후 위치를 지정한다.

(https://developer.apple.com/documentation/swiftui/proposedviewsize)

  • custom layout을 구현하기 위해 필요한 필수 메서드 두개는 아래와 같다.
    - sizeThatFits: custom layout(container)의 parent에게 자신이 필요한 사이즈를 알려주는 함수 (layout system의 2번 과정).
    proposal(min/max/ideal/specific size)을 다르게 하여 여러번 호출한 후 어떤 proposal을 사용할지 결정할 수도 있다.
    - placeSubviews: 실제로 subview들을 배치하는 함수. sizeThatFits을 호출해서 결정된 하나의 proposal과 그 return 값을 이용해서 호출된다. place() 메서드를 이용해서 직접 원하는 위치에 배치해야된다.

구현

전체코드


import SwiftUI

/// ┌──┬──┬──┐
/// │0 ├──┼──┤
/// └──┴──┴──┘  꼴의 레이아웃
struct BlockGridLayout: Layout {
    
    /// 높이가 두배인 아이템의 위치
    ///    left         right
    /// ┌──┬──┬──┐    ┌──┬──┬──┐
    /// │0 ├──┼──┤    ├──┤──┤0 │
    /// └──┴──┴──┘    └──┴──┴──┘
    /// (0번째 아이템이 항상 높이가 두 배인 아이템)
    private enum PortraitType {
        case left
        case right
        
        static func getPortraitType(groupIndex: Int) -> PortraitType {
            groupIndex % 2 == 0 ? .right : .left
        }
    }
    
    var spacing: CGFloat = 0.0
    
    private let columnCount: Int = 3
    private let numberOfSubviewsInGroup: Int = 5
    
    /// reports the size of the composite layout view
    /// - parameter proposal: 컨테이너의 parent view는 이 함수를 한 번 이상 호출해서 컨테이너의 flexibility를 체크한다. 
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let width = proposal.replacingUnspecifiedDimensions().width // unspecified일 경우 10으로 값을 고정
        let numberOfGroup = ceil(subviews.count.cgFloat / numberOfSubviewsInGroup.cgFloat)
        return CGSize(width: width, height: groupHeight(containerWidth: width) * numberOfGroup)
    }
    
    // assigns positions to the container's subviews
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews.enumerated().forEach { index, subview in
            subview.place(at: getOrigin(in: bounds, subviewAt: index),
                          proposal: getProposal(subviewAt: index, width: bounds.width))
        }
    }
    
    private func itemWidth(containerWidth: CGFloat) -> CGFloat {
        (containerWidth - (columnCount - 1).cgFloat * spacing) / columnCount.cgFloat
    }
    
    private func groupHeight(containerWidth: CGFloat) -> CGFloat {
        (itemWidth(containerWidth: containerWidth) + spacing) * 2
    }
    
    private func getProposal(subviewAt index: Int, width: CGFloat) -> ProposedViewSize {
        let itemWidth = itemWidth(containerWidth: width)
        // group 내 첫번째 아이템이 항상 제일 큰 사이즈이다.
        if index % numberOfSubviewsInGroup == 0 {
            // 가장 큰 사이즈의 아이템은 spacing만큼의 height을 더가져야한다.
            return ProposedViewSize(width: itemWidth, height: itemWidth * 2 + spacing)
        } else {
            return ProposedViewSize(width: itemWidth, height: itemWidth)
        }
    }
    
    private func getOrigin(in bounds: CGRect, subviewAt index: Int) -> CGPoint {
        let groupIndex = index / numberOfSubviewsInGroup
        let portraitType = PortraitType.getPortraitType(groupIndex: groupIndex)
        let itemWidth = itemWidth(containerWidth: bounds.width)
        
        let startX = bounds.minX
        let startY = bounds.minY + groupHeight(containerWidth: bounds.width) * groupIndex.cgFloat
        
        // 가장 큰, 첫번째 아이템을 제외한 네개의 아이템의 origin
        let originOfSquareGroup = portraitType == .left ? CGPoint(x: startX + itemWidth + spacing, y: startY) : CGPoint(x: startX, y: startY)
        
        switch index % numberOfSubviewsInGroup {
        case 0: return portraitType == .left ? CGPoint(x: startX, y: startY) : CGPoint(x: startX + itemWidth * 2 + spacing * 2, y: startY)
        case 1: return originOfSquareGroup
        case 2: return originOfSquareGroup + CGPoint(x: itemWidth + spacing, y: 0.0)
        case 3: return originOfSquareGroup + CGPoint(x: 0, y: itemWidth + spacing)
        case 4: return originOfSquareGroup + CGPoint(x: itemWidth + spacing, y: itemWidth + spacing)
        default: return .zero
        }
    }
}

extension CGPoint {
    static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }
}

extension Int {
    var cgFloat: CGFloat {
        CGFloat(self)
    }
}

usage


import SwiftUI

struct ContentView: View {
    var body: some View {
        ScrollView {
            BlockGridLayout(spacing: 1.0) {
                ForEach(InstagramItem.mockItems) { item in
                    Color.random()
                }
            }
        }
    }
}

// 아래는 테스트를 위한 코드
#Preview {
    ContentView()
}

struct InstagramItem: Identifiable {
    let id = UUID()
    
    static var mockItems: [InstagramItem] {
        (0..<19).map { _ in InstagramItem() }
    }
}

extension Color {
    static var random: Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
    
    static func random(opacity: Double? = nil) -> Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1),
            opacity: opacity ?? 1.0
        )
    }
}

테스트를 위해 랜덤한 컬러를 지정해줬다.

sizeThatFits

/// reports the size of the composite layout view
/// - parameter proposal: 컨테이너의 parent view는 이 함수를 한 번 이상 호출해서 컨테이너의 flexibility를 체크한다.
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let width = proposal.replacingUnspecifiedDimensions().width // unspecified일 경우 10으로 값을 고정
    let numberOfGroup = ceil(subviews.count.cgFloat / numberOfSubviewsInGroup.cgFloat)
    return CGSize(width: width, height: groupHeight(containerWidth: width) * numberOfGroup)
}

private func itemWidth(containerWidth: CGFloat) -> CGFloat {
    (containerWidth - (columnCount - 1).cgFloat * spacing) / columnCount.cgFloat
}

private func groupHeight(containerWidth: CGFloat) -> CGFloat {
    (itemWidth(containerWidth: containerWidth) + spacing) * 2
}
  • 테스트코드처럼 기본 ContentView에 바로 넣어준 경우에는 device size와 함께 한번만 호출됐다.
  • 여기에서 잘못된 값을 리턴하면 상위뷰가 하위뷰 사이즈를 판단하는데 오류가 생긴다
    • 테스트 코드에서는 height을 더 작게 리턴하니 스크롤이 끝까지 안되는 이슈가 생겼다.
  • 기본적으로 5개 아이템이 한 그룹인데, 5의 배수로 떨어지지 않는 경우를 대비하여 항상 소수점은 올림하여 height을 계산했다. 항상 큰 아이템을 index 0으로 갖게 해서 가능했다.
    • 실제 데이터를 사용하는 경우에도 항상 5의 배수 Index에 큰 아이템을 넣어줘야 한다는 규칙을 가져가는게 유리할 거라고 생각했다.
  • spacing을 제외한 available width를 구한 후 column count로 나눠주면 아이템의 기본 길이(itemWidth)를 구할 수 있다. 이를 이용해서 height을 구할때는 한 그룹에 아이템 개수가 두개 들어가고, spacing이 두번 들어간다는 걸 이용했다. (그룹간 spacing을 위해 항상 아래에 spacing만큼 여백을 추가해줘야한다)

placeSubviews

// assigns positions to the container's subviews
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    subviews.enumerated().forEach { index, subview in
        subview.place(at: getOrigin(in: bounds, subviewAt: index),
                      proposal: getProposal(subviewAt: index, width: bounds.width))
    }
}

private func getProposal(subviewAt index: Int, width: CGFloat) -> ProposedViewSize {
    let itemWidth = itemWidth(containerWidth: width)
    // group 내 첫번째 아이템이 항상 제일 큰 사이즈이다.
    if index % numberOfSubviewsInGroup == 0 {
        // 가장 큰 사이즈의 아이템은 spacing만큼의 height을 더가져야한다.
        return ProposedViewSize(width: itemWidth, height: itemWidth * 2 + spacing)
    } else {
        return ProposedViewSize(width: itemWidth, height: itemWidth)
    }
}

private func getOrigin(in bounds: CGRect, subviewAt index: Int) -> CGPoint {
    let groupIndex = index / numberOfSubviewsInGroup
    let portraitType = PortraitType.getPortraitType(groupIndex: groupIndex)
    let itemWidth = itemWidth(containerWidth: bounds.width)
    
    let startX = bounds.minX
    let startY = bounds.minY + groupHeight(containerWidth: bounds.width) * groupIndex.cgFloat
    
    // 가장 큰, 첫번째 아이템을 제외한 네개의 아이템의 origin
    let originOfSquareGroup = portraitType == .left ? CGPoint(x: startX + itemWidth + spacing, y: startY) : CGPoint(x: startX, y: startY)
    
    switch index % numberOfSubviewsInGroup {
    case 0: return portraitType == .left ? CGPoint(x: startX, y: startY) : CGPoint(x: startX + itemWidth * 2 + spacing * 2, y: startY)
    case 1: return originOfSquareGroup
    case 2: return originOfSquareGroup + CGPoint(x: itemWidth + spacing, y: 0.0)
    case 3: return originOfSquareGroup + CGPoint(x: 0, y: itemWidth + spacing)
    case 4: return originOfSquareGroup + CGPoint(x: itemWidth + spacing, y: itemWidth + spacing)
    default: return .zero
    }
}
  • placeSubviews 함수에서는 직접 subview.place() 함수를 호출해서 직접 subview를 배치해줘한다.
  • getProposal() 은 subview에 제안할 proposal을 구하는 함수이다.
    • group내 첫번째 아이템이 항상 제일 큰 - 높이가 두배인 - 직사각형이다.
    • 정사각형이 두개 사이에는 spacing이 존재하기 때문에 직사각형이 spacing만큼의 추가 height을 가져야 일반 정사각형과 위아래 align이 맞게 된다.
  • getOrigin()
    • index 0) 항상 가장 큰 아이템이다. portraitType에 따라 위치가 바뀔 수 있다.
    • index 1 ~ 4) 가장 큰 아이템을 제외하고 하나의 큰 square group이라고 생각해준다. origin(originOfSquareGroup - 아래 이미지의 빨간점) 을 먼저 구한 후 해당 origin을 기준으로 사각형 네개를 배치해준다.

개선할 수 있을 부분들?

  • cache) layout container 함수들간 공유하는 캐시. sizeThatFits, placeSubviews 함수에서 모두 사용 가능하다.
    • 문서에서는 복잡한 로직들이 많거나, subview들로부터 여러 LayoutValueKey를 중복해서 읽어와야 하거나, 뭔가 저장을 하고 싶은 경우에 사용하는 것 같은데 이번 레이아웃은 간단하기 때문에 cache를 이용해서 개선할 필요는 없을 것 같다.
  • 예전에 시도했던 collectionView with swiftUI 방식으로도 구현할 수 있을 것 같다. 다만 이 방식은 그냥 UIKit layout 방식에 하위뷰만 swiftUI만 쓴거라... 확실히 Layout을 이용하니 SwiftUI 의 declarative syntax를 그대로 이용할 수 있으니 좋았다.
  • 다만 CGPoint를 이용해서 직접 subview를 배치해주고, size를 직접 상위뷰에 맞게 직접 계산해주어야하니, Layout부분은 직관적으로 코드만 보고 이해하기는 어려움이 있을 것 같다. 주석을 자세히/잘 쓸 수 있는 방식을 계속 고민해봐야할 것 같다.
profile
개발블로그

0개의 댓글