基于Android实现工作管理甘特图效果的代码详解
目录
- 一、项目介绍
- 1.1 项目背景
- 1.2 功能设计
- 1.3 技术选型
- 二、相关知识
- 2.1 Canvas 绘制原理
- 2.2 RecyclerView 性能优化
- 2.3 手势与视图缩放
- 2.4 时间与坐标映射
- 2.5 数据层与 MVVM
- 三、实现思路
- 四、整合代码
- 五、方法说明
- 六、项目总结
- 6.1 成果回顾
- 6.2 技术收获
- 6.3 后续优化
一、项目介绍
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) {}python 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 onLpythonongPress(e: MotionEvent) {} override fun onFling(e1: MotionEvent, e2: MotionEvent, vx: Float, vy: Float): Boolean { scroller.fling( offsetX.toInt(), 0, php 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.databi编程nding.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工作管理甘特图的资料请关注编程客栈(www.devze.com)其它相关文章!
精彩评论