Skip to content

Commit 2b4288e

Browse files
committed
Grantt chart
Inspired by PhilJay/MPAndroidChart#5519 Fix Gantt chart label clipping, x-axis label overlap, and seekbar overlay - GanttChart.kt: dynamically compute chartLeft from max label width so long names like 'Development' are never clipped by the view edge - GanttChart.kt: compute grid-line count from available width / label width so x-axis time labels never overlap each other - GanttChart.kt: extract labelTextSize, gridLinesMin/Max as constants so measurement paint and drawing paint are always in sync - activity_time_interval_chart.xml: remove unused SeekBar widgets and TextViews that were rendering on top of the chart - TimeIntervalChartActivity.kt: remove OnSeekBarChangeListener interface and its unused override methods
1 parent 95a5773 commit 2b4288e

8 files changed

Lines changed: 419 additions & 2 deletions

File tree

app/src/androidTest/kotlin/info/appdev/chartexample/StartTest.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ class StartTest {
7575

7676
@After
7777
fun cleanUp() {
78-
Intents.release()
78+
// release() may have already been called by the last loop iteration
79+
try {
80+
Intents.release()
81+
} catch (_: IllegalStateException) { /* not initialised, nothing to do */ }
7982
// Clean up test timber tree
8083
Timber.uprootAll()
8184
}
@@ -249,12 +252,17 @@ class StartTest {
249252
if (!compose)
250253
doClickTest(index, contentClass, contentItem)
251254

252-
//Thread.sleep(100)
253255
Espresso.pressBack()
254256

255257
// Wait for MainActivity to be visible again
256258
composeTestRule.waitForIdle()
257259
Thread.sleep(200) // Small delay for back navigation
260+
261+
// Reset intent recording for next iteration; otherwise intents accumulate
262+
// across the loop and Intents.intended() can no longer find a fresh
263+
// unverified match for the current activity.
264+
Intents.release()
265+
Intents.init()
258266
} catch (e: Exception) {
259267
Timber.e("#$index/'${contentClass.simpleName}'->'$optionMenu' ${e.message}", e)
260268
onView(ViewMatchers.isRoot())
@@ -264,6 +272,16 @@ class StartTest {
264272
.replace(" ", "")
265273
)
266274
})
275+
// Navigate back so subsequent iterations start from MainActivity
276+
try {
277+
Espresso.pressBack()
278+
composeTestRule.waitForIdle()
279+
} catch (_: Exception) { /* already on MainActivity */ }
280+
// Reset intents so the next iteration starts clean
281+
try {
282+
Intents.release()
283+
Intents.init()
284+
} catch (_: Exception) { /* ignore if already released */ }
267285
}
268286
}
269287
}
@@ -278,6 +296,7 @@ class StartTest {
278296
contentItem.clazz == HorizontalBarFullComposeActivity::class.java ||
279297
contentItem.clazz == MultiLineComposeActivity::class.java ||
280298
contentItem.clazz == GradientActivity::class.java ||
299+
// contentItem.clazz == TimeIntervalChartActivity::class.java ||
281300
contentItem.clazz == TimeLineActivity::class.java
282301
) {
283302
// These charts have less clickable area, so skip further clicks

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
<activity android:name="info.appdev.chartexample.HalfPieChartActivity" />
6161
<activity android:name="info.appdev.chartexample.SpecificPositionsLineChartActivity" />
6262
<activity android:name="info.appdev.chartexample.TimeLineActivity" />
63+
<activity android:name="info.appdev.chartexample.TimeIntervalChartActivity" />
6364
</application>
6465

6566
</manifest>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package info.appdev.chartexample
2+
3+
import android.graphics.Color
4+
import android.os.Bundle
5+
import info.appdev.chartexample.databinding.ActivityTimeIntervalChartBinding
6+
import info.appdev.chartexample.notimportant.DemoBase
7+
import info.appdev.charting.data.EntryFloat
8+
import info.appdev.charting.data.GanttChartData
9+
import info.appdev.charting.data.GanttTask
10+
import info.appdev.charting.highlight.Highlight
11+
import info.appdev.charting.listener.OnChartValueSelectedListener
12+
13+
/**
14+
* Demo activity showing Gantt-style timeline visualization.
15+
* Each horizontal bar represents a task with start time and duration.
16+
*/
17+
class TimeIntervalChartActivity : DemoBase(), OnChartValueSelectedListener {
18+
19+
private lateinit var binding: ActivityTimeIntervalChartBinding
20+
21+
override fun onCreate(savedInstanceState: Bundle?) {
22+
super.onCreate(savedInstanceState)
23+
binding = ActivityTimeIntervalChartBinding.inflate(layoutInflater)
24+
setContentView(binding.root)
25+
26+
// Create Gantt chart data
27+
val ganttData = GanttChartData()
28+
29+
// Add sample project tasks
30+
ganttData.addTask(GanttTask("Design", 0f, 50f, Color.rgb(255, 107, 107))) // Red: 0-50
31+
ganttData.addTask(GanttTask("Dev", 40f, 100f, Color.rgb(66, 165, 245))) // Blue: 40-140
32+
ganttData.addTask(GanttTask("Testing", 120f, 40f, Color.rgb(76, 175, 80))) // Green: 120-160
33+
ganttData.addTask(GanttTask("Launch", 150f, 20f, Color.rgb(255, 193, 7))) // Yellow: 150-170
34+
35+
// Set data and render
36+
binding.chart1.setData(ganttData)
37+
}
38+
39+
override fun saveToGallery() = Unit
40+
41+
override fun onValueSelected(entryFloat: EntryFloat, highlight: Highlight) = Unit
42+
43+
override fun onNothingSelected() = Unit
44+
45+
}

app/src/main/kotlin/info/appdev/chartexample/notimportant/MainActivity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import info.appdev.chartexample.ScrollViewActivity
7575
import info.appdev.chartexample.SpecificPositionsLineChartActivity
7676
import info.appdev.chartexample.StackedBarActivity
7777
import info.appdev.chartexample.StackedBarActivityNegative
78+
import info.appdev.chartexample.TimeIntervalChartActivity
7879
import info.appdev.chartexample.TimeLineActivity
7980
import info.appdev.chartexample.compose.HorizontalBarComposeActivity
8081
import info.appdev.chartexample.compose.HorizontalBarFullComposeActivity
@@ -219,6 +220,7 @@ class MainActivity : ComponentActivity() {
219220
add(ContentItem("Demonstrate and fix issues"))
220221
add(ContentItem("Gradient", "Show a gradient edge case", GradientActivity::class.java))
221222
add(ContentItem("Timeline", "Show a time line with Unix timestamp", TimeLineActivity::class.java))
223+
add(ContentItem("Timeinterval", "Grantt chart", TimeIntervalChartActivity::class.java))
222224
}
223225
}
224226
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:layout_width="match_parent"
4+
android:layout_height="match_parent">
5+
6+
<info.appdev.charting.charts.GanttChart
7+
android:id="@+id/chart1"
8+
android:layout_width="match_parent"
9+
android:layout_height="match_parent"
10+
android:layout_above="@+id/seekBarX"/>
11+
12+
<SeekBar
13+
android:id="@+id/seekBarY"
14+
android:layout_width="match_parent"
15+
android:layout_height="wrap_content"
16+
android:layout_alignParentBottom="true"
17+
android:layout_alignParentLeft="true"
18+
android:layout_margin="8dp"
19+
android:layout_toLeftOf="@+id/tvYMax"
20+
android:layout_marginRight="5dp"
21+
android:max="100"
22+
android:paddingBottom="12dp" />
23+
24+
<SeekBar
25+
android:id="@+id/seekBarX"
26+
android:layout_width="match_parent"
27+
android:layout_height="wrap_content"
28+
android:layout_above="@+id/seekBarY"
29+
android:layout_margin="8dp"
30+
android:layout_marginBottom="35dp"
31+
android:layout_toLeftOf="@+id/tvXMax"
32+
android:layout_marginRight="5dp"
33+
android:max="100"
34+
android:paddingBottom="12dp" />
35+
36+
<TextView
37+
android:id="@+id/tvXMax"
38+
android:layout_width="50dp"
39+
android:layout_height="wrap_content"
40+
android:layout_alignBottom="@+id/seekBarX"
41+
android:layout_alignParentRight="true"
42+
android:text="@string/dash"
43+
android:layout_marginBottom="15dp"
44+
android:layout_marginRight="10dp"
45+
android:gravity="right"
46+
android:textAppearance="?android:attr/textAppearanceMedium" />
47+
48+
<TextView
49+
android:id="@+id/tvYMax"
50+
android:layout_width="50dp"
51+
android:layout_height="wrap_content"
52+
android:layout_alignBottom="@+id/seekBarY"
53+
android:layout_alignParentRight="true"
54+
android:text="@string/dash"
55+
android:layout_marginBottom="15dp"
56+
android:layout_marginRight="10dp"
57+
android:gravity="right"
58+
android:textAppearance="?android:attr/textAppearanceMedium" />
59+
60+
</RelativeLayout>
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package info.appdev.charting.charts
2+
3+
import android.content.Context
4+
import android.graphics.Canvas
5+
import android.graphics.Paint
6+
import android.graphics.RectF
7+
import android.util.AttributeSet
8+
import android.view.View
9+
import info.appdev.charting.data.GanttChartData
10+
import java.util.Locale
11+
12+
class GanttChart : View {
13+
private var data: GanttChartData? = null
14+
private var taskPaint: Paint? = null
15+
private var gridPaint: Paint? = null
16+
private var textPaint: Paint? = null
17+
18+
private var chartLeft = 0f
19+
private var chartTop = 0f
20+
private var chartRight = 0f
21+
private var chartBottom = 0f
22+
private val padding = 16f
23+
private val labelTextSize = 24f
24+
private val gridLinesMin = 2
25+
private val gridLinesMax = 10
26+
27+
constructor(context: Context?) : super(context) {
28+
init()
29+
}
30+
31+
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
32+
init()
33+
}
34+
35+
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
36+
init()
37+
}
38+
39+
private fun init() {
40+
taskPaint = Paint().apply {
41+
isAntiAlias = true
42+
}
43+
gridPaint = Paint().apply {
44+
color = -0x333334
45+
strokeWidth = 1f
46+
}
47+
textPaint = Paint().apply {
48+
color = -0x99999a
49+
textSize = 28f
50+
isAntiAlias = true
51+
}
52+
}
53+
54+
fun setData(data: GanttChartData?) {
55+
this.data = data
56+
invalidate()
57+
}
58+
59+
override fun onDraw(canvas: Canvas) {
60+
super.onDraw(canvas)
61+
62+
if (data == null || data!!.taskCount == 0) {
63+
return
64+
}
65+
66+
calculateDimensions()
67+
drawGrid(canvas)
68+
drawTasks(canvas)
69+
}
70+
71+
private fun calculateDimensions() {
72+
val labelMeasurePaint = Paint().apply {
73+
textSize = labelTextSize
74+
isAntiAlias = true
75+
}
76+
var maxLabelWidth = 0f
77+
if (data != null) {
78+
for (i in 0..<data!!.taskCount) {
79+
val w = labelMeasurePaint.measureText(data!!.getTask(i).name ?: "")
80+
if (w > maxLabelWidth) maxLabelWidth = w
81+
}
82+
}
83+
chartLeft = maxLabelWidth + padding * 3
84+
chartTop = padding + 30
85+
chartRight = width - padding
86+
chartBottom = height - padding - 30
87+
}
88+
89+
private val taskHeight: Float
90+
// Dynamically calculate task height based on available space
91+
get() {
92+
if (data == null || data!!.taskCount == 0) {
93+
return 40f
94+
}
95+
val availableHeight = chartBottom - chartTop
96+
val taskCount = data!!.taskCount
97+
// 50% of slot for bar, 50% for gap
98+
return (availableHeight / taskCount) * 0.5f
99+
}
100+
101+
private val taskSpacing: Float
102+
get() {
103+
if (data == null || data!!.taskCount == 0) {
104+
return 12f
105+
}
106+
val availableHeight = chartBottom - chartTop
107+
val taskCount = data!!.taskCount
108+
return (availableHeight / taskCount) * 0.5f
109+
}
110+
111+
private fun drawGrid(canvas: Canvas) {
112+
val minTime = data!!.minTime
113+
val maxTime = data!!.maxTime
114+
var timeRange = maxTime - minTime
115+
if (timeRange == 0f) {
116+
timeRange = 100f
117+
}
118+
119+
val timeLabelPaint = Paint().apply {
120+
color = -0x99999a
121+
textSize = 22f
122+
isAntiAlias = true
123+
textAlign = Paint.Align.CENTER
124+
}
125+
126+
// Calculate how many grid lines fit without overlapping labels
127+
val sampleLabel = String.format(Locale.getDefault(), "%.0f", maxTime)
128+
val labelWidth = timeLabelPaint.measureText(sampleLabel) + 8f
129+
val chartWidth = chartRight - chartLeft
130+
val maxGridLines = (chartWidth / labelWidth).toInt().coerceIn(gridLinesMin, gridLinesMax)
131+
132+
for (i in 0..maxGridLines) {
133+
val x = chartLeft + (i / maxGridLines.toFloat()) * chartWidth
134+
canvas.drawLine(x, chartTop, x, chartBottom, gridPaint!!)
135+
136+
val time = minTime + (i / maxGridLines.toFloat()) * timeRange
137+
canvas.drawText(String.format(Locale.getDefault(), "%.0f", time), x, chartBottom + 30, timeLabelPaint)
138+
}
139+
}
140+
141+
private fun drawTasks(canvas: Canvas) {
142+
val minTime = data!!.minTime
143+
val maxTime = data!!.maxTime
144+
var timeRange = maxTime - minTime
145+
if (timeRange == 0f) {
146+
timeRange = 100f
147+
}
148+
149+
val taskHeight = this.taskHeight
150+
val taskSpacing = this.taskSpacing
151+
val slotHeight = taskHeight + taskSpacing
152+
153+
val labelPaint = Paint()
154+
labelPaint.color = -0xcccccd
155+
labelPaint.textSize = labelTextSize
156+
labelPaint.isAntiAlias = true
157+
labelPaint.textAlign = Paint.Align.RIGHT
158+
159+
val borderPaint = Paint()
160+
borderPaint.color = -0x666667
161+
borderPaint.strokeWidth = 2f
162+
borderPaint.style = Paint.Style.STROKE
163+
164+
for (i in 0..<data!!.taskCount) {
165+
val task = data!!.getTask(i)
166+
167+
val taskY = chartTop + i * slotHeight
168+
val startX = chartLeft + ((task.startTime - minTime) / timeRange) * (chartRight - chartLeft)
169+
var endX = chartLeft + ((task.endTime - minTime) / timeRange) * (chartRight - chartLeft)
170+
171+
if (endX - startX < 10) {
172+
endX = startX + 10
173+
}
174+
175+
// Center label vertically in the slot
176+
val labelY = taskY + (taskHeight / 2) + 8
177+
canvas.drawText(task.name!!, chartLeft - padding, labelY, labelPaint)
178+
179+
val rect = RectF(startX, taskY, endX, taskY + taskHeight)
180+
taskPaint!!.color = task.color
181+
canvas.drawRect(rect, taskPaint!!)
182+
canvas.drawRect(rect, borderPaint)
183+
}
184+
}
185+
}

0 commit comments

Comments
 (0)