[SwiftUI] List の selection と NavigationLink

SwiftUI2021

     

TAGS:

⌛️ 4 min.
List の selection と NavigationLink の関係を調べました。

環境&対象

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

  • macOS Monterey 12.4 beta3
  • Xcode 13.3.1
  • iOS 15.4

発端

macOS 向けアプリを開発しているときに、List の要素に、NavigationLink を使っていると selection が期待通りに設定されないことに気づきました。

とりあえずは、無理矢理の実装で進めたのですが、例によって、どこにもドキュメントが見つからなかったので、調査とその結果を記録しておきたいというのがこの記事を書いたモチベーションです。

検索語がよくなかったのか、StackOverflow にも見つかりませんでした。
# もちろん、Apple のドキュメントに期待してはいけません・・・・😢

iOS と macOS も比較します

特に SwiftUI にはありがちなのですが、iOS と macOS では、振る舞いが違うこともあります。

ですので、iOS, macOS の両方で確認していきます。(tvOS や watchOS は、対象としていません。)

ベースアプリ

以下のような TabView のアプリでそれぞれに使い方の異なる List を表示して動作を確認していきます。
以降で Text(…) をそれぞれの List を使ったビューに置き換えていきます。

# あまり よくありませんが、リスト表示用データは、グローバル変数にしてます・・・

ContentView.swift

let data = ["0", "1", "2", "3"]

struct ContentView: View {
    var body: some View {
        TabView {
            Text("Single Selection")
                .tabItem { Image(systemName: "1.circle"); Text("Single") }
            Text("Multiple Selection")
                .tabItem { Image(systemName: "1.square"); Text("Single+Nav") }
            Text("Single Nav Selection")
                .tabItem { Image(systemName: "number.circle"); Text("Multi") }
            Text("Multiple Nav Selection")
                .tabItem { Image(systemName: "number.square"); Text("Multi+Nav") }
        }
        .padding()
    }
}

List の selection

List には、selection という引数があり、選択されている要素を保持することができます。

MEMO

リスト上で選択可能にするためには、iOS では、.editMode が .active であることが必要です。

selection に与える引数により、”1要素のみ選択可能” か “複数要素を選択可能” を設定することができます。

single selection

1要素のみ選択可能なリストとして SingleSelection View を作りました。

・onChange を使って、selection の変更を表示するようにして値の変更を確認できるようにしています。
・iOS では、.editMode を .active にする必要がありますので、そのようにしています。(以降のビューでも同様です)
[Swift] #if os, #available, @available をつかった、コードの切り分け

以降、複数のビューを追加していきますが、ContentView については、同様の変更を行うだけなので、省略していきます。ContentView の最終的なコードについては、最後の動作確認コードを見てください。

SingleSelection: View

let data = ["0", "1", "2", "3"]

struct ContentView: View {
    var body: some View {
        TabView {
            SingleSelection()
                .tabItem { Image(systemName: "1.circle"); Text("Single") }
#if os(iOS)
                .environment(\.editMode, .constant(.active))
#endif
            Text("Hello world")
                .tabItem { Image(systemName: "1.square"); Text("Single+Nav") }
            Text("Hello world")
                .tabItem { Image(systemName: "number.circle"); Text("Multi") }
            Text("Hello world")
                .tabItem { Image(systemName: "number.square"); Text("Multi+Nav") }
        }
        .padding()
    }
}
struct SingleSelection: View {
    @State private var selection: String? = nil
    
    var body: some View {
        List(data, id: \.self, selection: $selection) { datum in
            Text("\(datum)")
                .tag(datum)
        }
        .onChange(of: selection, perform: { newValue in
            print("selection changed to \(selection ?? "nil")")
        })
    }
}

iOS, macOS それぞれ 以下のような表示になりました。

Single_ios
Single_macos

iOS/macOS での振る舞い相違点(List の single selection)

ありません。いずれも 選択された要素が、selection に保持されます。

multi selection

次に複数要素が選択可能なリストとして MultiSelection View を作りました。

MultiSelection: View

struct MultiSelection: View {
    @State private var selections: Set<String> = Set()
    
    var body: some View {
        List(data, id: \.self, selection: $selections) { datum in
            Text("\(datum)")
                .tag(datum)
        }
        .onChange(of: selections, perform: { newValue in
            print("selection changed to \(selections)")
        })
    }
}

以下のような表示になります。

Multi_ios
Multi_macos

iOS/macOS での振る舞い相違点(List の selection)

ありません。いずれも 選択された複数要素が、 selections に保持されます。

# macOS で複数選択するには、⌘キーや Shift キー 等を使うことが必要です

NavigationLink との組み合わせ

List の選択と組み合わせて、NavigationLink を使うことがよくあります。

「複数要素が表示されたリストから選択された要素の詳細を表示する」というような UI です。

以下では、List の表示要素として、NavigationLink を使うケースの動作を確認しました。
# このケースで期待と異なる場合があることを確認しました。

single selection

SingleSelection View での List の要素を NavigationLink にしたものです。

iOS であれば、NavigationLink で指定したビューに遷移してしまうので、selection の値は気にならないかもしれませんが、macOS や (表示形態によっては、iPad OSでも) 元のリストは表示されたままですので、selection の値は大切です。

SingleNavSelection: View

struct SingleNavSelection: View {
    @State private var selection: String? = nil
    
    var body: some View {
        NavigationView {
            List(data, id: \.self, selection: $selection) { datum in
                NavigationLink("\(datum)", destination: {
                    Text("Detail for \(datum)")
                })
                .tag(datum)
            }
        }
        .onChange(of: selection, perform: { newValue in
            print("selection changed to \(selection ?? "nil")")
        })
    }
}

iOS, macOS それぞれ 以下のような表示になりました。

Single_Nav_ios
Single_Nav_macos

iOS/macOS での振る舞い相違点(List の selectionと NavigationLink の組み合わせ)

iOS では、selection は、設定されません。設定されずに、NavigationLink に従った画面遷移が行われます。
macOS では、selection は、設定されます。設定後、NavigationLink に従った画面表示が行われます。

MEMO

個人的には、iOS の振る舞いは驚きでした。どうして このような振る舞いなのでしょうか??
ご存知の方教えてください 🙏

不思議な(?)、振る舞いは、まだまだ続きます!

multi selection

MultiSelection View での List の要素を NavigationLink にしたものです。

struct MultiNavSelection: View {
    @State private var selections: Set<String> = Set()
    
    var body: some View {
        NavigationView {
            List(data, id: \.self, selection: $selections) { datum in
                NavigationLink("\(datum)", destination: {
                    Text("Detail for \(datum)")
                })
                .tag(datum)
            }
        }
        .onChange(of: selections, perform: { newValue in
            print("selection changed to \(selections)")
        })
    }
}

iOS, macOS それぞれ 以下のような表示になりました。

Multi_Nav_ios
Multi_Nav_ios

iOS/macOS での振る舞い相違点(List の selection と NavigationLink の組み合わせ)

single selection の時と同じように iOS では、selections は、設定されません。設定されずに、NavigationLink に従った画面遷移が行われます。1つ選択した段階で、画面遷移してしまいますので、複数選択には意味がありません。

macOS での selections も不思議な動作をします。

言葉で説明すると、以下のようになります。
2個目の要素を選択すると 画面遷移するとともに、1個もしくは0個の選択になろうとします。
1個か0個かの条件は、リスト上の並びに依存しているように見えます。

言葉での説明は、わかりにくいと思うので、動画にしました。マウス操作とコンソールの表示を見てください。

以下のような操作をしています。 ‘ のついたものは、内部的な動作を追記したものです。
1) 0 を選択
2) 0 の選択解除
3) 1 を選択
4) 1 を選択解除 – ここまでは OK
5) 0 を選択
6) 2 を追加選択 (ログを見ると 一度は、0 と 2 が selections に設定されています。)
6′) 2 だけの選択となる (ログを見ると 2 だけが selections に設定されています。)
7) 3 を追加選択 (ログを見ると 一度は、2 と 3 が selections に設定されています。)
7′) 3だけの選択となる (ログを見ると 3 だけが selections に設定されています。)
8) 2 を追加選択 (ログを見ると 一度は、2 と 3 が selections に設定されています。)
8′) 無選択の状態となる (ログを見ると selections が空になっています。)

MEMO
個人的には、どちらの振る舞いも驚きでした。
こちらについても 理由をご存知の方教えてください 🙏

動作確認コード(まとめ)

以下が、動作確認に使用したコードです。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/05/02
//  © 2022  SmallDeskSoftware
//

import SwiftUI

let data = ["0", "1", "2", "3"]

struct ContentView: View {
    var body: some View {
        TabView {
            SingleSelection()
                .tabItem { Image(systemName: "1.circle"); Text("Single") }
#if os(iOS)
                .environment(\.editMode, .constant(.active))
#endif
            SingleNavSelection()
                .tabItem { Image(systemName: "1.square"); Text("Single+Nav") }
#if os(iOS)
                .environment(\.editMode, .constant(.active))
#endif
            MultiSelection()
                .tabItem { Image(systemName: "number.circle"); Text("Multi") }
#if os(iOS)
                .environment(\.editMode, .constant(.active))
#endif
            MultiNavSelection()
                .tabItem { Image(systemName: "number.square"); Text("Multi+Nav") }
#if os(iOS)
                .environment(\.editMode, .constant(.active))
#endif

        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct SingleSelection: View {
    @State private var selection: String? = nil
    
    var body: some View {
        List(data, id: \.self, selection: $selection) { datum in
            Text("\(datum)")
                .tag(datum)
        }
        .onChange(of: selection, perform: { newValue in
            print("selection changed to \(selection ?? "nil")")
        })
    }
}

struct SingleNavSelection: View {
    @State private var selection: String? = nil
    
    var body: some View {
        NavigationView {
            List(data, id: \.self, selection: $selection) { datum in
                NavigationLink("\(datum)", destination: {
                    Text("Detail for \(datum)")
                })
                .tag(datum)
            }
        }
        .onChange(of: selection, perform: { newValue in
            print("selection changed to \(selection ?? "nil")")
        })
    }
}

struct MultiSelection: View {
    @State private var selections: Set = Set()
    
    var body: some View {
        List(data, id: \.self, selection: $selections) { datum in
            Text("\(datum)")
                .tag(datum)
        }
        .onChange(of: selections, perform: { newValue in
            print("selection changed to \(selections)")
        })
    }
}

struct MultiNavSelection: View {
    @State private var selections: Set = Set()
    
    var body: some View {
        NavigationView {
            List(data, id: \.self, selection: $selections) { datum in
                NavigationLink("\(datum)", destination: {
                    Text("Detail for \(datum)")
                })
                .tag(datum)
            }
        }
        .onChange(of: selections, perform: { newValue in
            print("selection changed to \(selections)")
        })
    }
}

まとめ

List の selection と NavigationLink の組み合わせでの使い方注意点

List の selection と NavigationLink の組み合わせでの使い方注意点
  • iOS では List の要素に NavigationLink を使うと、List の selection は設定されない
  • macOS では、List の要素に NavigationLink を使っても List の selection は “一定分” 設定される

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

コメントを残す

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