SwiftUIで純正メモ風の画面を作りながら、NavigationSplitViewとNavigationStackを整理する

純正メモアプリのような3カラム画面を題材に、selectionとpathをどう分けて扱うのか、実際のサンプルコードを使って整理します。

0:00
/0:09

SwiftUIのナビゲーションを勉強していると、NavigationStackNavigationPathNavigationSplitViewと似た名前が次々に出てきます。

ひとつずつ読んでいる間は分かったつもりになるのですが、いざ「純正メモアプリのような画面を作るなら、どれをどう組み合わせればいいんだろう」と考えると、急に自信がなくなりました。

そこで今回は、次のような小さなメモアプリを作って整理してみました。

フォルダ選択 → メモ選択 → メモ詳細内の追加画面
     selection    selection       path

iPadではフォルダ、メモ一覧、メモ詳細が3カラムで表示されます。iPhoneでは同じ画面が横幅に合わせて折りたたまれ、「フォルダ → メモ一覧 → メモ詳細」と順番に進めます。

今回作ったコードはこちらです。

SatoTakeshiX/swiftui-learning-lab - NotesNavigationSample

selectionとpathを分けて考える

純正メモのような画面では、フォルダ選択から詳細表示までのすべてをひとつのpathへ詰め込む必要はありません。

今回の構成では、状態を大きく2種類に分けています。

  • NavigationSplitViewの各列で何を選んでいるか
  • 詳細列の中で、さらにどの画面へ進んだか

前者はselection、後者はpathで管理します。ここを分けて考えると、全体がかなり見通しよくなりました。

まずは3つの列を作る

ルート画面では、NavigationSplitViewを使ってSidebar、Content、Detailの3つを並べています。

NavigationSplitView(
    preferredCompactColumn: $navigation.preferredCompactColumn
) {
    FolderList(
        folders: store.folders,
        selection: Binding(
            get: { navigation.selectedFolderID },
            set: { navigation.selectFolder($0) }
        )
    )
} content: {
    NoteList(
        notes: store.notes(in: navigation.selectedFolderID),
        folderName: store.folder(id: navigation.selectedFolderID)?.name,
        selection: Binding(
            get: { navigation.selectedNoteID },
            set: { navigation.selectNote($0) }
        )
    )
} detail: {
    NoteDetailContainer(
        note: store.note(id: navigation.selectedNoteID),
        path: $navigation.detailPath
    )
}

ここで中心になる状態は、選択中のフォルダIDとメモIDです。

var selectedFolderID: Folder.ID?
var selectedNoteID: Note.ID?

Sidebarでフォルダを選ぶとselectedFolderIDが変わり、そのIDに属するメモがContent列へ表示されます。Content列でメモを選ぶとselectedNoteIDが変わり、Detail列に本文が表示されます。

つまり、Split Viewでは「次の画面をpushする」というより、「現在の選択値が変わった結果、隣の列に表示する内容が決まる」と考えるほうが自然でした。

フォルダ一覧は次のように作っています。

List(folders, selection: $selection) { folder in
    NavigationLink(value: folder.id) {
        Label(folder.name, systemImage: folder.symbol)
    }
}

ここでは、NavigationLinkvalueListselectionが同じ型になるようにします。

フォルダ一覧なら、どちらもFolder.IDです。

@Binding var selection: Folder.ID?

メモ一覧も同じで、NavigationLinkの値にはnote.idselectionにはNote.ID?を使っています。

この形にしておくと、iPadの複数カラムでも、iPhoneの折りたたまれた表示でも、SwiftUIが列同士の関係をナビゲーションとして扱ってくれます。

タップ時にonTapGestureでIDを代入する方法も考えられますが、画面遷移として表したい場所では、まずNavigationLink(value:)List(selection:)の組み合わせを使うのがよさそうです。

フォルダを変えたら、以前のメモ選択を消す

フォルダを切り替えたとき、以前のフォルダで選んでいたメモがDetail列に残ると不自然です。

そのため、フォルダ選択を変更する処理では、メモ選択と詳細画面のpathも一緒に空にしています。

func selectFolder(_ folderID: Folder.ID?) {
    guard selectedFolderID != folderID else { return }

    selectedFolderID = folderID
    selectedNoteID = nil
    detailPath.removeAll()
}

メモを選び直したときも、それまで開いていた「編集履歴」などを残さないようにpathをリセットします。

func selectNote(_ noteID: Note.ID?) {
    selectedNoteID = noteID
    detailPath.removeAll()

    if noteID != nil {
        preferredCompactColumn = .detail
    }
}

上の階層が変わったら、下の階層の状態を消す。このルールをナビゲーション用のモデルにまとめたことで、View側が少し読みやすくなりました。

詳細列の奥だけNavigationStackにする

メモ詳細からは、さらに次の画面へ進めるようにしました。

  • 添付ファイル
  • 編集履歴
  • 共同編集者

ここでNavigationStackとpathを使います。

まず、詳細列で起こり得る遷移をenumにまとめました。

enum NoteDetailRoute: Hashable, Codable {
    case attachment(UUID)
    case history
    case collaborators
}

そして、詳細列を表示するコンテナの中にNavigationStackを置きます。

NavigationStack(path: $path) {
    Group {
        if let note {
            NoteDetailView(note: note)
        } else {
            ContentUnavailableView(
                "メモを選択",
                systemImage: "note.text"
            )
        }
    }
    .navigationDestination(for: NoteDetailRoute.self) { route in
        destination(for: route)
    }
}

今回は遷移に使う型をNoteDetailRouteへ統一できるため、pathはNavigationPathではなく型付き配列にしました。

var detailPath: [NoteDetailRoute] = []

型がコードから見えるので、どの画面へ遷移できるのかを追いやすくなります。複数の異なる型を同じスタックに入れる必要がなければ、まずは[Route]のような型付きpathから考えるのが扱いやすそうです。

実際の遷移は普通のNavigationLinkで書けます。

NavigationLink(value: NoteDetailRoute.history) {
    Label("編集履歴", systemImage: "clock.arrow.circlepath")
}

このリンクで変化するのはSplit Viewの選択値ではなく、Detail列内のpathです。

前回開いていたメモを復元する

サンプルでは、最後に選んだフォルダとメモをAppStorageへ保存しています。

@AppStorage("lastFolderID") private var lastFolderID = ""
@AppStorage("lastNoteID") private var lastNoteID = ""

保存するのはViewそのものではなく、選択していたデータのIDです。

起動時には、保存されていたIDをそのまま代入するのではなく、現在のデータにまだ存在するかを確認します。

guard
    let folderID = UUID(uuidString: folderIDString),
    store.folder(id: folderID) != nil
else { return }

selectedFolderID = folderID

guard
    let noteID = UUID(uuidString: noteIDString),
    let note = store.note(id: noteID),
    note.folderID == folderID
else { return }

selectedNoteID = noteID
preferredCompactColumn = .detail

メモが削除されていたり、別のフォルダへ移動していたりする可能性があるためです。

実際のアプリでデータベースを使う場合は、次の順番になります。

  1. データベースからフォルダとメモを読み込む
  2. 保存されているIDが現在も存在するか確認する
  3. フォルダのselectionを復元する
  4. メモのselectionを復元する
  5. 必要なら詳細列のpathも復元する

今回は固定のサンプルデータなので単純ですが、復元する順序は実際のアプリでも意識しておきたいところです。

iPhoneとiPadで同じコードを使える

NavigationSplitViewは、十分な横幅がある環境では複数の列を表示し、iPhoneのように横幅が狭い環境では自動的に折りたたまれます。

そのため、このサンプルでは端末ごとに別のナビゲーションを組み立てていません。

iPhoneでは、メモ詳細から標準の戻るボタンを使って、メモ一覧、フォルダ一覧へ戻れます。起動時に保存済みのメモを復元できた場合は、preferredCompactColumn.detailにして詳細列を優先しています。

var preferredCompactColumn: NavigationSplitViewColumn = .sidebar

これはselectionの代わりではなく、コンパクト表示のときにどの列を優先したいかを補う状態として使っています。

作ってみて整理できたこと

今回のサンプルを作って、SwiftUIのナビゲーションを次のように分けて考えられるようになりました。

  • NavigationSplitViewは、アプリの大きな階層を複数の列で表す
  • selectionは、各列で現在選ばれているデータを表す
  • NavigationStackは、ひとつの列の中からさらに深い画面へ進むために使う
  • pathは、そのStack内の遷移履歴を表す
  • 復元するときはViewではなくIDを保存する
  • ナビゲーションに使う値はHashableにする
  • 永続化したいRouteはCodableにもしておく

「ナビゲーションは全部NavigationPathで管理する」と考えていたときよりも、選択状態と遷移履歴を分けたほうが、純正メモのような画面構成には素直に当てはまりました。

このサンプルは、iPhoneとiPadの両方で動かせます。コードだけでなく実際の戻る動作や、アプリを再起動したあとの選択状態も触ってみると、それぞれの状態が何を担当しているのか分かりやすいと思います。

今後も、SwiftUIを勉強しながら作った小さなサンプルを、swiftui-learning-labへ追加していく予定です。