Skip to content

Commit 2a97e86

Browse files
ksokolovskyiIvoneDjaja
authored andcommitted
Add haptic notifications support. (flutter#177721)
Closes flutter#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
1 parent 0efbecb commit 2a97e86

File tree

8 files changed

+221
-1
lines changed

8 files changed

+221
-1
lines changed

engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,9 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
681681
'HapticFeedbackType.mediumImpact' => vibrateMediumImpact,
682682
'HapticFeedbackType.heavyImpact' => vibrateHeavyImpact,
683683
'HapticFeedbackType.selectionClick' => vibrateSelectionClick,
684+
'HapticFeedbackType.successNotification' => vibrateMediumImpact,
685+
'HapticFeedbackType.warningNotification' => vibrateMediumImpact,
686+
'HapticFeedbackType.errorNotification' => vibrateHeavyImpact,
684687
_ => vibrateLongPress,
685688
};
686689
}

engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,10 @@ public enum HapticFeedbackType {
592592
LIGHT_IMPACT("HapticFeedbackType.lightImpact"),
593593
MEDIUM_IMPACT("HapticFeedbackType.mediumImpact"),
594594
HEAVY_IMPACT("HapticFeedbackType.heavyImpact"),
595-
SELECTION_CLICK("HapticFeedbackType.selectionClick");
595+
SELECTION_CLICK("HapticFeedbackType.selectionClick"),
596+
SUCCESS_NOTIFICATION("HapticFeedbackType.successNotification"),
597+
WARNING_NOTIFICATION("HapticFeedbackType.warningNotification"),
598+
ERROR_NOTIFICATION("HapticFeedbackType.errorNotification");
596599

597600
@NonNull
598601
static HapticFeedbackType fromValue(@Nullable String encodedName) throws NoSuchFieldException {

engine/src/flutter/shell/platform/android/io/flutter/plugin/platform/PlatformPlugin.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,21 @@ private void playSystemSound(@NonNull PlatformChannel.SoundType soundType) {
209209
case SELECTION_CLICK:
210210
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
211211
break;
212+
case SUCCESS_NOTIFICATION:
213+
if (Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
214+
view.performHapticFeedback(HapticFeedbackConstants.CONFIRM);
215+
}
216+
break;
217+
case WARNING_NOTIFICATION:
218+
if (Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
219+
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
220+
}
221+
break;
222+
case ERROR_NOTIFICATION:
223+
if (Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
224+
view.performHapticFeedback(HapticFeedbackConstants.REJECT);
225+
}
226+
break;
212227
}
213228
}
214229

engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import static org.junit.Assert.fail;
1616
import static org.mockito.Mockito.any;
1717
import static org.mockito.Mockito.anyBoolean;
18+
import static org.mockito.Mockito.clearInvocations;
1819
import static org.mockito.Mockito.doThrow;
1920
import static org.mockito.Mockito.mock;
2021
import static org.mockito.Mockito.mockStatic;
@@ -33,6 +34,7 @@
3334
import android.content.res.AssetFileDescriptor;
3435
import android.net.Uri;
3536
import android.os.Build;
37+
import android.view.HapticFeedbackConstants;
3638
import android.view.View;
3739
import android.view.Window;
3840
import android.view.WindowInsetsController;
@@ -752,4 +754,106 @@ public void startChoosenActivityWhenSharingText() {
752754
assertEquals(sendToIntent.getType(), "text/plain");
753755
assertEquals(sendToIntent.getStringExtra(Intent.EXTRA_TEXT), expectedContent);
754756
}
757+
758+
@Config(sdk = API_LEVELS.API_29)
759+
@Test
760+
public void vibrateHapticFeedbackWhenApiLevelIsLessThan30() {
761+
View fakeDecorView = mock(View.class);
762+
Window fakeWindow = mock(Window.class);
763+
Activity mockActivity = mock(Activity.class);
764+
when(fakeWindow.getDecorView()).thenReturn(fakeDecorView);
765+
when(mockActivity.getWindow()).thenReturn(fakeWindow);
766+
PlatformPlugin platformPlugin = new PlatformPlugin(mockActivity, mockPlatformChannel);
767+
768+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
769+
PlatformChannel.HapticFeedbackType.STANDARD);
770+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
771+
clearInvocations(fakeDecorView);
772+
773+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
774+
PlatformChannel.HapticFeedbackType.LIGHT_IMPACT);
775+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
776+
clearInvocations(fakeDecorView);
777+
778+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
779+
PlatformChannel.HapticFeedbackType.MEDIUM_IMPACT);
780+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
781+
clearInvocations(fakeDecorView);
782+
783+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
784+
PlatformChannel.HapticFeedbackType.HEAVY_IMPACT);
785+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
786+
clearInvocations(fakeDecorView);
787+
788+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
789+
PlatformChannel.HapticFeedbackType.SELECTION_CLICK);
790+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
791+
clearInvocations(fakeDecorView);
792+
793+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
794+
PlatformChannel.HapticFeedbackType.SUCCESS_NOTIFICATION);
795+
verify(fakeDecorView, never()).performHapticFeedback(HapticFeedbackConstants.CONFIRM);
796+
clearInvocations(fakeDecorView);
797+
798+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
799+
PlatformChannel.HapticFeedbackType.WARNING_NOTIFICATION);
800+
verify(fakeDecorView, never()).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
801+
clearInvocations(fakeDecorView);
802+
803+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
804+
PlatformChannel.HapticFeedbackType.ERROR_NOTIFICATION);
805+
verify(fakeDecorView, never()).performHapticFeedback(HapticFeedbackConstants.REJECT);
806+
clearInvocations(fakeDecorView);
807+
}
808+
809+
@Config(minSdk = API_LEVELS.API_30)
810+
@Test
811+
public void vibrateHapticFeedbackWhenApiLevelIsHigherOrEquals30() {
812+
View fakeDecorView = mock(View.class);
813+
Window fakeWindow = mock(Window.class);
814+
Activity mockActivity = mock(Activity.class);
815+
when(fakeWindow.getDecorView()).thenReturn(fakeDecorView);
816+
when(mockActivity.getWindow()).thenReturn(fakeWindow);
817+
PlatformPlugin platformPlugin = new PlatformPlugin(mockActivity, mockPlatformChannel);
818+
819+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
820+
PlatformChannel.HapticFeedbackType.STANDARD);
821+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
822+
clearInvocations(fakeDecorView);
823+
824+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
825+
PlatformChannel.HapticFeedbackType.LIGHT_IMPACT);
826+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
827+
clearInvocations(fakeDecorView);
828+
829+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
830+
PlatformChannel.HapticFeedbackType.MEDIUM_IMPACT);
831+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
832+
clearInvocations(fakeDecorView);
833+
834+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
835+
PlatformChannel.HapticFeedbackType.HEAVY_IMPACT);
836+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
837+
clearInvocations(fakeDecorView);
838+
839+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
840+
PlatformChannel.HapticFeedbackType.SELECTION_CLICK);
841+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
842+
clearInvocations(fakeDecorView);
843+
844+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
845+
PlatformChannel.HapticFeedbackType.SUCCESS_NOTIFICATION);
846+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.CONFIRM);
847+
clearInvocations(fakeDecorView);
848+
849+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
850+
PlatformChannel.HapticFeedbackType.WARNING_NOTIFICATION);
851+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
852+
clearInvocations(fakeDecorView);
853+
854+
platformPlugin.mPlatformMessageHandler.vibrateHapticFeedback(
855+
PlatformChannel.HapticFeedbackType.ERROR_NOTIFICATION);
856+
verify(fakeDecorView).performHapticFeedback(HapticFeedbackConstants.REJECT);
857+
clearInvocations(fakeDecorView);
858+
}
755859
}

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,15 @@ - (void)vibrateHapticFeedback:(NSString*)feedbackType {
265265
[[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleHeavy] impactOccurred];
266266
} else if ([@"HapticFeedbackType.selectionClick" isEqualToString:feedbackType]) {
267267
[[[UISelectionFeedbackGenerator alloc] init] selectionChanged];
268+
} else if ([@"HapticFeedbackType.successNotification" isEqualToString:feedbackType]) {
269+
[[[UINotificationFeedbackGenerator alloc] init]
270+
notificationOccurred:UINotificationFeedbackTypeSuccess];
271+
} else if ([@"HapticFeedbackType.warningNotification" isEqualToString:feedbackType]) {
272+
[[[UINotificationFeedbackGenerator alloc] init]
273+
notificationOccurred:UINotificationFeedbackTypeWarning];
274+
} else if ([@"HapticFeedbackType.errorNotification" isEqualToString:feedbackType]) {
275+
[[[UINotificationFeedbackGenerator alloc] init]
276+
notificationOccurred:UINotificationFeedbackTypeError];
268277
}
269278
}
270279

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ - (void)searchWeb:(NSString*)searchTerm;
2323
- (void)showLookUpViewController:(NSString*)term;
2424
- (void)showShareViewController:(NSString*)content;
2525
- (void)playSystemSound:(NSString*)soundType;
26+
- (void)vibrateHapticFeedback:(NSString*)feedbackType;
2627
@end
2728

2829
@interface UIViewController ()
@@ -312,6 +313,24 @@ - (void)testSystemSoundPlay {
312313
[self waitForExpectationsWithTimeout:1 handler:nil];
313314
}
314315

316+
- (void)testHapticFeedbackVibrate {
317+
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
318+
XCTestExpectation* invokeExpectation =
319+
[self expectationWithDescription:@"HapticFeedback.vibrate invoked"];
320+
FlutterPlatformPlugin* plugin = [[FlutterPlatformPlugin alloc] initWithEngine:engine];
321+
FlutterPlatformPlugin* mockPlugin = OCMPartialMock(plugin);
322+
323+
FlutterMethodCall* methodCall =
324+
[FlutterMethodCall methodCallWithMethodName:@"HapticFeedback.vibrate"
325+
arguments:@"HapticFeedbackType.lightImpact"];
326+
FlutterResult result = ^(id result) {
327+
OCMVerify([mockPlugin vibrateHapticFeedback:@"HapticFeedbackType.lightImpact"]);
328+
[invokeExpectation fulfill];
329+
};
330+
[mockPlugin handleMethodCall:methodCall result:result];
331+
[self waitForExpectationsWithTimeout:1 handler:nil];
332+
}
333+
315334
- (void)testViewControllerBasedStatusBarHiddenUpdate {
316335
id bundleMock = OCMPartialMock([NSBundle mainBundle]);
317336
OCMStub([bundleMock objectForInfoDictionaryKey:@"UIViewControllerBasedStatusBarAppearance"])

packages/flutter/lib/src/services/haptic_feedback.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,59 @@ abstract final class HapticFeedback {
9393
'HapticFeedbackType.selectionClick',
9494
);
9595
}
96+
97+
/// Provides a haptic feedback indicating that a task or action has completed
98+
/// successfully.
99+
///
100+
/// On iOS, this uses a `UINotificationFeedbackGenerator` with
101+
/// `UINotificationFeedbackTypeSuccess`.
102+
///
103+
/// On Android, this uses `HapticFeedbackConstants.CONFIRM` on API levels 30
104+
/// and above. This call has no effects on Android API levels below 30.
105+
///
106+
/// {@template flutter.services.HapticFeedback.notification}
107+
/// See also:
108+
///
109+
/// * [Human Interface Guidelines Playing Haptics](https://developer.apple.com/design/human-interface-guidelines/playing-haptics#Notification)
110+
/// {@endtemplate}
111+
static Future<void> successNotification() async {
112+
await SystemChannels.platform.invokeMethod<void>(
113+
'HapticFeedback.vibrate',
114+
'HapticFeedbackType.successNotification',
115+
);
116+
}
117+
118+
/// Provides a haptic feedback indicating that a task or action has produced
119+
/// a warning.
120+
///
121+
/// On iOS, this uses a `UINotificationFeedbackGenerator` with
122+
/// `UINotificationFeedbackTypeWarning`.
123+
///
124+
/// On Android, this uses `HapticFeedbackConstants.KEYBOARD_TAP` on API
125+
/// levels 30 and above. This call has no effects on Android API levels below
126+
/// 30.
127+
///
128+
/// {@macro flutter.services.HapticFeedback.notification}
129+
static Future<void> warningNotification() async {
130+
await SystemChannels.platform.invokeMethod<void>(
131+
'HapticFeedback.vibrate',
132+
'HapticFeedbackType.warningNotification',
133+
);
134+
}
135+
136+
/// Provides a haptic feedback indicating that a task or action has failed.
137+
///
138+
/// On iOS, this uses a `UINotificationFeedbackGenerator` with
139+
/// `UINotificationFeedbackTypeError`.
140+
///
141+
/// On Android, this uses `HapticFeedbackConstants.REJECT` on API levels 30
142+
/// and above. This call has no effects on Android API levels below 30.
143+
///
144+
/// {@macro flutter.services.HapticFeedback.notification}
145+
static Future<void> errorNotification() async {
146+
await SystemChannels.platform.invokeMethod<void>(
147+
'HapticFeedback.vibrate',
148+
'HapticFeedbackType.errorNotification',
149+
);
150+
}
96151
}

packages/flutter/test/services/haptic_feedback_test.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,17 @@ void main() {
5555
HapticFeedback.selectionClick,
5656
'HapticFeedbackType.selectionClick',
5757
);
58+
await callAndVerifyHapticFunction(
59+
HapticFeedback.successNotification,
60+
'HapticFeedbackType.successNotification',
61+
);
62+
await callAndVerifyHapticFunction(
63+
HapticFeedback.warningNotification,
64+
'HapticFeedbackType.warningNotification',
65+
);
66+
await callAndVerifyHapticFunction(
67+
HapticFeedback.errorNotification,
68+
'HapticFeedbackType.errorNotification',
69+
);
5870
});
5971
}

0 commit comments

Comments
 (0)