NavigationLinkとList

Discordの [swift-developers-japanサーバー](https://medium.com/swift-column/discord-ios-20d586e373c0) で興味深い話題があったので、気になって調べてみたものを雑にまとめました :slight_smile: （話題自体は [このへん](https://discordapp.com/channels/291054398077927425/585184753364238346/726002870708600849) から）

特に明記していない限り、Xcode 11.5とシミュレーター（iOS 13.5、iPhone SE (1st generation)）で確認しています。SwiftUIは ~~まだ発展途上なので~~ 進化が早いので、しばらく経つとここに書いたものは全く意味をなさないゴミになってるかもしれませんが、むしろ早くそうなることを望んでいます :sweat_drops: 

## NavigationLinkで遷移する前の画面はつながっている

まずは `NavigationLink` を使ったこんな例を見てください。
A, B, Cのボタンが並んだ画面（一覧画面）が表示され、Aを押すと詳細画面へ遷移します。

<img width="290" alt="FirstView" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/279744af-0599-2704-c305-da72abbc106e.png"> → <img width="290" alt="SecondView" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/ae4362d1-d4b0-c079-5973-0ead1479730f.png">

全体のコードはこうなっています。

```swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            FirstView()
        }
        .environmentObject(ItemContainer())
    }
}

struct FirstView: View {
    @EnvironmentObject var container: ItemContainer
    
    var body: some View {
        VStack(spacing: 16) {
            ForEach(container.items) { item in
                NavigationLink(destination: SecondView(id: item.id)) {
                    Text("\(item.id)")
                }
            }
        }
        .navigationBarTitle("Items")
    }
}

struct SecondView: View {
    @EnvironmentObject var container: ItemContainer
    let id: String
    
    var body: some View {
        VStack(spacing: 16) {
            Button("rotate") {
                self.container.rotateItem()
            }
            
            Button("remove first") {
                self.container.removeItem(id: self.container.items.first?.id ?? "")
            }
            
            Button("remove this") {
                self.container.removeItem(id: self.id)
            }
        }
        .navigationBarTitle(id)
    }
}

struct Item: Identifiable {
    let id: String
}

final class ItemContainer: ObservableObject {
    @Published var items: [Item] = [
        Item(id: "A"),
        Item(id: "B"),
        Item(id: "C"),
    ]
    
    func rotateItem() {
        var items = self.items
        if let item = items.popLast() {
            items.insert(item, at: 0)
        }
        self.items = items
    }
    
    func removeItem(id: String) {
        items = items.filter { $0.id != id }
    }
}
```

さて、Aの詳細画面にて「rotate」をタップすると、 `ItemContainer` の `rotateItem()` が呼ばれて、最後のアイテムを先頭へ持ってくるように並び替えます。つまり A, B, C だったものが C, A, B になるわけです。

前の画面（ `FirstView` ）は `ItemContainer` を参照しているので、このとき画面には見えていませんが、UIはちゃんと作り直されています。
「rotate」をタップしてちょろっと前の画面へpopしかけるジェスチャーをすればボタンの並びが C, A, B に変わっているのがわかります。もう一度「rotate」をタップすると B, C, A になっています。

<img width="290" alt="rotate" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/7833110f-5c0c-4ada-3b48-9198fb70c65a.gif">

次に「remove first」をタップすると、先頭のアイテムが削除されます。 B, C, A だったものが C, A になります。

<img width="290" alt="remove first" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/49bd38bb-e16a-91f6-e32c-61a01f36d4c3.gif">

「remove this」をタップすると、自分自身のアイテムを削除します。 A が削除されて C だけが残るはずですが、そうするとどうなるでしょう？

<img width="290" alt="remove this" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/c2656f93-275f-8b11-4cfb-87cc54976f63.gif">

答えは、自動的に一覧画面へ戻ります。おそらく、Aの詳細画面のViewや、そこへ遷移していた `NavigationLink` が存在しなくなったからでしょう。
つまり、前の画面のUIが作り変えられることが、自分の画面に影響するということです。
画面遷移単位ではなくて、全部がつながっているんだと考えると、一貫性のある妥当な動作だと言えます。

※なお、Aの詳細画面からさらに `NavigationLink` を使って第3画面まで進めるようにして、そこで A を削除すると、なぜか画面は戻りません。そこで手動で前の画面へ戻るとそのままAの詳細画面にいる矛盾した状態が作れます（iOS 13.5）。しかし、 Xcode 12 beta + iOS 14 beta では、第3画面で A を削除した時点で一覧画面まで戻るので、2つ画面を進んだ時に矛盾が発生するこの動作はおそらくiOS 13のバグなんじゃないかと思います。

## 一覧画面を `List` に変更する

ここで `FirstView` の `body` を `VStack` + `ForEach` ではなく、 `List` に変えてみます。

```swift
var body: some View {
    List(container.items) { item in
        NavigationLink(destination: SecondView(id: item.id)) {
            Text("\(item.id)")
        }
    }
    .navigationBarTitle("Items")
}
```

こちらの方がより一般的でしょう。画面はこうなります。

<img width="290" alt="List" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/4e81e393-636a-d450-bc24-0a5eb6acb4ac.png">

ここから先ほどと同じように、Aの詳細画面へ遷移して、同じように「rotate」をタップしてみます。A, B, C だったものが C, A, B の並び変わるだけのはずですが、やってみると、なぜか一覧画面に戻ってしまいます。

<img width="290" alt="List rotate" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33397/2a1dc9e6-6e72-2a3b-889b-ef9376a07fd7.gif">

これはちょっと困ってしまう挙動です。Aが削除されたわけではないのに勝手に画面が戻ってしまうのでは使い物になりません。

## 何が起こっているのだろうか（推測）

「rotate」で前の画面へ戻った際に、コンソールになにやらログが出ています。

```
[TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window). This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc), and will also cause unnecessary performance overhead due to extra layout passes. Make a symbolic breakpoint at UITableViewAlertForLayoutOutsideViewHierarchy to catch this in the debugger and see what caused this to occur, so you can avoid this action altogether if possible, or defer it until the table view has been added to a window. Table view: <_TtC7SwiftUIP33_BFB370BA5F1BADDC9D83021565761A4925UpdateCoalescingTableView: 0x7ffab2044800; baseClass = UITableView; frame = (0 0; 320 568); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x600000a54db0>; layer = <CALayer: 0x6000004becc0>; contentOffset: {0, -111}; contentSize: {320, 132}; adjustedContentInset: {111, 0, 0, 0}; dataSource: <_TtGC7SwiftUIP13$7fff2c9a5ad419ListCoreCoordinatorGVS_20SystemListDataSourceOs5Never_GOS_19SelectionManagerBoxS2___: 0x7ffab1514330>>
```

> UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window).

どうやら `List` は内部的には `UITableView` を使って実現されているようですが、ビュー階層（ここでのビューは `UIView` ）に存在しないときにそれを更新しようとして失敗してるように見えます。
おそらく見えている部分のセルに対応する `View` が仮想Viewツリー上に構築されるようになっているんじゃないかと思うのですが、そこで失敗していったん `List` の下のViewツリーがなくなっちゃってるのかもしれませんね。

`UITableViewAlertForLayoutOutsideViewHierarchy` にシンボリックブレークポイントを張るとデバッガでキャッチできるよ、とあるので張ってみましたが、 `-cellForRowAtIndexPath:` を呼んでいるなということしかわかりませんでした。

##### スタックトレース（の一部）

```
#0	0x00007fff48e6ddd0 in UITableViewAlertForLayoutOutsideViewHierarchy ()
#1	0x00007fff48e6cacc in -[UITableView _updateVisibleCellsNow:] ()
#2	0x00007fff48e81c3b in -[UITableView _cellForRowAtIndexPath:usingPresentationValues:] ()
#3	0x00007fff48e81b10 in -[UITableView cellForRowAtIndexPath:] ()
#4	0x00007fff2c606dc6 in ListCoreCoordinator.updateListContents(_:) ()
#5	0x00007fff2c6062a7 in ListCoreCoordinator.updateUITableView(_:to:) ()
#6	0x00007fff2c604e94 in ListRepresentable.updateUIView(_:context:) ()
```

## まとめ

- 画面遷移しても、Viewとしては前画面とつながっているし、見えていなくても前画面は更新される。それが現在の画面に影響を与えることもある。（これ自体は矛盾が発生しない一貫性のある妥当な動作だと思います）
  - が、iOS 13.5時点ではさらに遷移して3画面になるとなんか一貫性が崩れる → iOS 14 betaでは直っているのでこれはバグか？
- `List` から `NavigationLink` で画面遷移したあと、前画面の `List` で内容の順番に変更が入るだけで前画面に戻される。困る :fearful: 
- 内部的に `UITableView` を使ってるところでうまくいってないのか？（推測）