首页 > 解决方案 > SwiftUI - 在展开可选值时意外发现 nil

问题描述

我正在为我的 SwiftUI 应用程序中的错误而苦苦挣扎。在我的 JSON 中,我有类别(MenuSection)并且每个类别都有一个包含许多项目(MenuItem)的数组。JSON 是有效的!我已经正确解码了。我的 MenuItems 为 MenuSection 列出。然后我尝试完全按照 Apples 教程(里程碑项目)中所示的方式实现一个收藏按钮。启动应用程序会加载我的列表,其中包含每个 MenuSection 的 MenuItems。单击 MenuItem 会使应用程序崩溃,并显示我在标题中写的消息。在我添加最喜欢的 Button 之前,一切正常。为什么应用程序会发现 nil?我强制展开一个值,因为我知道有一个值。但它发现为零。有人可以帮忙解释一下问题是什么吗?我附上了 .json 的片段、解码器包、json 的结构和它找到 nil 的 ItemDetail(View)。

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"
            }
        ]
    }
]

解码器:

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
    }
}

结构:

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
}

项目详情:

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)
    }
}


收藏按钮:

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))
    }
}

内容视图:

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())
    }
}

标签: iosjsonswiftui

解决方案


问题在于var itemIndexin ItemDetailItemDetail整体是有问题的。你在其中重新创造你menu的,这是一个问题,因为你现在没有单一的事实来源。您正在比较两个不同的菜单。每当您想编辑数据的一部分时,您应该将该部分作为绑定发送到编辑。问题是您没有要使用的属性包装器的变量。

另一个问题是架构问题。你有一个MenuModel作为你声称的视图模型,但它只是一个[MenuItem]. 而且您实际上并没有使用它,而是实例化[MenuSection]并使用它。那应该是你的模型。但是因为你还没有把它变成一个类,它会引导你在你的视图中执行模型逻辑,而不是在模型中。

所以,我冒昧地重新安排了一些事情。首先,您的模型:

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

接下来ItemDetail现在使用绑定:

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() {}
    
}

所以ContentView现在必须提供一个:

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)
    }
}

您现在有了一个可以使用的视图模型,它是您唯一的事实来源。顺便说一句,您FavoriteButton正在导致立即驳回您的ItemDetail观点。您需要将其作为一个单独的问题提出。

ContentView最后,如果你有收藏夹按钮,整个ItemView管道会更简单ContentView。然后,您只需将非绑定发送MenuItemItemDetail.

编辑:我忘了把@Main放在:

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

有了它,您只需调用ContentView()预览提供程序即可。

因为ItemDetail我使用了这样的中间结构:

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()
    }
}

使用绑定和预览提供程序通过另一个声明@State变量传递给视图的结构要容易得多。


推荐阅读