App crashes when SwiftUI view with Picker disappears (since iOS 16)
Asked Answered
W

4

10

We have an application with some 'chat' functionality where questions are asked and the user can answer with some predefined options: for every question a new view is presented. One of those options is a view with a Picker, since iOS 16 this Picker causes the app to crash when the view with the Picker disappears with following error: Thread 1: Fatal error: Index out of range positioned at class AppDelegate: UIResponder, UIApplicationDelegate {. In the log I can see this error: Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range.

To troubleshoot this issue I refactored the code to a bare minimum where the picker isn't even used but still cause the error to occur. When I remove the Picker from this view it works again.

View where error occurs

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

Parent view where the view above is used (Note: even with all the animation related lines removed the crash still occurs):

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

The app was made originally for iOS 13 but is still maintained: with every new version of iOS the app kept working as expected until now with iOS 16.

Minimal reproducible code: (put TestView in your 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://mcmap.net/q/24228/-generate-random-alphanumeric-string-in-swift
    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)
            })
        }
    }
}
Wiener answered 6/10, 2022 at 13:10 Comment(12)
In your PickerQuestion, Picker, try using this Text($0).tag($0)Tightfisted
I tried to add the tag but it doesn't prevent the crashWiener
on what line does the error occurs?Tightfisted
It does not occur in one of my views, xcode jumps to class AppDelegate: UIResponder, UIApplicationDelegate { directly with the message Thread 1: Fatal error: Index out of range. In the log I see this error Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of rangeWiener
show us a minimal reproducible code that produces your error, a Minimal Reproducible Example: stackoverflow.com/help/minimal-reproducible-exampleTightfisted
Hi @workingdogsupportUkraine, I've added a Minimal Reproducible Example in my original question. (Add TestView in your ContentView). Iterate over the questions (generated randomly). at some point it will crash when the Picker is used. (Note: also here it just fails on iOS 16)Wiener
I had a quick look at your example code, and can reproduce the crash. I'm still baffled.Tightfisted
Have you find solution ? I'm face same issue on last update, when I change it to .pickerStyle(.menu) the crash won't happened, it the issue only with .wheelLoopy
Hi @Basel, I haven't found a solution yet: we've made some design changes to avoid the picker for now until a solution is foundWiener
@Wiener try my solution, it working fine without any crash !Loopy
@Basel, it's been a while since I've had the time to check on this issue. I've accepted Araxias answer since it's a solution in SwiftUI without using UIKit. I will try your answer later to see if it also works for our issue.Wiener
@Basel, tested your solution: it works as well but I left Araxias answer marked as solution since I can not accept multiple answers and Araxias answer is in pure SwiftUI without UIKitWiener
A
4

This crash still exists on iOS 16.2. It seems to only occur when you use ForEach within your Picker view. Thus the crash disappears when you manually provide each picker option's Text view in the Picker content instead of using ForEach to create the Text picker options.

Of course, hard-coding the picker options is not a feasible workaround in many cases.

But you can also work around the problem by moving the ForEach-loop that generates the picker options into another view. To achieve this define a helper view:

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

Then use PickerContent in your Picker instead of ForEach, e.g. (based on your example):

Picker("Please choose a value", selection: $selected) {
    PickerContent(pickerValues: colors)
}.pickerStyle(.wheel)
Amoakuh answered 7/1, 2023 at 14:55 Comment(2)
This works indeed for the issue we were experiencing!Wiener
Thank you. This is a very unusual issue that I did not find in any documentation. This solution worked perfectly!Vulcanology
M
2

It seems to be a bug in iOS 16.x while using Picker with "wheel style", I had the same issue in my app and used the following workaround:

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()
     }
 }
Microspore answered 13/10, 2022 at 12:59 Comment(3)
This doesn't seems a workaround. What it simply does is sets the .default picker style instead of .wheel pickerstyle in iOS 16.0.Graciagracie
It's a workaround for the crash itself, there is no other solution to avoid the crash on iOS 16, at least I didn't find any solution yet to avoid it. @WhiteSpidy please let me know if you find any other solution.Microspore
See my answer for a workaround to avoid the crash on iOS 16 while still using the .wheel picker style.Amoakuh
L
1

I don't find a solution using SwiftUI Picker

but I found a solution using the UIKit version

I faced the same issue in my app, after using the UIKit version, the Pickerview wheel style worked fine without any crash

import SwiftUI

struct PickerView: UIViewRepresentable {
    var array: [String]
    @Binding var selectedItem: String

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIPickerView {
        let picker = UIPickerView()
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIView(_ uiView: UIPickerView, context: Context) {
        uiView.selectRow(array.firstIndex(of: selectedItem) ?? 0, inComponent: 0, animated: false)
    }

    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var parent: PickerView

        init(_ picker: PickerView) {
            self.parent = picker
        }

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            1
        }

        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            parent.array.count
        }

        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            parent.array[row]
        }

        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            parent.selectedItem = parent.array[row]
        }
    }
}

Use it like this

   PickerView(array: myArray, selectedItem: $mySelectedItem)
Loopy answered 3/8, 2023 at 18:14 Comment(0)
G
-1

I found the same issue with iOS 16.0 and to get the exact same solution nothing worked and at last I had to used UIKit's wrapper with PickerView() in it. Also it only happens with wheel style I guess default works fine for me. Here's the working code to get the same exact wheel picker in iOS 16.

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

Here items is list of data you want to display in your wheel picker and selectedIndex is current selected index of your picker view.

Graciagracie answered 26/10, 2022 at 18:0 Comment(1)
Binding should only be used in a SwiftUI view not a classSamathasamau

© 2022 - 2024 — McMap. All rights reserved.