Skip to content

Commit a7226b2

Browse files
Improve interaction timing: presets, start delay, and dynamic duration (#14)
* Add high-level swipe speed presets for interactions * Update lib/recorder-ksp/src/main/kotlin/io/github/hdcodedev/composegif/ksp/InteractionGestureExpander.kt Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> * Handle NORMAL swipe speed preset in expander * Add interaction start delay and auto duration budgeting --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
1 parent dd3fdb7 commit a7226b2

File tree

11 files changed

+344
-17
lines changed

11 files changed

+344
-17
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,13 @@ import io.github.hdcodedev.composegif.annotations.GifInteractionTarget
8888
import io.github.hdcodedev.composegif.annotations.GifInteractionType
8989
import io.github.hdcodedev.composegif.annotations.GifSwipeDirection
9090
import io.github.hdcodedev.composegif.annotations.GifSwipeDistance
91+
import io.github.hdcodedev.composegif.annotations.GifSwipeSpeed
9192
import io.github.hdcodedev.composegif.annotations.RecordGif
9293

9394
@RecordGif(
9495
name = "line_chart_with_interaction",
9596
durationMs = 2600,
97+
interactionStartDelayMs = 1000,
9698
interactionNodeTag = "LineChartPlot",
9799
interactions = [
98100
GifInteraction(type = GifInteractionType.PAUSE, frames = 24),
@@ -101,9 +103,7 @@ import io.github.hdcodedev.composegif.annotations.RecordGif
101103
target = GifInteractionTarget.CENTER,
102104
direction = GifSwipeDirection.LEFT_TO_RIGHT,
103105
distance = GifSwipeDistance.MEDIUM,
104-
travelFrames = 8,
105-
holdStartFrames = 8,
106-
releaseFrames = 8,
106+
speed = GifSwipeSpeed.NORMAL,
107107
),
108108
GifInteraction(
109109
type = GifInteractionType.TAP,
@@ -120,6 +120,11 @@ fun LineChartWithInteraction() {
120120

121121
`interactionNodeTag` must match a test tag in your composable tree.
122122
`interactions` are expanded to deterministic low-level gestures by the KSP generator.
123+
`interactionStartDelayMs` defaults to `1000` (1 second) so entry animations can settle before interactions begin.
124+
Set `interactionStartDelayMs = 0` if you want interactions to start immediately.
125+
`durationMs` is treated as a minimum. If configured interactions need more time, the recorder extends effective duration automatically.
126+
For swipes, prefer `speed = GifSwipeSpeed.FAST|NORMAL|SLOW` for high-level timing presets.
127+
Use `speed = GifSwipeSpeed.CUSTOM` with `travelFrames` / `holdStartFrames` / `releaseFrames` only when you need exact frame-level control.
123128

124129
If you need exact control, `gestures` (coordinate-based) is still available as an advanced API.
125130

9.34 KB
Loading

app/src/main/java/com/harisdautovic/gifdemo/ChartDemos.kt

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import io.github.hdcodedev.composegif.annotations.GifInteractionTarget
2929
import io.github.hdcodedev.composegif.annotations.GifInteractionType
3030
import io.github.hdcodedev.composegif.annotations.GifSwipeDirection
3131
import io.github.hdcodedev.composegif.annotations.GifSwipeDistance
32+
import io.github.hdcodedev.composegif.annotations.GifSwipeSpeed
3233
import io.github.hdcodedev.composegif.annotations.RecordGif
3334

3435
@Composable
@@ -138,14 +139,7 @@ fun BarChartDemo() {
138139
target = GifInteractionTarget.CENTER,
139140
direction = GifSwipeDirection.LEFT_TO_RIGHT,
140141
distance = GifSwipeDistance.MEDIUM,
141-
holdStartFrames = 6,
142-
travelFrames = 8,
143-
releaseFrames = 6,
144-
),
145-
GifInteraction(
146-
type = GifInteractionType.TAP,
147-
target = GifInteractionTarget.RIGHT,
148-
framesAfter = 10,
142+
speed = GifSwipeSpeed.SLOW,
149143
),
150144
],
151145
)

lib/recorder-annotations/src/main/kotlin/io/github/hdcodedev/composegif/annotations/RecordGif.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ public annotation class RecordGif(
99
val widthPx: Int = 540,
1010
val heightPx: Int = 0,
1111
val theme: GifTheme = GifTheme.DARK,
12+
/**
13+
* Delay before replaying configured interactions/gestures.
14+
*
15+
* Useful for letting first-render animations settle before input starts.
16+
*/
17+
val interactionStartDelayMs: Int = 1000,
1218
val interactionNodeTag: String = "",
1319
val interactions: Array<GifInteraction> = [],
1420
val gestures: Array<GifGestureStep> = [],
@@ -52,6 +58,13 @@ public enum class GifSwipeDistance {
5258
LONG,
5359
}
5460

61+
public enum class GifSwipeSpeed {
62+
CUSTOM,
63+
FAST,
64+
NORMAL,
65+
SLOW,
66+
}
67+
5568
public annotation class GifInteraction(
5669
val type: GifInteractionType = GifInteractionType.PAUSE,
5770
val frames: Int = 0,
@@ -69,6 +82,12 @@ public annotation class GifInteraction(
6982
val target: GifInteractionTarget = GifInteractionTarget.CENTER,
7083
val direction: GifSwipeDirection = GifSwipeDirection.LEFT_TO_RIGHT,
7184
val distance: GifSwipeDistance = GifSwipeDistance.MEDIUM,
85+
/**
86+
* High-level swipe timing preset.
87+
*
88+
* Use `CUSTOM` to control timing with `travelFrames`, `holdStartFrames`, and `releaseFrames`.
89+
*/
90+
val speed: GifSwipeSpeed = GifSwipeSpeed.CUSTOM,
7291
val travelFrames: Int = 8,
7392
val holdStartFrames: Int = 0,
7493
val releaseFrames: Int = 0,

lib/recorder-ksp/src/main/kotlin/io/github/hdcodedev/composegif/ksp/InteractionGestureExpander.kt

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package io.github.hdcodedev.composegif.ksp
22

3+
import io.github.hdcodedev.composegif.annotations.GifSwipeSpeed
4+
35
internal data class InteractionSpec(
46
val type: String,
57
val frames: Int = 0,
68
val framesAfter: Int = 0,
79
val target: String = "CENTER",
810
val direction: String = "LEFT_TO_RIGHT",
911
val distance: String = "MEDIUM",
12+
val speed: GifSwipeSpeed = GifSwipeSpeed.CUSTOM,
1013
val travelFrames: Int = 8,
1114
val holdStartFrames: Int = 0,
1215
val releaseFrames: Int = 0,
@@ -53,14 +56,15 @@ internal object InteractionGestureExpander {
5356
"SWIPE" -> {
5457
val swipePoints =
5558
swipePoints(target = spec.target, direction = spec.direction, distance = spec.distance)
59+
val timing = swipeTiming(spec)
5660
buildList {
5761
add(
5862
GestureSpec(
5963
type = "DRAG_PATH",
6064
points = swipePoints,
61-
holdStartFrames = spec.holdStartFrames,
62-
framesPerWaypoint = spec.travelFrames,
63-
releaseFrames = spec.releaseFrames,
65+
holdStartFrames = timing.holdStartFrames,
66+
framesPerWaypoint = timing.travelFrames,
67+
releaseFrames = timing.releaseFrames,
6468
),
6569
)
6670
if (spec.framesAfter > 0) {
@@ -132,4 +136,29 @@ internal object InteractionGestureExpander {
132136
)
133137
}
134138
}
139+
140+
private fun swipeTiming(spec: InteractionSpec): SwipeTiming =
141+
if (spec.speed == GifSwipeSpeed.CUSTOM) {
142+
SwipeTiming(
143+
travelFrames = spec.travelFrames,
144+
holdStartFrames = spec.holdStartFrames,
145+
releaseFrames = spec.releaseFrames,
146+
)
147+
} else {
148+
speedToTiming(spec.speed)
149+
}
150+
151+
private fun speedToTiming(speed: GifSwipeSpeed): SwipeTiming =
152+
when (speed) {
153+
GifSwipeSpeed.FAST -> SwipeTiming(travelFrames = 24, holdStartFrames = 10, releaseFrames = 10)
154+
GifSwipeSpeed.NORMAL -> SwipeTiming(travelFrames = 36, holdStartFrames = 24, releaseFrames = 24)
155+
GifSwipeSpeed.SLOW -> SwipeTiming(travelFrames = 56, holdStartFrames = 44, releaseFrames = 44)
156+
GifSwipeSpeed.CUSTOM -> error("CUSTOM should be handled before speed mapping")
157+
}
135158
}
159+
160+
private data class SwipeTiming(
161+
val travelFrames: Int,
162+
val holdStartFrames: Int,
163+
val releaseFrames: Int,
164+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.github.hdcodedev.composegif.ksp
2+
3+
internal const val DEFAULT_INTERACTION_START_DELAY_MS = 1000
4+
5+
internal fun applyInteractionStartDelay(
6+
gestures: List<GestureSpec>,
7+
interactionStartDelayMs: Int,
8+
fps: Int,
9+
): List<GestureSpec> {
10+
if (gestures.isEmpty()) return gestures
11+
val delayFrames = interactionStartDelayFrames(interactionStartDelayMs, fps)
12+
if (delayFrames <= 0) return gestures
13+
return listOf(GestureSpec(type = "PAUSE", frames = delayFrames)) + gestures
14+
}
15+
16+
internal fun interactionStartDelayFrames(
17+
interactionStartDelayMs: Int,
18+
fps: Int,
19+
): Int {
20+
val clampedDelayMs = interactionStartDelayMs.coerceAtLeast(0).toLong()
21+
val safeFps = fps.coerceAtLeast(1).toLong()
22+
val frames = ((clampedDelayMs * safeFps) + 999L) / 1000L
23+
return frames.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
24+
}

lib/recorder-ksp/src/main/kotlin/io/github/hdcodedev/composegif/ksp/RecordGifSymbolProcessorProvider.kt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.squareup.kotlinpoet.PropertySpec
2121
import com.squareup.kotlinpoet.TypeSpec
2222
import com.squareup.kotlinpoet.asClassName
2323
import com.squareup.kotlinpoet.ksp.writeTo
24+
import io.github.hdcodedev.composegif.annotations.GifSwipeSpeed
2425
import io.github.hdcodedev.composegif.annotations.RecordGif
2526

2627
private const val GENERATED_PACKAGE = "io.github.hdcodedev.composegif.generated"
@@ -245,6 +246,8 @@ private class RecordGifSymbolProcessor(
245246

246247
private fun KSFunctionDeclaration.extractArguments(): AnnotationArgs {
247248
val args = annotations.first { it.shortName.asString() == "RecordGif" }.arguments
249+
val fps = args.valueAsInt("fps") ?: 50
250+
val interactionStartDelayMs = args.valueAsInt("interactionStartDelayMs") ?: DEFAULT_INTERACTION_START_DELAY_MS
248251
val explicitGestures =
249252
args.valueAsAnnotations("gestures").map { gesture ->
250253
val gestureArgs = gesture.arguments
@@ -278,21 +281,38 @@ private class RecordGifSymbolProcessor(
278281
target = interactionArgs.valueAsEnumName("target") ?: "CENTER",
279282
direction = interactionArgs.valueAsEnumName("direction") ?: "LEFT_TO_RIGHT",
280283
distance = interactionArgs.valueAsEnumName("distance") ?: "MEDIUM",
284+
speed =
285+
interactionArgs
286+
.valueAsEnumName("speed")
287+
?.let { enumName -> runCatching { GifSwipeSpeed.valueOf(enumName) }.getOrNull() }
288+
?: GifSwipeSpeed.CUSTOM,
281289
travelFrames = interactionArgs.valueAsInt("travelFrames") ?: 8,
282290
holdStartFrames = interactionArgs.valueAsInt("holdStartFrames") ?: 0,
283291
releaseFrames = interactionArgs.valueAsInt("releaseFrames") ?: 0,
284292
),
285293
)
286294
}
295+
val gestures =
296+
applyInteractionStartDelay(
297+
gestures = interactionGestures + explicitGestures,
298+
interactionStartDelayMs = interactionStartDelayMs,
299+
fps = fps,
300+
)
301+
val durationMs =
302+
ensureDurationMsAtLeastGestureBudget(
303+
durationMs = args.valueAsInt("durationMs") ?: DEFAULT_DURATION_MS,
304+
fps = fps,
305+
gestures = gestures,
306+
)
287307
return AnnotationArgs(
288308
name = args.valueAsString("name") ?: "",
289-
durationMs = args.valueAsInt("durationMs") ?: DEFAULT_DURATION_MS,
290-
fps = args.valueAsInt("fps") ?: 50,
309+
durationMs = durationMs,
310+
fps = fps,
291311
widthPx = args.valueAsInt("widthPx") ?: 540,
292312
heightPx = args.valueAsInt("heightPx") ?: 0,
293313
theme = (args.valueAsEnumName("theme") ?: "DARK"),
294314
interactionNodeTag = args.valueAsString("interactionNodeTag") ?: "",
295-
gestures = interactionGestures + explicitGestures,
315+
gestures = gestures,
296316
)
297317
}
298318

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.github.hdcodedev.composegif.ksp
2+
3+
internal fun ensureDurationMsAtLeastGestureBudget(
4+
durationMs: Int,
5+
fps: Int,
6+
gestures: List<GestureSpec>,
7+
): Int {
8+
val requiredFrames = requiredGestureFrames(gestures)
9+
if (requiredFrames <= 0) return durationMs
10+
val requiredMs = framesToMsCeil(requiredFrames, fps)
11+
return maxOf(durationMs, requiredMs)
12+
}
13+
14+
internal fun requiredGestureFrames(gestures: List<GestureSpec>): Int =
15+
gestures.sumOf { gesture ->
16+
when (gesture.type) {
17+
"PAUSE" -> gesture.frames.coerceAtLeast(0)
18+
"TAP" -> gesture.framesAfter.coerceAtLeast(0)
19+
"DRAG_PATH" -> {
20+
val holdFrames = gesture.holdStartFrames.coerceAtLeast(0)
21+
val releaseFrames = gesture.releaseFrames.coerceAtLeast(0)
22+
val waypointFrames = gesture.framesPerWaypoint.coerceAtLeast(0)
23+
val waypointCount = (gesture.points.size - 1).coerceAtLeast(0)
24+
holdFrames + (waypointFrames * waypointCount) + releaseFrames
25+
}
26+
else -> 0
27+
}
28+
}
29+
30+
internal fun framesToMsCeil(
31+
frames: Int,
32+
fps: Int,
33+
): Int {
34+
if (frames <= 0) return 0
35+
val safeFps = fps.coerceAtLeast(1).toLong()
36+
val framesLong = frames.toLong().coerceAtLeast(0L)
37+
val ms = ((framesLong * 1000L) + safeFps - 1L) / safeFps
38+
return ms.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
39+
}

lib/recorder-ksp/src/test/kotlin/io/github/hdcodedev/composegif/ksp/InteractionGestureExpanderTest.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import io.github.hdcodedev.composegif.annotations.GifInteractionTarget
55
import io.github.hdcodedev.composegif.annotations.GifInteractionType
66
import io.github.hdcodedev.composegif.annotations.GifSwipeDirection
77
import io.github.hdcodedev.composegif.annotations.GifSwipeDistance
8+
import io.github.hdcodedev.composegif.annotations.GifSwipeSpeed
89
import kotlin.test.Test
910
import kotlin.test.assertEquals
1011
import kotlin.test.assertTrue
@@ -73,6 +74,76 @@ class InteractionGestureExpanderTest {
7374
assertEquals(5, gestures[1].frames)
7475
}
7576

77+
@Test
78+
fun mapsSwipeInteractionSpeedPresetToTimingFrames() {
79+
val gestures =
80+
InteractionGestureExpander.expand(
81+
InteractionSpec(
82+
type = GifInteractionType.SWIPE.name,
83+
speed = GifSwipeSpeed.NORMAL,
84+
),
85+
)
86+
87+
val drag = gestures.single()
88+
assertEquals(24, drag.holdStartFrames)
89+
assertEquals(36, drag.framesPerWaypoint)
90+
assertEquals(24, drag.releaseFrames)
91+
}
92+
93+
@Test
94+
fun mapsSlowSwipeSpeedPresetToTimingFrames() {
95+
val gestures =
96+
InteractionGestureExpander.expand(
97+
InteractionSpec(
98+
type = GifInteractionType.SWIPE.name,
99+
speed = GifSwipeSpeed.SLOW,
100+
),
101+
)
102+
103+
val drag = gestures.single()
104+
assertEquals(44, drag.holdStartFrames)
105+
assertEquals(56, drag.framesPerWaypoint)
106+
assertEquals(44, drag.releaseFrames)
107+
}
108+
109+
@Test
110+
fun customSwipeSpeedUsesManualTimingFields() {
111+
val gestures =
112+
InteractionGestureExpander.expand(
113+
InteractionSpec(
114+
type = GifInteractionType.SWIPE.name,
115+
speed = GifSwipeSpeed.CUSTOM,
116+
holdStartFrames = 3,
117+
travelFrames = 11,
118+
releaseFrames = 4,
119+
),
120+
)
121+
122+
val drag = gestures.single()
123+
assertEquals(3, drag.holdStartFrames)
124+
assertEquals(11, drag.framesPerWaypoint)
125+
assertEquals(4, drag.releaseFrames)
126+
}
127+
128+
@Test
129+
fun speedPresetOverridesManualTimingFields() {
130+
val gestures =
131+
InteractionGestureExpander.expand(
132+
InteractionSpec(
133+
type = GifInteractionType.SWIPE.name,
134+
speed = GifSwipeSpeed.FAST,
135+
holdStartFrames = 99,
136+
travelFrames = 99,
137+
releaseFrames = 99,
138+
),
139+
)
140+
141+
val drag = gestures.single()
142+
assertEquals(10, drag.holdStartFrames)
143+
assertEquals(24, drag.framesPerWaypoint)
144+
assertEquals(10, drag.releaseFrames)
145+
}
146+
76147
@Test
77148
fun mapsAllSwipeDirections() {
78149
val leftToRight =

0 commit comments

Comments
 (0)