Skip to content

Commit 07f67cc

Browse files
authored
Merge pull request #66 from pushpalroy/proy/fix/performance
Fix correctness, performance, and polish issues in timeline rendering
2 parents 47c7615 + 9f8cad7 commit 07f67cc

6 files changed

Lines changed: 167 additions & 148 deletions

File tree

.github/workflows/publish.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ jobs:
4444
# Update README installation snippet
4545
sed -i '' "s/implementation(\"io.github.pushpalroy:jetlime:.*\")/implementation(\"io.github.pushpalroy:jetlime:$VERSION\")/g" README.md
4646
47+
# Update commented-out maven testing snippet in sample app
48+
sed -i '' "s|// implementation(\"io.github.pushpalroy:jetlime:.*\")|// implementation(\"io.github.pushpalroy:jetlime:$VERSION\")|g" sample/composeApp/build.gradle.kts
49+
4750
- name: Publish to Maven Central
4851
shell: bash
4952
env:
@@ -55,6 +58,11 @@ jobs:
5558
run: |
5659
./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
5760
61+
- name: Generate Dokka API docs
62+
shell: bash
63+
run: |
64+
./scripts/run_dokka.sh
65+
5866
- name: Commit version updates
5967
shell: bash
6068
run: |
@@ -70,3 +78,14 @@ jobs:
7078
VERSION="${{ github.event.inputs.version }}"
7179
git tag -a "$VERSION" -m "Release $VERSION"
7280
git push origin "$VERSION"
81+
82+
- name: Create GitHub release
83+
shell: bash
84+
env:
85+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86+
run: |
87+
VERSION="${{ github.event.inputs.version }}"
88+
gh release create "$VERSION" \
89+
--title "v$VERSION" \
90+
--target main \
91+
--generate-notes

jetlime/src/commonMain/kotlin/com/pushpal/jetlime/EventPosition.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class EventPosition internal constructor(val name: String) {
3838
companion object {
3939

4040
/** Represents the start position in a sequence. */
41-
private val START = EventPosition("Start")
41+
internal val START = EventPosition("Start")
4242

4343
/** Represents the middle position in a sequence. */
4444
private val MIDDLE = EventPosition("Middle")
@@ -78,7 +78,7 @@ class EventPosition internal constructor(val name: String) {
7878

7979
/** Helper to check if current position is not the start. */
8080
@Stable
81-
fun isNotStart(): Boolean = name != "Start"
81+
fun isNotStart(): Boolean = this != START
8282

8383
/**
8484
* Checks if this instance is equal to another object. Two instances of [EventPosition] are

jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeEvent.kt

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ internal fun VerticalEvent(
117117
modifier: Modifier = Modifier,
118118
content: @Composable () -> Unit,
119119
) {
120-
val verticalAlignment = remember { jetLimeStyle.lineVerticalAlignment }
120+
val verticalAlignment = jetLimeStyle.lineVerticalAlignment
121121
val radiusAnimFactor by calculateRadiusAnimFactor(style)
122122
Box(
123123
modifier = modifier
@@ -148,17 +148,6 @@ internal fun VerticalEvent(
148148
val radius = style.pointRadius.toPx() * radiusAnimFactor
149149
val strokeWidth = style.pointStrokeWidth.toPx()
150150

151-
// Line
152-
// Upward connector only for CENTER placement (connects from top of item to point center)
153-
if (style.pointPlacement == PointPlacement.CENTER && style.position.isNotStart()) {
154-
drawLine(
155-
brush = jetLimeStyle.lineBrush,
156-
start = Offset(x = xOffset, y = 0f),
157-
end = Offset(x = xOffset, y = yOffset),
158-
strokeWidth = jetLimeStyle.lineThickness.toPx(),
159-
pathEffect = jetLimeStyle.pathEffect,
160-
)
161-
}
162151
// Line logic for CENTER placement: keep continuity through centers
163152
if (style.pointPlacement == PointPlacement.CENTER) {
164153
// Upward segment (skip for first item)
@@ -330,7 +319,7 @@ internal fun HorizontalEvent(
330319
modifier: Modifier = Modifier,
331320
content: @Composable () -> Unit,
332321
) {
333-
val horizontalAlignment = remember { jetLimeStyle.lineHorizontalAlignment }
322+
val horizontalAlignment = jetLimeStyle.lineHorizontalAlignment
334323
val radiusAnimFactor by calculateRadiusAnimFactor(style)
335324
val layoutDirection = LocalLayoutDirection.current
336325
val isRtl = layoutDirection == LayoutDirection.Rtl
@@ -535,15 +524,12 @@ private fun PlaceHorizontalEventContent(
535524
*/
536525
@Composable
537526
internal fun calculateRadiusAnimFactor(style: JetLimeEventStyle): State<Float> {
527+
val animation = style.pointAnimation ?: return remember { mutableFloatStateOf(1.0f) }
538528
val infiniteTransition = rememberInfiniteTransition(label = "RadiusInfiniteTransition")
539-
return if (style.pointAnimation != null) {
540-
infiniteTransition.animateFloat(
541-
initialValue = style.pointAnimation.initialValue,
542-
targetValue = style.pointAnimation.targetValue,
543-
animationSpec = style.pointAnimation.animationSpec,
544-
label = "RadiusFloatAnimation",
545-
)
546-
} else {
547-
remember { mutableFloatStateOf(1.0f) }
548-
}
529+
return infiniteTransition.animateFloat(
530+
initialValue = animation.initialValue,
531+
targetValue = animation.targetValue,
532+
animationSpec = animation.animationSpec,
533+
label = "RadiusFloatAnimation",
534+
)
549535
}

jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeExtendedEvent.kt

Lines changed: 106 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,16 @@
2424
*/
2525
package com.pushpal.jetlime
2626

27-
import androidx.compose.foundation.Canvas
2827
import androidx.compose.foundation.layout.Box
2928
import androidx.compose.foundation.layout.BoxScope
30-
import androidx.compose.foundation.layout.BoxWithConstraints
3129
import androidx.compose.foundation.layout.padding
3230
import androidx.compose.runtime.Composable
3331
import androidx.compose.runtime.ExperimentalComposeApi
3432
import androidx.compose.runtime.getValue
3533
import androidx.compose.runtime.mutableFloatStateOf
3634
import androidx.compose.runtime.remember
37-
import androidx.compose.runtime.setValue
3835
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.draw.drawBehind
3937
import androidx.compose.ui.geometry.Offset
4038
import androidx.compose.ui.geometry.Size
4139
import androidx.compose.ui.graphics.ColorFilter
@@ -99,113 +97,14 @@ fun JetLimeExtendedEvent(
9997
val layoutDirection = LocalLayoutDirection.current
10098
val isRtl = layoutDirection == LayoutDirection.Rtl
10199

102-
BoxWithConstraints(modifier = modifier) {
103-
var logicalTimelineXOffset by remember { mutableFloatStateOf(0f) }
104-
val maxAdditionalContentWidth = with(density) { additionalContentMaxWidth.toPx() }
100+
val timelineXState = remember { mutableFloatStateOf(0f) }
101+
val maxAdditionalContentWidth = with(density) { additionalContentMaxWidth.toPx() }
105102

106-
Layout(
107-
content = {
108-
// Box for main content with optional padding at the bottom
109-
Box(
110-
modifier = Modifier.padding(
111-
bottom = if (style.position.isNotEnd()) {
112-
jetLimeStyle.itemSpacing
113-
} else {
114-
0.dp
115-
},
116-
),
117-
) {
118-
content()
119-
}
120-
// Additional content passed as a composable lambda
121-
additionalContent()
122-
},
123-
) { measurables, constraints ->
124-
// Ensuring that there is at least one child in the layout
125-
require(measurables.isNotEmpty()) {
126-
"JetLimeExtendedEvent should have at-least one child for content"
127-
}
128-
// Thickness of the line drawn for the timeline
129-
val timelineThickness = jetLimeStyle.lineThickness.toPx()
130-
// Distance between the content/additional content and the timeline
131-
val contentDistance = jetLimeStyle.contentDistance.toPx()
132-
133-
// Extracting the first and potentially second child for layout
134-
val contentMeasurable = measurables.first()
135-
val additionalContentMeasurable = measurables.getOrNull(1)
136-
137-
// Measuring the additional content if it exists
138-
val additionalContentPlaceable = additionalContentMeasurable?.let { measurable ->
139-
// Calculating intrinsic width and adjusting it according to the maximum allowed width
140-
val intrinsicWidth = measurable.minIntrinsicWidth(constraints.maxHeight)
141-
val adjustedMinWidth = intrinsicWidth.coerceAtMost(maxAdditionalContentWidth.toInt())
142-
// Ensure we do not exceed available width
143-
val maxWidthForAdditional =
144-
maxAdditionalContentWidth.coerceAtMost(constraints.maxWidth.toFloat()).toInt()
145-
val newConstraints = constraints.copy(
146-
minWidth = adjustedMinWidth.coerceAtMost(maxWidthForAdditional),
147-
maxWidth = maxWidthForAdditional,
148-
)
149-
// Measuring the additional content with the new constraints
150-
measurable.measure(newConstraints)
151-
}
152-
153-
// Calculating the logical X offset for the timeline based on the width of the additional content
154-
logicalTimelineXOffset =
155-
(additionalContentPlaceable?.width?.toFloat() ?: 0f) + contentDistance
156-
157-
// Calculating the X offset and width available for the main content in logical LTR space
158-
val logicalContentXOffset = logicalTimelineXOffset + timelineThickness + contentDistance
159-
val contentWidth = constraints.maxWidth - logicalContentXOffset
160-
161-
// Measuring the main content with the calculated width
162-
val contentPlaceable = contentMeasurable.measure(
163-
constraints.copy(minWidth = 0, maxWidth = contentWidth.toInt()),
164-
)
165-
166-
// Determining the height of the layout based on the measured content
167-
val contentHeight = contentPlaceable.height
168-
val layoutHeight = additionalContentPlaceable?.let { additional ->
169-
maxOf(contentHeight, additional.height)
170-
} ?: contentHeight
171-
172-
val totalWidth = constraints.maxWidth
173-
174-
// Placing the measured composables in the layout, mirroring in RTL so that
175-
// additional content is always on the logical "left" of the timeline and main
176-
// content on the "right", but visually flipped when isRtl is true.
177-
layout(totalWidth, layoutHeight) {
178-
if (isRtl) {
179-
// In RTL, place additional content flush to the right so it stays visible
180-
additionalContentPlaceable?.placeRelative(
181-
x = totalWidth - additionalContentPlaceable.width,
182-
y = 0,
183-
)
184-
} else {
185-
// LTR: original behavior, additional content starts from left
186-
additionalContentPlaceable?.placeRelative(x = 0, y = 0)
187-
}
188-
189-
// Place main content on the opposite side of the timeline depending on direction
190-
val contentX = if (isRtl) {
191-
// In RTL, main content should be left of the timeline, but still within bounds
192-
(logicalTimelineXOffset - contentPlaceable.width - jetLimeStyle.contentDistance.toPx())
193-
.coerceAtLeast(0f)
194-
.toInt()
195-
} else {
196-
logicalContentXOffset.toInt()
197-
}
198-
contentPlaceable.placeRelative(x = contentX, y = 0)
199-
}
200-
}
201-
202-
// Drawing on canvas for additional graphical elements
203-
Canvas(modifier = Modifier.matchParentSize()) {
204-
// Use the logical timeline offset directly in both LTR and RTL so that
205-
// the line stays aligned with the layout’s coordinate system. RTL
206-
// placement is handled by how content is positioned relative to this
207-
// logical offset, avoiding overlap with the main content.
208-
val timelineXOffset = logicalTimelineXOffset
103+
Layout(
104+
modifier = modifier.drawBehind {
105+
// Line and point are drawn here so a state write from the measure phase only
106+
// invalidates the draw phase (not composition) and stays in the same frame.
107+
val timelineXOffset = timelineXState.floatValue
209108

210109
val yOffset = when (style.pointPlacement) {
211110
PointPlacement.START -> style.pointRadius.toPx() * jetLimeStyle.pointStartFactor
@@ -304,6 +203,104 @@ fun JetLimeExtendedEvent(
304203
style = Stroke(width = strokeWidth),
305204
)
306205
}
206+
},
207+
content = {
208+
// Box for main content with optional padding at the bottom
209+
Box(
210+
modifier = Modifier.padding(
211+
bottom = if (style.position.isNotEnd()) {
212+
jetLimeStyle.itemSpacing
213+
} else {
214+
0.dp
215+
},
216+
),
217+
) {
218+
content()
219+
}
220+
// Additional content wrapped in a Box to provide the expected BoxScope receiver.
221+
Box {
222+
additionalContent()
223+
}
224+
},
225+
) { measurables, constraints ->
226+
// Ensuring that there is at least one child in the layout
227+
require(measurables.isNotEmpty()) {
228+
"JetLimeExtendedEvent should have at-least one child for content"
229+
}
230+
// Thickness of the line drawn for the timeline
231+
val timelineThickness = jetLimeStyle.lineThickness.toPx()
232+
// Distance between the content/additional content and the timeline
233+
val contentDistance = jetLimeStyle.contentDistance.toPx()
234+
235+
// Extracting the first and potentially second child for layout
236+
val contentMeasurable = measurables.first()
237+
val additionalContentMeasurable = measurables.getOrNull(1)
238+
239+
// Measuring the additional content if it exists
240+
val additionalContentPlaceable = additionalContentMeasurable?.let { measurable ->
241+
// Calculating intrinsic width and adjusting it according to the maximum allowed width
242+
val intrinsicWidth = measurable.minIntrinsicWidth(constraints.maxHeight)
243+
val adjustedMinWidth = intrinsicWidth.coerceAtMost(maxAdditionalContentWidth.toInt())
244+
// Ensure we do not exceed available width
245+
val maxWidthForAdditional =
246+
maxAdditionalContentWidth.coerceAtMost(constraints.maxWidth.toFloat()).toInt()
247+
val newConstraints = constraints.copy(
248+
minWidth = adjustedMinWidth.coerceAtMost(maxWidthForAdditional),
249+
maxWidth = maxWidthForAdditional,
250+
)
251+
// Measuring the additional content with the new constraints
252+
measurable.measure(newConstraints)
253+
}
254+
255+
// Calculating the logical X offset for the timeline based on the width of the additional content
256+
val logicalTimelineXOffset =
257+
(additionalContentPlaceable?.width?.toFloat() ?: 0f) + contentDistance
258+
// Publish for the drawBehind modifier. Only the draw phase observes this state,
259+
// so the write does not trigger recomposition.
260+
timelineXState.floatValue = logicalTimelineXOffset
261+
262+
// Calculating the X offset and width available for the main content in logical LTR space
263+
val logicalContentXOffset = logicalTimelineXOffset + timelineThickness + contentDistance
264+
val contentWidth = constraints.maxWidth - logicalContentXOffset
265+
266+
// Measuring the main content with the calculated width
267+
val contentPlaceable = contentMeasurable.measure(
268+
constraints.copy(minWidth = 0, maxWidth = contentWidth.toInt()),
269+
)
270+
271+
// Determining the height of the layout based on the measured content
272+
val contentHeight = contentPlaceable.height
273+
val layoutHeight = additionalContentPlaceable?.let { additional ->
274+
maxOf(contentHeight, additional.height)
275+
} ?: contentHeight
276+
277+
val totalWidth = constraints.maxWidth
278+
279+
// Placing the measured composables in the layout, mirroring in RTL so that
280+
// additional content is always on the logical "left" of the timeline and main
281+
// content on the "right", but visually flipped when isRtl is true.
282+
layout(totalWidth, layoutHeight) {
283+
if (isRtl) {
284+
// In RTL, place additional content flush to the right so it stays visible
285+
additionalContentPlaceable?.placeRelative(
286+
x = totalWidth - additionalContentPlaceable.width,
287+
y = 0,
288+
)
289+
} else {
290+
// LTR: original behavior, additional content starts from left
291+
additionalContentPlaceable?.placeRelative(x = 0, y = 0)
292+
}
293+
294+
// Place main content on the opposite side of the timeline depending on direction
295+
val contentX = if (isRtl) {
296+
// In RTL, main content should be left of the timeline, but still within bounds
297+
(logicalTimelineXOffset - contentPlaceable.width - jetLimeStyle.contentDistance.toPx())
298+
.coerceAtLeast(0f)
299+
.toInt()
300+
} else {
301+
logicalContentXOffset.toInt()
302+
}
303+
contentPlaceable.placeRelative(x = contentX, y = 0)
307304
}
308305
}
309306
}

jetlime/src/commonMain/kotlin/com/pushpal/jetlime/JetLimeList.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
3434
import androidx.compose.foundation.lazy.rememberLazyListState
3535
import androidx.compose.runtime.Composable
3636
import androidx.compose.runtime.CompositionLocalProvider
37-
import androidx.compose.runtime.compositionLocalOf
37+
import androidx.compose.runtime.remember
38+
import androidx.compose.runtime.staticCompositionLocalOf
3839
import androidx.compose.ui.Alignment
3940
import androidx.compose.ui.Modifier
4041
import androidx.compose.ui.unit.dp
@@ -84,7 +85,8 @@ fun <T> JetLimeColumn(
8485
key: ((index: Int, item: T) -> Any)? = null,
8586
itemContent: @Composable (index: Int, T, EventPosition) -> Unit,
8687
) {
87-
CompositionLocalProvider(LocalJetLimeStyle provides style.alignment(VERTICAL)) {
88+
val providedStyle = remember(style) { style.alignment(VERTICAL) }
89+
CompositionLocalProvider(LocalJetLimeStyle provides providedStyle) {
8890
LazyColumn(
8991
modifier = modifier,
9092
state = listState,
@@ -149,7 +151,8 @@ fun <T> JetLimeRow(
149151
key: ((index: Int, item: T) -> Any)? = null,
150152
itemContent: @Composable (index: Int, T, EventPosition) -> Unit,
151153
) {
152-
CompositionLocalProvider(LocalJetLimeStyle provides style.alignment(HORIZONTAL)) {
154+
val providedStyle = remember(style) { style.alignment(HORIZONTAL) }
155+
CompositionLocalProvider(LocalJetLimeStyle provides providedStyle) {
153156
LazyRow(
154157
modifier = modifier,
155158
state = listState,
@@ -177,4 +180,4 @@ fun <T> JetLimeRow(
177180
* This is used to provide a default or overridden style configuration down the composition tree. Accessing this without a provider
178181
* will result in an error, ensuring that the style is always defined when used within a composable context.
179182
*/
180-
val LocalJetLimeStyle = compositionLocalOf<JetLimeStyle> { error("No JetLimeStyle provided") }
183+
val LocalJetLimeStyle = staticCompositionLocalOf<JetLimeStyle> { error("No JetLimeStyle provided") }

0 commit comments

Comments
 (0)