From b4f6b3375eeb77bd43ff685c754f4db965a694e2 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 15 Feb 2026 18:46:00 +0100 Subject: [PATCH 1/4] feat(ios): replace AudioServicesPlaySystemSound with CoreHaptics for Vibration API --- .../Libraries/Vibration/RCTVibration.mm | 222 +++++++++++++++++- .../Vibration/React-RCTVibration.podspec | 2 +- .../Libraries/Vibration/Vibration.js | 75 +----- .../modules/NativeVibration.js | 2 - .../js/examples/Vibration/VibrationExample.js | 54 +++-- 5 files changed, 246 insertions(+), 109 deletions(-) diff --git a/packages/react-native/Libraries/Vibration/RCTVibration.mm b/packages/react-native/Libraries/Vibration/RCTVibration.mm index 389dc871c4d2..6a04c4bc612c 100644 --- a/packages/react-native/Libraries/Vibration/RCTVibration.mm +++ b/packages/react-native/Libraries/Vibration/RCTVibration.mm @@ -5,45 +5,245 @@ * LICENSE file in the root directory of this source tree. */ -#import - -#import +#import #import #import +#import #import "RCTVibrationPlugins.h" @interface RCTVibration () @end -@implementation RCTVibration +@implementation RCTVibration { + CHHapticEngine *_engine; + id _player; + NSLock *_lock; // Ensures thread safety for engine/player access +} RCT_EXPORT_MODULE() -- (void)vibrate +- (instancetype)init { - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); + if (self = [super init]) { + _lock = [[NSLock alloc] init]; + } + return self; } -RCT_EXPORT_METHOD(vibrate : (double)pattern) +#pragma mark - Haptic Engine Management + +- (CHHapticEngine *)ensureEngine { - [self vibrate]; + [_lock lock]; + if (_engine) { + [_lock unlock]; + return _engine; + } + + NSError *error = nil; + _engine = [[CHHapticEngine alloc] initAndReturnError:&error]; + + if (error) { + RCTLogWarn(@"Failed to create haptic engine: %@", error.localizedDescription); + [_lock unlock]; + return nil; + } + + _engine.playsHapticsOnly = YES; + _engine.autoShutdownEnabled = YES; + + __weak RCTVibration *weakSelf = self; + _engine.resetHandler = ^{ + RCTVibration *strongSelf = weakSelf; + [strongSelf->_lock lock]; + [strongSelf->_engine startAndReturnError:nil]; + [strongSelf->_lock unlock]; + }; + + _engine.stoppedHandler = ^(CHHapticEngineStoppedReason reason) { + RCTVibration *strongSelf = weakSelf; + [strongSelf->_lock lock]; + strongSelf->_engine = nil; + [strongSelf->_lock unlock]; + }; + + [_engine startAndReturnError:&error]; + if (error) { + RCTLogWarn(@"Failed to start haptic engine: %@", error.localizedDescription); + _engine = nil; + [_lock unlock]; + return nil; + } + + CHHapticEngine *engine = _engine; + [_lock unlock]; + return engine; } +- (void)stopCurrentPlayer +{ + [_lock lock]; + if (_player) { + [_player stopAtTime:CHHapticTimeImmediate error:nil]; + _player = nil; + } + [_lock unlock]; +} + +#pragma mark - Pattern Building + +- (CHHapticPattern *)hapticPatternFromArray:(NSArray *)pattern startIndex:(NSUInteger)startIndex +{ + if (startIndex >= pattern.count) + return nil; + + NSMutableArray *events = [NSMutableArray array]; + NSTimeInterval currentTime = 0; + + for (NSUInteger i = startIndex; i < pattern.count; i++) { + double valueMs = [pattern[i] doubleValue]; + NSTimeInterval valueSeconds = MAX(valueMs, 0) / 1000.0; + + if (i % 2 == 0) { + currentTime += valueSeconds; + } else if (valueSeconds > 0) { + CHHapticEventParameter *intensity = + [[CHHapticEventParameter alloc] initWithParameterID:CHHapticEventParameterIDHapticIntensity value:1.0]; + CHHapticEventParameter *sharpness = + [[CHHapticEventParameter alloc] initWithParameterID:CHHapticEventParameterIDHapticSharpness value:0.5]; + + [events addObject:[[CHHapticEvent alloc] initWithEventType:CHHapticEventTypeHapticContinuous + parameters:@[ intensity, sharpness ] + relativeTime:currentTime + duration:valueSeconds]]; + currentTime += valueSeconds; + } + } + + if (events.count == 0) + return nil; + + NSError *error = nil; + CHHapticPattern *hapticPattern = [[CHHapticPattern alloc] initWithEvents:events parameters:@[] error:&error]; + if (error) { + RCTLogWarn(@"Failed to create haptic pattern: %@", error.localizedDescription); + return nil; + } + + return hapticPattern; +} + +#pragma mark - Execution Helper + +- (void)_playPattern:(CHHapticPattern *)pattern isLooping:(BOOL)isLooping +{ + CHHapticEngine *engine = [self ensureEngine]; + if (!engine || !pattern) + return; + + NSError *error = nil; + id newPlayer; + + if (isLooping) { + id advPlayer = [engine createAdvancedPlayerWithPattern:pattern error:&error]; + advPlayer.loopEnabled = YES; + newPlayer = advPlayer; + } else { + newPlayer = [engine createPlayerWithPattern:pattern error:&error]; + } + + if (!error && newPlayer) { + [_lock lock]; + _player = newPlayer; + [_player startAtTime:CHHapticTimeImmediate error:nil]; + [_lock unlock]; + } +} + +#pragma mark - Exported Methods + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { return std::make_shared(params); } -RCT_EXPORT_METHOD(vibrateByPattern : (NSArray *)pattern repeat : (double)repeat) +RCT_EXPORT_METHOD(vibrate : (double)durationMs) +{ + if (![CHHapticEngine capabilitiesForHardware].supportsHaptics) + return; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self stopCurrentPlayer]; + + double duration = (durationMs <= 0) ? 400 : durationMs; + CHHapticPattern *pattern = [self hapticPatternFromArray:@[ @0, @(duration) ] startIndex:0]; + [self _playPattern:pattern isLooping:NO]; + }); +} + +RCT_EXPORT_METHOD(vibrateByPattern : (NSArray *)pattern repeat : (double)repeat) { - RCTLogError(@"Vibration.vibrateByPattern does not have an iOS implementation"); + if (![CHHapticEngine capabilitiesForHardware].supportsHaptics || pattern.count == 0) + return; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self stopCurrentPlayer]; + + NSInteger repeatIndex = (NSInteger)repeat; + BOOL shouldLoop = (repeatIndex >= 0 && repeatIndex < (NSInteger)pattern.count); + + if (!shouldLoop) { + [self _playPattern:[self hapticPatternFromArray:pattern startIndex:0] isLooping:NO]; + } else if (repeatIndex == 0) { + [self _playPattern:[self hapticPatternFromArray:pattern startIndex:0] isLooping:YES]; + } else { + // Play prefix once, then loop the remainder + CHHapticPattern *fullPattern = [self hapticPatternFromArray:pattern startIndex:0]; + if (!fullPattern) + return; + + CHHapticEngine *engine = [self ensureEngine]; + NSError *error = nil; + id prefixPlayer = [engine createAdvancedPlayerWithPattern:fullPattern + error:&error]; + + if (error || !prefixPlayer) + return; + + __weak RCTVibration *weakSelf = self; + prefixPlayer.completionHandler = ^(NSError *_Nullable err) { + dispatch_async(dispatch_get_main_queue(), ^{ + RCTVibration *strongSelf = weakSelf; + if (!strongSelf) + return; + + [strongSelf->_lock lock]; + BOOL wasCancelled = (strongSelf->_player == nil); + [strongSelf->_lock unlock]; + + if (wasCancelled) + return; + + CHHapticPattern *loopPart = [strongSelf hapticPatternFromArray:pattern startIndex:(NSUInteger)repeatIndex]; + [strongSelf _playPattern:loopPart isLooping:YES]; + }); + }; + + [_lock lock]; + self->_player = prefixPlayer; + [self->_player startAtTime:CHHapticTimeImmediate error:nil]; + [_lock unlock]; + } + }); } RCT_EXPORT_METHOD(cancel) { - RCTLogError(@"Vibration.cancel does not have an iOS implementation"); + dispatch_async(dispatch_get_main_queue(), ^{ + [self stopCurrentPlayer]; + }); } @end diff --git a/packages/react-native/Libraries/Vibration/React-RCTVibration.podspec b/packages/react-native/Libraries/Vibration/React-RCTVibration.podspec index 9768c13d4b38..8b4ba98dc6a1 100644 --- a/packages/react-native/Libraries/Vibration/React-RCTVibration.podspec +++ b/packages/react-native/Libraries/Vibration/React-RCTVibration.podspec @@ -39,7 +39,7 @@ Pod::Spec.new do |s| "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "HEADER_SEARCH_PATHS" => header_search_paths.join(' ') } - s.frameworks = "AudioToolbox" + s.frameworks = "CoreHaptics" s.dependency "React-jsi" s.dependency "React-Core/RCTVibrationHeaders" diff --git a/packages/react-native/Libraries/Vibration/Vibration.js b/packages/react-native/Libraries/Vibration/Vibration.js index 86b00c898a13..85c444c7e533 100644 --- a/packages/react-native/Libraries/Vibration/Vibration.js +++ b/packages/react-native/Libraries/Vibration/Vibration.js @@ -10,60 +10,14 @@ import NativeVibration from './NativeVibration'; -const Platform = require('../Utilities/Platform').default; - /** * Vibration API * * See https://reactnative.dev/docs/vibration */ -let _vibrating: boolean = false; -let _id: number = 0; // _id is necessary to prevent race condition. const _default_vibration_length = 400; -function vibrateByPattern(pattern: Array, repeat: boolean = false) { - if (_vibrating) { - return; - } - _vibrating = true; - if (pattern[0] === 0) { - NativeVibration.vibrate(_default_vibration_length); - // $FlowFixMe[reassign-const] - pattern = pattern.slice(1); - } - if (pattern.length === 0) { - _vibrating = false; - return; - } - setTimeout(() => vibrateScheduler(++_id, pattern, repeat, 1), pattern[0]); -} - -function vibrateScheduler( - id: number, - pattern: Array, - repeat: boolean, - nextIndex: number, -) { - if (!_vibrating || id !== _id) { - return; - } - NativeVibration.vibrate(_default_vibration_length); - if (nextIndex >= pattern.length) { - if (repeat) { - // $FlowFixMe[reassign-const] - nextIndex = 0; - } else { - _vibrating = false; - return; - } - } - setTimeout( - () => vibrateScheduler(id, pattern, repeat, nextIndex + 1), - pattern[nextIndex], - ); -} - const Vibration = { /** * Trigger a vibration with specified `pattern`. @@ -74,25 +28,12 @@ const Vibration = { pattern?: number | Array = _default_vibration_length, repeat?: boolean = false, ) { - if (Platform.OS === 'android') { - if (typeof pattern === 'number') { - NativeVibration.vibrate(pattern); - } else if (Array.isArray(pattern)) { - NativeVibration.vibrateByPattern(pattern, repeat ? 0 : -1); - } else { - throw new Error('Vibration pattern should be a number or array'); - } + if (typeof pattern === 'number') { + NativeVibration.vibrate(pattern); + } else if (Array.isArray(pattern)) { + NativeVibration.vibrateByPattern(pattern, repeat ? 0 : -1); } else { - if (_vibrating) { - return; - } - if (typeof pattern === 'number') { - NativeVibration.vibrate(pattern); - } else if (Array.isArray(pattern)) { - vibrateByPattern(pattern, repeat); - } else { - throw new Error('Vibration pattern should be a number or array'); - } + throw new Error('Vibration pattern should be a number or array'); } }, /** @@ -101,11 +42,7 @@ const Vibration = { * See https://reactnative.dev/docs/vibration#cancel */ cancel: function () { - if (Platform.OS === 'ios') { - _vibrating = false; - } else { - NativeVibration.cancel(); - } + NativeVibration.cancel(); }, }; diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeVibration.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeVibration.js index 64704c8d7700..9789e89584bf 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeVibration.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeVibration.js @@ -15,8 +15,6 @@ import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboMod export interface Spec extends TurboModule { +getConstants: () => {}; +vibrate: (pattern: number) => void; - - // Android only +vibrateByPattern: (pattern: Array, repeat: number) => void; +cancel: () => void; } diff --git a/packages/rn-tester/js/examples/Vibration/VibrationExample.js b/packages/rn-tester/js/examples/Vibration/VibrationExample.js index 0592b4913b3b..b157096eb883 100644 --- a/packages/rn-tester/js/examples/Vibration/VibrationExample.js +++ b/packages/rn-tester/js/examples/Vibration/VibrationExample.js @@ -14,13 +14,7 @@ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import RNTesterText from '../../components/RNTesterText'; import React from 'react'; -import { - Platform, - StyleSheet, - TouchableHighlight, - Vibration, - View, -} from 'react-native'; +import {StyleSheet, TouchableHighlight, Vibration, View} from 'react-native'; exports.framework = 'React'; exports.title = 'Vibration'; @@ -28,26 +22,16 @@ exports.category = 'Basic'; exports.documentationURL = 'https://reactnative.dev/docs/vibration'; exports.description = 'Vibration API'; -let pattern, patternLiteral, patternDescription; -if (Platform.OS === 'android') { - pattern = [0, 500, 200, 500]; - patternLiteral = '[0, 500, 200, 500]'; - patternDescription = `${patternLiteral} -arg 0: duration to wait before turning the vibrator on. -arg with odd: vibration length. -arg with even: duration to wait before next vibration. -`; -} else { - pattern = [0, 1000, 2000, 3000]; - patternLiteral = '[0, 1000, 2000, 3000]'; - patternDescription = `${patternLiteral} -vibration length on iOS is fixed. -pattern controls durations BETWEEN each vibration only. +const pattern = [0, 400, 500, 800]; +const patternLiteral = '[0, 400, 500, 800]'; +const patternDescription = `${patternLiteral} +Pattern format: [wait, vibrate, wait, vibrate, ...] + - Even indices (0, 2, 4...): pause duration in ms + - Odd indices (1, 3, 5...): vibration duration in ms -arg 0: duration to wait before turning the vibrator on. -subsequent args: duration to wait before next vibration. +This pattern: 400ms buzz → 500ms pause → 800ms buzz +The second buzz is noticeably longer than the first. `; -} exports.examples = [ { @@ -68,7 +52,25 @@ exports.examples = [ style={styles.wrapper} onPress={() => Vibration.vibrate()}> - Vibrate + + Vibrate (default 400ms) + + + + ); + }, + }, + { + title: 'Vibration.vibrate(1000)', + render(): React.Node { + return ( + Vibration.vibrate(1000)}> + + + Vibrate for 1 second + ); From 93eab91503cf25de312b35f878a371b4c1184aee Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 15 Feb 2026 19:08:15 +0100 Subject: [PATCH 2/4] Update VibrationExample.js pattern for improved vibration sequence --- .../js/examples/Vibration/VibrationExample.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/rn-tester/js/examples/Vibration/VibrationExample.js b/packages/rn-tester/js/examples/Vibration/VibrationExample.js index b157096eb883..5473e1fea8fd 100644 --- a/packages/rn-tester/js/examples/Vibration/VibrationExample.js +++ b/packages/rn-tester/js/examples/Vibration/VibrationExample.js @@ -22,15 +22,12 @@ exports.category = 'Basic'; exports.documentationURL = 'https://reactnative.dev/docs/vibration'; exports.description = 'Vibration API'; -const pattern = [0, 400, 500, 800]; -const patternLiteral = '[0, 400, 500, 800]'; +const pattern = [0, 500, 200, 500]; +const patternLiteral = '[0, 500, 200, 500]'; const patternDescription = `${patternLiteral} -Pattern format: [wait, vibrate, wait, vibrate, ...] - - Even indices (0, 2, 4...): pause duration in ms - - Odd indices (1, 3, 5...): vibration duration in ms - -This pattern: 400ms buzz → 500ms pause → 800ms buzz -The second buzz is noticeably longer than the first. +arg 0: duration to wait before turning the vibrator on. +arg with odd: vibration length. +arg with even: duration to wait before next vibration. `; exports.examples = [ From f2a1b92136107c37bb3dbff889702d0ef675ce20 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 15 Feb 2026 19:23:38 +0100 Subject: [PATCH 3/4] remove comment --- packages/react-native/Libraries/Vibration/RCTVibration.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/Libraries/Vibration/RCTVibration.mm b/packages/react-native/Libraries/Vibration/RCTVibration.mm index 6a04c4bc612c..eb03dfc4f11b 100644 --- a/packages/react-native/Libraries/Vibration/RCTVibration.mm +++ b/packages/react-native/Libraries/Vibration/RCTVibration.mm @@ -18,7 +18,7 @@ @interface RCTVibration () @implementation RCTVibration { CHHapticEngine *_engine; id _player; - NSLock *_lock; // Ensures thread safety for engine/player access + NSLock *_lock; } RCT_EXPORT_MODULE() From 4db5a6b501cc0d9090ef7a790048211124b30ede Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 15 Feb 2026 19:27:28 +0100 Subject: [PATCH 4/4] Remove unnecessary comments and clean up code structure --- packages/react-native/Libraries/Vibration/RCTVibration.mm | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/react-native/Libraries/Vibration/RCTVibration.mm b/packages/react-native/Libraries/Vibration/RCTVibration.mm index eb03dfc4f11b..8baf483cfc7e 100644 --- a/packages/react-native/Libraries/Vibration/RCTVibration.mm +++ b/packages/react-native/Libraries/Vibration/RCTVibration.mm @@ -31,7 +31,7 @@ - (instancetype)init return self; } -#pragma mark - Haptic Engine Management + - (CHHapticEngine *)ensureEngine { @@ -91,8 +91,6 @@ - (void)stopCurrentPlayer [_lock unlock]; } -#pragma mark - Pattern Building - - (CHHapticPattern *)hapticPatternFromArray:(NSArray *)pattern startIndex:(NSUInteger)startIndex { if (startIndex >= pattern.count) @@ -134,8 +132,6 @@ - (CHHapticPattern *)hapticPatternFromArray:(NSArray *)pattern start return hapticPattern; } -#pragma mark - Execution Helper - - (void)_playPattern:(CHHapticPattern *)pattern isLooping:(BOOL)isLooping { CHHapticEngine *engine = [self ensureEngine]; @@ -161,8 +157,6 @@ - (void)_playPattern:(CHHapticPattern *)pattern isLooping:(BOOL)isLooping } } -#pragma mark - Exported Methods - - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params {