diff --git a/packages/react-native/Libraries/Vibration/RCTVibration.mm b/packages/react-native/Libraries/Vibration/RCTVibration.mm index 389dc871c4d2..8baf483cfc7e 100644 --- a/packages/react-native/Libraries/Vibration/RCTVibration.mm +++ b/packages/react-native/Libraries/Vibration/RCTVibration.mm @@ -5,29 +5,156 @@ * 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; +} RCT_EXPORT_MODULE() -- (void)vibrate +- (instancetype)init +{ + if (self = [super init]) { + _lock = [[NSLock alloc] init]; + } + return self; +} + + + +- (CHHapticEngine *)ensureEngine { - AudioServicesPlaySystemSound(kSystemSoundID_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; } -RCT_EXPORT_METHOD(vibrate : (double)pattern) +- (void)stopCurrentPlayer { - [self vibrate]; + [_lock lock]; + if (_player) { + [_player stopAtTime:CHHapticTimeImmediate error:nil]; + _player = nil; + } + [_lock unlock]; +} + +- (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; +} + +- (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]; + } } - (std::shared_ptr)getTurboModule: @@ -36,14 +163,81 @@ - (void)vibrate return std::make_shared(params); } -RCT_EXPORT_METHOD(vibrateByPattern : (NSArray *)pattern repeat : (double)repeat) +RCT_EXPORT_METHOD(vibrate : (double)durationMs) { - RCTLogError(@"Vibration.vibrateByPattern does not have an iOS implementation"); + 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) +{ + 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..5473e1fea8fd 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,13 @@ 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} +const pattern = [0, 500, 200, 500]; +const patternLiteral = '[0, 500, 200, 500]'; +const 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. - -arg 0: duration to wait before turning the vibrator on. -subsequent args: duration to wait before next vibration. -`; -} exports.examples = [ { @@ -68,7 +49,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 + );