SwiftUI: Incorrect UI when switching to a new list from an old one with swipe button shown
Asked Answered
C

1

6

The issue can be reproduced consistently with the code below. Xcode 13.3 + iOS 15.4 (both are latest).

enum ListID: String, CaseIterable, Hashable, Identifiable {
    case list1 = "List1"
    case list2 = "List2"
    
    var id: Self {
        self
    }
}

struct ContentView: View {
    @State var listID: ListID = .list1
    
    var body: some View {
        VStack {
            // 1) Picker
            Picker(selection: $listID) {
                ForEach(ListID.allCases) { id in
                    Text(id.rawValue)
                }
            } label: {
                Text("Select a list")
            }
            .pickerStyle(.segmented)
            // 2) List
            switch listID {
            case .list1:
                createList(Array(1...2), id: .list1)
            case .list2:
                createList(Array(101...102), id: .list2)
            }
        }

    }
    
    @ViewBuilder func createList(_ itemValues: [Int], id: ListID) -> some View {
        List {
            ForEach(itemValues, id:\.self) { value in
                Text("\(value)")
                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                        Button("Edit") {
                            // do nothing
                        }
                        .tint(.blue)
                    }
            }
        }
        .id(id)
    }
}

Steps to reproduce the issue:

  1. start up the app. See picture 1.
  2. swipe item 1 in list 1, keep the "Edit" button untouched (don't click on it). See picture 2
  3. then select list 2 in the picker. You should see there is an extra space before the items in the list. What's more, all list items are not swipable any more. See picture 3.
  4. then select list 1 in the picker. It has the same issue. See picture 4.

step 1 step 2 step 3 step 4

The issue is not specific to picker. For example, it can be reproduced if we replace picker with a set of buttons. I believe the issue occurs only if the old list is destroyed in SwiftUI view hierarchy. From my understanding of structured identity in SwiftUI, list 1 and list 2 are considered separate views in SwiftUI view hiearch. So it's not clear how they could affect each other. The only reason I can guess is that, while list 1 and list 2 are considered separate virtual views, SwiftUI actually use the same physical view for them (e.g., for performance purpose, etc.). So it seems a SwiftUI bug to me.

Thinking along that line, I can work round the issue by not destroying lists:

ZStack {
    createList(Array(1...2), id: .list1)
        .opacity(listID == .list1 ? 1 : 0)
    createList(Array(101...102), id: .list2)
        .opacity(listID == .list2 ? 1 : 0)
}

This works perfectly in this specific example, but unfortunately it's not scalable. For example, in my calendar app when user clicks on a date in the calendar, I'd like to show a list of events on the date (I'd like to use different list for different date. I do so by calling id() to set different id to each list). There isn't an obvious/elegant way to apply the above work around in this case.

So I wonder if anyone knows how to work around the issue in a more general way? Thanks.

Croton answered 7/4, 2022 at 6:26 Comment(3)
try to use developer.apple.com/documentation/swiftui/tabview with PageTabViewStyle instead of ZStack and opacityMarvelous
@QuangHà Thanks for the suggestion. That's a better API for sure. However, that is not applicable to my calendar app either. The issue with both the ZStack and TabView approaches is that they require the number of lists are fixed. But in calendar app, the number of lists (that is, dates) are not fixed.Croton
Update (two month later): I don't get feedback from Apple, but my experiments show that the bug has been fixed in SwiftUI 4 (XCode 14.0 beta + iOS 16 beta).Croton
C
0

I end up working around the issue by using a single virtual view for different lists. To that end, I need to move List outside switch statement (otherwise SwiftUI's structured identity mechanism would consider the two lists as different ones).

The workaround works reliably in my testing (including testing in my actual app). It's clean and general. I prefer to assigning a different id to each list because I think it's clean and better in architecture, but unfortunately it's not usable until Apple fixes the bug. I have submitted FB9976079 about this issue.

I'll keep my question open and welcome anyone leave your answer or comments.

enum ListID: String, CaseIterable, Hashable, Identifiable {
    case list1 = "List1"
    case list2 = "List2"

    var id: Self {
        self
    }
}

struct ContentView: View {
    @State var listID: ListID = .list1

    var body: some View {
        VStack {
            // 1) Picker
            Picker(selection: $listID) {
                ForEach(ListID.allCases) { id in
                    Text(id.rawValue)
                }
            } label: {
                Text("Select a list")
            }
            .pickerStyle(.segmented)
            // 2) List
            List {
                switch listID {
                case .list1:
                    createSection(Array(1...2), id: .list1)
                case .list2:
                    createSection(Array(101...105), id: .list2)
                }
            }
        }

    }

    // Note: the id param is not used as List id.
    @ViewBuilder func createSection(_ itemValues: [Int], id: ListID) -> some View {
        Section {
            ForEach(itemValues, id:\.self) { value in
                Text("\(value)")
                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                        Button("Edit") {
                            // do nothing
                        }
                        .tint(.blue)
                    }
            }
        }
        .id(id)
    }
}
Croton answered 7/4, 2022 at 12:5 Comment(1)
One side effect of this work around. Since these are different lists logically, when user switch among them, I'd like to have fade-in/out animation. That's the default transition behavior when we uses multiple lists. However, now we have to use a single list, SwiftUI performs animation based on diff result, which is a bit annoying.Croton

© 2022 - 2024 — McMap. All rights reserved.