Add haptic notifications support. (#177721)
Closes https://github.com/flutter/flutter/issues/150029
### Description
- Adds `successNotification`, `warningNotification` and
`errorNotification` haptics to the framework
- Adds `UINotificationFeedbackTypeSuccess`,
`UINotificationFeedbackTypeWarning` and
`UINotificationFeedbackTypeError` haptics support on iOS
- Adds `HapticFeedbackConstants.CONFIRM` and
`HapticFeedbackConstants.REJECT` haptics support on Android
- Adds tests
| iOS | Android | Web |
|:-:|:-:|:-:|
| UINotificationFeedbackTypeSuccess | HapticFeedbackConstants.CONFIRM |
20ms vibration |
| UINotificationFeedbackTypeWarning |
HapticFeedbackConstants.KEYBOARD_TAP | 20ms vibration |
| UINotificationFeedbackTypeError | HapticFeedbackConstants.REJECT |
30ms vibration |
## Pre-launch Checklist
- [X] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [X] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [X] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [X] I signed the [CLA].
- [X] I listed at least one issue that this PR fixes in the description
above.
- [X] I updated/added relevant documentation (doc comments with `///`).
- [X] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [X] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [X] All existing and new tests are passing.
<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
index e9f54aa..c868219 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
@@ -681,6 +681,9 @@
'HapticFeedbackType.mediumImpact' => vibrateMediumImpact,
'HapticFeedbackType.heavyImpact' => vibrateHeavyImpact,
'HapticFeedbackType.selectionClick' => vibrateSelectionClick,
+ 'HapticFeedbackType.successNotification' => vibrateMediumImpact,
+ 'HapticFeedbackType.warningNotification' => vibrateMediumImpact,
+ 'HapticFeedbackType.errorNotification' => vibrateHeavyImpact,
_ => vibrateLongPress,
};
}
diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java
index 1f9c105..d25068e 100644
--- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java
+++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java
@@ -592,7 +592,10 @@
LIGHT_IMPACT("HapticFeedbackType.lightImpact"),
MEDIUM_IMPACT("HapticFeedbackType.mediumImpact"),
HEAVY_IMPACT("HapticFeedbackType.heavyImpact"),
- SELECTION_CLICK("HapticFeedbackType.selectionClick");
+ SELECTION_CLICK("HapticFeedbackType.selectionClick"),
+ SUCCESS_NOTIFICATION("HapticFeedbackType.successNotification"),
+ WARNING_NOTIFICATION("HapticFeedbackType.warningNotification"),
+ ERROR_NOTIFICATION("HapticFeedbackType.errorNotification");
@NonNull
static HapticFeedbackType fromValue(@Nullable String encodedName) throws NoSuchFieldException {
diff --git a/engine/src/flutter/shell/platform/android/io/flutter/plugin/platform/PlatformPlugin.java b/engine/src/flutter/shell/platform/android/io/flutter/plugin/platform/PlatformPlugin.java
index 8315fa3..3c3ceea 100644
--- a/engine/src/flutter/shell/platform/android/io/flutter/plugin/platform/PlatformPlugin.java
+++ b/engine/src/flutter/shell/platform/android/io/flutter/plugin/platform/PlatformPlugin.java
@@ -209,6 +209,21 @@
case SELECTION_CLICK:
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
break;
+ case SUCCESS_NOTIFICATION:
+ if (Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
+ view.performHapticFeedback(HapticFeedbackConstants.CONFIRM);
+ }
+ break;
+ case WARNING_NOTIFICATION:
+ if (Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
+ view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ }
+ break;
+ case ERROR_NOTIFICATION:
+ if (Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
+ view.performHapticFeedback(HapticFeedbackConstants.REJECT);
+ }
+ break;
}
}
diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java
index 9d2abb0..423176c 100644
--- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java
+++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java
@@ -15,6 +15,7 @@
import static org.junit.Assert.fail;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -33,6 +34,7 @@
import android.content.res.AssetFileDescriptor;
import android.net.Uri;
import android.os.Build;
+import android.view.HapticFeedbackConstants;
import android.view.View;
import android.view.Window;
import android.view.WindowInsetsController;
@@ -752,4 +754,106 @@
assertEquals(sendToIntent.getType(), "text/plain");
assertEquals(sendToIntent.getStringExtra(Intent.EXTRA_TEXT), expectedContent);
}
+
+ @Config(sdk = API_LEVELS.API_29)
+ @Test
+ public void vibrateHapticFeedbackWhenApiLevelIsLessThan30() {
+ View fakeDecorView = mock(View.class);
+ Window fakeWindow = mock(Window.class);
+ Activity mockActivity = mock(Activity.class);
+ when(fakeWindow.getDecorView()).thenReturn(fakeDecorView);
+ when(mockActivity.getWindow()).thenReturn(fakeWindow);
+ PlatformPlugin platformPlugin = new PlatformPlugin(mockActivity, mockPlatformChannel);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.STANDARD);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.LIGHT_IMPACT);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.MEDIUM_IMPACT);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.HEAVY_IMPACT);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.SELECTION_CLICK);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.SUCCESS_NOTIFICATION);
+ verify(fakeDecorView, never()).performHapticFeedback(HapticFeedbackConstants.CONFIRM);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.WARNING_NOTIFICATION);
+ verify(fakeDecorView, never()).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.ERROR_NOTIFICATION);
+ verify(fakeDecorView, never()).performHapticFeedback(HapticFeedbackConstants.REJECT);
+ clearInvocations(fakeDecorView);
+ }
+
+ @Config(minSdk = API_LEVELS.API_30)
+ @Test
+ public void vibrateHapticFeedbackWhenApiLevelIsHigherOrEquals30() {
+ View fakeDecorView = mock(View.class);
+ Window fakeWindow = mock(Window.class);
+ Activity mockActivity = mock(Activity.class);
+ when(fakeWindow.getDecorView()).thenReturn(fakeDecorView);
+ when(mockActivity.getWindow()).thenReturn(fakeWindow);
+ PlatformPlugin platformPlugin = new PlatformPlugin(mockActivity, mockPlatformChannel);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.STANDARD);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.LIGHT_IMPACT);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.MEDIUM_IMPACT);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.HEAVY_IMPACT);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.SELECTION_CLICK);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.SUCCESS_NOTIFICATION);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CONFIRM);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.WARNING_NOTIFICATION);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ clearInvocations(fakeDecorView);
+
+ platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
+ PlatformChannel.HapticFeedbackType.ERROR_NOTIFICATION);
+ verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.REJECT);
+ clearInvocations(fakeDecorView);
+ }
}
diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm
index 1adf1bd..808d87a 100644
--- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm
+++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm
@@ -265,6 +265,15 @@
[[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleHeavy] impactOccurred];
} else if ([@"HapticFeedbackType.selectionClick" isEqualToString:feedbackType]) {
[[[UISelectionFeedbackGenerator alloc] init] selectionChanged];
+ } else if ([@"HapticFeedbackType.successNotification" isEqualToString:feedbackType]) {
+ [[[UINotificationFeedbackGenerator alloc] init]
+ notificationOccurred:UINotificationFeedbackTypeSuccess];
+ } else if ([@"HapticFeedbackType.warningNotification" isEqualToString:feedbackType]) {
+ [[[UINotificationFeedbackGenerator alloc] init]
+ notificationOccurred:UINotificationFeedbackTypeWarning];
+ } else if ([@"HapticFeedbackType.errorNotification" isEqualToString:feedbackType]) {
+ [[[UINotificationFeedbackGenerator alloc] init]
+ notificationOccurred:UINotificationFeedbackTypeError];
}
}
diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm
index 83f54d7..7fd8d8c 100644
--- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm
+++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm
@@ -23,6 +23,7 @@
- (void)showLookUpViewController:(NSString*)term;
- (void)showShareViewController:(NSString*)content;
- (void)playSystemSound:(NSString*)soundType;
+- (void)vibrateHapticFeedback:(NSString*)feedbackType;
@end
@interface UIViewController ()
@@ -312,6 +313,24 @@
[self waitForExpectationsWithTimeout:1 handler:nil];
}
+- (void)testHapticFeedbackVibrate {
+ FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
+ XCTestExpectation* invokeExpectation =
+ [self expectationWithDescription:@"HapticFeedback.vibrate invoked"];
+ FlutterPlatformPlugin* plugin = [[FlutterPlatformPlugin alloc] initWithEngine:engine];
+ FlutterPlatformPlugin* mockPlugin = OCMPartialMock(plugin);
+
+ FlutterMethodCall* methodCall =
+ [FlutterMethodCall methodCallWithMethodName:@"HapticFeedback.vibrate"
+ arguments:@"HapticFeedbackType.lightImpact"];
+ FlutterResult result = ^(id result) {
+ OCMVerify([mockPlugin vibrateHapticFeedback:@"HapticFeedbackType.lightImpact"]);
+ [invokeExpectation fulfill];
+ };
+ [mockPlugin handleMethodCall:methodCall result:result];
+ [self waitForExpectationsWithTimeout:1 handler:nil];
+}
+
- (void)testViewControllerBasedStatusBarHiddenUpdate {
id bundleMock = OCMPartialMock([NSBundle mainBundle]);
OCMStub([bundleMock objectForInfoDictionaryKey:@"UIViewControllerBasedStatusBarAppearance"])
diff --git a/packages/flutter/lib/src/services/haptic_feedback.dart b/packages/flutter/lib/src/services/haptic_feedback.dart
index 7105fb4..1ec0434 100644
--- a/packages/flutter/lib/src/services/haptic_feedback.dart
+++ b/packages/flutter/lib/src/services/haptic_feedback.dart
@@ -93,4 +93,59 @@
'HapticFeedbackType.selectionClick',
);
}
+
+ /// Provides a haptic feedback indicating that a task or action has completed
+ /// successfully.
+ ///
+ /// On iOS, this uses a `UINotificationFeedbackGenerator` with
+ /// `UINotificationFeedbackTypeSuccess`.
+ ///
+ /// On Android, this uses `HapticFeedbackConstants.CONFIRM` on API levels 30
+ /// and above. This call has no effects on Android API levels below 30.
+ ///
+ /// {@template flutter.services.HapticFeedback.notification}
+ /// See also:
+ ///
+ /// * [Human Interface Guidelines Playing Haptics](https://developer.apple.com/design/human-interface-guidelines/playing-haptics#Notification)
+ /// {@endtemplate}
+ static Future<void> successNotification() async {
+ await SystemChannels.platform.invokeMethod<void>(
+ 'HapticFeedback.vibrate',
+ 'HapticFeedbackType.successNotification',
+ );
+ }
+
+ /// Provides a haptic feedback indicating that a task or action has produced
+ /// a warning.
+ ///
+ /// On iOS, this uses a `UINotificationFeedbackGenerator` with
+ /// `UINotificationFeedbackTypeWarning`.
+ ///
+ /// On Android, this uses `HapticFeedbackConstants.KEYBOARD_TAP` on API
+ /// levels 30 and above. This call has no effects on Android API levels below
+ /// 30.
+ ///
+ /// {@macro flutter.services.HapticFeedback.notification}
+ static Future<void> warningNotification() async {
+ await SystemChannels.platform.invokeMethod<void>(
+ 'HapticFeedback.vibrate',
+ 'HapticFeedbackType.warningNotification',
+ );
+ }
+
+ /// Provides a haptic feedback indicating that a task or action has failed.
+ ///
+ /// On iOS, this uses a `UINotificationFeedbackGenerator` with
+ /// `UINotificationFeedbackTypeError`.
+ ///
+ /// On Android, this uses `HapticFeedbackConstants.REJECT` on API levels 30
+ /// and above. This call has no effects on Android API levels below 30.
+ ///
+ /// {@macro flutter.services.HapticFeedback.notification}
+ static Future<void> errorNotification() async {
+ await SystemChannels.platform.invokeMethod<void>(
+ 'HapticFeedback.vibrate',
+ 'HapticFeedbackType.errorNotification',
+ );
+ }
}
diff --git a/packages/flutter/test/services/haptic_feedback_test.dart b/packages/flutter/test/services/haptic_feedback_test.dart
index baefeae..d471d7e 100644
--- a/packages/flutter/test/services/haptic_feedback_test.dart
+++ b/packages/flutter/test/services/haptic_feedback_test.dart
@@ -55,5 +55,17 @@
HapticFeedback.selectionClick,
'HapticFeedbackType.selectionClick',
);
+ await callAndVerifyHapticFunction(
+ HapticFeedback.successNotification,
+ 'HapticFeedbackType.successNotification',
+ );
+ await callAndVerifyHapticFunction(
+ HapticFeedback.warningNotification,
+ 'HapticFeedbackType.warningNotification',
+ );
+ await callAndVerifyHapticFunction(
+ HapticFeedback.errorNotification,
+ 'HapticFeedbackType.errorNotification',
+ );
});
}