📝

【SwiftUI】HeaderLayout with Layoutプロトコル

に公開

既存のHStackなどのコンテナでは、左側、真ん中、右側に計3つのビューが配置されているヘッダーを実装しようとした時、ヘッダーの「真ん中(左右中央)」にビューを配置するというのは意外と難しいです。

安直な実装では上手くいかない例

VStack(spacing: 40) {
    /// 左右のビューのサイズが同じでなければ、真ん中のビューは左右中央に表示されない。
    ///
    HStack(alignment: .lastTextBaseline, spacing: .zero) {
        Text("No. 0006")
            .font(.headline)
        Text("リザードン")
            .font(.title2)
            .fontWeight(.bold)
            .frame(maxWidth: .infinity)
        Text("🔥")
            .font(.title3)
    }
    /// 分かりにくいが、アライメントが機能していない。また、ViewThatFitsでよしなに切り替えができないなどの問題がある。
    ///
    HStack(alignment: .lastTextBaseline, spacing: .zero) {
        Text("No. 0006")
            .font(.headline)
        Spacer()
        Text("🔥")
            .font(.title3)
    }
    .overlay {
        Text("リザードン")
            .font(.title2)
            .fontWeight(.bold)
    }
}

そこで2022年に登場したLayoutプロトコルを用いて、ヘッダーのレイアウトを自作してみました。

上記の画像では、ポケモンのIDが左側に、ポケモンのタイプが右側に、ポケモンの名前が「真ん中(左右中央)」に表示されています。

使用したレイアウトのコードは下記の通りです。

HeaderLayout.swift
struct HeaderLayout: Layout {
    var alignment: VerticalAlignment
    var minSpacing: CGFloat
    var idealSpacing: CGFloat
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let proposal = ProposedViewSize(width: proposal.width, height: nil)
        let dimentionsList = subviews.map { $0.dimensions(in: proposal) }
        
        let additionalSpacing = max(dimentionsList[0].width, dimentionsList[2].width) - min(dimentionsList[0].width, dimentionsList[2].width)
        
        let height = containerHeight(dimentionsList: dimentionsList)
        
        if let proposedWidth = proposal.width {
            let minWidth = dimentionsList.reduce(0) { $0 + $1.width } + (minSpacing * 2) + additionalSpacing
            let width = max(proposedWidth, minWidth)
            
            return .init(width: width, height: height)
        } else {
            let spacing = (idealSpacing * 2) + additionalSpacing
            let width = dimentionsList[0].width + dimentionsList[1].width + dimentionsList[2].width + spacing
            
            return .init(width: width, height: height)
        }
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {        
        let proposal = ProposedViewSize(width: proposal.width, height: nil)
        let dimentionsList = subviews.map { $0.dimensions(in: proposal) }
        let alignmentPosition = alignmentPosition(dimentionsList: dimentionsList)
        
        let leftContentPosition = CGPoint(
            x: bounds.minX,
            y: bounds.minY + alignmentPosition - dimentionsList[0][alignment] )
        
        let centerContentPosition = CGPoint(
            x: bounds.midX,
            y: bounds.minY + alignmentPosition - dimentionsList[1][alignment] )
        
        let rightContentPosition = CGPoint(
            x: bounds.maxX,
            y: bounds.minY + alignmentPosition - dimentionsList[2][alignment] )
        
        subviews[0].place(at: leftContentPosition, anchor: .topLeading, proposal: proposal)
        subviews[1].place(at: centerContentPosition, anchor: .top, proposal: proposal)
        subviews[2].place(at: rightContentPosition, anchor: .topTrailing, proposal: proposal)
    }
    
    private func containerHeight(dimentionsList: [ViewDimensions]) -> CGFloat {
        let relativeTopPositions = dimentionsList.map { viewDimentions in
            viewDimentions[alignment] * -1
        }
        
        let relativeBottomPositions = dimentionsList.map { viewDimentions in
            let relativeTopPosition = viewDimentions[alignment] * -1
            return relativeTopPosition + viewDimentions.height
        }
        
        return max(relativeBottomPositions.max()!, 0) - min(relativeTopPositions.min()!, 0)
    }
    
    private func alignmentPosition(dimentionsList: [ViewDimensions]) -> CGFloat {
        let alignmentPositions = dimentionsList.map { viewDimentions in
            viewDimentions[alignment]
        }
        
        return max(alignmentPositions.max()!, 0)
    }
}

Layout側では、ビューの数を制限することはできないため、Headerビューを実装し、ビューの数を3つに制限します。

Header.swift
struct Header<C1: View, C2: View, C3: View>: View {
    var alignment: VerticalAlignment = .center
    var minSpacing: CGFloat = 10
    var idealSpacing: CGFloat = 30
    
    @ViewBuilder var centerContent: C1
    @ViewBuilder var leftContent: C2
    @ViewBuilder var rightContent: C3
    
    var body: some View {
        HeaderLayout(alignment: alignment, minSpacing: minSpacing, idealSpacing: idealSpacing) {
            leftContent; centerContent; rightContent
        }
    }
}

冒頭で提示したサンプル画像のコードは下記の通りです。

ContentView.swift
struct ContentView: View {
    private static let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png")
    
    var body: some View {
        VStack(spacing: .zero) {
            header
            image
            Spacer()
        }
        .padding()
    }
    
    var header: some View {
        Header(alignment: .lastTextBaseline) {
            Text("リザードン")
                .font(.title2)
                .fontWeight(.bold)
        } leftContent: {
            Text("No. 0006")
                .font(.headline)
        } rightContent: {
            Text("🔥")
                .font(.title3)
        }
    }
    
    var image: some View {
        AsyncImage(url: Self.url) {
            switch $0 {
            case .empty:
                ProgressView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .scaledToFit()
                    .controlSize(.extraLarge)
            case .success(let image):
                image
                    .resizable()
                    .scaledToFit()
                    .scaleEffect(0.75)
            default:
                Text("🤪")
            }
        }
    }
}
Bonus:HStackの高さとアライメントポジションの決定プロセス
/// 【HStackの高さとアライメントポジションの決定プロセス】
///
/// 各ビューのアライメントポジションを0とした時の最上部と最下部を計算する。
/// 最上部は、「アライメントポジションの値 × -1」で求められる。
/// 最下部は、「最上部 + ビューの高さ」で求められる。
///
/// 例) アライメントポジションがVerticalAlignment.centerで、一辺が100のRectangleの場合
/// アライメントポジションの値は50なので、「50 * -1」で最上部は-50となる。
/// 最下部は、「-50 + 100」で50となる。
///
/// コンテナの高さは、「max(最下部群, 0) - min(最上部群, 0)」で求められる。
/// アライメントポジションは、「max(各ビューのアライメントポジションの値の最大値, 0)」で求められる。
///
/// 例) アライメントポジションがVerticalAlignment.centerで、一辺が100のRectangle、一辺が50のRectangle、一辺が200のRectangleがある場合
/// 最上部はそれぞれ-50、-25、-100となり、最下部はそれぞれ50、25、100となる。
/// よってコンテナの高さは、「max(50, 25, 100, 0) - min(-50, -25, -100, 0)」で、200となる。
/// 各ビューのアライメントポジションは、それぞれ50、25、100であるため、コンテナのアライメントポジションは「max(50、25、100, 0)」で100となる。
///
/// ※ 「最上部 = VerticalAlignment.top」でないこと、「最下部 = VerticalAlignment.bottom」でないことに注意。


struct ContentView: View {
    @State private var bool = true

    var body: some View {
        Group {
            if bool == true {
                FirstView()
            } else {
                SecondView()
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottom) {
            Button("Action") {
                bool.toggle()
            }
        }
    }
}


/// ピンクの四角形
/// アライメントポジションの値は0なので、「0 * -1」で最上部は0となる。 最下部は、「0 + 100」で100となる。
///
/// ブルーの四角形
/// アライメントポジションの値は-200なので、「-200 * -1」で最上部は200となる。最下部は、「200 + 50」で250となる。
///
/// オレンジの四角形
/// アライメントポジションの値は200なので、「200 * -1」で最上部は-200となる。最下部は、「-200 + 50」で-150となる。
///
/// コンテナの高さは、「max(100, 250, -150, 0) - min(0, 200, -200, 0)」で450となる。
/// コンテナのアライメントポジションは、「max(0, -200, 200, 0)」で200となる。
///
struct FirstView: View {
    var body: some View {
        HStack(alignment: .top, spacing: .zero) { // (300.0, 450.0)
            Rectangle()
                .fill(.pink)
                .frame(width: 100, height: 50)
            
            Rectangle()
                .fill(.blue)
                .frame(width: 100, height: 50)
                .alignmentGuide(.top) { _ in -200 }
            
            Rectangle()
                .fill(.orange)
                .frame(width: 100, height: 50)
                .alignmentGuide(.top) { _ in 200 }
        }
        .background { supportContents }
    }
    
    var supportContents: some View {
        let rowCount = 9
        
        return VStack(spacing: .zero) {
            ForEach(0..<rowCount, id: \.self) { i in
                Rectangle()
                    .fill(i.isMultiple(of: 2) ? AnyShapeStyle(.primary) : AnyShapeStyle(.gray))
                    .frame(height: 50)
                    .opacity(0.1)
                    .overlay(alignment: .topLeading) {
                        Text((i * 50).description)
                            .frame(width: 32, height: 26, alignment: .trailing)
                            .offset(x: -40, y: -13)
                    }
                    .overlay(alignment: .bottomLeading) {
                        if i == rowCount - 1 {
                            Text((i * 50 + 50).description)
                                .frame(width: 32, height: 26, alignment: .trailing)
                                .offset(x: -40, y: 13)
                        }
                    }
            }
        }
    }
}


/// ピンクの四角形
/// アライメントポジションの値は80なので、「80 * -1」で最上部は-80となる。最下部は、「-80 + 80」で0となる。
///
/// ブルーの四角形
/// アライメントポジションの値は50なので、「50 * -1」で最上部は-50となる。最下部は、「-50 + 90」で40となる。
///
/// オレンジの四角形
/// アライメントポジションの値は70なので、「70 * -1」で最上部は-70となる。最下部は「-70 + 90」で20となる。
///
/// グリーンの四角形
/// アライメントポジションの値は40なので、「40 * -1」で最上部は-40となる。最下部は「-40 + 100」で60となる。
///
/// コンテナの高さは、「max(0, 40, 20, 60, 0) - min(-80, -50, -70, -40, 0)」で140となる。
/// コンテナのアライメントポジションは、「max(80, 50, 70, 40, 0)」で80となる。
///
struct SecondView: View {
    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: .zero) { // (160.0, 140.0)
            Rectangle()
                .fill(.pink)
                .frame(width: 40, height: 80)
            
            Rectangle()
                .fill(.blue)
                .frame(width: 40, height: 90)
                .alignmentGuide(.firstTextBaseline) { _ in 50 }
            
            Rectangle()
                .fill(.orange)
                .frame(width: 40, height: 90)
                .alignmentGuide(.firstTextBaseline) { _ in 70 }
            
            Rectangle()
                .fill(.green)
                .frame(width: 40, height: 100)
                .alignmentGuide(.firstTextBaseline) { _ in 40 }
        }
        .overlay { supportContents }
        .scaleEffect(1.5)
    }
    var supportContents: some View {
        let rowCount = 14
        
        return VStack(spacing: .zero) {
            ForEach(0..<rowCount, id: \.self) { i in
                Rectangle()
                    .fill(i.isMultiple(of: 2) ? AnyShapeStyle(.primary) : AnyShapeStyle(.gray))
                    .frame(height: 10)
                    .opacity(0.1)
                    .overlay(alignment: .topLeading) {
                        if i.isMultiple(of: 2) {
                            Text((i * 10).description)
                                .frame(width: 32, height: 26, alignment: .trailing)
                                .offset(x: -40, y: -13)
                        }
                    }
                    .overlay(alignment: .bottomLeading) {
                        if i == rowCount - 1 {
                            Text((i * 10 + 10).description)
                                .frame(width: 32, height: 26, alignment: .trailing)
                                .offset(x: -40, y: 13)
                        }
                    }
                    .font(.caption2)
            }
        }
    }
    
    var sample: some View {
        let rowCount = 9
        
        return VStack(spacing: .zero) {
            ForEach(0..<rowCount, id: \.self) { i in
                Rectangle()
                    .fill(i.isMultiple(of: 2) ? AnyShapeStyle(.primary) : AnyShapeStyle(.gray))
                    .frame(height: 50)
                    .opacity(0.1)
                    .overlay(alignment: .topLeading) {
                        Text((i * 50).description)
                            .frame(width: 32, height: 26, alignment: .trailing)
                            .offset(x: -40, y: -13)
                    }
                    .overlay(alignment: .bottomLeading) {
                        if i == rowCount - 1 {
                            Text((i * 50 + 50).description)
                                .frame(width: 32, height: 26, alignment: .trailing)
                                .offset(x: -40, y: 13)
                        }
                    }
            }
        }
    }
}

Discussion