🦋
SwiftUI: 親Viewのアスペクト比に合わせて子Viewをいい感じに配置するLayout
SwiftUIのLazyVGrid
を使うと列数が決まっている時は子要素が可変数でもいい感じに配置してくれます。iPhoneやiPadの複数端末の画面に最適なレイアウトを考えると、Viewのアスペクト比に合わせて列数を変えたい場合もあります。
今回はそんな我儘レイアウトを叶えるLayout
を実装してみました。
- 親Viewのアスペクト比に合わせて列数を調整
- 子Viewの要素数に合わせて列数を調整
つまり👉 親Viewのアスペクト比と子Viewの要素数を考慮して最適なレイアウトに変わる
FlexibleHStack
struct FlexibleHStack: Layout {
let spacing: CGFloat
init(spacing: CGFloat = 8) {
self.spacing = spacing
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
return proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let aspectRatio = bounds.width / bounds.height
let count = subviews.count
// 取り得るaspectRatio全パターン
let ratios = (0 ..< count).map { i in
let top = CGFloat(count - i)
let bottom = ceil(CGFloat(count) / top)
return top / bottom
}
// 一番誤差の少ないレイアウト構成
let index = ratios.map { abs(aspectRatio - $0) }
.enumerated()
.min { $0.element < $1.element }?
.offset ?? 0
let columnsCount: Int = (count - index)
// 列数と行数を算出
let hCount = CGFloat(columnsCount)
let vCount = ceil(CGFloat(count) / CGFloat(columnsCount))
let width = (bounds.width - (hCount - 1) * spacing) / hCount
let height = (bounds.height - (vCount - 1) * spacing) / vCount
subviews.indices.forEach { index in
let x = bounds.minX + CGFloat(index % columnsCount) * (width + spacing)
let y = bounds.minY + CGFloat(index / columnsCount) * (height + spacing)
subviews[index].place(at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(width: width, height: height))
}
}
}
使い方
FlexibleHStack {
ForEach(items) { item in
ChildView(item)
}
}
GIFアニメのサンプルコード
struct Item: Identifiable {
let id: UUID = .init()
let value: String
let color: Color
}
struct ContentView: View {
@State var ratio: CGFloat = 1.0
@State var count: Int = 4
var array: [Item] {
(0 ..< count).map { i in
switch i % 4 {
case 0: Item(value: "📘", color: .blue)
case 1: Item(value: "📕", color: .red)
case 2: Item(value: "📗", color: .green)
case 3: Item(value: "📙", color: .orange)
default: fatalError()
}
}
}
var body: some View {
VStack {
FlexibleHStack {
ForEach(array) { item in
Rectangle()
.foregroundStyle(Color.clear)
.border(item.color)
.overlay {
Text(item.value)
.font(.system(size: 200))
.minimumScaleFactor(0.01)
.scaledToFit()
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(ratio, contentMode: .fit)
.border(Color.gray)
Spacer()
HStack {
Text("Aspect Ratio:")
Slider(value: $ratio, in: 0.1 ... 4.0, step: 0.1)
Text(String(format: "%0.1lf", ratio))
}
HStack {
Text("Item Count:")
Slider(
value: Binding<Double>(
get: { Double(count) },
set: { count = Int($0.rounded()) }
),
in: 1.0 ... 12.0,
step: 1.0
)
Text("\(count)")
}
}
.padding()
}
}
Discussion
Alignmentに対応した!
ライブラリ化してOSSにしています。