SwiftUI中FSCalendar Coordinator首次点击无法更新State问题
复现说明
在SwiftUI应用中使用FSCalendar展示日历,点击日期时会在CalendarView.swift中弹出sheet。部分日期关联有事件,点击这些日期时会修改sheet展示事件详情。这些事件是名为Birth的CoreData实体,包含date(Date类型)和name(String类型)字段。当日历的didSelect方法触发时,会检查是否有事件日期与点击日期匹配。点击后的日期存储在CalendarView.swift的selectedDate状态中,并通过Binding传递给CalendarViewRepresentable。若日期匹配事件日期,会将selectedItems状态设为包含该事件的数组;若匹配多个事件,则将其他事件添加到selectedItems数组中。
测试环境:iOS17.5模拟器及真机,CalendarView.swift位于WindowGroup >> TabView内
问题
首次点击日历中的日期时,selectedDate和selectedItems状态未更新,仍保持初始值(本案例中初始值为nil)。但再次点击同一日期或其他日期后,状态更新恢复正常,能显示正确日期。
代码
CalendarView.swift
import SwiftUI import CoreData struct CalendarView: View { @Environment(\.managedObjectContext) var viewContext @State var entries: [Birth] = [] @State var selectedDate: Date? = nil @State var selectedItems: [Birth]? = nil @State var presentSelection: Bool = false var body: some View { CalendarViewRepresentable(items: $entries, selectedDate: $selectedDate, selectedItems: $selectedItems, presentSelection: $presentSelection) .onAppear { fetchBirths() } .sheet(isPresented: $presentSelection, content: { if selectedItems != nil { Text(selectedDate?.description ?? "Date not found") Text(String(describing: selectedItems!.first!.name)) .presentationDetents([.height(170)]) .presentationCornerRadius(20) } else { Text(String(describing: selectedItems?.count)) Text(selectedDate?.description ?? "Date not found") .presentationDetents([.height(170)]) .presentationCornerRadius(20) } }) } private func fetchBirths() { let request: NSFetchRequest<Birth> = Birth.fetchRequest() request.relationshipKeyPathsForPrefetching = ["group"] do { let results = try viewContext.fetch(request) self.entries = results } catch { print("Error fetching data: \(error)") } } }
Calendar.swift(CalendarViewRepresentable)
import SwiftUI import FSCalendar struct CalendarViewRepresentable: UIViewRepresentable { @Environment(\.colorScheme) var colorScheme typealias UIViewType = FSCalendar var calendar = FSCalendar() @Binding var items: [Birth] @Binding var selectedDate: Date? @Binding var selectedItems: [Birth]? @Binding var presentSelection: Bool func makeUIView(context: Context) -> FSCalendar { calendar.delegate = context.coordinator calendar.dataSource = context.coordinator // Appearance calendar.appearance.todayColor = UIColor(named: "todayBGColor") calendar.appearance.titleTodayColor = .accent calendar.appearance.titleFont = .boldSystemFont(ofSize: 16) calendar.appearance.titleWeekendColor = .darkGray calendar.appearance.selectionColor = UIColor(named: "selectionDateBG") calendar.appearance.titleSelectionColor = UIColor(named: "selectionDateFG") calendar.appearance.eventDefaultColor = .accent calendar.appearance.eventSelectionColor = .accent calendar.appearance.imageOffset = CGPoint(x: 0, y: 5) calendar.appearance.eventOffset = CGPoint(x: 15.5, y: 3.5) calendar.appearance.weekdayTextColor = .accent calendar.appearance.borderRadius = 0.65 calendar.appearance.headerDateFormat = "MMMM" calendar.appearance.headerMinimumDissolvedAlpha = 0.1 calendar.appearance.headerTitleFont = .systemFont( ofSize: 27, weight: .black) calendar.appearance.headerTitleColor = .label calendar.appearance.titleDefaultColor = .label calendar.firstWeekday = 2 calendar.scrollDirection = .vertical calendar.scope = .month calendar.clipsToBounds = false return calendar } func updateUIView(_ uiView: FSCalendar, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, FSCalendarDelegate, FSCalendarDataSource { var parent: CalendarViewRepresentable func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) { // <<<<<< SELECTION MADE HERE parent.presentSelection = false parent.selectedItems = nil for item in parent.items { if item.date! === date { // "===" Date extension in the code below if parent.selectedItems == nil { parent.selectedItems = [item] } else { parent.selectedItems?.append(item) } } } parent.selectedDate = date parent.presentSelection = true } func calendar(_ calendar: FSCalendar, numberOfEventsFor date: Date) -> Int { // Sets event dots let today = Date() let eventDates: [Date] = parent.items.map { item in let daysLeft = daysToDate(date: item.date) return Calendar.current.date(byAdding: .day, value: daysLeft, to: today)! } var eventCount = 0 eventDates.forEach { eventDate in if eventDate.formatted(date: .complete, time: .omitted) == date.formatted( date: .complete, time: .omitted){ if eventCount != 2 { eventCount += 1; } } } return eventCount } func calendar(_ calendar: FSCalendar, imageFor date: Date) -> UIImage? { var image: UIImage? = nil let _: [Date?] = parent.items.map { item in let daysLeft = daysToDate(date: item.date) let itemDate = Calendar.current.date(byAdding: .day, value: daysLeft, to: Date())! if itemDate === date { var data = item.picture if item.picture == nil { data = Data() } image = data!.toSmallAvatarImage() } return nil } return image } func maximumDate(for calendar: FSCalendar) -> Date { var dt = Calendar.current.dateComponents([.year, .month, .day], from: Date()) dt.day = Int(dt.day ?? 0) - 1 let start = Calendar.current.date(from: dt)! return Calendar.current.date(byAdding: .month, value: 12, to: start)! } func minimumDate(for calendar: FSCalendar) -> Date { let dt = Calendar.current.dateComponents([.year, .month], from: Date()) return Calendar.current.date(from: dt)! } init(_ parent: CalendarViewRepresentable) { self.parent = parent } } }
Date扩展
static func ===(first: Date, second: Date) -> Bool { let firstDate = Calendar.current.dateComponents([.month, .day], from: first) let secondDate = Calendar.current.dateComponents([.month, .day], from: second) return firstDate.day == secondDate.day && firstDate.month == secondDate.month }
解决方案
问题根源
- 状态切换时机冲突:在
didSelect中先设置presentSelection = false再立即设为true,SwiftUI来不及处理状态的快速变更,导致sheet弹出时状态未同步。 - UIKit视图实例复用:
CalendarViewRepresentable提前初始化FSCalendar实例,可能导致旧实例的绑定逻辑干扰状态更新。
修复步骤
1. 调整状态更新逻辑,延迟弹出Sheet
修改didSelect方法,移除不必要的presentSelection = false,并通过DispatchQueue.main.async延迟弹出Sheet,确保状态更新完成:
func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) { parent.selectedItems = nil // 用filter简化事件筛选逻辑 parent.selectedItems = parent.items.filter { guard let itemDate = $0.date else { return false } return itemDate === date } parent.selectedDate = date // 延迟执行,确保SwiftUI完成状态更新 DispatchQueue.main.async { parent.presentSelection = true } }
2. 避免提前初始化UIKit视图
将FSCalendar实例的创建移到makeUIView方法内,确保每次创建视图都是全新实例:
struct CalendarViewRepresentable: UIViewRepresentable { // ... 保留其他代码 // 移除这里的calendar初始化 // var calendar = FSCalendar() func makeUIView(context: Context) -> FSCalendar { let calendar = FSCalendar() // 在此处创建实例 calendar.delegate = context.coordinator calendar.dataSource = context.coordinator // ... 保留外观设置代码 return calendar } // ... 保留其他代码 }
3. 优化Sheet内容的状态依赖
将Sheet内容提取为独立子视图,确保状态变更时实时刷新:
struct CalendarView: View { // ... 保留其他代码 private func sheetContent() -> some View { VStack { Text(selectedDate?.description ?? "Date not found") if let items = selectedItems, !items.isEmpty { Text(String(describing: items.first!.name)) } else { Text(String(describing: selectedItems?.count ?? 0)) } } .presentationDetents([.height(170)]) .presentationCornerRadius(20) } var body: some View { CalendarViewRepresentable(...) .onAppear { fetchBirths() } .sheet(isPresented: $presentSelection, content: sheetContent) } }
内容的提问来源于stack exchange,提问作者Alp Durak

