Skip to content

Commit 755a7c1

Browse files
committed
fix: conflict between transition and continuous haptics
1 parent fbc1613 commit 755a7c1

File tree

3 files changed

+157
-39
lines changed

3 files changed

+157
-39
lines changed

README.md

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ They’re separate because they’re different object types in Core Haptics (eve
2727

2828
## Usage
2929

30-
### Haptic provider (recommended)
30+
### Haptic "provider" (recommended)
3131

32-
Wrap your app inside `HapticsProvider`. This initializes the engine, creates the continuous player, and automatically destroys the engine when the app goes to background.
32+
Wrap your app inside `HapticProvider`. This initializes the engine and automatically destroys it when the app goes to background.
3333

3434
```tsx
35-
import { HapticsProvider } from 'react-native-ahaps';
35+
import { HapticProvider } from 'react-native-ahaps';
3636

3737
export function App() {
38-
return <HapticsProvider>{/* {Rest of your app} */}</HapticsProvider>;
38+
return <HapticProvider>{/* {Rest of your app} */}</HapticProvider>;
3939
}
4040
```
4141

@@ -123,20 +123,47 @@ startHaptic(
123123

124124
### Real-time continuous mode (continuous player)
125125

126-
Use this when you _can’t_ predefine a pattern. You start the player, update it in real time, then stop it.
126+
Use this when you _can't_ predefine a pattern. You start the player, update it in real time, then stop it.
127+
128+
**Using the hook (recommended):**
129+
130+
```tsx
131+
import { useContinuousPlayer } from 'react-native-ahaps';
132+
133+
function MyComponent() {
134+
const { start, stop, update } = useContinuousPlayer('my-player', 1.0, 0.5);
135+
136+
const gesture = Gesture.Pan()
137+
.onBegin(() => {
138+
start();
139+
})
140+
.onUpdate((e) => {
141+
update(e.translationY / 100, 0.5);
142+
})
143+
.onEnd(() => {
144+
stop();
145+
});
146+
}
147+
```
148+
149+
**Manual control:**
127150

128151
```ts
129152
import {
130153
createContinuousPlayer,
131154
startContinuousPlayer,
132155
updateContinuousPlayer,
133156
stopContinuousPlayer,
157+
destroyContinuousPlayer,
134158
} from 'react-native-ahaps';
135159

136-
createContinuousPlayer(1.0, 0.5);
137-
startContinuousPlayer();
138-
updateContinuousPlayer(0.8, 0.1);
139-
stopContinuousPlayer();
160+
const PLAYER_ID = 'my-player';
161+
162+
createContinuousPlayer(PLAYER_ID, 1.0, 0.5);
163+
startContinuousPlayer(PLAYER_ID);
164+
updateContinuousPlayer(PLAYER_ID, 0.8, 0.1);
165+
stopContinuousPlayer(PLAYER_ID);
166+
destroyContinuousPlayer(PLAYER_ID);
140167
```
141168

142169
### Opt out of the provider (manual engine control)
@@ -167,15 +194,18 @@ export function SomeScreen() {
167194

168195
## API (tables)
169196

170-
| Function | Purpose |
171-
| ------------------------------------------------------------ | ------------------------------------------------------------------- |
172-
| `useHapticEngine(options?)` | Initialize engine + continuous player; destroy engine on background |
173-
| `initializeEngine()` / `destroyEngine()` | Manual engine lifecycle |
174-
| `startHaptic(events, curves)` | Play a pattern (transient + continuous events, optional curves) |
175-
| `stopAllHaptics()` | Stop any running haptics (useful on unmount/navigation) |
176-
| `createContinuousPlayer(initialIntensity, initialSharpness)` | Create the real-time continuous player |
177-
| `startContinuousPlayer()` / `stopContinuousPlayer()` | Start/stop real-time continuous playback |
178-
| `updateContinuousPlayer(intensityControl, sharpnessControl)` | Update real-time intensity/sharpness |
197+
| Function | Purpose |
198+
| ---------------------------------------------------------------------- | --------------------------------------------------------------- |
199+
| `HapticProvider` | Component that initializes engine; destroys on background |
200+
| `useHapticEngine()` | Hook to manage engine lifecycle (used internally by provider) |
201+
| `initializeEngine()` / `destroyEngine()` | Manual engine lifecycle |
202+
| `startHaptic(events, curves)` | Play a pattern (transient + continuous events, optional curves) |
203+
| `stopAllHaptics()` | Stop any running haptics (useful on unmount/navigation) |
204+
| `useContinuousPlayer(playerId, initialIntensity, initialSharpness)` | Hook to manage a continuous player lifecycle |
205+
| `createContinuousPlayer(playerId, initialIntensity, initialSharpness)` | Create a continuous player with given ID |
206+
| `startContinuousPlayer(playerId)` / `stopContinuousPlayer(playerId)` | Start/stop continuous playback for player |
207+
| `updateContinuousPlayer(playerId, intensityControl, sharpnessControl)` | Update intensity/sharpness for player |
208+
| `destroyContinuousPlayer(playerId)` | Destroy player and release resources |
179209

180210
## Types (inputs)
181211

@@ -213,6 +243,46 @@ export function SomeScreen() {
213243
| `relativeTime` | `number` |
214244
| `controlPoints` | `HapticCurveControlPoint[]` |
215245

246+
## Limitations
247+
248+
### Parameter curves affect all events in a pattern
249+
250+
In CoreHaptics, `CHHapticParameterCurve` uses `hapticIntensityControl` and `hapticSharpnessControl` — these are **pattern-level multipliers**, not per-event modifiers. Any curve you define will multiply the intensity/sharpness of **all events** playing at that moment, including transients.
251+
252+
**Example problem:**
253+
254+
```ts
255+
startHaptic(
256+
[
257+
{ type: 'continuous', relativeTime: 0, duration: 2000, parameters: [...] },
258+
{ type: 'transient', relativeTime: 1000, parameters: [{ type: 'intensity', value: 1.0 }, ...] },
259+
],
260+
[
261+
{ type: 'intensity', relativeTime: 0, controlPoints: [
262+
{ relativeTime: 0, value: 1.0 },
263+
{ relativeTime: 1000, value: 0.3 }, // At t=1000ms, intensity control = 0.3
264+
{ relativeTime: 2000, value: 0.3 },
265+
]},
266+
]
267+
);
268+
```
269+
270+
The transient at `t=1000ms` has base intensity `1.0`, but the curve sets `intensityControl=0.3` at that moment. **Effective intensity: 1.0 × 0.3 = 0.3**. The transient feels weaker than expected.
271+
272+
**Workaround:** Play continuous and transient events in separate `startHaptic()` calls:
273+
274+
```ts
275+
// Continuous with curves
276+
startHaptic(continuousEvents, curves);
277+
278+
// Transients without curves (separate pattern)
279+
startHaptic(transientEvents, []);
280+
```
281+
282+
Each call creates an isolated pattern/player — curves from one won't affect events in the other.
283+
284+
> **Note:** The library automatically resets control values to `1.0` at the end of each continuous event, so transients **after** a continuous event finishes are not affected. This limitation only applies to transients **during** a continuous event with active curves.
285+
216286
## Contributing
217287

218288
- [Development workflow](CONTRIBUTING.md#development-workflow)

example/src/contexts/RecorderContext.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,22 @@ export function RecorderProvider({ children }: { children: ReactNode }) {
310310
seekTime
311311
);
312312

313-
startHaptic(events, curves);
313+
// Split events into continuous and transient to play them in separate patterns.
314+
// This prevents CHHapticParameterCurves (which are pattern-level multipliers)
315+
// from affecting transient events - they should play at their recorded intensity.
316+
const continuousEvents = events.filter((e) => e.type === 'continuous');
317+
const transientEvents = events.filter((e) => e.type === 'transient');
318+
319+
// Play continuous events with their associated parameter curves
320+
if (continuousEvents.length > 0) {
321+
startHaptic(continuousEvents, curves);
322+
}
323+
324+
// Play transient events in a separate pattern with NO curves
325+
// so they're not affected by continuous intensity/sharpness modulation
326+
if (transientEvents.length > 0) {
327+
startHaptic(transientEvents, []);
328+
}
314329
};
315330

316331
const startPlayback = () => {

ios/Ahap.swift

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,33 +49,66 @@ class Ahap: HybridAhapSpec {
4949

5050
func startHaptic(events: [HapticEvent], curves: [HapticCurve]) throws {
5151
let hapticEvents = events.map { event -> CHHapticEvent in
52-
let eventType: CHHapticEvent.EventType = (event.type == .continuous) ? .hapticContinuous : .hapticTransient
53-
let parameters = event.parameters.map { parameter -> CHHapticEventParameter in
52+
let eventType: CHHapticEvent.EventType = (event.type == .continuous) ? .hapticContinuous : .hapticTransient
53+
let parameters = event.parameters.map { parameter -> CHHapticEventParameter in
5454
let parameterID: CHHapticEvent.ParameterID = (parameter.type == .intensity) ? .hapticIntensity : .hapticSharpness
5555
return CHHapticEventParameter(parameterID: parameterID, value: Float(parameter.value))
56+
}
57+
return CHHapticEvent(
58+
eventType: eventType,
59+
parameters: parameters,
60+
relativeTime: event.relativeTime / 1000.0,
61+
duration: event.type == .continuous ? (event.duration ?? 1000.0) / 1000.0 : 0
62+
)
5663
}
57-
return CHHapticEvent(
58-
eventType: eventType,
59-
parameters: parameters,
60-
relativeTime: event.relativeTime / 1000.0,
61-
duration: event.type == .continuous ? (event.duration ?? 1000.0) / 1000.0 : 0
62-
)
63-
}
6464

65-
let hapticCurves = curves.map { curve -> CHHapticParameterCurve in
66-
let parameterID: CHHapticDynamicParameter.ID = (curve.type == .intensity) ? .hapticIntensityControl : .hapticSharpnessControl
67-
let controlPoints = curve.controlPoints.map { controlPoint -> CHHapticParameterCurve.ControlPoint in
68-
return CHHapticParameterCurve.ControlPoint(relativeTime: controlPoint.relativeTime / 1000.0, value: Float(controlPoint.value))
69-
}
70-
return CHHapticParameterCurve(
65+
var hapticCurves: [CHHapticParameterCurve] = []
66+
for curve in curves {
67+
let parameterID: CHHapticDynamicParameter.ID = (curve.type == .intensity) ? .hapticIntensityControl : .hapticSharpnessControl
68+
69+
var controlPoints: [CHHapticParameterCurve.ControlPoint] = []
70+
for controlPoint in curve.controlPoints {
71+
let point = CHHapticParameterCurve.ControlPoint(
72+
relativeTime: controlPoint.relativeTime / 1000.0,
73+
value: Float(controlPoint.value)
74+
)
75+
controlPoints.append(point)
76+
}
77+
78+
// Find the matching continuous event to get its duration.
79+
// The curve's relativeTime should match a continuous event's relativeTime.
80+
var matchingEvent: HapticEvent? = nil
81+
for event in events {
82+
let isContinuous = event.type == .continuous
83+
let diff = event.relativeTime - curve.relativeTime
84+
let timeDiff = diff < 0 ? -diff : diff
85+
if isContinuous && timeDiff < 1 {
86+
matchingEvent = event
87+
break
88+
}
89+
}
90+
91+
// Add a reset control point at the end of the continuous event.
92+
// This ensures hapticIntensityControl/hapticSharpnessControl return to 1.0 (neutral)
93+
// so subsequent events (like transients) aren't affected by this curve's final value.
94+
if let event = matchingEvent, let duration = event.duration {
95+
let resetTime = duration / 1000.0
96+
// Only add if it's after the last control point
97+
if let lastPoint = controlPoints.last, resetTime > lastPoint.relativeTime {
98+
controlPoints.append(CHHapticParameterCurve.ControlPoint(relativeTime: resetTime, value: 1.0))
99+
}
100+
}
101+
102+
let hapticCurve = CHHapticParameterCurve(
71103
parameterID: parameterID,
72104
controlPoints: controlPoints,
73105
relativeTime: curve.relativeTime / 1000.0
74-
)
75-
}
106+
)
107+
hapticCurves.append(hapticCurve)
108+
}
76109

77-
let state = AnyHapticAnimationState(hapticEvents: hapticEvents, hapticCurves: hapticCurves)
78-
haptics.createHapticPlayers(for: [state])
79-
haptics.startHapticPlayer(for: state)
110+
let state = AnyHapticAnimationState(hapticEvents: hapticEvents, hapticCurves: hapticCurves)
111+
haptics.createHapticPlayers(for: [state])
112+
haptics.startHapticPlayer(for: state)
80113
}
81114
}

0 commit comments

Comments
 (0)