|
24 | 24 | */ |
25 | 25 | package com.pushpal.jetlime |
26 | 26 |
|
27 | | -import androidx.compose.foundation.Canvas |
28 | 27 | import androidx.compose.foundation.layout.Box |
29 | 28 | import androidx.compose.foundation.layout.BoxScope |
30 | | -import androidx.compose.foundation.layout.BoxWithConstraints |
31 | 29 | import androidx.compose.foundation.layout.padding |
32 | 30 | import androidx.compose.runtime.Composable |
33 | 31 | import androidx.compose.runtime.ExperimentalComposeApi |
34 | 32 | import androidx.compose.runtime.getValue |
35 | 33 | import androidx.compose.runtime.mutableFloatStateOf |
36 | 34 | import androidx.compose.runtime.remember |
37 | | -import androidx.compose.runtime.setValue |
38 | 35 | import androidx.compose.ui.Modifier |
| 36 | +import androidx.compose.ui.draw.drawBehind |
39 | 37 | import androidx.compose.ui.geometry.Offset |
40 | 38 | import androidx.compose.ui.geometry.Size |
41 | 39 | import androidx.compose.ui.graphics.ColorFilter |
@@ -99,113 +97,14 @@ fun JetLimeExtendedEvent( |
99 | 97 | val layoutDirection = LocalLayoutDirection.current |
100 | 98 | val isRtl = layoutDirection == LayoutDirection.Rtl |
101 | 99 |
|
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() } |
105 | 102 |
|
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 |
209 | 108 |
|
210 | 109 | val yOffset = when (style.pointPlacement) { |
211 | 110 | PointPlacement.START -> style.pointRadius.toPx() * jetLimeStyle.pointStartFactor |
@@ -304,6 +203,104 @@ fun JetLimeExtendedEvent( |
304 | 203 | style = Stroke(width = strokeWidth), |
305 | 204 | ) |
306 | 205 | } |
| 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) |
307 | 304 | } |
308 | 305 | } |
309 | 306 | } |
0 commit comments