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

如何在Kotlin中实现可切换周/月的无限滚动日历(无外部依赖)

Kotlin实现可切换无限滚动周历/月历(无外部依赖)

整体方案

用RecyclerView配合LinearSnapHelper实现周/月历的整单元滚动,通过切换两个RecyclerView的显示状态实现周/月视图切换,利用Adapter的"虚拟无限数据"技巧实现无限滚动效果。核心依赖Android原生组件,无需第三方库。

1. 日期工具类封装

先封装日期计算工具类,避免重复逻辑:

object DateUtils {
    // 获取指定日期所在周的第一天(周一为起始,可根据需求改为周日)
    fun getWeekStart(date: Calendar): Calendar {
        val cal = date.clone() as Calendar
        val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
        val offset = if (dayOfWeek == Calendar.SUNDAY) -6 else 1 - dayOfWeek
        cal.add(Calendar.DAY_OF_MONTH, offset)
        return cal
    }

    // 获取指定周的7天日期列表
    fun getWeekDays(startOfWeek: Calendar): List<Calendar> {
        val days = mutableListOf<Calendar>()
        val cal = startOfWeek.clone() as Calendar
        repeat(7) {
            days.add(cal.clone() as Calendar)
            cal.add(Calendar.DAY_OF_MONTH, 1)
        }
        return days
    }

    // 获取指定月份的完整日历数据(包含上月末尾、当月、下月开头,凑满6行*7列)
    fun getMonthDays(year: Int, month: Int): List<Calendar> {
        val days = mutableListOf<Calendar>()
        val firstDayOfMonth = Calendar.getInstance().apply {
            set(year, month, 1)
        }
        // 计算需要填充的上月天数
        val firstDayOfWeek = firstDayOfMonth.get(Calendar.DAY_OF_WEEK)
        val prevMonthDaysCount = if (firstDayOfWeek == Calendar.SUNDAY) 6 else firstDayOfWeek - 2
        
        // 添加上月末尾日期
        val prevMonthCal = firstDayOfMonth.clone() as Calendar
        prevMonthCal.add(Calendar.DAY_OF_MONTH, -prevMonthDaysCount)
        repeat(prevMonthDaysCount) {
            days.add(prevMonthCal.clone() as Calendar)
            prevMonthCal.add(Calendar.DAY_OF_MONTH, 1)
        }
        
        // 添加当月日期
        val daysInMonth = firstDayOfMonth.getActualMaximum(Calendar.DAY_OF_MONTH)
        repeat(daysInMonth) {
            days.add(firstDayOfMonth.clone() as Calendar)
            firstDayOfMonth.add(Calendar.DAY_OF_MONTH, 1)
        }
        
        // 添加下月开头日期,凑满42天
        val remaining = 42 - days.size
        repeat(remaining) {
            days.add(firstDayOfMonth.clone() as Calendar)
            firstDayOfMonth.add(Calendar.DAY_OF_MONTH, 1)
        }
        return days
    }

    // 判断两个日期是否为同一天
    fun isSameDay(cal1: Calendar, cal2: Calendar): Boolean {
        return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
                cal1.get(Calendar.MONTH) == cal2.get(Calendar.MONTH) &&
                cal1.get(Calendar.DAY_OF_MONTH) == cal2.get(Calendar.DAY_OF_MONTH)
    }
}

2. 周历实现

周历Item布局(item_week_calendar.xml)

用横向线性布局承载7个日期文本:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:paddingVertical="8dp">

    <TextView
        android:id="@+id/tv_day1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        android:textSize="16sp"/>

    <TextView
        android:id="@+id/tv_day2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        android:textSize="16sp"/>

    <!-- 重复tv_day3到tv_day7,结构与上面一致 -->
</LinearLayout>

周历Adapter

class WeekCalendarAdapter(private val onDateClick: (Calendar) -> Unit) : RecyclerView.Adapter<WeekCalendarAdapter.ViewHolder>() {
    private val basePosition = Int.MAX_VALUE / 2
    private var currentWeekStart = DateUtils.getWeekStart(Calendar.getInstance())

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val dayTexts = listOf(
            itemView.findViewById<TextView>(R.id.tv_day1),
            itemView.findViewById<TextView>(R.id.tv_day2),
            itemView.findViewById<TextView>(R.id.tv_day3),
            itemView.findViewById<TextView>(R.id.tv_day4),
            itemView.findViewById<TextView>(R.id.tv_day5),
            itemView.findViewById<TextView>(R.id.tv_day6),
            itemView.findViewById<TextView>(R.id.tv_day7)
        )
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_week_calendar, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val offset = position - basePosition
        val weekStart = currentWeekStart.clone() as Calendar
        weekStart.add(Calendar.WEEK_OF_YEAR, offset)
        val weekDays = DateUtils.getWeekDays(weekStart)

        val today = Calendar.getInstance()
        weekDays.forEachIndexed { index, cal ->
            val dayText = holder.dayTexts[index]
            dayText.text = cal.get(Calendar.DAY_OF_MONTH).toString()
            
            // 标记当天日期
            if (DateUtils.isSameDay(cal, today)) {
                dayText.setTextColor(Color.RED)
                dayText.setBackgroundResource(R.drawable.bg_circle_accent)
            } else {
                dayText.setTextColor(Color.BLACK)
                dayText.background = null
            }
            
            dayText.setOnClickListener { onDateClick(cal) }
        }
    }

    override fun getItemCount(): Int = Int.MAX_VALUE

    // 滚动到当前周
    fun scrollToCurrentWeek(recyclerView: RecyclerView) {
        recyclerView.scrollToPosition(basePosition)
    }
}

周历初始化(Activity/Fragment中)

val weekRecyclerView = findViewById<RecyclerView>(R.id.week_recycler_view)
weekRecyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
val weekAdapter = WeekCalendarAdapter { date ->
    // 处理日期点击逻辑
}
weekRecyclerView.adapter = weekAdapter
// 添加SnapHelper确保滚动停在整周
LinearSnapHelper().attachToRecyclerView(weekRecyclerView)
// 初始定位到当前周
weekAdapter.scrollToCurrentWeek(weekRecyclerView)

3. 月历实现

月历Item布局(item_month_calendar.xml)

用GridView显示42个日期:

<GridView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/gv_month"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:numColumns="7"
    android:verticalSpacing="8dp"
    android:horizontalSpacing="8dp"
    android:padding="8dp"/>

月历Adapter

class MonthCalendarAdapter(private val onDateClick: (Calendar) -> Unit) : RecyclerView.Adapter<MonthCalendarAdapter.ViewHolder>() {
    private val basePosition = Int.MAX_VALUE / 2
    private var currentMonth = Calendar.getInstance().apply {
        set(Calendar.DAY_OF_MONTH, 1)
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val gridView = itemView.findViewById<GridView>(R.id.gv_month)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_month_calendar, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val offset = position - basePosition
        val monthCal = currentMonth.clone() as Calendar
        monthCal.add(Calendar.MONTH, offset)
        val monthDays = DateUtils.getMonthDays(monthCal.get(Calendar.YEAR), monthCal.get(Calendar.MONTH))

        val today = Calendar.getInstance()
        holder.gridView.adapter = object : BaseAdapter() {
            override fun getCount(): Int = monthDays.size
            override fun getItem(position: Int): Any = monthDays[position]
            override fun getItemId(position: Int): Long = position.toLong()

            override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
                val tv = convertView as TextView? ?: TextView(parent?.context).apply {
                    gravity = Gravity.CENTER
                    textSize = 14sp
                }
                val cal = monthDays[position]
                tv.text = cal.get(Calendar.DAY_OF_MONTH).toString()
                
                // 标记当天日期
                if (DateUtils.isSameDay(cal, today)) {
                    tv.setTextColor(Color.RED)
                    tv.setBackgroundResource(R.drawable.bg_circle_accent)
                } else {
                    // 区分非当月日期
                    if (cal.get(Calendar.MONTH) != monthCal.get(Calendar.MONTH)) {
                        tv.setTextColor(Color.GRAY)
                    } else {
                        tv.setTextColor(Color.BLACK)
                    }
                    tv.background = null
                }
                
                tv.setOnClickListener { onDateClick(cal) }
                return tv
            }
        }
    }

    override fun getItemCount(): Int = Int.MAX_VALUE

    // 滚动到当前月
    fun scrollToCurrentMonth(recyclerView: RecyclerView) {
        recyclerView.scrollToPosition(basePosition)
    }
}

月历初始化(Activity/Fragment中)

val monthRecyclerView = findViewById<RecyclerView>(R.id.month_recycler_view)
monthRecyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
val monthAdapter = MonthCalendarAdapter { date ->
    // 处理日期点击逻辑
}
monthRecyclerView.adapter = monthAdapter
LinearSnapHelper().attachToRecyclerView(monthRecyclerView)
monthAdapter.scrollToCurrentMonth(monthRecyclerView)
// 默认隐藏月历视图
monthRecyclerView.visibility = View.GONE

4. 周/月视图切换逻辑

处理顶部应用栏图标点击事件:

val toggleIcon = findViewById<ImageView>(R.id.iv_toggle)
var isWeekView = true
toggleIcon.setOnClickListener {
    isWeekView = !isWeekView
    if (isWeekView) {
        weekRecyclerView.visibility = View.VISIBLE
        monthRecyclerView.visibility = View.GONE
        toggleIcon.setImageResource(R.drawable.ic_month)
    } else {
        weekRecyclerView.visibility = View.GONE
        monthRecyclerView.visibility = View.VISIBLE
        toggleIcon.setImageResource(R.drawable.ic_week)
    }
}

5. 无限滚动核心逻辑

  • Adapter的getItemCount返回Int.MAX_VALUE,构建虚拟的超大数据集
  • 以Int.MAX_VALUE / 2作为起始基准位置,避免快速滚动触发边界问题
  • 通过position - basePosition计算偏移量,动态生成对应周/月的日期数据

优化建议

  • 可根据需求调整周起始日、日期样式、滚动方向
  • 月历若存在性能问题,可替换为横向RecyclerView嵌套纵向RecyclerView
  • 可添加顶部标题栏,显示当前周/月的文字描述(如"2024年第15周")

内容的提问来源于stack exchange,提问作者Sab

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.27 11:03:15