🦋

SwiftUI: 親Viewのアスペクト比に合わせて子Viewをいい感じに配置するLayout

2023/11/27に公開1

SwiftUIのLazyVGridを使うと列数が決まっている時は子要素が可変数でもいい感じに配置してくれます。iPhoneやiPadの複数端末の画面に最適なレイアウトを考えると、Viewのアスペクト比に合わせて列数を変えたい場合もあります。

今回はそんな我儘レイアウトを叶えるLayoutを実装してみました。

  1. 親Viewのアスペクト比に合わせて列数を調整
  2. 子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

KyomeKyome

Alignmentに対応した!

enum BoxAlignment {
    case leading
    case center
    case trailing
    case topLeading
    case top
    case topTrailing
    case bottomLeading
    case bottom
    case bottomTrailing
}


struct FlexibleHStack: Layout {
    let alignment: BoxAlignment
    let spacing: CGFloat

    init(alignment: BoxAlignment = .center, spacing: CGFloat = 8) {
        self.alignment = alignment
        self.spacing = spacing
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        return proposal.replacingUnspecifiedDimensions()
    }

    private func aspectRatio(of subviews: Subviews) -> CGFloat {
        let maxSize = subviews.reduce(CGSize.zero) { partialResult, subview in
            let size = subview.sizeThatFits(.unspecified)
            return CGSize(width: max(partialResult.width, size.width),
                          height: max(partialResult.height, size.height))
        }
        return maxSize.width / maxSize.height
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        if subviews.isEmpty { return }
        let parentAspectRatio = bounds.width / bounds.height
        let count = subviews.count
        let subviewAspectRatio = aspectRatio(of: subviews)
        if parentAspectRatio * subviewAspectRatio == 0 { return }

        let ratios = (0 ..< count).map { i in
            let top = CGFloat(count - i)
            let bottom = ceil(CGFloat(count) / top)
            return subviewAspectRatio * top / bottom
        }
        let index = ratios.map { abs(parentAspectRatio - $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 w = (bounds.width - (hCount - 1) * spacing) / hCount
        let h = (bounds.height - (vCount - 1) * spacing) / vCount
        let idealSize: CGSize
        if subviewAspectRatio < (w / h) {
            idealSize = CGSize(width: subviewAspectRatio * h, height: h)
        } else {
            idealSize = CGSize(width: w, height: w / subviewAspectRatio)
        }
        let diff = CGSize(width: hCount * (w - idealSize.width),
                          height: vCount * (h - idealSize.height))
        let offset: CGSize
        switch alignment {
        case .leading:          offset = CGSize(width: 0,                height: 0.5 * diff.height)
        case .center:           offset = CGSize(width: 0.5 * diff.width, height: 0.5 * diff.height)
        case .trailing:         offset = CGSize(width: diff.width,       height: 0.5 * diff.height)
        case .topLeading:       offset = CGSize(width: 0,                height: 0)
        case .top:              offset = CGSize(width: 0.5 * diff.width, height: 0)
        case .topTrailing:      offset = CGSize(width: diff.width,       height: 0)
        case .bottomLeading:    offset = CGSize(width: 0,                height: diff.height)
        case .bottom:           offset = CGSize(width: 0.5 * diff.width, height: diff.height)
        case .bottomTrailing:   offset = CGSize(width: diff.width,       height: diff.height)
        }

        subviews.indices.forEach { index in
            let x = bounds.minX + offset.width + CGFloat(index % columnsCount) * (idealSize.width + spacing)
            let y = bounds.minY + offset.height + CGFloat(index / columnsCount) * (idealSize.height + spacing)
            subviews[index].place(at: CGPoint(x: x, y: y),
                                  anchor: .topLeading,
                                  proposal: ProposedViewSize(idealSize))
        }
    }
}