Skip to content

Commit a9b3278

Browse files
authored
fix(compose): recompile haptic pattern when Jindong keys change (#84)
* fix(compose): recompile haptic pattern when Jindong keys change * chore: remove useless comments * test: separate RecordingHapticExecutor from test code * docs: correct Android impl and Repeat count behavior in DSL reference * docs: document reactive recompilation in the quick-start guide
1 parent 9f7bf95 commit a9b3278

10 files changed

Lines changed: 136 additions & 6 deletions

File tree

documentation/content/docs/api/jindong-compose/composable-dsl/haptic.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@ Jindong(trigger) {
129129

130130
| Platform | Implementation |
131131
|----------|----------------|
132-
| Android | `VibrationEffect.createOneShot()` |
133-
| iOS | `CHHapticEvent` with `HapticTransient` or `HapticContinuous` |
132+
| Android | `VibrationEffect.createWaveform()` |
133+
| iOS | `CHHapticEvent` (`HapticContinuous`) played via `CHHapticEngine` |
134134

135135
## Notes
136136

documentation/content/docs/api/jindong-compose/composable-dsl/repeat-with-index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ RepeatWithIndex(5) { i -> Haptic((50 + i * 10).ms) }
167167

168168
- Index is 0-based (0 to count-1)
169169
- Each iteration generates a separate node
170-
- `count` must be a positive value.
170+
- A `count` of 0 or less simply produces no output
171171
- The index can be used in any expression within the content block
172172

173173
## See Also

documentation/content/docs/api/jindong-compose/composable-dsl/repeat.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ RepeatWithIndex(5) { index ->
166166
## Notes
167167

168168
- `count` of 0 produces no output
169-
- If count is negative, it can cause app crash.
169+
- A negative `count` throws `IllegalArgumentException` (it must be non-negative)
170170
- Each iteration runs sequentially
171171
- Content block is evaluated `count` times at composition
172172
- All iterations have the same pattern

documentation/content/docs/guide/quick-start.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,25 @@ Jindong(buttonClickCount) { ... }
253253
Jindong(System.currentTimeMillis()) { ... }
254254
```
255255

256+
### Reactive Patterns
257+
258+
When a key changes, the content block is recompiled with the current state, so a
259+
value the block captures must also be one of the keys for the pattern to reflect it:
260+
261+
```kotlin
262+
var level by remember { mutableStateOf(1) }
263+
264+
// `level` is captured AND a key, so the pattern grows as `level` changes
265+
Jindong(level) {
266+
RepeatWithIndex(level) { index ->
267+
Haptic(50.ms, HapticIntensity.Custom(1f - index * 0.1f))
268+
}
269+
}
270+
```
271+
272+
If a captured value is not in the keys, the pattern keeps the value from the last
273+
key change instead of updating.
274+
256275
## Next Steps
257276

258277
- [Jindong API](/docs/api/jindong-compose/jindong) - Deep dive into the main API

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ kover = "0.9.4"
2121
[libraries]
2222
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
2323
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeRuntime" }
24+
compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
2425
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
2526
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
2627
kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }

jindong-compose/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ kotlin {
6969
commonTest.dependencies {
7070
implementation(libs.kotest.framework.engine)
7171
implementation(libs.kotest.assertions.core)
72+
implementation(libs.compose.ui.test)
73+
implementation(libs.kotlinx.coroutines.test)
7274
}
7375
}
7476
}

jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ fun Jindong(
5656
vararg keys: Any?,
5757
content: @Composable JindongScope.() -> Unit,
5858
) {
59-
val pattern = rememberHapticPattern(content)
59+
val pattern = rememberHapticPattern(*keys) { content() }
6060
val executor = LocalHapticExecutor.current
6161

6262
LaunchedEffect(*keys) {

jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/RememberHapticPattern.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import io.github.compose.jindong.core.model.HapticPattern
2222
/**
2323
* Compiles and memoizes a haptic DSL pattern.
2424
*
25+
* @param keys Inputs that invalidate the memoized pattern when changed
2526
* @param content DSL block defining the haptic pattern
2627
* @return The compiled [HapticPattern]
2728
*
@@ -30,7 +31,8 @@ import io.github.compose.jindong.core.model.HapticPattern
3031
*/
3132
@Composable
3233
internal fun rememberHapticPattern(
34+
vararg keys: Any?,
3335
content: @Composable JindongScope.() -> Unit,
34-
): HapticPattern = remember {
36+
): HapticPattern = remember(*keys) {
3537
compilePattern(content)
3638
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
@file:OptIn(ExperimentalTestApi::class)
17+
18+
package io.github.compose.jindong
19+
20+
import androidx.compose.runtime.CompositionLocalProvider
21+
import androidx.compose.runtime.getValue
22+
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.ui.test.ExperimentalTestApi
24+
import androidx.compose.ui.test.runComposeUiTest
25+
import io.github.compose.jindong.core.ms
26+
import io.github.compose.jindong.dsl.Haptic
27+
import io.github.compose.jindong.dsl.RepeatWithIndex
28+
import io.github.compose.jindong.executor.LocalHapticExecutor
29+
import io.github.compose.jindong.executor.RecordingHapticExecutor
30+
import io.kotest.core.spec.style.FunSpec
31+
import io.kotest.matchers.shouldBe
32+
33+
class JindongReactiveTest :
34+
FunSpec({
35+
test("Jindong recompiles its pattern when the key changes") {
36+
runComposeUiTest {
37+
val recorder = RecordingHapticExecutor()
38+
val countState = mutableStateOf(2)
39+
40+
setContent {
41+
val count by countState
42+
CompositionLocalProvider(LocalHapticExecutor provides recorder) {
43+
Jindong(count) {
44+
RepeatWithIndex(count) {
45+
Haptic(50.ms)
46+
}
47+
}
48+
}
49+
}
50+
51+
waitForIdle()
52+
// Initial key = 2 -> RepeatWithIndex emits 2 events.
53+
recorder.executedPatterns.last().events.size shouldBe 2
54+
55+
countState.value = 5
56+
waitForIdle()
57+
58+
// After the key changes to 5, the captured content must recompile to 5 events,
59+
// not return the stale first-compile result.
60+
recorder.executedPatterns.last().events.size shouldBe 5
61+
}
62+
}
63+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.compose.jindong.executor
17+
18+
import io.github.compose.jindong.Jindong
19+
import io.github.compose.jindong.core.executor.HapticExecutor
20+
import io.github.compose.jindong.core.executor.HapticHandle
21+
import io.github.compose.jindong.core.model.HapticPattern
22+
23+
/**
24+
* Records executed patterns so a test can inspect what [Jindong] played for a given key.
25+
*/
26+
internal class RecordingHapticExecutor : HapticExecutor {
27+
override val isSupported: Boolean = true
28+
29+
val executedPatterns = mutableListOf<HapticPattern>()
30+
31+
override suspend fun execute(pattern: HapticPattern) {
32+
executedPatterns += pattern
33+
}
34+
35+
override fun executeAsync(pattern: HapticPattern): HapticHandle = NoopHapticHandle
36+
37+
override fun release() = Unit
38+
39+
internal object NoopHapticHandle : HapticHandle {
40+
override val isActive: Boolean = false
41+
override fun cancel() = Unit
42+
}
43+
}

0 commit comments

Comments
 (0)