[SwiftUI] Toolbar の整理

SwiftUI

ツールバーが肥大化した時の 整理方法 を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.2.2
  • Xcode 12.4
  • iOS 14.4

.toolbar の肥大化

アプリの機能を充実させていくと、Toolbar が肥大化してきます。

UNDO/REDO, Edit ボタン、・・・ View の本体と同じくらいの長さになることも。

Toolbar の整理方法を説明します。

長い .toolbar の例

以下の例を使います。別記事向けに書いている TODO アプリのビューですが、ビュー本体よりも、toolbar の記述の方が長くなってます。

長い toolbar の例

struct LucidListView: View {
    var viewModel: LucidMobileViewModel
    @Binding var todoModel: TODOModel
    @State private var showNewItemSheet = false
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(todoModel.sortedTODOItems, id: \.id) { item in
                        Text(item.title)
                    }
                }
            }
            .navigationTitle("TODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        showNewItemSheet.toggle()
                    }, label: {
                        Label(title: { Text("AddItem") }, icon: { Image(systemName: "plus") })
                    })
                }
                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: {
                        viewModel.undo()
                    }, label: { Text("UNDO") })
                    .disabled(!viewModel.canUndo)
                    Button(action: {
                        viewModel.redo()
                    }, label: { Text("REDO") })
                    .disabled(!viewModel.canRedo)
                }
            }
            .sheet(isPresented: $showNewItemSheet) {
                NewTODOItem(viewModel: viewModel, showSheet: $showNewItemSheet)
            }
        }
        .padding(.horizontal)
    }
}

機能的には、NavigationBar の左上に Edit ボタン、右上に 追加の + ボタン。
BottomBar には、UNDO/REDO のボタンを追加しているコードです。

普通に必要になりそうな機能なのですが、それだけで、toolbar は、こんなに長くなってしまいます。

39行の body のうち、21行が toolbar の記述です。toolbar はある意味定型文なので、不必要に長いとコードの見通しが悪くなりメンテナンス性が下がってしまいます。

ToolbarItem の切り出し

どう切り出すかは、その後どのように再利用するかという設計依存ですが、ここでは、「左上に表示される EditButton 」として、切り出してみます。

カスタム ToolbarContent の定義

View を切り出して再利用できるのと同様に、Toolbar もカスタムな ToolbarContent を定義して再利用できます。

NavBarLeadingEdit

struct NavBarLeadingEdit: ToolbarContent {
    var navBarLeadingEdit: some ToolbarContent {
        ToolbarItem(placement: .navigationBarLeading) {
            EditButton()
        }
    }
}

"var body" は同じですが、型は "some View" ではなく "some ToolbarContent" になります。

使用する側のコードは、以下のようになります。

少し短くなった toolbar

struct LucidListView: View {
    var viewModel: LucidMobileViewModel
    @Binding var todoModel: TODOModel
    @State private var showNewItemSheet = false
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(todoModel.sortedTODOItems, id: \.id) { item in
                        Text(item.title)
                    }
                }
            }
            .navigationTitle("TODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                NavBarLeadingEdit()                    // <- 1行にまとまりました
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        showNewItemSheet.toggle()
                    }, label: {
                        Label(title: { Text("AddItem") }, icon: { Image(systemName: "plus") })
                    })
                }
                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: {
                        viewModel.undo()
                    }, label: { Text("UNDO") })
                    .disabled(!viewModel.canUndo)
                    Button(action: {
                        viewModel.redo()
                    }, label: { Text("REDO") })
                    .disabled(!viewModel.canRedo)
                }            }
            .sheet(isPresented: $showNewItemSheet) {
                NewTODOItem(viewModel: viewModel, showSheet: $showNewItemSheet)
            }
        }
        .padding(.horizontal)
    }
}

NavBarLeadingEdit として、別のビューでも簡単に再利用することができます。

struct 内変数として定義する Toolbar

外部に struct として切り出すまでも無い時には、struct の内部変数として定義することもできます。

例えば、現在のビューにしか関連しない 要素追加ボタンは、struct にしてもあまり再利用しない気がします。
このような時には、(Viewの) struct の内部変数にまとめてしまうこともできます。

UNDO/REDO も同様に内部変数にまとめました。

struct の内部変数としての ToolbarContent
 
    var navBarTrailingAdd: some ToolbarContent {
        ToolbarItem(placement: .navigationBarTrailing) {
            Button(action: {
                showNewItemSheet.toggle()
            }, label: {
                Label(title: { Text("AddItem") }, icon: { Image(systemName: "plus") })
            })
        }
    }
    var bottombarUndoRedo: some ToolbarContent {
        var body: some ToolbarContent {
            ToolbarItemGroup(placement: .bottomBar) {
                Button(action: {
                    viewModel.undo()
                }, label: { Text("UNDO") })
                .disabled(!viewModel.canUndo)
                Button(action: {
                    viewModel.redo()
                }, label: { Text("REDO") })
                .disabled(!viewModel.canRedo)
            }
        }
    }

上記の変数を使う側は以下のようになります。

example

struct LucidListView: View {
    var viewModel: LucidMobileViewModel
    @Binding var todoModel: TODOModel
    @State private var showNewItemSheet = false
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(todoModel.sortedTODOItems, id: \.id) { item in
                        Text(item.title)
                    }
                }
            }
            .navigationTitle("TODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                NavBarLeadingEdit()
                navBarTrailingAdd   // <-- それぞれ 変数定義された toolbar が展開されます
                bottombarUndoRedo
            }
            .sheet(isPresented: $showNewItemSheet) {
                NewTODOItem(viewModel: viewModel, showSheet: $showNewItemSheet)
            }
        }
        .padding(.horizontal)
    }
...

struct の内部変数として定義すると、「別ビューでの再利用は難しい」というデメリットがありますが、「必要な情報には、そのままアクセスできる」というメリットもあります。

Toolbar 定義をまとめた最終形

カスタム ToolbarContent 定義と struct 内変数として定義した ToolbarContent を組み合わせたコードは、以下のようになります。

短くなった Toolbar 定義を持つ View

struct LucidListView: View {
    var viewModel: LucidMobileViewModel
    @Binding var todoModel: TODOModel
    @State private var showNewItemSheet = false
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(todoModel.sortedTODOItems, id: \.id) { item in
                        Text(item.title)
                    }
                }
            }
            .navigationTitle("TODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                NavBarLeadingEdit()
                navBarTrailingAdd
                bottombarUndoRedo
            }
            .sheet(isPresented: $showNewItemSheet) {
                NewTODOItem(viewModel: viewModel, showSheet: $showNewItemSheet)
            }
        }
        .padding(.horizontal)
    }

    var navBarTrailingAdd: some ToolbarContent {
        ToolbarItem(placement: .navigationBarTrailing) {
            Button(action: {
                showNewItemSheet.toggle()
            }, label: {
                Label(title: { Text("AddItem") }, icon: { Image(systemName: "plus") })
            })
        }
    }
    var bottombarUndoRedo: some ToolbarContent {
        var body: some ToolbarContent {
            ToolbarItemGroup(placement: .bottomBar) {
                Button(action: {
                    viewModel.undo()
                }, label: { Text("UNDO") })
                .disabled(!viewModel.canUndo)
                Button(action: {
                    viewModel.redo()
                }, label: { Text("REDO") })
                .disabled(!viewModel.canRedo)
            }
        }
    }
}

struct NavBarLeadingEdit: ToolbarContent {
    var navBarLeadingEdit: some ToolbarContent {
        ToolbarItem(placement: .navigationBarLeading) {
            EditButton()
        }
    }
}

全体で考えるとコードが少なくなってはいませんが、ビューの body 部分は、39行もあった body が 22行にまで減り、ずいぶん見通しがよくなりました。

注意
外部 struct 定義した ToolbarContent は、@State や @ObservedObject からの変更通知を受け取ってアップデートされることはないようです。

ビュー struct 内部に定義されていると、ビューと合わせてアップデートされますが、外部 struct 化すると変更は伝播されないようです。
(2021.3.8 時点)

まとめ:toolbar のまとめ方

toolbar のまとめ方
  • struct MyOwnToolbar: some ToolBarContent として、カスタムツールバーを定義する
  • var myownToolbar: some ToolbarContent として、ビュー内で、カスタムツールバーを定義する

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です