基于Android实现工作管理甘特图效果的代码详解
作者:Katie。
一、项目介绍
1.1 项目背景
在现代项目管理与团队协作中,甘特图(Gantt Chart) 是最直观的进度可视化手段之一。它将项目拆分为若干任务(Task),以横轴表示时间、纵轴表示任务序列,通过条状图(Bar)呈现每个任务的开始、结束和持续时长,帮助管理者一目了然地掌握项目进度、资源分配与关键路径。
在移动端,尤其是 Android 应用场景,越来越多的团队管理、日程安排、考勤排班、生产计划等应用也需要在 App 内展示甘特图,以便移动办公或现场管理。由于 Android 原生并无甘特图组件,需要开发者自行实现或集成第三方库。本项目目标在不依赖重量级第三方库的前提下,构建一个高性能、灵活可定制、支持滚动缩放与交互的 Android 原生甘特图组件,满足以下需求:
任务条可视化:在甘特图上绘制每个任务的条状表示,并支持不同颜色、图标标记。
时间轴刻度:横轴显示日期/小时刻度,并支持等级切换(日视图/周视图/月视图)。
竖向滚动:任务过多时能上下滚动,自动复用行视图减少内存占用。
横向滚动与缩放:时间跨度长时能左右滚动,并可通过手势缩放时间轴(放大查看小时级细节/缩小看月级全局)。
任务交互:点击任务弹出详情,长按可拖拽调整开始/结束时间。
性能优化:采用
RecyclerView、Canvas批绘、ViewHolder复用等技术,保证高帧率。可配置性:支持多种主题风格(浅色/深色)、条高度、文字大小、行高、时间格式自定义。
MVVM 架构:前后端分离,数据由
ViewModel管理,UI 仅关注渲染与交互。离线缓存:可将任务数据存储于本地
Room数据库,实现离线展示与增量同步。
1.2 功能设计
| 功能模块 | 说明 |
|---|---|
| 时间轴刻度 | 支持日/周/月/季度四种视图模式,并根据当前缩放级别动态渲染刻度 |
| 任务列表 | 纵向显示任务序列,使用 RecyclerView 实现可滚动、可复用 |
| 甘特条渲染 | 计算任务的开始/结束时间对应的 X 坐标,在 Canvas 上绘制条形,支持自定义颜色 |
| 缩放与滚动 | 结合 ScaleGestureDetector 和 HorizontalScrollView,实现平滑缩放和滚动 |
| 任务交互 | 点击弹出 PopupWindow 显示任务详情;支持长按拖拽改变时间(高级功能可选) |
| 数据层 | 使用 Room 持久化任务数据;ViewModel 暴露 LiveData<List<Task>> |
| 配置与主题 | 在 attrs.xml 定义可自定义属性,如甘特条高度、颜色数组、时间格式等 |
1.3 技术选型
语言:Kotlin
UI:AndroidX、Material Components、ConstraintLayout
图形绘制:Canvas + Paint + Path + PorterDuff(用于图层混合,可用于复杂高亮)
手势识别:
GestureDetector+ScaleGestureDetector列表复用:
RecyclerView+LinearLayoutManager数据持久化:Room + LiveData + ViewModel
协程:Kotlin Coroutines +
ViewModelScope依赖注入:Hilt (可选)
日期处理:ThreeTenABP (
java.time)
二、相关知识
2.1 Canvas 绘制原理
Canvas.drawRect/ drawRoundRect:绘制任务条;
Canvas.drawLine/ drawText:绘制刻度线和刻度文字;
图层(saveLayer/ restore):在需要遮罩或混合模式时使用;
2.2 RecyclerView 性能优化
ViewHolder 模式:复用任务行布局;
ItemDecoration:可用于绘制水平分隔线或辅助网格;
DiffUtil:高效计算数据变更并局部刷新;
2.3 手势与视图缩放
ScaleGestureDetector:监听双指捏合手势,实现缩放中心为手指焦点;
GestureDetector:监听单指滚动、双击等;
矩阵(Matrix):在 Canvas 平移与缩放时可用;
2.4 时间与坐标映射
时间轴范围:根据任务的最早开始和最晚结束计算总时长(毫秒);
像素映射:
x = (task.startTime - minTime) / timeSpan * totalWidth;动态宽度:总宽度根据当前缩放级别和屏幕宽度计算;
2.5 数据层与 MVVM
Room 实体:
@Entity data class Task(...);DAO:增删改查和查询任务列表;
ViewModel:使用
MutableLiveData<List<Task>>管理任务,协程异步加载;Activity/Fragment:观察 LiveData 并将任务列表提交给适配器;
三、实现思路
总体框架
MainActivity(或GanttChartFragment)初始化 ViewModel、RecyclerView 与时间轴头部;视图分为两部分:左侧任务列表 + 右侧甘特图区域,后者可水平滚动;
使用嵌套
RecyclerView:水平滚动用RecyclerView+LinearLayoutManager(HORIZONTAL);或更轻量:右侧放置一个自定义
GanttChartView,外层套HorizontalScrollView。
核心视图:GanttChartView
继承
View,在onDraw()中完成时间轴与任务条的绘制;支持
setTasks(List<Task>)、setScale(scaleFactor: Float)接口;维护
minTime、maxTime、timeSpan、viewWidth、rowHeight、barHeight等参数。
任务行复用
在
RecyclerView.Adapter的onBindViewHolder()中,将任务数据传给GanttChartViewHolder,后者调用ganttView.setTask(task)并invalidate();GanttChartViewHolder内维护单个行高与索引,用以计算 Y 坐标。
手势缩放与滚动
在
GanttChartView内部实例化并注册ScaleGestureDetector,在onTouchEvent()中转发,更新scaleFactor并重新测量宽度后invalidate();外层
HorizontalScrollView负责水平滚动;
点击与拖拽(高级功能,可选)
监听
GestureDetector的onSingleTapUp(event),计算点击 X/Y 的时间和任务索引,弹出详情对话框;长按后启动拖拽,实时更新任务开始或结束时间并重绘。
时间刻度与视图更新
在
GanttChartView.onDraw()中先绘制顶部刻度行,循环for (i in 0..numTicks):
val x = leftPadding + i * (timeSpanPerTick / timeSpan) * viewWidth canvas.drawLine(x, 0f, x, headerHeight, axisPaint) canvas.drawText(formatTime(minTime + i * timeSpanPerTick), x, textY, textPaint)
- 下方依次绘制每个任务行的矩形条与任务名称。
状态管理与刷新
当
scaleFactor或任务列表更新时,调用ganttRecyclerView.adapter?.notifyDataSetChanged();可使用
DiffUtil精细刷新;
四、整合代码
以下将所有核心源文件与布局文件整合到同一代码块,用注释区分文件,并附详注释。
// ---------------- 文件: build.gradle (Module) ----------------
/*
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
}
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.gantt"
minSdkVersion 21
targetSdkVersion 34
versionCode 1
versionName "1.0"
}
buildFeatures { viewBinding true }
}
dependencies {
implementation "androidx.core:core-ktx:1.10.1"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "com.google.android.material:material:1.9.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.recyclerview:recyclerview:1.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
implementation "androidx.room:room-runtime:2.5.2"
kapt "androidx.room:room-compiler:2.5.2"
implementation "org.threeten:threetenbp:1.6.0" // 或 ThreeTenABP
implementation "com.jakewharton.threetenabp:threetenabp:1.4.4"
}
*/
// ---------------- 文件: Task.kt ----------------
package com.example.gantt.data
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.threeten.bp.Instant
import org.threeten.bp.ZonedDateTime
/**
* Task:Room 实体,表示甘特图中的一个任务
*/
@Entity(tableName = "tasks")
data class Task(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val startTime: Long, // 毫秒时间戳
val endTime: Long,
val color: Int // ARGB 颜色
)
// ---------------- 文件: TaskDao.kt ----------------
package com.example.gantt.data
import androidx.lifecycle.LiveData
import androidx.room.*
/**
* TaskDao:任务增删改查接口
*/
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY startTime")
fun getAllTasks(): LiveData<List<Task>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(task: Task)
@Delete
suspend fun delete(task: Task)
}
// ---------------- 文件: AppDatabase.kt ----------------
package com.example.gantt.data
import androidx.room.Database
import androidx.room.RoomDatabase
/**
* AppDatabase:Room 数据库
*/
@Database(entities = [Task::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
// ---------------- 文件: GanttViewModel.kt ----------------
package com.example.gantt.viewmodel
import android.app.Application
import androidx.lifecycle.*
import androidx.room.Room
import com.example.gantt.data.AppDatabase
import com.example.gantt.data.Task
import kotlinx.coroutines.launch
/**
* GanttViewModel:持有任务列表,提供增删改查
*/
class GanttViewModel(application: Application) : AndroidViewModel(application) {
private val db = Room.databaseBuilder(application, AppDatabase::class.java, "gantt.db").build()
private val dao = db.taskDao()
val tasks: LiveData<List<Task>> = dao.getAllTasks()
fun addTask(task: Task) = viewModelScope.launch {
dao.insert(task)
}
fun deleteTask(task: Task) = viewModelScope.launch {
dao.delete(task)
}
}
// ---------------- 文件: activity_main.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_height="match_parent">
<!-- 左侧任务名称列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTasks"
android:layout_width="120dp" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<!-- 右侧甘特图区域,水平滚动 -->
<HorizontalScrollView
android:id="@+id/scrollHorizontal"
android:layout_width="0dp" android:layout_height="0dp"
android:scrollbars="none"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/rvTasks" app:layout_constraintEnd_toEndOf="parent">
<com.example.gantt.ui.GanttChartView
android:id="@+id/ganttView"
android:layout_width="wrap_content" android:layout_height="match_parent"/>
</HorizontalScrollView>
<!-- 新增任务按钮 -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAdd"
android:layout_width="wrap_content" android:layout_height="wrap_content"
app:srcCompat="@android:drawable/ic_input_add"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:layout_margin="16dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
// ---------------- 文件: item_task_name.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tvTaskName"
android:layout_width="match_parent" android:layout_height="48dp"
android:gravity="center_vertical"
android:paddingStart="8dp"
android:textSize="16sp"
android:textColor="#333"/>
// ---------------- 文件: TaskNameAdapter.kt ----------------
package com.example.gantt.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.gantt.data.Task
import com.example.gantt.databinding.ItemTaskNameBinding
/**
* TaskNameAdapter:左侧任务名称列表
*/
class TaskNameAdapter : ListAdapter<Task, TaskNameAdapter.NameVH>(DIFF) {
companion object {
val DIFF = object : DiffUtil.ItemCallback<Task>() {
override fun areItemsTheSame(old: Task, new: Task) = old.id == new.id
override fun areContentsTheSame(old: Task, new: Task) = old == new
}
}
inner class NameVH(val binding: ItemTaskNameBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
NameVH(ItemTaskNameBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: NameVH, position: Int) {
holder.binding.tvTaskName.text = getItem(position).name
}
}
// ---------------- 文件: GanttChartView.kt ----------------
package com.example.gantt.ui
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.*
import android.widget.OverScroller
import androidx.core.content.ContextCompat
import com.example.gantt.R
import com.example.gantt.data.Task
import org.threeten.bp.Instant
import org.threeten.bp.ZoneId
/**
* GanttChartView:自定义甘特图 View
*/
class GanttChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
): View(context, attrs), GestureDetector.OnGestureListener, ScaleGestureDetector.OnScaleGestureListener {
// 数据
private var tasks: List<Task> = emptyList()
private var minTime = Long.MAX_VALUE
private var maxTime = 0L
private var timeSpan = 1L // ms
// 画笔
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.GRAY; strokeWidth=2f }
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.DKGRAY; textSize = 24f }
// 布局参数
private val rowHeight = 80f
private val headerHeight = 80f
private var scaleFactor = 1f
private var offsetX = 0f
// 手势
private val scroller = OverScroller(context)
private val gestureDetector = GestureDetector(context, this)
private val scaleDetector = ScaleGestureDetector(context, this)
init {
barPaint.style = Paint.Style.FILL
}
/** 外部设置任务并重新计算范围 */
fun setTasks(list: List<Task>) {
tasks = list
if (tasks.isNotEmpty()) {
minTime = tasks.minOf { it.startTime }
maxTime = tasks.maxOf { it.endTime }
timeSpan = maxTime - minTime
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (tasks.isEmpty()) return
// 1. 绘制时间轴
val totalWidth = width.toFloat() * scaleFactor
val tickCount = 6
for (i in 0..tickCount) {
val x = offsetX + i / tickCount.toFloat() * totalWidth
canvas.drawLine(x, 0f, x, headerHeight, axisPaint)
val time = minTime + i / tickCount.toFloat() * timeSpan
val label = Instant.ofEpochMilli(time)
.atZone(ZoneId.systemDefault()).toLocalDate().toString()
canvas.drawText(label, x + 10, headerHeight - 20, textPaint)
}
// 2. 绘制每行任务条
tasks.forEachIndexed { idx, task ->
val top = headerHeight + idx * rowHeight
val bottom = top + rowHeight * 0.6f
// 计算左右
val left = offsetX + (task.startTime - minTime) / timeSpan.toFloat() * totalWidth
val right = offsetX + (task.endTime - minTime) / timeSpan.toFloat() * totalWidth
barPaint.color = task.color
canvas.drawRect(left, top + 10, right, bottom, barPaint)
}
}
// ================ 手势与缩放 ================
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
if (!scaleDetector.isInProgress) {
gestureDetector.onTouchEvent(event)
}
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
scaleFactor *= detector.scaleFactor
scaleFactor = scaleFactor.coerceIn(0.5f, 3f)
invalidate()
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector) = true
override fun onScaleEnd(detector: ScaleGestureDetector) {}
override fun onDown(e: MotionEvent) = true
override fun onShowPress(e: MotionEvent) {}
override fun onSingleTapUp(e: MotionEvent) = false
override fun onScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float): Boolean {
offsetX -= dx
offsetX = offsetX.coerceIn(-width.toFloat(), width.toFloat() * scaleFactor)
invalidate()
return true
}
override fun onLongPress(e: MotionEvent) {}
override fun onFling(e1: MotionEvent, e2: MotionEvent, vx: Float, vy: Float): Boolean {
scroller.fling(
offsetX.toInt(), 0,
vx.toInt(), 0,
(-width).toInt(), (width * scaleFactor).toInt(),
0, 0
)
postInvalidateOnAnimation()
return true
}
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
offsetX = scroller.currX.toFloat()
invalidate()
}
}
}
// ---------------- 文件: MainActivity.kt ----------------
package com.example.gantt.ui
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.gantt.databinding.ActivityMainBinding
import com.example.gantt.data.Task
import com.example.gantt.viewmodel.GanttViewModel
import org.threeten.bp.ZonedDateTime
/**
* MainActivity:示例甘特图展示
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val vm: GanttViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 左侧任务名列表
val nameAdapter = TaskNameAdapter()
binding.rvTasks.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = nameAdapter
}
// 观察任务数据
vm.tasks.observe(this) { list ->
nameAdapter.submitList(list)
binding.ganttView.setTasks(list)
}
// 新增示例任务
binding.fabAdd.setOnClickListener {
val now = System.currentTimeMillis()
val task = Task(
name = "任务${now%100}",
startTime = now,
endTime = now + 3600_000 * (1 + (now%5).toInt()),
color = android.graphics.Color.rgb(((now/1000)%255).toInt(),120,150)
)
vm.addTask(task)
}
}
}
// ---------------- 文件: activity_main.xml (ViewBinding) ----------------
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTasks"
android:layout_width="120dp" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<HorizontalScrollView
android:id="@+id/scrollHorizontal"
android:layout_width="0dp" android:layout_height="0dp"
android:scrollbars="none"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/rvTasks" app:layout_constraintEnd_toEndOf="parent">
<com.example.gantt.ui.GanttChartView
android:id="@+id/ganttView"
android:layout_width="2000dp" android:layout_height="match_parent"/>
</HorizontalScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAdd"
android:layout_width="wrap_content" android:layout_height="wrap_content"
app:srcCompat="@android:drawable/ic_input_add"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:layout_margin="16dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>五、方法说明
TaskDao.getAllTasks():异步获取任务列表并以
LiveData形式暴露,自动监听数据变化;GanttViewModel.addTask()/deleteTask():在
ViewModelScope中执行 Room 操作,保证 UI 线程不被阻塞;TaskNameAdapter:左侧垂直列表,仅负责显示任务名称;
GanttChartView.setTasks(list):接受任务列表,计算
minTime、maxTime、timeSpan并刷新视图;GanttChartView.onDraw():先绘制顶部时间刻度,再遍历任务列表绘制每个任务条;
GestureDetector.OnGestureListener 与 ScaleGestureDetector.OnScaleGestureListener:分别响应单指滚动以平移视图、双指缩放以调整
scaleFactor;OverScroller:在
onFling()中启动惯性滑动,并在computeScroll()连续更新offsetX;MainActivity:
绑定
RecyclerView与GanttChartView;观察
ViewModel.tasks,双向提交数据;点击
fabAdd随机新增任务演示效果。
六、项目总结
6.1 成果回顾
完成了一个原生 Android 甘特图组件,支持纵向任务列表、横向时间轴、自动计算坐标与自适应缩放;
采用
RecyclerView与纯View绘制相结合,实现高性能渲染与交互;支持手势缩放、滚动与惯性滑动,用户体验流畅;
数据层基于 Room + LiveData + ViewModel,实现离线存储与实时刷新。
6.2 技术收获
深入理解了 Canvas 坐标映射、时间→像素转换与自定义 View 绘制机制;
掌握了
GestureDetector、ScaleGestureDetector、OverScroller等手势与惯性滑动 API;学会在 MVVM 架构中整合 Room 数据库与 UI 组件;
学习了如何在 Android 上实现可配置、高性能的大数据量可视化组件。
6.3 后续优化
动态加载:对超大时间跨度任务,按需加载时间刻度与任务条,避免一次性绘制过多元素;
任务交互:添加任务拖拽改变起止时间、滑动调整时长、长按弹出上下文菜单;
视图联动:任务列表与甘特图联动,点击任务名高亮甘特条,点击甘特条滚动列表;
主题与样式:支持深色模式、可定制行高、条高度、刻度字体、间隔颜色等;
性能检测:使用 Systrace 分析绘制与手势响应,进一步优化帧率;
无障碍:为甘特条和时间刻度添加
contentDescription,提升 A11Y 体验;单元测试与 UI 自动化测试:重点测试时间映射、缩放逻辑与滑动边界。
以上就是基于Android实现工作管理甘特图效果的代码详解的详细内容,更多关于Android工作管理甘特图的资料请关注脚本之家其它相关文章!
