📝
【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