-1

I'm struggling with an error in my SwiftUI App. In my JSON I have categories(MenuSection) & each category has an array with many items(MenuItem). JSON is valid! I have decoded it properly. My MenuItems are listed for the MenuSection. Then I tried to implement a Favourite Button exactly as shown in Apples tutorials (landmark project). Launching the app loads my list with the MenuItems for each MenuSection. Clicking on the MenuItem crashes the App with the message I have written in the title. Before I added the favourite Button everything works. Why does the app find nil? I force unwrapped a value because I know that there is a value. But it find nil. Can someone please help and explain what the problem is? I have attached a snipped of the .json, the decoder bundle, the structs for the json and the ItemDetail(View) where it find nil.

JSON:

[
    {
        "id": "9849D1B2-94E8-497D-A901-46EB4D2956D2",
        "name": "Breakfast",
        "items": [
            {
                "id": "4C7D5174-A430-489E-BDDE-BD01BAD957FD",
                "name": "Article One",
                "author": "Joseph",
                "level": ["E"],
                "isFavorite": true,
                "description": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim."
            },
            {
                "id": "01CDACBC-215F-44E0-9D49-2FDC13EF38C6",
                "name": "Article Two",
                "author": "Joseph",
                "level": ["E"],
                "isFavorite": false,
                "description": "Description for Article 1.2"
            },
            {
                "id": "E69F1198-1D7C-42C7-A917-0DC3D4C67B99",
                "name": "Article Three",
                "author": "Joseph",
                "level": ["E"],
                "isFavorite": false,
                "description": "Description for Article 1.3"
            }
        ]
    },
    {
        "id": "D8F266BA-7816-4EBC-93F7-F3CBCE2ACE38",
        "name": "Lunch",
        "items": [
            {
                "id": "E7142000-15C2-432F-9D75-C3D2323A747B",
                "name": "Article 2.1",
                "author": "Joseph",
                "level": ["M"],
                "isFavorite": false,
                "description": "Description for Article 2.1"
            },
            {
                "id": "E22FF383-BFA0-4E08-9432-6EF94E505554",
                "name": "Article 2.2",
                "author": "Joseph",
                "level": ["M"],
                "isFavorite": false,
                "description": "Description for Article 2.2"
            },
            {
                "id": "9978979F-0479-4A49-85B8-776EEF06A560",
                "name": "Article 2.3",
                "author": "Joseph",
                "level": ["M"],
                "isFavorite": false,
                "description": "Description for Article 2.3"
            }
        ]
    }
]

Decoder:

import Foundation
import Combine


final class MenuModel: ObservableObject {
    @Published var items = [MenuItem]()
}


extension Bundle {
    func decode<T: Decodable>(_ type: T.Type, from file: String) -> T {
        guard let url = self.url(forResource: file, withExtension: nil) else {
            fatalError("Failed to locate \(file) in bundle.")
        }

        guard let data = try? Data(contentsOf: url) else {
            fatalError("Failed to load \(file) from bundle.")
        }

        let decoder = JSONDecoder()

        guard let loaded = try? decoder.decode(T.self, from: data) else {
            fatalError("Failed to decode \(file) from bundle.")
        }

        return loaded
    }
}

Struct:

import SwiftUI

struct MenuSection: Hashable, Codable, Identifiable {
    var id = UUID()
    var name: String
    var items: [MenuItem]
}

struct MenuItem: Hashable, Codable, Equatable, Identifiable {
    var id = UUID()
    var name: String
    var author: String
    var level: [String]
    var isFavorite: Bool
    var description: String
    
    var mainImage: String {
        name.replacingOccurrences(of: " ", with: "-").lowercased()
    }
    
    var thumbnailImage: String {
        "\(mainImage)-thumb"
    }
    
    
#if DEBUG
    static let example = MenuItem(id: UUID(), name: "Article One", author: "Joseph", level: ["E"], isFavorite: true, description: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem.")
#endif
}

ItemDetail:

import SwiftUI

struct ItemDetail: View {
    @EnvironmentObject var menuModel: MenuModel
    var item: MenuItem
    
    let menu = Bundle.main.decode([MenuSection].self, from: "menu.json")
    
    let colors: [String: Color] = ["E": .green, "M": .yellow, "D": .red]
    
    
    var itemIndex: Int! {
        menuModel.items.firstIndex(where: { $0.id == item.id })
    }

    
    var body: some View {
        ScrollView {
            VStack(){ 
                <SOME CODE TO SHOW IMAGES AND TEXT> 
            }
                .navigationTitle(item.name)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        FavoriteButton(isSet: $menuModel.items[itemIndex].isFavorite) <here gets nil for 'itemIndex'>
                        }
                    }
               
            }
        }
    
    func setFavorite() {}
    func report() {}
    
}

struct ItemDetail_Previews: PreviewProvider {
    static let menuModel = MenuModel()
    
    static var previews: some View {
            ItemDetail(item: MenuModel().items[0])
                .environmentObject(menuModel)
    }
}


Favorite Button:

import SwiftUI

struct FavoriteButton: View {
    @Binding var isSet: Bool
    
    var body: some View {
        Button {
            isSet.toggle()
        } label: {
            
            Label("Toggle Favorite", systemImage: isSet ? "star.fill" : "star")
                .labelStyle(.iconOnly)
                .foregroundColor(isSet ? .yellow : .gray)
        }
    }
}

struct FavoriteButton_Previews: PreviewProvider {
    static var previews: some View {
        FavoriteButton(isSet: .constant(true))
    }
}

ContentView:

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var menuModel: MenuModel

    let menu = Bundle.main.decode([MenuSection].self, from: "menu.json")

    
    //create search string
    @State private var searchString = ""
    
    //search result - search in "SearchModel"
    var searchResult : [MenuSection] {
        if searchString.isEmpty { return menu }
        return menu.map { menuSection in
            var menuSearch = menuSection
            menuSearch.items = menuSection.items.filter { $0.name.lowercased().contains(searchString.lowercased()) }
            return menuSearch
        }.filter { !$0.items.isEmpty }
    }
    
    
    // VIEW
    var body: some View {
        NavigationView {
            List {
                ForEach(searchResult, id:\.self) { section in
                    Section(header: Text(section.name)) {
                        ForEach(section.items) { item in
                            NavigationLink(destination: ItemDetail(item: item)) {
                                ItemRow(item: item)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Menu")
        }
        .searchable(text: $searchString)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(MenuModel())
    }
}

Joseph
  • 3
  • 2
  • Can you provide the code – cedricbahirwe Nov 11 '21 at 14:21
  • can you provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example)? – cedricbahirwe Nov 11 '21 at 14:23
  • I added the the code. I deleted some code in the VStack in ItemDetail and deleted another section in the json. – Joseph Nov 11 '21 at 14:32
  • Are you sure about : “item:MenuMosel().items[0] in the previews static var definition ? – Ptit Xav Nov 11 '21 at 14:42
  • @PtitXav I'm sure, without the favourite Button as ToolbarItem where 'itemIndex' causes the error it works well. – Joseph Nov 11 '21 at 14:51
  • Can you provide code for `FavoriteButton` view – cedricbahirwe Nov 11 '21 at 14:52
  • @cedricbahirwe code added to the post. The FavoriteButton works on a project without the sections well. – Joseph Nov 11 '21 at 14:56
  • I just try the whole code and everything is working fine: Checkout https://gist.github.com/cedricbahirwe/34284846d54190bc8d14daebb5d485e6 – cedricbahirwe Nov 11 '21 at 15:11
  • Checkout the View that is presenting the `ItemDetail` , there is probably where the problem may come from – cedricbahirwe Nov 11 '21 at 15:16
  • @cedricbahirwe thanks, some of your code worked when trying to access the items in the first section. But when I click on the items in the other section(s) the app crashes again with same error message. I added my original ContentView to the post. – Joseph Nov 11 '21 at 16:12
  • @joseph : you read menu.json in 2 different places (contentView and ItemDetails). Did you try to check values for item.id and menuItms.id in both places. – Ptit Xav Nov 11 '21 at 17:35
  • Does this answer your question? [What does "Fatal error: Unexpectedly found nil while unwrapping an Optional value" mean?](https://stackoverflow.com/questions/32170456/what-does-fatal-error-unexpectedly-found-nil-while-unwrapping-an-optional-valu) – pkamb Nov 12 '21 at 01:42
  • The issue found is that you're access an empty array `menuModel.items` which does not contain any element – cedricbahirwe Nov 12 '21 at 08:05
  • @cedricbahirwe so my access in general is wrong? Then I should probably test few things in smaller projects to definitely unterstand how to access the right way. – Joseph Nov 12 '21 at 10:03

1 Answers1

0

The issue is with var itemIndex in ItemDetail. ItemDetail as a whole is problematic. You are recreating your menu in it, and that is a problem as you now don't have a single source of truth. You are comparing two different menus. Whenever you want to edit a part of your data, you should just send that part in to edit as a binding. The problem is that you do not have variable with a property wrapper to use.

The other issue is an architecture issue. You have a MenuModel as your purported view model, but it is just an [MenuItem]. And you don't actually use it, instead, you instantiate an [MenuSection] and use that. That should be your model. But because you haven't made this a class, it leads you down the path of performing model logic in your view, not in the model.

So, I took the liberty of rearranging some things. First, your model:

final class MenuModel: ObservableObject {
    @Published var sections: [MenuSection]
    
    init() {
        sections = Bundle.main.decode([MenuSection].self, from: "menu.json")
    }
}

Next ItemDetail now uses a binding:

struct ItemDetail: View {
    @Binding var item: MenuItem
    
    let colors: [String: Color] = ["E": .green, "M": .yellow, "D": .red]
    
        
    var body: some View {
        ScrollView {
            VStack(){
                Text("Hello, World!")
            }
                .navigationTitle(item.name)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        FavoriteButton(isSet: $item.isFavorite) //<here gets nil for 'itemIndex'>
                        }
                    }
               
            }
        }
    
    func setFavorite() {}
    func report() {}
    
}

So ContentView must now provide one:

struct ContentView: View {
    @EnvironmentObject var menu: MenuModel
    
    //create search string
    @State private var searchString = ""
    
    //search result - search in "SearchModel"
    var searchResult : [MenuSection] {
        if searchString.isEmpty { return menu.sections }
        return menu.sections.map { menuSection in
            var menuSearch = menuSection
            menuSearch.items = menuSection.items.filter { $0.name.lowercased().contains(searchString.lowercased()) }
            return menuSearch
        }.filter { !$0.items.isEmpty }
    }
    
    
    // VIEW
    var body: some View {
        NavigationView {
            List {
                //Because we need to drill down, we need the index. But ForEach by indicies doesn't play well
                //with List( rearranging, etc.) so we zip the array items with their indicies and make them an array
                //again, and id them by the items, not the index. This allows us to use both the item and the index later.
                ForEach(Array(zip(searchResult, searchResult.indices)), id:\.0) { section, sectionIndex in
                    Section(header: Text(section.name)) {
                        ForEach(Array(zip(section.items, section.items.indices)), id: \.0) { item, index in
                            NavigationLink(destination: ItemDetail(item: $menu.sections[sectionIndex].items[index])) {
                                Text(item.name)
                            }
                        }
                    }
                }
            }
            .navigationTitle("Menu")
        }
        .searchable(text: $searchString)
    }
}

You now have a view model you can use, and it is your single source of truth. As an aside, your FavoriteButton is causing an immediate dismissal of your ItemDetail view. You will need to put that up as a separate question.

Lastly, the whole ContentView to ItemView pipeline would be simpler if you had the favorites button in ContentView. You would then just need to send a non binding MenuItem to ItemDetail.

Edit: I forgot to put the @Main in:

@main
struct UnexpectedNilAppApp: App {
    let menu = MenuModel()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(menu)
        }
    }
}

With that, you can just call ContentView() in the preview provider.

For ItemDetail I used an intermediary struct like this:

struct itemDetailPreviewIntermediary: View {
    
    @State var menu = MenuModel()
    var body: some View {
        NavigationView {
            ItemDetail(item: $menu.sections[0].items[0])
        }
    }
}

struct ItemDetail_Previews: PreviewProvider {

    static var previews: some View {
        itemDetailPreviewIntermediary()
    }
}

It is a lot easier to work with bindings and preview providers to go through another struct that declares an @State variable to pass to the view.

Yrb
  • 8,103
  • 2
  • 14
  • 44