繁体   English   中英

当带有 Picker 的 SwiftUI 视图消失时应用程序崩溃(自 iOS 16 起)

[英]App crashes when SwiftUI view with Picker disappears (since iOS 16)

我们有一个具有一些“聊天”功能的应用程序,可以在其中提出问题,用户可以使用一些预定义的选项来回答:对于每个问题,都会显示一个新视图。 其中一个选项是带有 Picker 的视图,因为 iOS 16 当带有 Picker 的视图消失并出现以下错误时,此 Picker 会导致应用程序崩溃: Thread 1: Fatal error: Index out of range positioned at class AppDelegate: UIResponder, UIApplicationDelegate { 在日志中我可以看到这个错误: Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range

为了解决这个问题,我将代码重构到最低限度,即使没有使用选择器,但仍然会导致错误发生。 当我从此视图中删除选择器时,它再次起作用。

查看哪里出错

struct PickerQuestion: View {
    
    @EnvironmentObject() var questionVM: QuestionVM

    let question: Question
    
    var colors = ["A", "B", "C", "D"]
    @State private var selected = "A"

    var body: some View {
        VStack {
            // When removing the Picker from this view the error does not occur anymore
            Picker("Please choose a value", selection: $selected) {
                ForEach(colors, id: \.self) {
                    Text($0)
                }
            }.pickerStyle(.wheel) // with .menu style the crash does not occur

            Text("You selected: \(selected)")

            Button("Submit", action: {
                // In this function I provide an answer that is always valid so I do not
                // have to use the Picker it's value
                questionVM.answerQuestion(...)

                // In this function I submit the answer to the backend.
                // The backend will provide a new question which can be again a Picker
                // question or another type of question: in both cases the app crashes
                // when this view disappears. (the result of the backend is provided to
                // the view with `DispatchQueue.main.async {}`)
                questionVM.submitAnswerForQuestionWith(questionId: question.id)
            })
        }
    }
}

使用上述视图的父视图(注意:即使删除了所有 animation 相关行,崩溃仍然发生):

struct QuestionContainerView: View {
    
    @EnvironmentObject() var questionVM: QuestionVM
    
    @State var questionVisible = true
    @State var questionId = ""
    
    @State var animate: Bool = false
    
    var body: some View {
        VStack {
            HeaderView(...)
            Spacer()
            if questionVM.currentQuestion != nil {
                ZStack(alignment: .bottom) {
                    if questionVisible {
                        getViewForQuestion(question: questionVM.currentQuestion!)
                            .transition(.asymmetric(
                                insertion: .move(edge: self.questionVM.scrollDirection == .Next ? .trailing : .leading),
                                removal: .opacity
                            ))
                            .zIndex(0)
                            .onAppear {
                                self.animate.toggle()
                            }
                            .environmentObject(questionVM)
                    } else {
                        EmptyView()
                    }
                }
            }
        }
        .onAppear {
            self.questionVM.getQuestion()
        }
        .onReceive(self.questionVM.$currentQuestion) { q in
            if let question = q, question.id != self.questionId {
                self.questionVisible = false
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    withAnimation {
                        self.questionVisible = true
                        self.questionId = question.id
                    }
                }
            }
        }
    }
    
    func getViewForQuestion(question: Question) -> AnyView {
        switch question.questionType {
        case .Picker:
            return AnyView(TestPickerQuestion(question: question))
        case .Other:
            ...
        case ...
        }
    }
}

该应用程序最初是为 iOS 13 制作的,但仍在维护:随着 iOS 的每个新版本,该应用程序一直按预期运行,直到现在 iOS 16。

最少的可重现代码:(TestView放在您的ContentView中)

struct MinimalQuestion {
    var id: String = randomString(length: 10)
    var text: String
    var type: QuestionType
    var answer: String? = nil
    
    enum QuestionType: String {
        case Picker = "PICKER"
        case Info = "INFO"
        case Boolean = "BOOLEAN"
    }
    
    // https://stackoverflow.com/a/26845710/7142073
    private static func randomString(length: Int) -> String {
        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0..<length).map{ _ in letters.randomElement()! })
    }
}

class QuestionViewModel: ObservableObject {
    
    @Published var questions: [MinimalQuestion] = []
    
    @Published var current: MinimalQuestion? = nil//MinimalQuestion(text: "Picker Question", type: .Picker)
    
    @Published var scrollDirection: ScrollDirection = .Next
    
    func getQuestion() {
        DispatchQueue.global(qos: .userInitiated).async {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0.1...0.2)) {
                var question: MinimalQuestion
                switch Int.random(in: 0...2) {
                case 1:
                    question = MinimalQuestion(text: "Info", type: .Info)
                case 2:
                    question = MinimalQuestion(text: "Boolean question", type: .Boolean)
                default:
                    question = MinimalQuestion(text: "Picker Question", type: .Picker)
                }
                self.questions.append(question)
                self.current = question
            }
        }
    }
    
    func answerQuestion(question: MinimalQuestion, answer: String) {
        if let index = self.questions.firstIndex(where: { $0.id == question.id }) {
            self.questions[index].answer = answer
            self.current = self.questions[index]
        }
    }
    
    func submitQuestion(questionId: MinimalQuestion) {
        DispatchQueue.global(qos: .userInitiated).async {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0.1...0.2)) {
                self.getQuestion()
            }
        }
    }
    
    func restart() {
        self.questions = []
        self.current = nil
        self.getQuestion()
    }
}

struct TestView: View {
    
    @StateObject var questionVM: QuestionViewModel = QuestionViewModel()

    @State var questionVisible = true
    @State var questionId = ""
    
    @State var animate: Bool = false
    
    var body: some View {
        return VStack {
            Text("Questionaire")
            Spacer()
            if questionVM.current != nil {
                ZStack(alignment: .bottom) {
                    if questionVisible {
                        getViewForQuestion(question: questionVM.current!).environmentObject(questionVM)
                            .frame(maxWidth: .infinity)
                            .transition(.asymmetric(
                                insertion: .move(edge: self.questionVM.scrollDirection == .Next ? .trailing : .leading),
                                removal: .opacity
                            ))
                            .zIndex(0)
                            .onAppear {
                                self.animate.toggle()
                            }
                    } else {
                        EmptyView()
                    }
                }.frame(maxWidth: .infinity)
            }
            Spacer()
        }
        .frame(maxWidth: .infinity)
        .onAppear {
            self.questionVM.getQuestion()
        }
        .onReceive(self.questionVM.$current) { q in
            print("NEW QUESTION OF TYPE \(q?.type)")
            if let question = q, question.id != self.questionId {
                self.questionVisible = false
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    withAnimation {
                        self.questionVisible = true
                        self.questionId = question.id
                    }
                }
            }
        }
    }
    
    func getViewForQuestion(question: MinimalQuestion) -> AnyView {
        switch question.type {
        case .Info:
            return AnyView(InfoQView(question: question))
        case .Picker:
            return AnyView(PickerQView(question: question))
        case .Boolean:
            return AnyView(BoolQView(question: question))
        }
    }
}

struct PickerQView: View {
    
    @EnvironmentObject() var questionVM: QuestionViewModel
    
    var colors = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]
    @State private var selected: String? = nil

    let question: MinimalQuestion
    
    var body: some View {
        VStack {
            // When removing the Picker from this view the error does not occur anymore
            Picker("Please choose a value", selection: $selected) {
                ForEach(colors, id: \.self) {
                    Text("\($0)")
                }
            }.pickerStyle(.wheel)

            Text("You selected: \(selected ?? "")")

            Button("Submit", action: {
                questionVM.submitQuestion(questionId: question)
            })
        }.onChange(of: selected) { value in
            if let safeValue = value {
                questionVM.answerQuestion(question: question, answer: String(safeValue))
            }
        }
    }
}

struct InfoQView: View {
    
    @EnvironmentObject() var questionVM: QuestionViewModel
    
    let question: MinimalQuestion
    
    var body: some View {
        VStack {
            Text(question.text)
            Button("OK", action: {
                questionVM.answerQuestion(question: question, answer: "OK")
                questionVM.submitQuestion(questionId: question)
            })
        }
    }
}

struct BoolQView: View {
    
    @EnvironmentObject() var questionVM: QuestionViewModel
    
    let question: MinimalQuestion
    
    @State var isToggled = false
    
    var body: some View {
        VStack {
            Toggle(question.text, isOn: self.$isToggled)
            Button("OK", action: {
                questionVM.answerQuestion(question: question, answer: "\(isToggled)")
                questionVM.submitQuestion(questionId: question)
            })
        }
    }
}

这似乎是 iOS 16.x 中的一个错误,同时使用带有“轮式”的选择器,我在我的应用程序中遇到了同样的问题并使用了以下解决方法:

extension Picker {
    @ViewBuilder
    func pickerViewModifier() -> some View {
        if #available(iOS 16.0, *) {
            self
        } else {
            self.pickerStyle(.wheel)
        }
    }
 }

 struct SomeView: View {
     var body: some View {
         Picker()
             .pickerViewModifier()
     }
 }

这个崩溃在 iOS 16.2 上依然存在。 它似乎只发生在您在Picker视图中使用ForEach时。 因此,当您在Picker内容中手动提供每个选择器选项的Text视图而不是使用ForEach创建Text选择器选项时,崩溃就会消失。

当然,在许多情况下,对选择器选项进行硬编码并不是一个可行的解决方法。

但是您也可以通过将生成选择器选项的ForEach循环移动到另一个视图来解决该问题。 为此,定义一个辅助视图:

struct PickerContent<Data>: View where Data : RandomAccessCollection, Data.Element : Hashable {
    let pickerValues: Data
    
    var body: some View {
        ForEach(pickerValues, id: \.self) {
            let text = "\($0)"
            Text(text)
        }
    }
}

然后在您的Picker中使用PickerContent而不是ForEach ,例如(基于您的示例):

Picker("Please choose a value", selection: $selected) {
    PickerContent(pickerValues: colors)
}.pickerStyle(.wheel)

我在 iOS 16.0 上发现了同样的问题,为了获得完全相同的解决方案,没有任何效果,最后我不得不在其中使用 UIKit 的包装器和PickerView() 它也只发生在wheel样式上,我想default对我来说很好用。 这是在 iOS 16 中获得完全相同的wheel拾取器的工作代码。

struct CustomUIPicker: UIViewRepresentable {
    
    @Binding var items: [String]
    @Binding var selectedIndex: Int
    
    func makeCoordinator() -> CustomPickerCoordinator {
        CustomPickerCoordinator(items: $items, selectedIndex: $selectedIndex)
    }
    
    func makeUIView(context: Context) -> UIPickerView {
        let pickerView = UIPickerView()
        pickerView.delegate = context.coordinator
        pickerView.dataSource = context.coordinator
        pickerView.selectRow(selectedIndex, inComponent: 0, animated: true)
        return pickerView
    }

    func updateUIView(_ uiView: UIPickerView, context: Context) {
    }
}

extension CustomUIPicker {
    
    class CustomPickerCoordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
        
        @Binding private var items: [String]
        @Binding var selectedIndex: Int
        
        init(items: Binding<[String]>, selectedIndex: Binding<Int>) {
            _items = items
            _selectedIndex = selectedIndex
        }
        
        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            1
        }
        
        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            items.count
        }
        
        func pickerView( _ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            return items[row]
        }
        
        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            selectedIndex = row
        }
    }
}

这里的items是您要在wheel选择器中显示的数据列表,而selectedIndex是您picker view的当前选定索引。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM