🦋

SwiftUI: 表示領域が不足したら要素を重ねて表示するHStack

2023/09/25に公開1

SwiftUIのHStackで以下の図のようなことをしたい。

そんな時はLayoutを使う。

struct OverlappingHStack: Layout {
    let alignment: HorizontalAlignment
    let spacing: CGFloat

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

    private func maxHeight(subviews: Subviews) -> CGFloat {
        return subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? .zero
    }

    private func maxWidth(subviews: Subviews) -> CGFloat {
        return subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? .zero
    }

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

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        if subviews.isEmpty { return }
        let mh = maxHeight(subviews: subviews)
        let mw = maxWidth(subviews: subviews)
        let idealWidth = mw * CGFloat(subviews.count) + spacing * CGFloat(subviews.count - 1)
        if bounds.width < idealWidth {
            let w = (bounds.width - mw) / CGFloat(subviews.count - 1)
            var point = CGPoint(x: bounds.minX + 0.5 * mw, y: bounds.midY)
            subviews.indices.forEach { index in
                subviews[index].place(at: point,
                                      anchor: .center,
                                      proposal: ProposedViewSize(width: mw, height: mh))
                point.x += w
            }
        } else {
            let diff = bounds.width - idealWidth
            let offset: CGFloat
            switch alignment {
            case .leading:  offset = 0
            case .center:   offset = 0.5 * diff
            case .trailing: offset = diff
            default:        offset = 0.5 * diff
            }
            var point = CGPoint(x: bounds.minX + offset, y: bounds.midY)
            subviews.indices.forEach { index in
                subviews[index].place(at: point,
                                      anchor: .leading,
                                      proposal: ProposedViewSize(width: mw, height: mh))
                point.x += mw
                if index < subviews.count - 1 {
                    point.x += spacing
                }
            }
        }
    }
}
struct SampleView: View {
    let array: [String] = ["⚽️", "🏀", "🏈", "⚾️", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱"]

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            overlappingHStack(Array(array.prefix(2)))
            overlappingHStack(Array(array.prefix(5)))
            overlappingHStack(array)
        }
        .padding()
    }

    func overlappingHStack(_ array: [String]) -> some View {
        OverlappingHStack(alignment: .trailing) {
            ForEach(array, id: \.self) { item in
                Text(item)
                    .font(.system(size: 100))
            }
        }
    }
}

ちゃんと.leading.center.trailingにも対応している。

左から.leading.center.trailing

重ねる順番を変更できるかは調査したい。

Discussion

KyomeKyome

このHStackのさらに発展
index番目の要素が選択されている時は他が退くHStack

struct OverlappingHStack: Layout {
    @Binding var index: Int
    let alignment: HorizontalAlignment
    let spacing: CGFloat

    init(index: Binding<Int>, alignment: HorizontalAlignment = .center, spacing: CGFloat = 8) {
        _index = index
        self.alignment = alignment
        self.spacing = spacing
    }

    private func maxHeight(subviews: Subviews) -> CGFloat {
        return subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? .zero
    }

    private func maxWidth(subviews: Subviews) -> CGFloat {
        return subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? .zero
    }

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

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        if subviews.isEmpty { return }
        let mh = maxHeight(subviews: subviews)
        let mw = maxWidth(subviews: subviews)
        let idealWidth = mw * CGFloat(subviews.count) + spacing * CGFloat(subviews.count - 1)

        if bounds.width < idealWidth {
            if subviews.indices.contains(index) {
                var point = CGPoint(x: bounds.minX + 0.5 * mw, y: bounds.midY)
                let before = subviews.prefix(index)
                let after = subviews.suffix(subviews.count - index - 1)
                let n: CGFloat = (before.isEmpty ? 0 : 1) + (after.isEmpty ? 0 : 1)
                let w = (bounds.width - ((1 + n * 0.5) * mw + n * spacing)) / CGFloat(subviews.count - 1)
                if !before.isEmpty {
                    (0 ..< before.count).forEach { index in
                        subviews[index].place(at: point,
                                              anchor: .center,
                                              proposal: ProposedViewSize(width: mw, height: mh))
                        point.x += w
                    }
                    point.x += 0.5 * mw + spacing
                }
                subviews[index].place(at: point,
                                      anchor: .center,
                                      proposal: ProposedViewSize(width: mw, height: mh))
                if !after.isEmpty {
                    point.x += spacing + 0.5 * mw + w
                    ((index + 1) ..< subviews.count).forEach { index in
                        subviews[index].place(at: point,
                                              anchor: .center,
                                              proposal: ProposedViewSize(width: mw, height: mh))
                        point.x += w
                    }
                }
            } else {
                var point = CGPoint(x: bounds.minX + 0.5 * mw, y: bounds.midY)
                let w = (bounds.width - mw) / CGFloat(subviews.count - 1)
                subviews.indices.forEach { index in
                    subviews[index].place(at: point,
                                          anchor: .center,
                                          proposal: ProposedViewSize(width: mw, height: mh))
                    point.x += w
                }
            }
        } else {
            let diff = bounds.width - idealWidth
            let offset: CGFloat
            switch alignment {
            case .leading:  offset = 0
            case .center:   offset = 0.5 * diff
            case .trailing: offset = diff
            default:        offset = 0.5 * diff
            }
            var point = CGPoint(x: bounds.minX + offset, y: bounds.midY)
            subviews.indices.forEach { index in
                subviews[index].place(at: point,
                                      anchor: .leading,
                                      proposal: ProposedViewSize(width: mw, height: mh))
                point.x += mw
                if index < subviews.count - 1 {
                    point.x += spacing
                }
            }
        }
    }
}
struct ContentView: View {
    @State var index: Int = 2
    let array = ["⚽️", "🏀", "🏈", "⚾️", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱"]

    var body: some View {
        VStack {
            HStack(spacing: 0) {
                OverlappingHStack(index: $index, spacing: 24) {
                    ForEach(array.indices, id: \.self) { i in
                        Text(array[i])
                            .font(.system(size: 120))
                            .zIndex(Double(array.count - abs(i - index)))
                    }
                }
            }
            Slider(value: Binding(get: { Float(index) }, set: { index = Int($0.rounded()) }), in: -1 ... 10)
            Text(String(index))
        }
        .padding()
    }
}