Android

关注公众号 jb51net

关闭
首页 > 软件编程 > Android > Android工作管理甘特图

基于Android实现工作管理甘特图效果的代码详解

作者:Katie。

在现代项目管理与团队协作中,甘特图(Gantt Chart) 是最直观的进度可视化手段之一,在移动端,尤其是 Android 应用场景,越来越多的日程安排、考勤排班等应用也需要在 App 内展示甘特图,以便移动办公或现场管理,所以本文介绍了基于Android实现工作管理甘特图效果

一、项目介绍

1.1 项目背景

在现代项目管理与团队协作中,甘特图(Gantt Chart) 是最直观的进度可视化手段之一。它将项目拆分为若干任务(Task),以横轴表示时间、纵轴表示任务序列,通过条状图(Bar)呈现每个任务的开始、结束和持续时长,帮助管理者一目了然地掌握项目进度、资源分配与关键路径。

在移动端,尤其是 Android 应用场景,越来越多的团队管理、日程安排、考勤排班、生产计划等应用也需要在 App 内展示甘特图,以便移动办公或现场管理。由于 Android 原生并无甘特图组件,需要开发者自行实现或集成第三方库。本项目目标在不依赖重量级第三方库的前提下,构建一个高性能、灵活可定制、支持滚动缩放与交互的 Android 原生甘特图组件,满足以下需求:

1.2 功能设计

功能模块说明
时间轴刻度支持日/周/月/季度四种视图模式,并根据当前缩放级别动态渲染刻度
任务列表纵向显示任务序列,使用 RecyclerView 实现可滚动、可复用
甘特条渲染计算任务的开始/结束时间对应的 X 坐标,在 Canvas 上绘制条形,支持自定义颜色
缩放与滚动结合 ScaleGestureDetector 和 HorizontalScrollView,实现平滑缩放和滚动
任务交互点击弹出 PopupWindow 显示任务详情;支持长按拖拽改变时间(高级功能可选)
数据层使用 Room 持久化任务数据;ViewModel 暴露 LiveData<List<Task>>
配置与主题在 attrs.xml 定义可自定义属性,如甘特条高度、颜色数组、时间格式等

1.3 技术选型

二、相关知识

2.1 Canvas 绘制原理

2.2 RecyclerView 性能优化

2.3 手势与视图缩放

2.4 时间与坐标映射

2.5 数据层与 MVVM

三、实现思路

  1. 总体框架

    • MainActivity(或 GanttChartFragment)初始化 ViewModel、RecyclerView 与时间轴头部;

    • 视图分为两部分:左侧任务列表 + 右侧甘特图区域,后者可水平滚动;

    • 使用嵌套 RecyclerView:水平滚动用 RecyclerView + LinearLayoutManager(HORIZONTAL)

    • 或更轻量:右侧放置一个自定义 GanttChartView,外层套 HorizontalScrollView

  2. 核心视图:GanttChartView

    • 继承 View,在 onDraw() 中完成时间轴与任务条的绘制;

    • 支持 setTasks(List<Task>)setScale(scaleFactor: Float) 接口;

    • 维护 minTimemaxTimetimeSpanviewWidthrowHeightbarHeight 等参数。

  3. 任务行复用

    • 在 RecyclerView.Adapter 的 onBindViewHolder() 中,将任务数据传给 GanttChartViewHolder,后者调用 ganttView.setTask(task) 并 invalidate()

    • GanttChartViewHolder 内维护单个行高与索引,用以计算 Y 坐标。

  4. 手势缩放与滚动

    • 在 GanttChartView 内部实例化并注册 ScaleGestureDetector,在 onTouchEvent() 中转发,更新 scaleFactor 并重新测量宽度后 invalidate()

    • 外层 HorizontalScrollView 负责水平滚动;

  5. 点击与拖拽(高级功能,可选)

    • 监听 GestureDetector 的 onSingleTapUp(event),计算点击 X/Y 的时间和任务索引,弹出详情对话框;

    • 长按后启动拖拽,实时更新任务开始或结束时间并重绘。

  6. 时间刻度与视图更新

    • 在 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)

四、整合代码

以下将所有核心源文件与布局文件整合到同一代码块,用注释区分文件,并附详注释。

// ---------------- 文件: 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>

五、方法说明

六、项目总结

6.1 成果回顾

6.2 技术收获

6.3 后续优化

以上就是基于Android实现工作管理甘特图效果的代码详解的详细内容,更多关于Android工作管理甘特图的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文