Reputation: 260
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://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)
})
}
}
}
Upvotes: 10
Views: 1785
Reputation: 700
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)
Upvotes: 1
Reputation: 169
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)
Upvotes: 4
Reputation: 1107
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
.
Upvotes: -1
Reputation: 21
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()
}
}
Upvotes: 2