0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIでToolbarを共通化する方法

Posted at

はじめに

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を利用した状態管理が可能です
0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?