自定义简易日历

自定义简易日历

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

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

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

  • 可切换上个月/下个月

  • 支持回到今天

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

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

}

效果展示

相关推荐
梁同学与Android2 小时前
Android ---【经验篇】阿里云 CentOS 服务器环境搭建 + SpringBoot项目部署(二)
android·spring boot·后端
xuyin12042 小时前
android 如何提高message的优先级
android
PyAIGCMaster2 小时前
安卓原生开发工具,一性性成生所有类型图标。
android
_李小白2 小时前
【Android FrameWork】延伸阅读:StorageStatsService 与应用目录管理
android
Just_Paranoid2 小时前
【SystemUI】基于 Android R 实现下拉状态栏毛玻璃背景
android·canvas·systemui·renderscript
vocal2 小时前
【我的AOSP第一课】Android bootanim 的启动
android
shenshizhong2 小时前
Compose + Mvi 架构的玩android 项目,请尝鲜
android·架构·android jetpack
Chuck_Chan2 小时前
Launcher3模块化-组件化
android
xuyin12043 小时前
Android内存优化
android