🦋
SwiftUI:[ボタン、テキスト、ボタン]構成の自作ヘッダー
NavigationView
やNavigationStack
を使わずにヘッダーを用意したい際に、単純にHStack
の中に[ボタン、テキスト、ボタン]のようにパーツを置くだけでは中央のテキストを確実にセンタリングしつつ、なるべく表示領域を確保するように実装することはできません。
VStack {
HStack {
Button("Cancel", action: {})
.border(Color.red)
Text("Title")
.frame(maxWidth: .infinity)
.border(Color.blue)
Button("Done", action: {})
.border(Color.red)
}
Rectangle()
.frame(width: 1, height: 20)
Spacer()
}
Titleが少し右に寄ってしまっている
このように左右のボタンの幅が異なると、中央のテキストが左右どちらかに寄ってしまいます。
そこで、単純に左右のボタンの幅をframe()
で指定する案がすぐに思いつくかもしれませんが、アクセシビリティの「さらに大きな文字」などによるDynamic Typeへの対応を考えると、安易にコンポーネントのサイズを固定するのは悪手でしょう。
では、ZStack
を活用して常にテキストが中央に来るようにするとどうでしょうか?
VStack {
ZStack {
HStack {
Button("Cancel", action: {})
.border(Color.red)
Spacer()
Button("Done", action: {})
.border(Color.red)
}
Text("Title")
.frame(maxWidth: .infinity)
.border(Color.blue)
}
Rectangle()
.frame(width: 1, height: 20)
Spacer()
}
Titleが中央にある
一見良さそうです。が、タイトルが長い時にボタンとテキストが重なってしまいます。
TitleとCancel/Doneが重なっている
本題
SwiftUIのLayout
で要件にあったレイアウトコンテナを作ります。
要件
- [ボタン、テキスト、ボタン]のように子Viewを持つ
- テキストが中央寄せで表示される
- ボタンは両端揃えで表示される
- ボタンを省略してもレイアウトが崩れないようにできる
- テキストが長い時にボタンに重ならない(テキストは省略されても良い)
- Dynamic Typeに従ってボタンやテキストのサイズが変更できる
実装
HeaderHStack
import SwiftUI
struct HeaderHStack: Layout {
// Subviewsの中で一番大きい高さを求める
private func maxHeight(subviews: Subviews) -> CGFloat {
return subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? .zero
}
// Subviewsの両端の要素で取り得るサイズを求める
private func maxSizeOfBothEdges(subviews: Subviews) -> CGSize {
if subviews.count < 2 { return .zero }
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
guard let first = subviewSizes.first, let last = subviewSizes.last else { return .zero }
return CGSize(width: max(first.width, last.width),
height: max(first.height, last.height))
}
// Subviewsの最初のspacingを求める
private func spacing(subviews: Subviews) -> CGFloat {
return subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 }
return subviews[index].spacing.distance(to: subviews[index + 1].spacing, along: .horizontal)
}
.first ?? .zero
}
// Layoutで実装必須のメソッド
// 提案された幅とSubviewsの中で一番大きい高さを返す
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
return CGSize(width: proposal.replacingUnspecifiedDimensions().width,
height: maxHeight(subviews: subviews))
}
// Layoutで実装必須のメソッド
// Subviewsのリストで、最初と最後のsubviewは特別に端になるように配置し、それ以外は中央に配置する
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
if subviews.count < 3 { return }
let maxSize = maxSizeOfBothEdges(subviews: subviews)
let spacing = spacing(subviews: subviews)
subviews.indices.forEach { index in
if index == 0 {
subviews[index].place(at: CGPoint(x: bounds.minX, y: bounds.midY),
anchor: .leading,
proposal: ProposedViewSize(maxSize))
} else if index == subviews.count - 1 {
subviews[index].place(at: CGPoint(x: bounds.maxX, y: bounds.midY),
anchor: .trailing,
proposal: ProposedViewSize(maxSize))
} else {
let size = subviews[index].sizeThatFits(.unspecified)
let width = bounds.width - 2 * (maxSize.width + spacing)
subviews[index].place(at: CGPoint(x: bounds.midX, y: bounds.midY),
anchor: .center,
proposal: ProposedViewSize(width: width, height: size.height))
}
}
}
}
HeaderHStack
を使ってみます。
VStack {
HeaderHStack {
Button("Cancel", action: {})
.border(Color.red)
Text("Title")
.frame(maxWidth: .infinity)
.border(Color.blue)
Button("Done", action: {})
.border(Color.red)
}
Rectangle()
.frame(width: 1, height: 20)
Spacer()
}
Titleが中央に!
Titleが長くても破綻しない
ボタンを省略したい時は、Color.clear
を入れてあげればOKです。
VStack {
HeaderHStack {
Color.clear
.border(Color.red)
Text("Title")
.frame(maxWidth: .infinity)
.border(Color.blue)
Button("Done", action: {})
.border(Color.red)
}
Rectangle()
.frame(width: 1, height: 20)
Spacer()
}
Buttonを省略
残課題
-
Layout
でSubviews
の個数や種類を限定する方法があるのか未調査 -
Button
の省略方法をColor.clear
ではなく、本当に省略するようにしたい
Discussion