自定义简易日历

自定义简易日历

  • 当前月份日期可点击选中

  • 今天日期有红色圆圈标记

  • 跨月日期为灰色,不可点击

  • 可切换上个月/下个月

  • 支持回到今天

  • 可以设置红点绿点日期。比如打卡日期

自定义日历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))
    }

}

效果展示

相关推荐
阿巴斯甜15 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker15 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952716 小时前
Andorid Google 登录接入文档
android
黄林晴18 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android