🦋

SwiftUI:[ボタン、テキスト、ボタン]構成の自作ヘッダー

2023/09/13に公開

NavigationViewNavigationStackを使わずにヘッダーを用意したい際に、単純に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を省略

残課題

  • LayoutSubviewsの個数や種類を限定する方法があるのか未調査
  • Buttonの省略方法をColor.clearではなく、本当に省略するようにしたい

Discussion