✂️

[SwiftUI]toolbarでボタンが切れる?左上の大きなボタンが欠ける理由と対策

に公開

発生した問題

以下のようなバグに遭遇しました。

左上に配置したボタンが、画面遷移後に戻ると上下が切れてしまうという現象です。
今回は、ボタンが欠けてしまう理由とその対策について記載していきます。
ボタンが欠ける例

原因:toolbar {} の制限

このバグの原因は、ボタンを toolbar {} の中で定義していたことにあります。

toolbar {}決まった高さしか持てません
そのため、大きなボタン(特に丸ボタン)を配置すると、上下が切れてしまうことがあります。

💻 現在のコード(バグあり)

toolbar {} を使用していたコード
import SwiftUI

struct RequestButtonView: View {
    var body: some View {
        NavigationView {
            VStack {
                Spacer()
                
                // 中央の青いボタン(ContentViewへ遷移)
                NavigationLink(destination: ListView()) {
                    Text("リスト")
                        .foregroundColor(.white)
                        .padding()
                        .frame(width: 200)
                        .background(Color.blue)
                        .cornerRadius(10)
                }

                Spacer()
            }
            .navigationTitle("")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button(action: {
                        print("リクエストボタンがタップされました")
                    }) {
                        ZStack {
                            Circle()
                                .fill(Color.cyan)
                                .frame(width: 80, height: 80)

                            VStack(spacing: 2) {
                                Image(systemName: "sparkles")
                                    .font(.system(size: 20, weight: .bold))
                                    .foregroundColor(.white)

                                Text("リクエスト")
                                    .font(.caption2)
                                    .foregroundColor(.white)
                            }
                        }
                    }
                }
            }
        }
    }
}

#Preview {
    RequestButtonView()
}

対策:toolbar {}をやめて、ZStack+alignmentを使う

修正ポイント

  • toolbar {}を削除
  • 全体をZStack(alignment: .topLeading)で囲む
  • ボタンに.padding(.top, 16).padding(.leading, 16)を追加して左上に配置

まずは、.toolbar {ToolbarItem(placement: .navigationBarLeading) {}}箇所を削除していきます。

-           .toolbar {
-               ToolbarItem(placement: .navigationBarLeading) {
                    Button(action: {
                        print("リクエストボタンがタップされました")
                    }) {
                        ZStack {
                            Circle()
                                .fill(Color.cyan)
                                .frame(width: 80, height: 80)

                            VStack(spacing: 2) {
                                Image(systemName: "sparkles")
                                    .font(.system(size: 20, weight: .bold))
                                    .foregroundColor(.white)

                                Text("リクエスト")
                                    .font(.caption2)
                                    .foregroundColor(.white)
                            }
                        }
                    }
-               }
-           }

全体的にコードの修正を行いましたが、今回注目して欲しいのは、ZStack(alignment: .topLeading) {}箇所です。

import SwiftUI

struct RequestButtonView: View {
    var body: some View {
        NavigationView {
+           ZStack(alignment: .topLeading) {
                // 中央のコンテンツ
                VStack {
                    Spacer()
                    
                    NavigationLink(destination: ListView()) {
                        Text("リスト")
                            .foregroundColor(.white)
                            .padding()
                            .frame(width: 200)
                            .background(Color.blue)
                            .cornerRadius(10)
                    }
                    
                    Spacer()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity) // 必須!

                // 左上のボタン
                Button(action: {
                    print("リクエストボタンがタップされました")
                }) {
                    ZStack {
                        Circle()
                            .fill(Color.cyan)
                            .frame(width: 80, height: 80)
                        
                        VStack(spacing: 2) {
                            Image(systemName: "sparkles")
                                .font(.system(size: 20, weight: .bold))
                                .foregroundColor(.white)
                            
                            Text("リクエスト")
                                .font(.caption2)
                                .foregroundColor(.white)
                        }
                    }
                }
                .padding(.top, 16)
                .padding(.leading, 16)
+           }
        }
    }
}

#Preview {
    RequestButtonView()
}

解説:ZStack(alignment:)のしくみ

SwiftUIのZStackは、複数の要素を重ねて配置できるコンテナです。

ZStack(alignment: .topLeading) {
    // 背景または中央のUI
    // 左上のボタン
}

.topLeadingの意味

.topLeadingは「左上寄せ配置」を意味します。
.padding(.top) + .padding(.leading)を組み合わせて、自然な余白付きの左上配置になります。

配置の位置一覧

下記の画像は、SwiftUIで使える alignment の一覧です。
画像からも.topLeadingは「左上寄せ配置」であることが分かります。

配置を表示するコード
import SwiftUI

struct ArrangementView: View {
    var body: some View {
        ZStack {
            AlignmentPanel(text: ".topLeading\n(左上寄せ)", alignment: .topLeading, color: Color(red: 1.0, green: 0.8, blue: 0.8)) // ピンク系
            AlignmentPanel(text: ".top\n(上寄せ)", alignment: .top, color: Color(red: 1.0, green: 1.0, blue: 0.8)) // クリームイエロー
            AlignmentPanel(text: ".topTrailing\n(右上寄せ)", alignment: .topTrailing, color: Color(red: 0.8, green: 1.0, blue: 0.8)) // ミントグリーン
            AlignmentPanel(text: ".leading\n(左寄せ)", alignment: .leading, color: Color(red: 0.8, green: 0.9, blue: 1.0)) // パステルブルー
            AlignmentPanel(text: ".center\n(中央配置、デフォルト)", alignment: .center, color: Color(red: 1.0, green: 0.9, blue: 1.0)) // ラベンダー
            AlignmentPanel(text: ".trailing\n(右寄せ)", alignment: .trailing, color: Color(red: 0.95, green: 0.95, blue: 0.95)) // やさしいグレー
            AlignmentPanel(text: ".bottomLeading\n(左下寄せ)", alignment: .bottomLeading, color: Color(red: 0.9, green: 0.95, blue: 0.9)) // グリーングレー
            AlignmentPanel(text: ".bottom\n(下寄せ)", alignment: .bottom, color: Color(red: 0.95, green: 0.9, blue: 0.85)) // ベージュ
            AlignmentPanel(text: ".bottomTrailing\n(右下寄せ)", alignment: .bottomTrailing, color: Color(red: 0.85, green: 0.95, blue: 0.95)) // アクアグレー
        }
    }
}

struct AlignmentPanel: View {
    let text: String
    let alignment: Alignment
    let color: Color

    var body: some View {
        let words = text.components(separatedBy: "\n")
        VStack {
            ForEach(0..<words.count, id: \.self) { index in
                Text(words[index])
            }
        }
        .frame(width: 128, height: 100)
        .background(color)
        .cornerRadius(12) // 角を丸くしてさらに柔らかく
        .frame(width: 400, height: 600, alignment: alignment)
    }
}

#Preview {
    ArrangementView()
}

図解:修正後のレイアウト

修正後の構成は以下のようになります。

まず、全体をZStackで覆います。
リストボタンは中央に配置したいため、左右にSpacer()を入れることにより中央に配置されます。
最後にZStackの引数としてalignment: .topLeadingを指定したためZStack内にあるリクエストボタンは左上に寄せて配置されるという仕組みになります。

結果:ボタンが欠けずに表示されるようになった

こうして、最終的に左上に配置したリクエストボタンは、画面遷移をしてもボタンの上下が切れないようになりました✨

まとめ

  • toolbar {}は高さ制限があるため、大きなUIを配置すると欠けてしまう
  • ZStack(alignment:)を使うことで、自由な位置にUIを配置可能
  • .topLeading.padding()を併用すると、自然な左上配置が実現可能

Discussion