NavigationSplitView でアレもしたいコレもしたい
はじめに
iPhone、iPadのAppを作成する際に、機能などの単位で画面を分割し、相互に行き来するような構成にすることは一般的です。特にiPhoneは画面の広さが限られるため、視認性や操作性の観点で、1つの画面に詰め込める要素の数には自ずと限界が出てきます。一方で、複数の画面で構成すると、入力の順序や機能への到達方法がわかりづらくなるため、画面の遷移にルールや一貫性が必要になります。それが無いと使っている間に迷ってしまい使い勝手が悪くなります。
複数の画面間での遷移のルールや一貫性を定義し、さらにApp間で統一することでiPhone、iPadのデバイスとしての使用感を向上させるために、Appleはデザインガイドラインを用意し、それを実装したものがSwiftUIではNavigationViewになります。
筆者は、Swift PlaygroundだけでAppを開発し、AppStoreに公開していますが、UIはできる限り自作はせず、SwiftUIを使用するようにしています。その上で、iOS、iPad向けのAppをシングルソース、かつ、OSによる処理の分岐が発生しないように実装しています。
画面ナビゲーションはNavigationViewを使用し、iOSではシングルカラムで、iPadOSではマルチカラムで、標準の動作で切り替わるように作って来ましたが、そのNavigationViewが非推奨になるというのは大きな問題です。後継のNavigationSplitViewで同じ画面のナビゲーションを実現するにはどうすればいいのか、ということには大いに関心があります(心配性)。
本書では、今後のNavigationSplitViewへの切り替えに備え、筆者が実施したテスト実装に基づいて記載します。また、検索によるリストの絞り込みやスワイプによるリスト編集など、画面遷移に関連した機能をNavigationSplitViewと組み合わせて使用する実装を検討します。既存のNavigationViewに関する知識を有していることを前提とします。誤りがありましたら、より良い情報にしていくために、ぜひご指摘いただければと思います。
筆者の開発環境(2025/02/15現在)
iPad 9th / iPadOS 18.3.1 / Swift Playground 4.6
NavigationViewでの画面遷移
NavigationView時代のプログラマブルな画面遷移、厳密にはiPadは遷移では無いかもしれませんが(iPhoneはシングルカラムなので画面遷移と言える)、筆者は以下のAPIを使用し、EmptyViewを使って見えないようにNavigationLinkを隠して仕込んでいました。
NavigationView (Deprecated)
https://developer.apple.com/documentation/swiftui/navigationview
NavigationLink
init(destination:isActive:label:) (Deprecated)
https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:isactive:label:)
var body: some View {
VStack {
NavigationLink(destination: EventDetails(newEvent: $newEvent, editingEvent: $editingEvent), isActive: $configValues.isDetailVisible) {
EmptyView()
}
ZStack {
if configValues.isListVisible {
プログラムにおいて、画面を切り替えたいタイミングでisActiveで設定したバインディング変数をtrueにすることで、画面を遷移させていました。ただ、このようにNavigationLinkを初期化する使い方は、それ自体が非推奨になりましたので、NavigaionSplitViewではできなくなりました。
遷移先の画面から戻るにはdismissを使用しました。
dismiss
https://developer.apple.com/documentation/swiftui/environmentvalues/dismiss
struct Summary: View {
@EnvironmentObject private var configValues: ConfigValues
@EnvironmentObject private var eventData: EventData
@EnvironmentObject private var ls: LocalizedString
@Environment(\.dismiss) private var dismiss
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
configValues.isEditing = false
configValues.isDateChanged = false
withAnimation {
configValues.isSummaryVisible = false
configValues.isListVisible = true
dismiss()
}
} label: {
筆者のAppは2カラム構成で、左のカラムはサイドバーにマッピングされ、iPadでは標準のボタンで開閉できましたが、細かい制御はしませんでした。3カラム構成では、このやり方で制御できるのかは試していません。
iPadでのランドスケープ(横持ち)表示
iPadでのポートレート(縦持ち)表示
iPhoneでのポートレート(縦持ち)表示(左カラム)
iPhoneでのポートレート(縦持ち)表示(右カラム)
Swift Playgroundでは、端末の回転の制限ができませんでした。iPhoneでのランドスケープ(横持ち)の画面では、左カラムの画面において、視認性、操作性が確保できないため、筆者のAppではiPhoneの横持ちでの使用を非推奨にしています。
NavigationSplitViewでの画面遷移
NavigationSplitViewでの画面遷移の方法を理解するために実施したテスト実装について記載します。SwiftUIの標準の機能を組み合わせ、いくつかのパターンで実装します。
NavigationSplitView
https://developer.apple.com/documentation/swiftui/navigationsplitview
1.リストをタップして遷移したい
NavigationSplitViewで、単純にテキストとアイコンを表示した項目をリスト表示して、項目をタップすると詳細に遷移する実装のしかたは、マイグレーションガイドで紹介されています。
Listをタップして遷移
Migrating to new navigation types
Update selection-based navigation
https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types#Update-selection-based-navigation
NavigationSplitViewでのプログラマブルな画面遷移は、selectionベースになりました。selectionは、リストを構成する側のListで設定することができます。
List
init(_:id:selection:rowContent:)
https://developer.apple.com/documentation/swiftui/list/init(_:id:selection:rowcontent:)
ListやForEachに並べられたNavigationLinkは、タップされるとselectionで設定されたバインディング変数に、NavigationLinkに設定された値を入れます。
NavigationLink
init(_:value:)
https://developer.apple.com/documentation/swiftui/navigationlink/init(_:value:)
NavigationSplitViewで複数のカラムを設定し、あるカラムのListやForEachのselectionに設定された変数に値が入ると、シングルカラム画面では、右のカラムにフォーカスが移り、画面が遷移します。selectionの変数の値をnilにすると、シングルカラム画面では、その時点で遷移元の左のカラムにフォーカスが移り、画面が戻ります。
本記事のNavigationSplitViewのテスト実装では、3つのカラムで画面を構成しています。
NavigationSplitView
init(sidebar:content:detail:)
https://developer.apple.com/documentation/swiftui/navigationsplitview/init(sidebar:content:detail:)
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var model: DataModel
var body: some View {
NavigationSplitView() {
Sidebar()
.navigationTitle("Category")
} content: {
Content()
.navigationTitle("Item")
} detail: {
Detail()
}
}
}
左のカラムの中身であるSidebarは、Categoryのリストを表示します。NavigationLinkをタップするとそれぞれのvalueに保持されたcategoryが、Listのselectionに設定されたselectedCategory変数に格納されます。
左カラム:Sidebar
import SwiftUI
struct Sidebar: View {
@EnvironmentObject private var model: DataModel
var body: some View {
List(model.categories, id: \.self, selection: $model.selectedCategory) { category in
NavigationLink(category.name, value: category)
}
}
}
中央のカラムであるContentは、Itemのリストを表示します。NavigationLinkをタップするとそれぞれのvalueに保持されたitemが、Listのselectionに設定されたselectedItem変数に格納されます。
中央カラム:Content
import SwiftUI
struct Content: View {
@EnvironmentObject private var model: DataModel
var body: some View {
if let category = model.selectedCategory {
List(category.items, id: \.id, selection: $model.selectedItem) { item in
NavigationLink(value: item, label: { Label(item.name, systemImage: item.iname) })
.foregroundStyle(item.color)
}
} else {
Text("Pick a category")
}
}
}
右のカラムのDetailは、Itemの中身を表示します。selectedItemの中身がnilの場合、メッセージを表示します。selectedItemにItemが格納されていると、テキストとアイコンを表示します。
右カラム:Detail
Detail内のBackボタンをタップすると、シングルカラムの画面ではフォーカスが中央のカラムに移動します。selectedItemをnilにすることで、プログラマブルに画面を遷移させることができます。selectedItemをnilにすると、Itemのリストは未選択状態になるので、Detailはメッセージを表示します。
シングルカラムの画面では、SwiftUIが自動的に「< Item」ボタンを表示します。 このボタンをタップすると、中央のカラムにフォーカスが戻ります。
import SwiftUI
struct Detail: View {
@EnvironmentObject private var model: DataModel
var body: some View {
if let item = model.selectedItem {
HStack {
Image(systemName: item.iname)
Text(item.name)
}
.foregroundStyle(item.color)
} else {
Text("No item selected")
}
Button {
model.selectedItem = nil
} label: {
Text("Back")
}
.buttonStyle(.borderedProminent)
.padding()
}
}
「リストをタップして遷移」の実装では、Categoryを変更しても、selectedItemの値は変わらないため、Detailには表示中のContentのリストに含まれないItemの中身が表示され続けます。onChangeでselectedCategoryが変わった際にselectedItemをnilにする処理を入れた方が自然かもしれません。
2.リストのタップを契機に処理をして遷移したい
NavigationLinkは、Listのselectionに値を入れるという機能を提供してくれるものの、タップを契機に何らかの処理をする必要がある場合、処理を記述する場所がありません。タップを契機とした処理を記述したい場合には、NavigationLinkの代わりにButtonを使用すれば実現できます。actionには、selectionで設定したバインディング変数に値を入れる処理を含める必要があります。
Buttonで処理して遷移
中央のカラムのリスト項目をButtonにしています。actionの処理にselectedItemにitemを設定する処理を記載すれば画面遷移します。
import SwiftUI
struct Content_button: View {
@EnvironmentObject private var model: DataModel
var body: some View {
if let category = model.selectedCategory {
List(category.items, id: \.id, selection: $model.selectedItem) { item in
Button {
model.selectedItem = item
} label: {
ListRow(item: item)
}
}
} else {
Text("Pick a category")
}
}
}
通常のLabelとは異なる表示にし、選択されたitemを保持する項目はチェックマークを表示しています。リストの項目のチェックマークは、iPadなど、マルチカラムの画面では役立ちます。
Buttonの表示
import SwiftUI
struct ListRow: View {
@EnvironmentObject private var model: DataModel
var item: Item
var body: some View {
HStack {
Image(systemName: item.iname)
VStack {
Text(item.name)
Text(item.iname)
.font(.caption)
}
if(model.selectedItem == item) {
Image(systemName: "checkmark")
}
}
.foregroundStyle(item.color)
}
}
左のカラムのCategoryのリストは、onChangeでselectedCategoryの変化を検知するとselectedItemをnilにします。Detailの表示が未選択のメッセージになり、Contentのリストが切り替わったのに古いリストの項目を表示し続ける違和感がなくなります。また、チェックマークも外れます。
import SwiftUI
struct Sidebar_button: View {
@EnvironmentObject private var model: DataModel
var body: some View {
List(model.categories, id: \.self, selection: $model.selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.onChange(of: model.selectedCategory) {
model.selectedItem = nil
}
}
}
Buttonを使用せず、NavigationLinkにonTapGestureのモディファイアを付加すれば処理を記載することができそうですが、NavigationLinkへのタップ処理を奪い取る形になり、NavigationLink自体の処理を妨げます。
onTapGesture(count:perform:)
https://developer.apple.com/documentation/swiftui/view/ontapgesture(count:perform:)
3.ピッカーなどで選択した結果に基づいて遷移したい
実際にAppを構築する際には、必ずしもリストをタップして遷移する訳ではありません。Listを使わずにNavigationSplitViewの画面遷移をコントロールするにはどのようにすればいいのでしょうか。ピッカーで選択した結果に基づいて遷移するように実装します。
ピッカーで選択して遷移
NavigationSplitViewへのSelectionを示すバインディング変数の登録は、navigationDestinationで実施します。NavigationSplitViewとNavigationStackが導入された当初は、NavigationStack用のnavigationDestinationしか定義されていませんでした。iOS17から、NavigationSplitViewにも使用できるnavigationDestinationが提供され、List以外でのプログラマブルな遷移ができるようになりました(多分)。
引数のitemにSelectionを示すバインディング変数を、destinationに遷移する先のカラムで表示するViewを設定します。Selectionの変数に値を設定すると、シングルカラムの画面では右のカラムにフォーカスが移動し、destinationで設定されたViewが表示されます。NavigationSplitViewにあらかじめカラムのViewを設定しておいても、destinationで設定したViewが優先されます。ただ、NavigationSplitView自体が再描画されるタイミングでは、あらかじめ設定しておいたViewが表示されるため、注意が必要です。
navigationDestination(item:destination:)
https://developer.apple.com/documentation/swiftui/view/navigationdestination(item:destination:)
中央のカラムでitemの選択をListからPickerに変更しています。Pickerで値を選択し、Buttonをタップすると、右のカラムでitemの中身を表示します。
Pickerには選択値を格納するバインディング変数を設定するためのselection引数がありますが、オプショナル型は使用できないため、navigationDestinationのitemと同じ変数を設定できません。Picker用にはオプショナルではない別の変数を用意して、Buttonをタップすると、Pickerの値をnavigationDestinationのitemで指定した変数に入れるようにしています。
import SwiftUI
struct Content_picker: View {
@EnvironmentObject private var model: DataModel
var body: some View {
if let category = model.selectedCategory {
Picker("category.name", selection: $model.pickerSelection) {
ForEach(category.items, id: \.self) { item in
Label(item.name, systemImage: item.iname)
.foregroundStyle(item.color)
}
}
.pickerStyle(.wheel)
Button {
model.selectedItem = model.pickerSelection
} label: {
Text("Select")
}
.navigationDestination(item: $model.selectedItem, destination: {_ in
Detail()
})
.buttonStyle(.borderedProminent)
.padding()
} else {
Text("Pick a category")
}
}
}
左のカラムのCategoryのリストは、onChangeでselectedCategoryの変化を検知するとselectedItemをnilにするのはボタンのListで遷移する場合と同様ですが、加えて、Pickerの選択値の再設定をしています。
import SwiftUI
struct Sidebar_picker: View {
@EnvironmentObject private var model: DataModel
var body: some View {
List(model.categories, id: \.self, selection: $model.selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.onChange(of: model.selectedCategory) {
model.selectedItem = nil
if let category = model.selectedCategory {
model.pickerSelection = category.items[0]
} else {
model.pickerSelection = Item()
}
}
}
}
Pickerの選択値の再設定は、中央のカラム側でonAppearなどで再設定することもできますが、iPadを使用する場合、Viewが常に表示されているとonAppearが呼ばれないことがあります。iOSとiPadOSでシングルコードにする場合は、切り替えの元側で処理をした方が堅牢な実装になります。
4.検索機能をつけたリストで遷移したい
NavigationSplitViewのリストに、SwiftUIに標準で存在する検索機能を組み合わせた上で、遷移するように実装します。筆者が作成したAppでは、独自に検索機能を作成しましたが、標準機能が使えるようであれば、リプレースすることが考えられます。
Adding a search interface to your app
https://developer.apple.com/documentation/swiftui/adding-a-search-interface-to-your-app
検索機能付きのリストで遷移
標準の検索機能を使用するには、searchableのモディファイアを付加します。検索文字列を入力するためのテキストボックスが付加されますが、検索文字列を使用して絞り込みを行う処理は、手動で実装する必要があります。
searchable(text:placement:prompt:)
https://developer.apple.com/documentation/swiftui/view/searchable(text:placement:prompt:)
中央のカラムのItemのリストに、絞り込みのための検索文字列を入力できるようにします。Listに渡すItemの配列のみ修正すれば、NavigationSplitViewのSelection周りの変更は不要です。
import SwiftUI
struct Content_searchable: View {
@EnvironmentObject private var model: DataModel
var body: some View {
if let _ = model.selectedCategory {
List(model.filteredItems, id: \.id, selection: $model.selectedItem) { item in
NavigationLink(value: item, label: { Label(item.name, systemImage: item.iname) })
.foregroundStyle(item.color)
}
.searchable(text: $model.searchText)
} else {
Text("Pick a category")
}
}
}
リストに列挙する項目は、コンピューテッドプロパティとして定義し、検索文字列が空文字列でない場合、selectedCategoryのItemの配列にfilterで絞り込みをして返します。
@Published var searchText: String = ""
var filteredItems: [item] {
if let category = selectedCategory {
if searchText.isEmpty {
return category.items
} else {
return category.items.filter({$0.name.localizedCaseInsensitiveContains(searchText)})
}
} else {
return []
}
}
filterの条件は、大文字/小文字を無視して、検索文字列を含むかどうで判断することにし、以下の関数を使用しています。
localizedCaseInsensitiveContains(_:)
https://developer.apple.com/documentation/swift/stringprotocol/localizedcaseinsensitivecontains(_:)
5.編集機能をつけたリストで遷移したい、スワイプで編集したい
NavigationSplitViewのリストに、SwiftUIに標準で存在する編集機能を組み合わせた上で、遷移するように実装します。標準の編集機能を使用するには、EditModeを使用します。また、スワイプして編集する機能を組み込みます。筆者がAppStoreに公開しているAppでは、リストの編集は直接は行えず、詳細画面側で削除する機能しかありません。スワイプによる操作はAppのモダナイズのために、今後取り込んで行きたい機能です。
EditMode
https://developer.apple.com/documentation/swiftui/editmode
編集機能付きのリストで遷移
中央のカラムは、環境変数editModeの取得やEditButtonのツールバーへの配置を行うとともに、Listへの項目の列挙を編集可能なForEachで実施するように変更します。onDelete、onMoveなど、editActionsに対応するモディファイアを追加し、それぞれの処理を定義します。Listには、NavigationSplitViewのSelectionを保持するselectionのみを残します。
ForEach
init(_:editActions:content:)
https://developer.apple.com/documentation/swiftui/foreach/init(_:editactions:content:)
onDelete(perform:)
https://developer.apple.com/documentation/swiftui/dynamicviewcontent/ondelete(perform:)
onMove(perform:)
https://developer.apple.com/documentation/swiftui/dynamicviewcontent/onmove(perform:)
EditModeやEditButtonは、今回はリストの編集にしか使用しておらず、リストのインタフェースの変更は標準機能だけで実現しています。TextをTextFieldに切り替える、といったインタフェースの変更をする場合には、モードの状態を判定する処理が必要になります。
また、スワイプでの編集を行えるように、swipeActionsモディファイアを追加しています。左にスワイプすると削除ができるように設定しています。
swipeActions(edge:allowsFullSwipe:content:)
https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)
onDelete、onMoveはリストのForEach側に設定しますが、swipeActionsはリストの項目側に設定します。swipeActionsは、edgeを.trailingにすると、ForEachのeditActionsの.deleteで設定される削除ボタンを上書きします。onDeleteのモディファイアは呼び出されなくなりますので定義していません(swipeActionsをサポートしていないOSバージョンも対象とする場合は、onDeleteは必要です)。
import SwiftUI
struct Content_editable: View {
@EnvironmentObject private var model: DataModel
@Environment(\.editMode) private var editMode
var body: some View {
if let category = model.selectedCategory {
List(selection: $model.selectedItem) {
ForEach($model.targetItems, editActions: [.delete, .move]) { $item in
NavigationLink(value: item, label: { Label(item.name, systemImage: item.iname) })
.foregroundStyle(item.color)
.swipeActions(edge: .trailing, content: {
Button(role: .destructive) {
if model.selectedItem == item {
model.selectedItem = nil
}
model.targetItems.removeAll(where: {$0.id == item.id})
if let i = model.categories.map({$0.id}).firstIndex(of: category.id) {
model.categories[i].items = model.targetItems
}
} label: {
Label("Delete", systemImage: "trash")
}
})
}
.onMove(perform: { indices, newOffset in
model.targetItems.move(fromOffsets: indices, toOffset: newOffset)
if let i = model.categories.map({$0.id}).firstIndex(of: category.id) {
model.categories[i].items = model.targetItems
}
})
}
.toolbar(content: {
EditButton()
})
} else {
Text("Pick a category")
}
}
}
swipeActions、onMoveでは、ForEachで列挙の対象とするtargetItemsに変更を反映した後、Categoryのitemsに書き戻しています。Swiftは値渡しであり、CategoryのitemsとtargetItemsでは実体が異なるため、このような処理が必要になります。
Categoryに書き戻す際に、selectedCategoryから、配列上の実体を特定するためにfirstIndexを使用しています。firstIndexはEquatableに準拠して比較しますが、「=」の処理を実装しないと全プロパティが完全に一致する場合のみtrueになります。swipeActionsやonMoveが呼びされる際には、UIの内部的なitemsは項目数や順序が変わっているため、firstIndexがtrueにならない場合があります。そのため、Categoryの比較はidで実施しています(Category構造体側にEquatableの「=」の処理を定義し、idで比較するように実装することも可能です)。
左のカラムでは、onChangeでCategoryの変更を検知して、ForEachの配列を切り替えています(こちらも値渡し)。
import SwiftUI
struct Sidebar_editable: View {
@EnvironmentObject private var model: DataModel
var body: some View {
List(model.categories, id: \.self, selection: $model.selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.onChange(of: model.selectedCategory) {
model.selectedItem = nil
if let category = model.selectedCategory, let i = model.categories.map({$0.id}).firstIndex(of: category.id) {
model.targetItems = model.categories[i].items
} else {
model.targetItems = []
}
}
}
}
6.検索と編集の両方ができるリストで遷移したい
NavigationSplitViewのリストに、検索と編集の両方をできるようにした上で、遷移するように実装します。NavigationSplitViewのSelection周りは、編集のみの場合と同様ですが、リスト項目を格納する配列にfilteredItemsとtargetItemsの機能をどのように持たせるか、検索で絞り込んだ状態でのリスト項目の移動をどう扱うか、検討します。
検索、編集機能付きのリストで遷移
リスト項目を格納する配列は、編集をする都合上、コンピューテッドプロパティは使用できないため、実体を持つtargetItemsを使います。searchTextが空文字列でない場合は、選択されたCategoryのitemsをfilterで絞り込んで設定します。設定をする関数setTargets()をデータモデルに定義します。
@Published var targetItems: [Item] = []
func setTargets() {
if let category = selectedCategory {
if searchText.isEmpty {
targetItems = category.items
} else {
targetItems = category.items.filter({$0.name.localizedCaseInsensitiveContains(searchText)})
}
} else {
selectedItem = nil
targetItems = []
}
}
検索で絞り込んだ状態でのリスト項目の移動は、そもそも動作が定義できません。moveDisabledモディファイアを使用し、searchTextが空文字列ではない場合、trueにすることで移動を制限します。
moveDisabled(:)
https://developer.apple.com/documentation/swiftui/view/movedisabled(:)
中央のカラムでは、リストを構成するListにsearchableと検索文字列の変更を検知するonChange、中身を列挙する変更可能なForEachにonMove、並べるButtonにswipeActionsとmoveDisabledの各モディファイアを設定しています。
import SwiftUI
struct Content_full: View {
@EnvironmentObject private var model: DataModel
@Environment(\.editMode) private var editMode
var body: some View {
if let category = model.selectedCategory {
List(selection: $model.selectedItem) {
ForEach($model.targetItems, editActions: [.delete, .move]) { $item in
Button {
model.selectedItem = item
} label: {
ListRow(item: item)
}
.swipeActions(edge: .trailing, content: {
Button(role: .destructive) {
if model.selectedItem == item {
model.selectedItem = nil
}
if let i = model.categories.map({$0.id}).firstIndex(of: category.id) {
model.categories[i].items.removeAll(where: {$0.id == item.id})
}
model.targetItems.removeAll(where: {$0.id == item.id})
} label: {
Label("Delete", systemImage: "trash")
}
})
.moveDisabled(!model.searchText.isEmpty)
}
.onMove(perform: { indices, newOffset in
model.targetItems.move(fromOffsets: indices, toOffset: newOffset)
if let i = model.categories.map({$0.id}).firstIndex(of: category.id) {
model.categories[i].items = model.targetItems
}
})
}
.searchable(text: $model.searchText)
.onChange(of: model.searchText) {
model.setTargets()
}
.toolbar(content: {
EditButton()
})
} else {
Text("Pick a category")
}
}
}
swipeActionsでの削除処理は、検索の絞り込み状態で実行される可能性があるため、Categoryのitemsへは書き戻すのではなく、removeAllで削除を直接実施しています。編集可能なForEachのcontentクロージャの引数はバイディング変数であるため、ForEachのtargetItemsからの削除を先に行うと、中身のItemが変わります。Categoryのitemsからの削除後にtargetItemsからの削除を行うようにしています(逆順にするとAppがクラッシュします)。
左のカラムでは、Categoryの変更を検知すると、setTargets()を呼ぶようにします。
import SwiftUI
struct Sidebar_full: View {
@EnvironmentObject private var model: DataModel
var body: some View {
List(model.categories, id: \.self, selection: $model.selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.onChange(of: model.selectedCategory) {
model.selectedItem = nil
model.setTargets()
}
}
}
検索での絞り込み中の移動ができないという制限はあるものの、検索と編集の両方が動作しますが、解決できない動作がありました。検索で絞り込みを行った状態で、削除を行い、検索を解除すると、リストに削除した項目が現れるという事象です。Categoryの選択が一時的に外れることが原因のようで、Categoryを選択し直すと削除された状態になります。SwiftUIの考慮不足と考えますが、いつか解決するのでしょうか。
NavigationSplitViewアレコレ
NavigationSplitViewのテスト実装の過程で気付いた事項を記載します。
NavigationSplitViewの表示スタイル
iPadでランドスケープ表示(縦持ち)した場合のNavigationSplitViewの表示について記載します。ネット上には、表示やスタイル設定の解説をしているサイトは多数ありますので、全般的な説明は対象外とします。
iPadでランドスケープ表示(縦持ち)した場合、デフォルトのままですと、起動時は右カラムとして設定したDetailが全画面表示されます。ツールバーのサイドバーボタンをタップすると、Detailにオーバーラップする形で、中央のカラムに設定したContentが左からスライドインして表示されます。さらにツールバーの「< Category」をタップすると、左カラムのSidebarが現れ、Contentはさらに右にスライドして、オーバーラップ面積が広がります。
全画面表示されるDetail
サイドバーボタンで現れるContent
さらに隣に現れるSidebar
最初にDetailのみが全画面表示されると使い勝手が悪いようであれば、columnVisibilityをdoubleColmunに設定すると、最初から中央のカラムのContentがオーバーラップして表示されるようにできます。
NavigationSplitView
init(columnVisibility:sidebar:content:detail:)
https://developer.apple.com/documentation/swiftui/navigationsplitview/init(columnvisibility:sidebar:content:detail:)
doubleColmun設定での起動時表示
オーバーラップ表示が望ましくない場合は、navigationSplitViewStyleモディファイアをbalancedに設定することで、画面幅を分割して表示するようにできます。ただ当該設定は、左と中央のカラムが現れると右カラムの幅が奪われますので、各カラムの幅を設定する必要があります。また、ポートレート表示(横持ち)でも同様の動作となります。
navigationSplitViewStyle(_:)
https://developer.apple.com/documentation/swiftui/view/navigationsplitviewstyle(_:)
doubleColmun、balanced設定での起動時表示
doubleColmun、balanced設定での3カラム表示(縦持ち)
doubleColmun、balanced設定での3カラム表示(横持ち)
colmunVisibilityはNavigationSplitViewの初期化時の引数でバインディング変数であるため、@State変数として宣言します。
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var model: DataModel
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility ) {
Sidebar()
.navigationTitle("Category")
} content: {
Content()
.navigationTitle("Item")
} detail: {
Detail()
}
.navigationSplitViewStyle(.balanced)
}
}
複数のSelection関連設定を同時に使用した場合の動作
NavigationSplitViewは、Listのselection、NavigationLink、navigationDestinationなど、複数の遷移を制御する要素を組み合わせて使用します。それでは、重ねて設定してしまった場合にはどのような動作になるのでしょうか。
List(category.items, id: \.id, selection: $model.selectedItem) { item in
NavigationLink(value: item, label: { Label(item.name, systemImage: item.iname) })
.foregroundStyle(item.color)
.navigationDestination(item: $model.selectedItem, destination: {_ in
Detail()
})
}
selectionの定義されたList内部で、さらにnavigationDestinationのようにselectionを設定する要素を使用した場合は、シングルカラム画面では、リスト項目をタップするとリスト上は一瞬だけ選択されて、右のカラムに遷移しますが、即元のカラムに戻るような動作をします。マルチカラム画面で見ると、リスト項目をタップしても選択されず、右のカラムは未選択状態の内容の表示のままでした。このような動作状態になったら、重ねて定義している可能性があると言えます。
navigationDestinationのAPIドキュメントには以下の記載があります。
You can add more than one navigation destination modifier to the stack if it needs to present more than one kind of data.
Do not put a navigation destination modifier inside a “lazy” container, like List or LazyVStack. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation split view can always see the destination.
NavigationSplitViewに2個以上のnavigationDestinationを追加しない、Listの中に置かない、を満たすようにした以下は期待通りに動作します。Listのselectionは合っても同様に動作しますが、ButtonをNavigationLinkに変えると動作しませんでした。
List(category.items, id: \.id) { item in
Button {
model.selectedItem = item
} label: {
ListRow(item: item)
}
}
.navigationDestination(item: $model.selectedItem, destination: {_ in
Detail()
})
終わりに
iOS/iPadOSには、バージョンが上がるごとに機能が追加されていきます。新しいOSバージョンで実装された機能を使用すると、それより前のバージョンのOSに対しては、機能を作るか、削るか、OS自体をターゲットから外すか、しないといけなくなります。新しいAPIが登場すると、古いAPIは場合によっては非推奨(Deprecated)になり、終息に向かいます。
一方で、APIを呼び出すApp側もビルドのターゲットにするOSやAPIのバージョンを定めますが、Swift Playgroundは、バージョン4.4までOSのターゲットバージョンを設定する機能がなく、15.2に固定されていました。Chartsや新しくなったMapなど魅力的なコンポーネントもありますが、それらを使う際には、コード上でOSバージョンによる分岐処理の実装が求められ煩雑でした。現在はターゲットバージョンの設定機能が付き、最新の4.6では、iOS/iPadOS 18.1までをターゲットにできます。
NavigationSplitViewのテスト実装を通して、画面遷移の制御のしかた、動作がわかってきました。iOS、iPad向けのAppをシングルソース、かつ、OSによる処理の分岐が発生しないように実装する、という筆者の実装スタイルも維持できそうです。Selectionベースに変わるのは、大きな変更ではありますが、NavigationViewが非推奨化されましたので、対応していかざるを得ません。積極的にターゲットOSを上げ、新しいAPIを使用していきたいところです。
リソース
ContentView()は、必要に応じて、Sidebar()、Content()の部分を書き換えれば動作します。ボタンの場合、ピッカーの場合など、組み合わせがあります。Detail()は全て共通です。データモデルとAppのエントリーポイント(@main)の実装を記載します。
1.データモデル
データモデルは、全ての場合を包含しており共通です。
import SwiftUI
class DataModel: ObservableObject {
@Published var categories: [Category] = [
Category(name: "Communication",
items: [
Item(name: "Microphone", iname: "microphone", color: .blue),
Item(name: "Message", iname: "message", color: .cyan),
Item(name: "Phone", iname: "phone", color: .brown)
]),
Category(name: "Weather",
items: [
Item(name: "Sun", iname: "sun.max", color: .gray),
Item(name: "Cloud", iname: "cloud", color: .purple),
Item(name: "Wind", iname: "wind", color: .orange),
Item(name: "Rain", iname: "umbrella", color: .teal)
]),
Category(name: "Traffic",
items: [
Item(name: "Airplane", iname: "airplane", color: .red),
Item(name: "Car", iname: "car.side", color: .green),
Item(name: "Truck", iname: "truck.box", color: .indigo)
])
]
@Published var selectedCategory: Category? = nil
@Published var selectedItem: Item? = nil
@Published var pickerSelection: Item = Item()
@Published var searchText: String = ""
var filteredItems: [Item] {
if let category = selectedCategory {
if searchText.isEmpty {
return category.items
} else {
return category.items.filter({$0.name.localizedCaseInsensitiveContains(searchText)})
}
} else {
return []
}
}
@Published var targetItems: [Item] = []
func setTargets() {
if let category = selectedCategory {
if searchText.isEmpty {
targetItems = category.items
} else {
targetItems = category.items.filter({$0.name.localizedCaseInsensitiveContains(searchText)})
}
} else {
selectedItem = nil
targetItems = []
}
}
}
struct Category: Identifiable, Hashable {
var id = UUID()
var name = ""
var items:[Item] = []
}
struct Item: Identifiable, Hashable {
var id = UUID()
var name = ""
var iname = ""
var color: Color = .red
}
2.@main
Appのエントリーポイントは、Swift PlaygroundのAppのデフォルトのテンプレートをそのまま使用し、MyApp.swiftとしています。
import SwiftUI
@main
struct MyApp: App {
@StateObject private var model = DataModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
おしまい
Discussion