Open3

[SwiftUI] iOS16.4以上でListのアニメーションが動作しない+解決策

kntkymtkntkymt

記事を表すArticleList で一覧表示し、ボタンを押すとアニメーション付きでRowを削除する画面

import SwiftUI

struct Article: Identifiable, Hashable {

    enum Category {
        case news
        case pr
    }

    var id: Int
    var title: String
    var category: Category
}

struct SampleView: View {

    @State var articles: [Article] = [
        Article(id: 1, title: "title1", category: .news),
        Article(id: 2, title: "title2", category: .news),
        Article(id: 3, title: "title3", category: .news),
        Article(id: 4, title: "title4", category: .news),
        Article(id: 5, title: "title5", category: .pr),
        Article(id: 6, title: "title6", category: .news),
        Article(id: 7, title: "title7", category: .news),
        Article(id: 8, title: "title8", category: .news),
        Article(id: 9, title: "title9", category: .news),
        Article(id: 10, title: "title10", category: .pr),
    ]

    var body: some View {
        VStack {
            List(articles) { article in
                Group {
                    switch article.category {
                    case .news:
                        HStack {
                            Image(systemName: "newspaper")

                            Text(article.title)
                        }

                    case .pr:
                        HStack {
                            Image(systemName: "star")

                            Text(article.title)

                            Text("PR")
                        }
                    }
                }
            }

            Button {
                _ = withAnimation {
                    articles.removeFirst()
                }
            } label: {
                Text("remove article")
            }
        }
    }
}

このコードはiOS16.4以上と未満で挙動が異なる
(a) はRowが上部にスライドしながら消えていくアニメーションが出ている
(b) は最下部がフェードアウトしていくようなアニメーションが出ている(アニメーションがない、ではない)

(a) が正しい挙動だと思われる

(a) iOS16.2 (b) iOS16.4

こういうケースは大抵 List の Rowのidに問題があり
スナップショット間でidが上手く追跡できてないため
アニメーションが正常に動作していない
のだと思われる(?)

kntkymtkntkymt

色々デバッグ

分岐をなくす

(a)の正しい挙動
List + 分岐が良くないっぽい

コード
struct SampleViewNoCondition: View {
    @State var articles: [Article] = (0...10).map { Article(id: $0, title: "title\($0)", category: $0.isMultiple(of: 5) ? .pr : .news) }

    var body: some View {
        VStack {
            List(articles) { article in
                HStack {
                    Image(systemName: "newspaper")

                    Text(article.title)
                }
            }

            Button {
                _ = withAnimation {
                    articles.removeFirst()
                }
            } label: {
                Text("remove article")
            }
        }
    }
}

idを手動でつける

(b) の間違った挙動

コード
struct SampleViewAddId: View {
    @State var articles: [Article] = (0...10).map { Article(id: $0, title: "title\($0)", category: $0.isMultiple(of: 5) ? .pr : .news) }

    var body: some View {
        VStack {
            List {
                ForEach(articles) { article in
                    Group {
                        switch article.category {
                        case .news:
                            HStack {
                                Image(systemName: "newspaper")

                                Text(article.title)
                            }

                        case .pr:
                            HStack {
                                Image(systemName: "star")

                                Text(article.title)

                                Text("PR")
                            }
                        }
                    }
                    .id(article.id)
                }
            }

            Button {
                _ = withAnimation {
                    articles.removeFirst()
                }
            } label: {
                Text("remove article")
            }
        }
    }
}

ScrollViewReaderを利用してidを確かめる

(わかりにくいが)title20 までスクロールする
スクロール位置は毎回同じなので
「1回removeしたらidがおかしくなる」とかではなさそう

コード
struct SampleViewScroll: View {

    @State var articles: [Article] = (0...100).map { Article(id: $0, title: "title\($0)", category: $0.isMultiple(of: 5) ? .pr : .news) }

    var body: some View {
        VStack {
            ScrollViewReader { proxy in
                List(articles) { article in
                    Group {
                        switch article.category {
                        case .news:
                            HStack {
                                Image(systemName: "newspaper")

                                Text(article.title)
                            }

                        case .pr:
                            HStack {
                                Image(systemName: "star")

                                Text(article.title)

                                Text("PR")
                            }
                        }
                    }
                }


                HStack {
                    Button {
                        _ = withAnimation {
                            articles.removeFirst()
                        }
                    } label: {
                        Text("remove article")
                    }

                    Button {
                        withAnimation {
                            proxy.scrollTo(20, anchor: .top)
                        }
                    } label: {
                        Text("scroll to 20")
                    }
                }
            }
        }
    }
}
kntkymtkntkymt

解決策

Sectionで囲う
これでiOS16.4以上も未満も (a) の正しい挙動が出た

 struct SampleViewSolved: View {

    @State var articles: [Article] = (0...10).map { Article(id: $0, title: "title\($0)", category: $0.isMultiple(of: 5) ? .pr : .news) }

    var body: some View {
        VStack {
            List {
                Section {
                    ForEach(articles) { article in
                        Group {
                            switch article.category {
                            case .news:
                                HStack {
                                    Image(systemName: "newspaper")

                                    Text(article.title)
                                }

                            case .pr:
                                HStack {
                                    Image(systemName: "star")

                                    Text(article.title)

                                    Text("PR")
                                }
                            }
                        }
                    }
                }
            }

            Button {
                _ = withAnimation {
                    articles.removeFirst()
                }
            } label: {
                Text("remove article")
            }
        }
    }
}

なぜ?

わからん、有識者の方教えてください

そもそもSection なしで List を使うのが間違ってるのか?とも思ったが
List 単体で使ってる例が公式ドキュメントにあったのでそういうわけでもなさそう

https://developer.apple.com/documentation/swiftui/list

まとめ

SwiftUIはiOSのメジャーアップデートは言わずもがな、マイナーアップデートでも挙動が変わるので
「マイナーだから大丈夫っしょw」と思わずちゃんとデバッグしておいた方が良さそうです