diff --git a/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt b/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt index 948fde0..216913d 100644 --- a/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt +++ b/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt @@ -58,17 +58,19 @@ class HapticFeedbackPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "canVibrate") { - canVibrate(result) - } else { - val pattern = Pattern.values().find { it.name == call.method } - if (pattern != null) { - val args = call.arguments as? Map<*, *> - val usage = Usage.fromArguments(args) - val useAndroidHapticConstants = (args?.get("useAndroidHapticConstants") as? Boolean) ?: false - vibratePattern(pattern, usage, useAndroidHapticConstants, result) - } else { - result.notImplemented() + when (call.method) { + "canVibrate" -> canVibrate(result) + "prepare" -> result.success(null) + else -> { + val pattern = Pattern.values().find { it.name == call.method } + if (pattern != null) { + val args = call.arguments as? Map<*, *> + val usage = Usage.fromArguments(args) + val useAndroidHapticConstants = (args?.get("useAndroidHapticConstants") as? Boolean) ?: false + vibratePattern(pattern, usage, useAndroidHapticConstants, result) + } else { + result.notImplemented() + } } } } diff --git a/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift b/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift index 52017b0..e48bdf5 100644 --- a/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift +++ b/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift @@ -1,7 +1,27 @@ import CoreHaptics import Flutter +import UIKit public class HapticFeedbackPlugin: NSObject, FlutterPlugin { + private let notificationGenerator = UINotificationFeedbackGenerator() + private let lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) + private let mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + private let heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) + private let rigidImpactGenerator: UIImpactFeedbackGenerator + private let softImpactGenerator: UIImpactFeedbackGenerator + private let selectionGenerator = UISelectionFeedbackGenerator() + + override init() { + if #available(iOS 13.0, *) { + rigidImpactGenerator = UIImpactFeedbackGenerator(style: .rigid) + softImpactGenerator = UIImpactFeedbackGenerator(style: .soft) + } else { + rigidImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + softImpactGenerator = UIImpactFeedbackGenerator(style: .light) + } + super.init() + } + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "haptic_feedback", binaryMessenger: registrar.messenger()) let instance = HapticFeedbackPlugin() @@ -12,32 +32,35 @@ public class HapticFeedbackPlugin: NSObject, FlutterPlugin { switch call.method { case "canVibrate": canVibrate(result: result) + case "prepare": + prepare(type: call.arguments as? String, result: result) case "success": - notification(type: .success, result: result) + notificationGenerator.notificationOccurred(.success) + result(nil) case "warning": - notification(type: .warning, result: result) + notificationGenerator.notificationOccurred(.warning) + result(nil) case "error": - notification(type: .error, result: result) + notificationGenerator.notificationOccurred(.error) + result(nil) case "light": - impact(style: .light, result: result) + lightImpactGenerator.impactOccurred() + result(nil) case "medium": - impact(style: .medium, result: result) + mediumImpactGenerator.impactOccurred() + result(nil) case "heavy": - impact(style: .heavy, result: result) + heavyImpactGenerator.impactOccurred() + result(nil) case "rigid": - if #available(iOS 13.0, *) { - impact(style: .rigid, result: result) - } else { - impact(style: .medium, result: result) - } + rigidImpactGenerator.impactOccurred() + result(nil) case "soft": - if #available(iOS 13.0, *) { - impact(style: .soft, result: result) - } else { - impact(style: .light, result: result) - } + softImpactGenerator.impactOccurred() + result(nil) case "selection": - selection(result: result) + selectionGenerator.selectionChanged() + result(nil) default: result(FlutterMethodNotImplemented) } @@ -57,18 +80,25 @@ public class HapticFeedbackPlugin: NSObject, FlutterPlugin { result(false) } - private func notification(type: UINotificationFeedbackGenerator.FeedbackType, result: @escaping FlutterResult) { - UINotificationFeedbackGenerator().notificationOccurred(type) - result(nil) - } - - private func impact(style: UIImpactFeedbackGenerator.FeedbackStyle, result: @escaping FlutterResult) { - UIImpactFeedbackGenerator(style: style).impactOccurred() - result(nil) - } - - private func selection(result: @escaping FlutterResult) { - UISelectionFeedbackGenerator().selectionChanged() + private func prepare(type: String?, result: @escaping FlutterResult) { + switch type { + case "success", "warning", "error": + notificationGenerator.prepare() + case "light": + lightImpactGenerator.prepare() + case "medium": + mediumImpactGenerator.prepare() + case "heavy": + heavyImpactGenerator.prepare() + case "rigid": + rigidImpactGenerator.prepare() + case "soft": + softImpactGenerator.prepare() + case "selection": + selectionGenerator.prepare() + default: + break + } result(nil) } } diff --git a/lib/src/haptic_feedback_method_channel.dart b/lib/src/haptic_feedback_method_channel.dart index 01b0f86..74a3ef6 100644 --- a/lib/src/haptic_feedback_method_channel.dart +++ b/lib/src/haptic_feedback_method_channel.dart @@ -29,4 +29,9 @@ class MethodChannelHapticFeedback extends HapticFeedbackPlatform { return await methodChannel.invokeMethod(type.name, arguments); } + + @override + Future prepare(HapticsType type) async { + return await methodChannel.invokeMethod('prepare', type.name); + } } diff --git a/lib/src/haptic_feedback_platform_interface.dart b/lib/src/haptic_feedback_platform_interface.dart index e081615..8756f79 100644 --- a/lib/src/haptic_feedback_platform_interface.dart +++ b/lib/src/haptic_feedback_platform_interface.dart @@ -44,4 +44,12 @@ abstract class HapticFeedbackPlatform extends PlatformInterface { 'Use the implementation method of MethodChannelHapticFeedback.', ); } + + /// Prepares the haptic engine for the given [type] on iOS, reducing latency + /// for the next haptic event. This is a no-op on Android. + Future prepare(HapticsType type) { + throw UnsupportedError( + 'Use the implementation method of MethodChannelHapticFeedback.', + ); + } } diff --git a/lib/src/haptics.dart b/lib/src/haptics.dart index f4a8d92..3d331aa 100644 --- a/lib/src/haptics.dart +++ b/lib/src/haptics.dart @@ -46,4 +46,22 @@ class Haptics { useAndroidHapticConstants: useAndroidHapticConstants, ); } + + /// Prepares the haptic engine for a haptic event of [type], reducing the + /// latency of the first haptic in a session on iOS. + /// + /// On iOS, this calls `prepare()` on the underlying `UIFeedbackGenerator`, + /// warming up the Taptic Engine ahead of the actual feedback event. The + /// engine stays warm for a few seconds. Call this method when you know + /// a haptic event is imminent (e.g., when a screen loads or a gesture + /// begins). + /// + /// This method is a no-op on Android and unsupported platforms. + static Future prepare(HapticsType type) async { + if (!isPlatformSupported) { + return; + } + + return HapticFeedbackPlatform.instance.prepare(type); + } } diff --git a/test/haptic_feedback_method_channel_test.dart b/test/haptic_feedback_method_channel_test.dart index ebe137a..4cc4d2f 100644 --- a/test/haptic_feedback_method_channel_test.dart +++ b/test/haptic_feedback_method_channel_test.dart @@ -63,4 +63,20 @@ void main() { expect(lastMethodCall?.method, 'heavy'); expect(lastMethodCall?.arguments, {'useAndroidHapticConstants': true}); }); + + test('prepare invokes prepare method with type name', () async { + await platform.prepare(HapticsType.light); + + expect(lastMethodCall?.method, 'prepare'); + expect(lastMethodCall?.arguments, 'light'); + }); + + test('prepare forwards each haptics type correctly', () async { + for (final type in HapticsType.values) { + await platform.prepare(type); + + expect(lastMethodCall?.method, 'prepare'); + expect(lastMethodCall?.arguments, type.name); + } + }); } diff --git a/test/haptic_feedback_test.dart b/test/haptic_feedback_test.dart index 5eb06b7..87c7930 100644 --- a/test/haptic_feedback_test.dart +++ b/test/haptic_feedback_test.dart @@ -49,8 +49,7 @@ void main() { expect(recordingPlatform.lastUseAndroidHapticConstants, false); }); - test( - 'vibrate forwards useAndroidHapticConstants to the platform implementation', + test('vibrate forwards useAndroidHapticConstants to the platform implementation', () async { final recordingPlatform = RecordingHapticFeedbackPlatform(); HapticFeedbackPlatform.instance = recordingPlatform; @@ -63,4 +62,25 @@ void main() { expect(recordingPlatform.lastType, HapticsType.success); expect(recordingPlatform.lastUseAndroidHapticConstants, false); }); + + test('prepare forwards type to the platform implementation', () async { + final recordingPlatform = RecordingHapticFeedbackPlatform(); + HapticFeedbackPlatform.instance = recordingPlatform; + + await Haptics.prepare(HapticsType.light); + + expect(recordingPlatform.lastPreparedType, HapticsType.light); + }); + + test('prepare performs nothing on unsupported platforms', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final recordingPlatform = RecordingHapticFeedbackPlatform(); + HapticFeedbackPlatform.instance = recordingPlatform; + + await Haptics.prepare(HapticsType.heavy); + + expect(recordingPlatform.lastPreparedType, isNull); + }); } diff --git a/test/support/mock_platforms.dart b/test/support/mock_platforms.dart index ffa2afb..6a38abc 100644 --- a/test/support/mock_platforms.dart +++ b/test/support/mock_platforms.dart @@ -19,6 +19,9 @@ class MockHapticFeedbackPlatform HapticsUsage? usage, bool useAndroidHapticConstants = false, }) async {} + + @override + Future prepare(HapticsType type) async {} } class RecordingHapticFeedbackPlatform @@ -27,6 +30,7 @@ class RecordingHapticFeedbackPlatform HapticsType? lastType; HapticsUsage? lastUsage; bool? lastUseAndroidHapticConstants; + HapticsType? lastPreparedType; @override Future canVibrate() async { @@ -43,4 +47,9 @@ class RecordingHapticFeedbackPlatform lastUsage = usage; lastUseAndroidHapticConstants = useAndroidHapticConstants; } + + @override + Future prepare(HapticsType type) async { + lastPreparedType = type; + } }