自定义简易日历
-
当前月份日期可点击选中
-
今天日期有红色圆圈标记
-
跨月日期为灰色,不可点击
-
可切换上个月/下个月
-
支持回到今天
-
可以设置红点绿点日期。比如打卡日期
自定义日历SimpleCalendarView.kt
kotlin
package cn.nio.calendar
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import java.util.Calendar
import kotlin.math.min
class SimpleCalendarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
// 颜色
private val todayColor = Color.RED
private val selectedColor = Color.parseColor("#00B594")
private val normalTextColor = Color.BLACK
private val selectedTextColor = Color.WHITE
private val otherMonthTextColor = Color.parseColor("#CCCCCC")
private val gridLineColor = Color.parseColor("#E0E0E0")
private val weekBackgroundColor = Color.parseColor("#F5F5F5")
// 新增:红点和绿点颜色
private val redDotColor = Color.parseColor("#FF4444")
private val greenDotColor = Color.parseColor("#00B594")
// 数据
private val calendar = Calendar.getInstance()
private var currentYear: Int = calendar.get(Calendar.YEAR)
private var currentMonth: Int = calendar.get(Calendar.MONTH)
private var selectedDate: Calendar? = null
private val today: Calendar = Calendar.getInstance()
// 新增:存储需要显示点的日期
private val redDotDates = mutableSetOf<String>() // 格式: "2024-01-15"
private val greenDotDates = mutableSetOf<String>()
// 画笔
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val weekPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val selectedPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val todayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
// 新增:点画笔
private val redDotPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val greenDotPaint = Paint(Paint.ANTI_ALIAS_FLAG)
// 尺寸
private var cellWidth: Float = 0f
private var cellHeight: Float = 0f
private var weekHeight: Float = 0f
// 新增:点相关尺寸
private var dotRadius: Float = 0f
private var dotSpacing: Float = 0f
// 监听器
private var onDateClickListener: ((year: Int, month: Int, day: Int) -> Unit)? = null
// 星期标题
private val weekDays = arrayOf("日", "一", "二", "三", "四", "五", "六")
init {
// 初始化画笔
paint.color = normalTextColor
paint.textSize = spToPx(16f)
paint.textAlign = Paint.Align.CENTER
weekPaint.color = normalTextColor
weekPaint.textSize = spToPx(18f)
weekPaint.textAlign = Paint.Align.CENTER
weekPaint.typeface = Typeface.DEFAULT_BOLD
selectedPaint.color = selectedColor
selectedPaint.style = Paint.Style.FILL
todayPaint.color = todayColor
todayPaint.style = Paint.Style.STROKE
todayPaint.strokeWidth = 3f
// 初始化点画笔
redDotPaint.color = redDotColor
redDotPaint.style = Paint.Style.FILL
greenDotPaint.color = greenDotColor
greenDotPaint.style = Paint.Style.FILL
// 设置初始选中日期为今天
selectedDate = today.clone() as Calendar
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
// val height = (width * 6 / 7f).toInt()
setMeasuredDimension(width, width)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
cellWidth = w / 7f
weekHeight = h * 0.15f
cellHeight = (h - weekHeight) / 6f
// 计算点的尺寸
dotRadius = dpToPx(2f)
dotSpacing = dpToPx(1f)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制白色背景
canvas.drawColor(Color.WHITE)
// 绘制星期栏
drawWeekBackground(canvas)
drawWeekDays(canvas)
// 绘制日期
drawDates(canvas)
}
private fun drawWeekBackground(canvas: Canvas) {
val rect = RectF(0f, 0f, width.toFloat(), weekHeight)
paint.color = weekBackgroundColor
paint.style = Paint.Style.FILL
canvas.drawRect(rect, paint)
paint.style = Paint.Style.FILL
}
private fun drawWeekDays(canvas: Canvas) {
val baseline = weekHeight * 0.7f
for (i in weekDays.indices) {
val x = i * cellWidth + cellWidth / 2
canvas.drawText(weekDays[i], x, baseline, weekPaint)
}
}
private fun drawDates(canvas: Canvas) {
// 设置当前月份第一天
calendar.set(currentYear, currentMonth, 1)
val firstDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) - 1
// 获取当月实际天数
val daysInMonth = getMonthDays(currentYear, currentMonth)
// 绘制当前月份日期
for (day in 1..daysInMonth) {
drawDateCell(canvas, day, firstDayOfWeek, daysInMonth, true)
}
// 绘制跨月日期(灰色,不可点击)
drawOtherMonthDays(canvas, firstDayOfWeek, daysInMonth)
// 绘制网格线
drawGridLines(canvas)
}
private fun drawDateCell(
canvas: Canvas,
day: Int,
firstDayOfWeek: Int,
daysInMonth: Int,
isCurrentMonth: Boolean,
) {
val position = firstDayOfWeek + (day - 1)
val row = position / 7
val col = position % 7
val x = col * cellWidth + cellWidth / 2
val y = weekHeight + row * cellHeight + cellHeight * 0.6f
// 检查是否为选中日期
val isSelected = selectedDate?.let {
it.get(Calendar.YEAR) == currentYear &&
it.get(Calendar.MONTH) == currentMonth &&
it.get(Calendar.DAY_OF_MONTH) == day
} ?: false
// 检查是否为今天
val isToday = today.get(Calendar.YEAR) == currentYear &&
today.get(Calendar.MONTH) == currentMonth &&
today.get(Calendar.DAY_OF_MONTH) == day
// 绘制选中背景
if (isSelected && isCurrentMonth) {
val radius = min(cellWidth, cellHeight) * 0.3f
canvas.drawCircle(x, y - (paint.textSize / 3), radius, selectedPaint)
}
// 绘制今天圆圈
if (isToday && isCurrentMonth) {
val radius = min(cellWidth, cellHeight) * 0.35f
canvas.drawCircle(x, y - (paint.textSize / 3), radius, todayPaint)
}
// 绘制日期文字
paint.color = when {
isSelected -> selectedTextColor
else -> normalTextColor
}
paint.textSize = spToPx(16f)
canvas.drawText(day.toString(), x, y, paint)
// 新增:绘制红点和绿点(仅在当前月份日期显示)
if (isCurrentMonth) {
val dateKey = formatDateKey(currentYear, currentMonth, day)
val redDotExists = redDotDates.contains(dateKey)
val greenDotExists = greenDotDates.contains(dateKey)
// 计算点的位置
val dotY = y + paint.textSize / 2 + dotSpacing
// 如果只有一种点,居中显示
when {
redDotExists && greenDotExists -> {
// 两种点都存在,在左右两侧显示
val leftX = x - dotRadius * 1.5f
val rightX = x + dotRadius * 1.5f
canvas.drawCircle(leftX, dotY, dotRadius, redDotPaint)
canvas.drawCircle(rightX, dotY, dotRadius, greenDotPaint)
}
redDotExists -> {
// 只有红点
canvas.drawCircle(x, dotY, dotRadius, redDotPaint)
}
greenDotExists -> {
// 只有绿点
canvas.drawCircle(x, dotY, dotRadius, greenDotPaint)
}
}
}
}
private fun drawOtherMonthDays(canvas: Canvas, firstDayOfWeek: Int, daysInMonth: Int) {
// 绘制上个月在当月日历中显示的日期
if (firstDayOfWeek > 0) {
val prevMonthCalendar = Calendar.getInstance().apply {
set(currentYear, currentMonth, 1)
add(Calendar.MONTH, -1)
}
val daysInPrevMonth = getMonthDays(
prevMonthCalendar.get(Calendar.YEAR),
prevMonthCalendar.get(Calendar.MONTH)
)
for (i in 0 until firstDayOfWeek) {
val day = daysInPrevMonth - firstDayOfWeek + i + 1
val row = 0
val col = i
drawOtherMonthDate(canvas, row, col, day)
}
}
// 绘制下个月在当月日历中显示的日期
val totalRows = 6
val totalCells = totalRows * 7
val cellsUsedByCurrentMonth = firstDayOfWeek + daysInMonth
val remainingCells = totalCells - cellsUsedByCurrentMonth
if (remainingCells > 0) {
var nextMonthDay = 1
for (i in 0 until remainingCells) {
val position = cellsUsedByCurrentMonth + i
val row = position / 7
val col = position % 7
drawOtherMonthDate(canvas, row, col, nextMonthDay)
nextMonthDay++
}
}
}
private fun drawOtherMonthDate(canvas: Canvas, row: Int, col: Int, day: Int) {
val x = col * cellWidth + cellWidth / 2
val y = weekHeight + row * cellHeight + cellHeight * 0.6f
// 保存当前画笔状态
val originalColor = paint.color
val originalTextSize = paint.textSize
// 绘制跨月日期文字(灰色)
paint.color = otherMonthTextColor
paint.textSize = spToPx(16f)
canvas.drawText(day.toString(), x, y, paint)
// 恢复画笔状态
paint.color = originalColor
paint.textSize = originalTextSize
}
private fun drawGridLines(canvas: Canvas) {
paint.color = gridLineColor
paint.strokeWidth = 1f
// 绘制星期栏底部分隔线
canvas.drawLine(0f, weekHeight, width.toFloat(), weekHeight, paint)
// 绘制垂直线
for (i in 1 until 7) {
val x = i * cellWidth
canvas.drawLine(x, weekHeight, x, height.toFloat(), paint)
}
// 绘制水平线
for (i in 1..6) {
val y = weekHeight + i * cellHeight
canvas.drawLine(0f, y, width.toFloat(), y, paint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> return true
MotionEvent.ACTION_UP -> {
handleClick(event.x, event.y)
return true
}
}
return super.onTouchEvent(event)
}
private fun handleClick(x: Float, y: Float) {
if (y < weekHeight) return
// 计算点击的行列
val row = ((y - weekHeight) / cellHeight).toInt()
val col = (x / cellWidth).toInt()
if (row < 0 || row >= 6 || col < 0 || col >= 7) return
// 获取当前月份第一天是星期几
calendar.set(currentYear, currentMonth, 1)
val firstDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) - 1
// 获取当月天数
val daysInMonth = getMonthDays(currentYear, currentMonth)
// 计算点击的日期索引
val position = row * 7 + col
val dayIndex = position - firstDayOfWeek + 1
if (dayIndex in 1..daysInMonth) {
// 点击当前月份日期
selectDate(currentYear, currentMonth, dayIndex)
}
// 跨月日期不可点击,不做任何处理
}
/**
* 获取月份天数
*/
private fun getMonthDays(year: Int, month: Int): Int {
val isLeapYear = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
return when (month) {
0, 2, 4, 6, 7, 9, 11 -> 31
3, 5, 8, 10 -> 30
1 -> if (isLeapYear) 29 else 28
else -> 0
}
}
/**
* 选择日期
*/
fun selectDate(year: Int, month: Int, day: Int) {
// 如果选择的日期不在当前月份,先切换到该月份
if (year != currentYear || month != currentMonth) {
currentYear = year
currentMonth = month
}
// 设置选中日期
selectedDate = Calendar.getInstance().apply {
set(year, month, day)
}
// 重绘
invalidate()
// 回调
onDateClickListener?.invoke(year, month, day)
}
/**
* 新增:设置红点日期
* @param dates 日期列表,格式: "2024-01-15"
*/
fun setRedDotDates(dates: List<String>) {
redDotDates.clear()
redDotDates.addAll(dates)
invalidate()
}
/**
* 新增:设置绿点日期
* @param dates 日期列表,格式: "2024-01-15"
*/
fun setGreenDotDates(dates: List<String>) {
greenDotDates.clear()
greenDotDates.addAll(dates)
invalidate()
}
/**
* 新增:添加单个红点日期
*/
fun addRedDotDate(year: Int, month: Int, day: Int) {
val dateKey = formatDateKey(year, month, day)
redDotDates.add(dateKey)
invalidate()
}
/**
* 新增:添加单个绿点日期
*/
fun addGreenDotDate(year: Int, month: Int, day: Int) {
val dateKey = formatDateKey(year, month, day)
greenDotDates.add(dateKey)
invalidate()
}
/**
* 新增:移除红点日期
*/
fun removeRedDotDate(year: Int, month: Int, day: Int) {
val dateKey = formatDateKey(year, month, day)
redDotDates.remove(dateKey)
invalidate()
}
/**
* 新增:移除绿点日期
*/
fun removeGreenDotDate(year: Int, month: Int, day: Int) {
val dateKey = formatDateKey(year, month, day)
greenDotDates.remove(dateKey)
invalidate()
}
/**
* 新增:清空所有红点
*/
fun clearRedDotDates() {
redDotDates.clear()
invalidate()
}
/**
* 新增:清空所有绿点
*/
fun clearGreenDotDates() {
greenDotDates.clear()
invalidate()
}
/**
* 新增:格式化日期键
*/
private fun formatDateKey(year: Int, month: Int, day: Int): String {
return String.format("%d-%02d-%02d", year, month + 1, day)
}
/**
* 设置当前显示的月份
*/
fun setCurrentMonth(year: Int, month: Int) {
currentYear = year
currentMonth = month
invalidate()
}
/**
* 获取当前显示的月份
*/
fun getCurrentMonth(): Pair<Int, Int> {
return Pair(currentYear, currentMonth)
}
/**
* 获取选中的日期
*/
fun getSelectedDate(): Triple<Int, Int, Int>? {
return selectedDate?.let {
Triple(
it.get(Calendar.YEAR),
it.get(Calendar.MONTH),
it.get(Calendar.DAY_OF_MONTH)
)
}
}
/**
* 切换到上个月
*/
fun previousMonth() {
var newYear = currentYear
var newMonth = currentMonth - 1
if (newMonth < 0) {
newYear--
newMonth = 11
}
setCurrentMonth(newYear, newMonth)
}
/**
* 切换到下个月
*/
fun nextMonth() {
var newYear = currentYear
var newMonth = currentMonth + 1
if (newMonth > 11) {
newYear++
newMonth = 0
}
setCurrentMonth(newYear, newMonth)
}
/**
* 回到今天
*/
fun goToToday() {
val now = Calendar.getInstance()
currentYear = now.get(Calendar.YEAR)
currentMonth = now.get(Calendar.MONTH)
selectedDate = now.clone() as Calendar
invalidate()
// 回调今天的日期
onDateClickListener?.invoke(
currentYear,
currentMonth,
now.get(Calendar.DAY_OF_MONTH)
)
}
/**
* 设置日期点击监听器
*/
fun setOnDateClickListener(listener: (year: Int, month: Int, day: Int) -> Unit) {
this.onDateClickListener = listener
}
/**
* sp转px
*/
private fun spToPx(sp: Float): Float {
return sp * resources.displayMetrics.scaledDensity
}
/**
* 新增:dp转px
*/
private fun dpToPx(dp: Float): Float {
return dp * resources.displayMetrics.density
}
}
样例布局activity_main.xml
ini
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<!-- 标题 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="简易日历"
android:textSize="18sp"
android:textStyle="bold" />
<!-- 月份导航 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal">
<Button
android:id="@+id/prevMonthButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="◀ 上个月" />
<TextView
android:id="@+id/monthYearTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:text="2024年2月"
android:textSize="18sp"
android:textStyle="bold" />
<Button
android:id="@+id/nextMonthButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="下个月 ▶" />
</LinearLayout>
<!-- 日历视图 -->
<cn.nio.calendar.SimpleCalendarView
android:id="@+id/calendarView"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<!-- 选中的日期 -->
<TextView
android:id="@+id/selectedDateTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="选中: 2024年2月15日"
android:textSize="13sp"
android:textStyle="bold" />
<!-- 回到今天按钮 -->
<Button
android:id="@+id/todayButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:text="回到今天" />
</LinearLayout>
样例MainActivity.kt
kotlin
package cn.nio.calendar
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* 简易日历
*/
class MainActivity : AppCompatActivity() {
private lateinit var calendarView: SimpleCalendarView
private lateinit var monthYearTextView: TextView
private lateinit var selectedDateTextView: TextView
private lateinit var prevMonthButton: Button
private lateinit var nextMonthButton: Button
private lateinit var todayButton: Button
// private val monthFormat = SimpleDateFormat("yyyy年MM月", Locale.getDefault())
// private val dateFormat = SimpleDateFormat("yyyy年MM月dd日", Locale.getDefault())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
// 初始化视图
calendarView = findViewById(R.id.calendarView)
monthYearTextView = findViewById(R.id.monthYearTextView)
selectedDateTextView = findViewById(R.id.selectedDateTextView)
prevMonthButton = findViewById(R.id.prevMonthButton)
nextMonthButton = findViewById(R.id.nextMonthButton)
todayButton = findViewById(R.id.todayButton)
// 初始化显示
updateMonthYearText()
updateSelectedDateText()
// 点击选中日期
calendarView.setOnDateClickListener { year, month, day ->
updateSelectedDateText()
}
// 上个月
prevMonthButton.setOnClickListener {
calendarView.previousMonth()
updateMonthYearText()
}
// 下个月
nextMonthButton.setOnClickListener {
calendarView.nextMonth()
updateMonthYearText()
}
// 回到今天
todayButton.setOnClickListener {
calendarView.goToToday()
updateMonthYearText()
updateSelectedDateText()
}
calculatePreviousDaysWithCalendar()
}
private fun updateMonthYearText() {
val (year, month) = calendarView.getCurrentMonth()
monthYearTextView.text = "${year}年${month + 1}月"
}
private fun updateSelectedDateText() {
val selectedDate = calendarView.getSelectedDate()
selectedDate?.let { (year, month, day) ->
selectedDateTextView.text = "选中: ${year}年${month + 1}月${day}日"
}
}
/**
* 设置红点绿点日期
*
*/
private fun calculatePreviousDaysWithCalendar() {
val calendar = Calendar.getInstance()
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) // 线程不安全,多线程需注意
// 计算前1天
calendar.add(Calendar.DAY_OF_MONTH, -1)
val previous1DayStr = sdf.format(calendar.time)
// 计算前2天(在原基础上再减1)
calendar.add(Calendar.DAY_OF_MONTH, -1)
val previous2DayStr = sdf.format(calendar.time)
// 计算前3天
calendar.add(Calendar.DAY_OF_MONTH, -1)
val previous3DayStr = sdf.format(calendar.time)
// 重置Calendar到当前时间(可选)
calendar.add(Calendar.DAY_OF_MONTH, 3)
println("当前日期:${sdf.format(Calendar.getInstance().time)}")
println("前1天:$previous1DayStr")
println("前2天:$previous2DayStr")
println("前3天:$previous3DayStr")
calendarView.setGreenDotDates(listOf(previous1DayStr, previous3DayStr))
calendarView.setRedDotDates(listOf(previous2DayStr))
}
}
效果展示
