swiftswiftuiobservableobjectswiftui-environmentenvironmentobject

Index out of range when updating a @Published var


I am new to Swift/SwiftUI and am trying to build an app that works with the trello API.

There is a "TrelloApi" class that is available as an @EnvironmentObject in the entire app. The same class is also used to make API calls.

One board is viewed at a time. A board has many lists and each list has many cards.

Now I have an issue with my rendering where whenever I switch boards and any list in the new board has fewer cards in it than before, I get the following error in an onReceive handler where I need to do some checks to update the cards appearance:

Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range
2022-10-19 09:04:11.319982+0200 trello[97617:17713580] Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range

Models

struct BoardPrefs: Codable {
    var backgroundImage: String? = "";
}

struct BasicBoard: Identifiable, Codable {
    var id: String;
    var name: String;
    var prefs: BoardPrefs;
}

struct Board: Identifiable, Codable {
    var id: String;
    var name: String;
    var prefs: BoardPrefs;
    
    var lists: [List] = [];
    var cards: [Card] = [];
    var labels: [Label] = [];
    
}

struct List: Identifiable, Codable, Hashable {
    var id: String;
    var name: String;
    var cards: [Card] = [];
    
    private enum CodingKeys: String, CodingKey {
        case id
        case name
    }
}

struct Card: Identifiable, Codable, Hashable {
    var id: String;
    var idList: String = "";
    var labels: [Label] = [];
    var idLabels: [String] = [];
    var name: String;
    var desc: String = "";
    var due: String?;
    var dueComplete: Bool = false;
}

TrelloApi.swift (HTTP call removed for simplicity)

class TrelloApi: ObservableObject {
    let key: String;
    let token: String;
    
    @Published var board: Board;
    @Published var boards: [BasicBoard];
    
    init(key: String, token: String) {
        self.key = key
        self.token = token
        self.board = Board(id: "", name: "", prefs: BoardPrefs())
        self.boards = []
    }
    
    func getBoard(id: String, completion: @escaping (Board) -> Void = { board in }) {
        if id == "board-1" {
            self.board = Board(id: "board-1", name: "board-1", prefs: BoardPrefs(), lists: [
                List(id: "board-1-list-1", name: "board-1-list-1", cards: [
                    Card(id: "b1-l1-card1", name: "b1-l1-card1"),
                ]),
                List(id: "board-1-list-2", name: "board-1-list-2", cards: [
                    Card(id: "b1-l2-card1", name: "b1-l2-card1"),
                    Card(id: "b1-l2-card2", name: "b1-l2-card2"),
                ])
            ])
            
            completion(self.board)
        } else {
            self.board = Board(id: "board-2", name: "board-2", prefs: BoardPrefs(), lists: [
                List(id: "board-2-list-1", name: "board-2-list-1", cards: [
                ]),
                List(id: "board-2-list-2", name: "board-2-list-2", cards: [
                    Card(id: "b2-l2-card1", name: "b2-l2-card1"),
                ])
            ])
            
            completion(self.board)
        }
    }
}

ContentView.swift

struct ContentView: View {
    @EnvironmentObject var trelloApi: TrelloApi;
    
    var body: some View {
        HStack {
            VStack {
                Text("Switch Board")
                Button(action: {
                    trelloApi.getBoard(id: "board-1")
                }) {
                    Text("board 1")
                }
                Button(action: {
                    trelloApi.getBoard(id: "board-2")
                }) {
                    Text("board 2")
                }
            }
            VStack {
                ScrollView([.horizontal]) {
                    ScrollView([.vertical]) {
                        VStack(){
                            HStack(alignment: .top) {
                                ForEach($trelloApi.board.lists) { list in
                                    TrelloListView(list: list)
                                        .fixedSize(horizontal: false, vertical: true)
                                }
                            }
                            .padding()
                            .frame(maxHeight: .infinity, alignment: .top)
                        }
                    }
                }
            }
        }.onAppear {
            trelloApi.getBoard(id: "board-1")
        }
        .frame(minWidth: 900, minHeight: 600, alignment: .top)
    }
}

TrelloListView.swift

struct TrelloListView: View {
    @EnvironmentObject var trelloApi: TrelloApi;
    @Binding var list: List;
    
    var body: some View {
        VStack() {
            Text(self.list.name)
            Divider()
            SwiftUI.List(self.$list.cards, id: \.id) { card in
                CardView(card: card)
                
            }
            .listStyle(.plain)
            .frame(minHeight: 200)
        }
        .padding(4)
        .cornerRadius(8)
        .frame(minWidth: 200)
    }
}

CardView.swift

struct CardView: View {
    @EnvironmentObject var trelloApi: TrelloApi;
    
    @Binding var card: Card;
    
    var body: some View {
        VStack(alignment: .leading) {
            HStack {
                VStack(alignment: .leading, spacing: 0) {
                    Text(card.name)
                        .bold()
                        .font(.system(size: 14))
                        .multilineTextAlignment(.leading)
                        .lineLimit(1)
                        .foregroundColor(.white)
                    
                    Text(card.desc)
                        .lineLimit(1)
                        .foregroundColor(.secondary)
                }.padding()
                Spacer()
            }
        }
        .frame(alignment: .leading)
        .onReceive(Just(card)) { newCard in
            // CRASH LOCATION: "Index out of range" for self.card.labels
            if self.card.labels != newCard.labels {
                print("(check if card color should change based on labels)")
            }
        }
        .cornerRadius(4)
    }
}

I've highlighted the crash location with a comment. I don't pass any indexes in the ForEach or List and I am overwriting the entire trelloApi.board object, so I am not sure why I am getting this error.

I've tried using ForEach inside of the SwiftUI.List instead, but that also doesn't change anything.

The minimal reproducible code can also be found on my GitHub repo: https://github.com/Rukenshia/trello/tree/troubleshooting-board-switch-crash/trello


Solution

  • The exact issue is hard to track down, but here are some observations and recommandations.

    The .onReceive() modifier you are using looks suspicious because you initialize the publisher yourself inline in the function call. You generally use .onReceive() to react to events published from publishers set up by another piece of code.

    Moreover, you are using this .onReceive() to react to changes in a @Binding property, which is redundant since by definition a @Binding already triggers view updates when its value changes.


    EDIT

    This seems to be the issue that causes the crash in your app. Changing the .onReceive() to .onChange() seems to solve the problem:

    .onChange(of: card) { newCard in
      if self.card.labels != newCard.labels {
        print("(check if card color should change based on labels)")
      }
    }
    

    You also seem to duplicate some state:

    .onReceive(Just(card)) { newCard in
      self.due = newCard.dueDate
    }
    

    Here, you duplicated the due date, there is one copy in self.due and another copy in self.card.dueDate. In SwiftUI there should only be one source of truth and for you it would be the card property. You duplicated the state in the init: self.due = card.wrappedValue.dueDate. Accessing the .wrappedValue of a @Binding/State is a code smell and the sign that you are doing something wrong.

    Lastly, ou use an anti-pattern which can be dangerous:

    struct CardView: View {
      @State private var isHovering: Bool
      
      func init(isHovering: String) {
        self._isHovering = State(initialValue: false)
      }
    
      var body: some View { 
        ...
      }
    }
    

    You should avoid initializing a @State property wrapper yourself in the view's init. A @State property must be initililized inline:

    struct CardView: View {
      @State private var isHovering: Bool = false
    
      var body: some View { 
        ...
      }
    }
    

    If for some reason you have to customize the value of a @State property, you could use the .onAppear() or the newer .task() view modifier to change its value after the view creation:

    struct CardView: View {
      @State private var isHovering: Bool = false
    
      var body: some View { 
        SomeView()
          .onAppear {
            isHovering = Bool.random()
          }
      }
    }
    

    As a general advice you should break up your views into smaller pieces. When a view depends on many @State properties and has lots of .onChange() or .onReceive() it is usually an indication that it is time to move the whole logic inside and ObservableObject or refactor into smaller components.