6
4

More than 3 years have passed since last update.

NavigationLinkとList

Posted at

Discordの swift-developers-japanサーバー で興味深い話題があったので、気になって調べてみたものを雑にまとめました :slight_smile: (話題自体は このへん から)

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

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

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

FirstViewSecondView

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

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」をタップすると、 ItemContainerrotateItem() が呼ばれて、最後のアイテムを先頭へ持ってくるように並び替えます。つまり A, B, C だったものが C, A, B になるわけです。

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

rotate

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

remove first

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

remove this

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

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

一覧画面を List に変更する

ここで FirstViewbodyVStack + ForEach ではなく、 List に変えてみます。

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

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

List

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

List rotate

これはちょっと困ってしまう挙動です。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 を使ってるところでうまくいってないのか?(推測)
6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4