You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI中FSCalendar Coordinator首次点击无法更新State问题

问题:SwiftUI中FSCalendar首次点击日期状态不更新

复现说明

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

解决方案

问题根源

  1. 状态切换时机冲突:在didSelect中先设置presentSelection = false再立即设为true,SwiftUI来不及处理状态的快速变更,导致sheet弹出时状态未同步。
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 03:24:52