如何使用SwiftUI实现可横向滚动、动态加载日期的水平日历组件
SwiftUI 单行横向滚动水平日历实现指南
预期实现效果:
基础实现逻辑说明
你已完成的HStack嵌套横向ScrollView的基础结构是可行的,需要补充三个核心能力:
- 滚动可见区域中心日期识别,同步更新顶部月份
- 日期数组动态追加能力,滚动到边界时自动补充前后日期
- 选中态样式自定义(可选)
核心功能实现代码
1. 兼容全版本的实现方案
import SwiftUI struct SingleCalendarView: View { @State private var dates: [Date] = [] @State private var currentCenterDate: Date = Date() @State private var monthString: String = "" let calendar = Calendar.current let dayItemWidth: CGFloat = 60 // 可根据需求调整单格宽度 private let dateFormatter = DateFormatter() var body: some View { VStack(spacing: 16) { // 顶部月份显示 Text(monthString) .font(.title) .fontWeight(.medium) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(dates, id: \.self) { day in VStack(spacing: 4) { Text(getDayShort(date: day)) .font(.caption) .foregroundColor(.secondary) Text("\(getDayNumber(date: day))") .font(.title2) .foregroundColor(calendar.isDate(day, inSameDayAs: currentCenterDate) ? .white : .primary) } .frame(width: dayItemWidth, height: 70) .background( RoundedRectangle(cornerRadius: 12) .fill(calendar.isDate(day, inSameDayAs: currentCenterDate) ? .blue : .clear) ) // 给每个日期格子加坐标标记,用于检测可见区域 .background( GeometryReader { geo in Color.clear .preference(key: DayViewPreferenceKey.self, value: [DayViewInfo(date: day, frame: geo.frame(in: .named("calendarScroll")))]) } ) } } .padding(.horizontal, (UIScreen.main.bounds.width - dayItemWidth)/2) // 让首尾项可以滚动到中心 } .coordinateSpace(name: "calendarScroll") // 监听滚动时的坐标变化,计算中心日期 .onPreferenceChange(DayViewPreferenceKey.self) { dayInfos in let scrollCenterX = UIScreen.main.bounds.width / 2 // 找到离中心最近的日期项 if let closest = dayInfos.min(by: { abs($0.frame.midX - scrollCenterX) < abs($1.frame.midX - scrollCenterX) }) { if !calendar.isDate(closest.date, inSameDayAs: currentCenterDate) { currentCenterDate = closest.date monthString = getMonth(date: closest.date) // 检测是否需要追加日期 checkAndAppendDatesIfNeeded() } } } } .onAppear { // 初始化生成前后各2个月的日期 dates = generateDateRange(around: Date(), previousMonths: 2, nextMonths: 2) monthString = getMonth(date: Date()) } } // 生成指定范围的连续日期 private func generateDateRange(around date: Date, previousMonths: Int, nextMonths: Int) -> [Date] { var allDates: [Date] = [] let startDate = calendar.date(byAdding: .month, value: -previousMonths, to: date)! let endDate = calendar.date(byAdding: .month, value: nextMonths, to: date)! var current = startDate while current <= endDate { allDates.append(current) current = calendar.date(byAdding: .day, value: 1, to: current)! } return allDates } // 滚动到边界时自动追加日期 private func checkAndAppendDatesIfNeeded() { guard let firstDate = dates.first, let lastDate = dates.last else { return } // 如果离第一个日期只剩7天,往前追加1个月 if calendar.dateComponents([.day], from: currentCenterDate, to: firstDate).day! >= -7 { let newDates = generateDateRange(around: calendar.date(byAdding: .month, value: -1, to: firstDate)!, previousMonths: 1, nextMonths: 0) dates.insert(contentsOf: newDates, at: 0) } // 如果离最后一个日期只剩7天,往后追加1个月 if calendar.dateComponents([.day], from: currentCenterDate, to: lastDate).day! <= 7 { let newDates = generateDateRange(around: calendar.date(byAdding: .month, value: 1, to: lastDate)!, previousMonths: 0, nextMonths: 1) dates.append(contentsOf: newDates) } } // 以下是原有日期处理方法优化,避免重复创建Formatter private func getMonth(date: Date) -> String { dateFormatter.dateFormat = "LLLL yyyy" return dateFormatter.string(from: date) } private func getDayShort(date: Date) -> String { dateFormatter.dateFormat = "E" return dateFormatter.string(from: date) } private func getDayNumber(date: Date) -> Int { calendar.component(.day, from: date) } } // 用于传递日期格子坐标的PreferenceKey struct DayViewInfo: Equatable { let date: Date let frame: CGRect } struct DayViewPreferenceKey: PreferenceKey { static var defaultValue: [DayViewInfo] = [] static func reduce(value: inout [DayViewInfo], nextValue: () -> [DayViewInfo]) { value.append(contentsOf: nextValue()) } }
2. iOS17+简化方案
如果只需要支持iOS17及以上版本,可以直接用scrollPosition修饰符替代PreferenceKey的坐标检测逻辑,代码更简洁:
// 替换原有ScrollView部分即可 ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(dates, id: \.self) { day in // 原有格子样式不变 } } .padding(.horizontal, (UIScreen.main.bounds.width - dayItemWidth)/2) .scrollTargetLayout() } .scrollPosition(id: $currentCenterDate) .onChange(of: currentCenterDate) { _, newDate in monthString = getMonth(date: newDate) checkAndAppendDatesIfNeeded() }
功能说明
- 月份会自动跟随滚动中心的日期同步更新
- 滚动到距离边界7天的位置时会自动追加前后1个月的日期,无需手动分页
- 自带选中态样式,可根据需求自定义颜色、圆角、尺寸等参数
内容的提问来源于stack exchange,提问作者user1465078
相关产品推荐
相关产品推荐

