이렇게 생긴 인스타그램 탐색창을 SwiftUI로! 만들어보려고 한다.
swiftUI layout system의 기본은 -
1. 상위뷰가 하위뷰에 (minimum/maximum/ideal인 경우에 대한) 사이즈를 물어보고
2. 하위뷰가 직접 자기 사이즈를 결정해서 상위뷰에 알려주면
3. 하위뷰들의 답변을 종합하여 각 뷰에 얼만큼을 배당할지를 결정한 후 위치를 지정한다.
(https://developer.apple.com/documentation/swiftui/proposedviewsize)
sizeThatFits
: custom layout(container)의 parent에게 자신이 필요한 사이즈를 알려주는 함수 (layout system의 2번 과정).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)
}
}
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
)
}
}
테스트를 위해 랜덤한 컬러를 지정해줬다.
/// 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
}
itemWidth
)를 구할 수 있다. 이를 이용해서 height을 구할때는 한 그룹에 아이템 개수가 두개 들어가고, spacing이 두번 들어간다는 걸 이용했다. (그룹간 spacing을 위해 항상 아래에 spacing만큼 여백을 추가해줘야한다)// 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을 구하는 함수이다.getOrigin()
originOfSquareGroup
- 아래 이미지의 빨간점) 을 먼저 구한 후 해당 origin을 기준으로 사각형 네개를 배치해준다.