Fix wheel picker crash caused by UIKit/SwiftUI race condition
The crash occurred when UIKit's didSelectRow callback fired during SwiftUI view teardown, causing an array index out of bounds error. Fixes: - Use Identifiable struct for stable ForEach identity - Hide picker before dismissing to prevent race condition - Add .id() modifier for stable picker identity - Disable interactive dismiss to prevent mid-scroll dismissal - Add small delay before dismiss callbacks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -552,8 +552,7 @@ struct NotificationTimePickerRow: View {
|
|||||||
},
|
},
|
||||||
onCancel: {
|
onCancel: {
|
||||||
showingTimePicker = false
|
showingTimePicker = false
|
||||||
},
|
}
|
||||||
formatHour: formatHour
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,18 +562,37 @@ struct NotificationTimePickerRow: View {
|
|||||||
|
|
||||||
struct TimePickerSheet: View {
|
struct TimePickerSheet: View {
|
||||||
@State private var selectedHour: Int
|
@State private var selectedHour: Int
|
||||||
|
@State private var isPresented: Bool = true
|
||||||
let onSave: (Int) -> Void
|
let onSave: (Int) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
let formatHour: (Int) -> String
|
|
||||||
|
|
||||||
// Pre-computed hours array to avoid range issues with wheel picker
|
// Pre-computed hour labels as a simple struct for stable identity
|
||||||
private let hours: [Int] = Array(0..<24)
|
private struct HourOption: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let label: String
|
||||||
|
|
||||||
init(selectedHour: Int, onSave: @escaping (Int) -> Void, onCancel: @escaping () -> Void, formatHour: @escaping (Int) -> String) {
|
var hour: Int { id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let hourOptions: [HourOption] = (0..<24).map { hour in
|
||||||
|
let label: String
|
||||||
|
switch hour {
|
||||||
|
case 0: label = "12:00 AM"
|
||||||
|
case 1...11: label = "\(hour):00 AM"
|
||||||
|
case 12: label = "12:00 PM"
|
||||||
|
default: label = "\(hour - 12):00 PM"
|
||||||
|
}
|
||||||
|
return HourOption(id: hour, label: label)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(selectedHour: Int, onSave: @escaping (Int) -> Void, onCancel: @escaping () -> Void) {
|
||||||
_selectedHour = State(initialValue: selectedHour)
|
_selectedHour = State(initialValue: selectedHour)
|
||||||
self.onSave = onSave
|
self.onSave = onSave
|
||||||
self.onCancel = onCancel
|
self.onCancel = onCancel
|
||||||
self.formatHour = formatHour
|
}
|
||||||
|
|
||||||
|
private func formatHour(_ hour: Int) -> String {
|
||||||
|
Self.hourOptions.first { $0.hour == hour }?.label ?? "\(hour):00"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -585,14 +603,17 @@ struct TimePickerSheet: View {
|
|||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
|
if isPresented {
|
||||||
Picker("Hour", selection: $selectedHour) {
|
Picker("Hour", selection: $selectedHour) {
|
||||||
ForEach(hours, id: \.self) { hour in
|
ForEach(Self.hourOptions) { option in
|
||||||
Text(formatHour(hour))
|
Text(option.label)
|
||||||
.tag(hour)
|
.tag(option.hour)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.wheel)
|
.pickerStyle(.wheel)
|
||||||
.frame(height: 150)
|
.frame(height: 150)
|
||||||
|
.id("hourPicker") // Stable identity to prevent view recycling issues
|
||||||
|
}
|
||||||
|
|
||||||
Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone")
|
Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -609,20 +630,29 @@ struct TimePickerSheet: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
|
// Hide picker before dismissing to prevent race condition
|
||||||
|
isPresented = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
|
// Hide picker before dismissing to prevent race condition
|
||||||
|
isPresented = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
onSave(selectedHour)
|
onSave(selectedHour)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
|
.interactiveDismissDisabled() // Prevent swipe-to-dismiss which can cause race condition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user