如何在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
相关产品推荐
相关产品推荐

