Posts toolbar SwiftUIでToolbarを共通化する方法

SwiftUIでToolbarを共通化する方法

はじめに

iOS26では、デザイン面で大きな変化がありました。 特にSwiftUIで提供されるコンポーネントのうち、TabViewToolbarなどのナビゲーション系のコンポーネントが、デフォルトでLiquid Glassを用いたデザインとなります。 今回はtoolbarモディファイア内の要素を、iOS26のAPIと組み合わせやすい方法で共通化します。

環境

Liquid Glassが表示可能なiOS26 beta2で動作確認をしています beta版OSでの挙動ですので、RC版などとは挙動が異なる可能性があります。

TL;DR

  • SwiftUIのtoolbarモディファイア内に渡すToolbarItemは、ToolbarContentプロトコルを利用することでstructに切り出し、共通化することができる
  • ToolbarContentプロトコルに準拠したstruct内では、Viewプロトコル内と同様に@Stateによる状態の管理が行える

これまでの実装とその問題点

私の個人開発アプリでは、以下のような仕様のボタンをtoolbarに配置していました

  • タップすると、画面Aをsheetで表示する
  • このボタンを複数の画面のtoolbarに配置する

各画面で毎回実装したくはないので、ToolbarSheetButtonModifierを作って共通化していました。

/// navigationBarにsheetで開くボタンを配置するModifier
/// - parameters:
///   - placement: toolbar上のどの位置にボタンを配置するか
///   - buttonLabel: 表示するボタンの外観を設定する
///   - sheetContent: ボタンを押したときに開くシートの内容を設定する
struct ToolbarSheetButtonModifier<ButtonLabel: View, SheetContent: View>: ViewModifier {
    @State private var isSheetPresented = false
    private let placement: ToolbarItemPlacement
    private let buttonLabel: ButtonLabel
    private let sheetContent: SheetContent

    init(
        placement: ToolbarItemPlacement = .topBarTrailing,
        @ViewBuilder buttonLabel: () -> ButtonLabel,
        @ViewBuilder sheetContent: () -> SheetContent
    ) {
        self.placement = placement
        self.buttonLabel = buttonLabel()
        self.sheetContent = sheetContent()
    }

    func body(content: Content) -> some View {
        content
            .toolbar {
                ToolbarItem(placement: placement) {
                    Button(
                        action: { isSheetPresented = true },
                        label: { buttonLabel }
                    )
                }
            }
            .sheet(isPresented: $isSheetPresented) { sheetContent }
    }
}

extension View {
    func toolbarSheetButton<ButtonLabel: View, SheetContent: View>(
        placement: ToolbarItemPlacement = .topBarTrailing,
        @ViewBuilder buttonLabel: () -> ButtonLabel,
        @ViewBuilder sheetContent: () -> SheetContent
    ) -> some View {
        self.modifier(
            ToolbarSheetButtonModifier(
                placement: placement,
                buttonLabel: buttonLabel,
                sheetContent: sheetContent
            )
        )
    }
}

利用時には、このモディファイアをつけるだけでsheetを表示するボタンが作れます。 シート制御のためのstateも作る必要がありません。 他の要素を表示する場合は別途toolbarモディファイアを使っても問題なく動作します

struct ContentView: View {
	……
    var body: some View {
		Text("ContentView")
			// モディファイアをつけるだけでtoolbarにsheetを表示するボタンを表示
			.toolbarSheetButton(
				buttonLabel: {  },
				sheetContent: {  }
			)
			// 通常のtoolbarと同時に使うこともできます
			.toolbar {
				ToolbarItem(placement: .topBarLeading) {  }
			}
    }
	……
}

しかし、Liquid Glassをベースとしたデザインに移行する際、この方法では柔軟な表示ができなくなりました

きっかけは、iOS26から、ToolbarSpacerというAPIが導入されたことです。

Liquid Glassは液体のような表現を伴うため、隣接するToolbarItem同士が一つの大きなまとまりを形成するような表現のUIを作ります。

そして、ToolbarSpacerという新たなAPIを利用することで、2つのToolbarItemを別のまとまりへと明示的に区切りることができます。

このAPIは単一のtoolbarモディファイア内の要素を区切るために利用しますが、toolbarの内容をモディファイアを使って共通化すると、toolbarのスコープが複数できてしまうため、うまく要素の分離ができません

ToolbarItemをViewに切り出すことを試みる

ToolbarSpacerの登場で、toolbarの中身を共通化するときにはtoolbarモディファイア単位ではなく、ToolbarItem単位での切り出しが必要になりました

しかし、ToolbarItemは、Viewと同様の方法では切り出しができません 例えば、以下のようなコードはコンパイルエラーになります

/// Static method 'buildExpression' requires that 'ToolbarItem<(), Image>' conform to 'View'
struct SampleToolbarItem: View {
    var body: some View {
        ToolbarItem(placement: .primaryAction) {
            Image(systemName: "sun.max")
        }
    }
}

困りました。どうやら、ToolbarItemはViewに準拠していないようです。

ToolbarItemはToolbarContentに切り出す

調べてみると、ToolbarItemToolbarContentというプロトコルを利用することで共通化可能なようです。

/// ViewではなくToolbarContentに準拠する必要がある
struct SampleToolbarItem: ToolbarContent {
    var body: some ToolbarContent {
        ToolbarItem(placement: .primaryAction) {
            Image(systemName: "sun.max")
        }
    }
}

また、この構造体内では@Stateによる状態管理がView構造体と同様に可能なようでした。 以下のコードで、toolbarのボタン押下時にsheetが表示されます

struct SampleToolbarItem: ToolbarContent {
    @State var isSheetShown = false

    var body: some ToolbarContent {
        ToolbarItem(placement: .primaryAction) {
            Button(
                action: { isSheetShown.toggle() },
                label: { Image(systemName: "sun.max") }
            )
            .sheet(isPresented: $isSheetShown) {
                Text("sheetを表示")
            }
        }
    }
}
struct ContentView: View {
	……
    var body: some View {
		Text("ContentView")
			.toolbar {
				SampleToolbarItem()
				ToolbarSpacer(.fixed, placement: .primaryAction)
				ToolbarItem(placement: .primaryAction) {  }
			}
    }
	……

これで、ToolbarSpacerを利用しつつ、toolbar内のボタンを共通化することができそうです。

まとめ

  • ToolbarSpacerの登場で、1つのViewに複数のtoolbarモディファイアを付ける方法では、柔軟なViewの実装がしづらくなりました
  • ToolbarContentプロトコルに準拠した構造体を作ることで、ToolbarItem単位での共通化を行うことができます
  • ToolbarContentに準拠した構造体内では、@Stateを利用した状態管理が可能です