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

如何使用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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.06 18:45:00