Provide haptic/acoustic feedback for tap & long-press on Android (#10920)
* Provide haptic/acoustic feedback for tap & long-press on Android * review comments * fixed example code * review comments * comment fix
This commit is contained in:
parent
9adb4a78a6
commit
fe40eed362
@ -42,6 +42,7 @@ export 'src/material/dropdown.dart';
|
||||
export 'src/material/expand_icon.dart';
|
||||
export 'src/material/expansion_panel.dart';
|
||||
export 'src/material/expansion_tile.dart';
|
||||
export 'src/material/feedback.dart';
|
||||
export 'src/material/flat_button.dart';
|
||||
export 'src/material/flexible_space_bar.dart';
|
||||
export 'src/material/floating_action_button.dart';
|
||||
|
@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
import 'debug.dart';
|
||||
import 'feedback.dart';
|
||||
import 'icons.dart';
|
||||
import 'tooltip.dart';
|
||||
|
||||
@ -102,7 +103,7 @@ class Chip extends StatelessWidget {
|
||||
if (deletable) {
|
||||
rightPadding = 0.0;
|
||||
children.add(new GestureDetector(
|
||||
onTap: onDeleted,
|
||||
onTap: Feedback.wrapForTap(onDeleted, context),
|
||||
child: new Tooltip(
|
||||
message: 'Delete "$label"',
|
||||
child: new Container(
|
||||
|
@ -17,6 +17,7 @@ import 'button_bar.dart';
|
||||
import 'colors.dart';
|
||||
import 'debug.dart';
|
||||
import 'dialog.dart';
|
||||
import 'feedback.dart';
|
||||
import 'flat_button.dart';
|
||||
import 'icon_button.dart';
|
||||
import 'icons.dart';
|
||||
@ -120,11 +121,11 @@ class _DatePickerHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
new GestureDetector(
|
||||
onTap: () => _handleChangeMode(_DatePickerMode.year),
|
||||
onTap: Feedback.wrapForTap(() => _handleChangeMode(_DatePickerMode.year), context),
|
||||
child: new Text(new DateFormat('yyyy').format(selectedDate), style: yearStyle),
|
||||
),
|
||||
new GestureDetector(
|
||||
onTap: () => _handleChangeMode(_DatePickerMode.day),
|
||||
onTap: Feedback.wrapForTap(() => _handleChangeMode(_DatePickerMode.day), context),
|
||||
child: new Text(new DateFormat('E, MMM\u00a0d').format(selectedDate), style: dayStyle),
|
||||
),
|
||||
],
|
||||
|
154
packages/flutter/lib/src/material/feedback.dart
Normal file
154
packages/flutter/lib/src/material/feedback.dart
Normal file
@ -0,0 +1,154 @@
|
||||
// Copyright 2017 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Provides platform-specific acoustic and/or haptic feedback for certain
|
||||
/// actions.
|
||||
///
|
||||
/// For example, to play the Android-typically click sound when a button is
|
||||
/// tapped, call [forTap]. For the Android-specific vibration when long pressing
|
||||
/// an element, call [forLongPress]. Alternatively, you can also wrap your
|
||||
/// [onTap] or [onLongPress] callback in [wrapForTap] or [wrapForLongPress] to
|
||||
/// achieve the same (see example code below).
|
||||
///
|
||||
/// Calling any of these methods is a no-op on iOS as actions on that platform
|
||||
/// typically don't provide haptic or acoustic feedback.
|
||||
///
|
||||
/// All methods in this class are usually called from within a [build] method
|
||||
/// or from a State's methods as you have to provide a [BuildContext].
|
||||
///
|
||||
/// ## Sample code
|
||||
///
|
||||
/// To trigger platform-specific feedback before executing the actual callback:
|
||||
///
|
||||
/// ```dart
|
||||
/// class WidgetWithWrappedHandler extends StatelessWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return new GestureDetector(
|
||||
/// onTap: Feedback.wrapForTap(_onTapHandler, context),
|
||||
/// onLongPress: Feedback.wrapForLongPress(_onLongPressHandler, context),
|
||||
/// child: const Text('X'),
|
||||
/// );
|
||||
/// }
|
||||
///
|
||||
/// void _onTapHandler() {
|
||||
/// // Respond to tap.
|
||||
/// }
|
||||
///
|
||||
/// void _onLongPressHandler() {
|
||||
/// // Respond to long press.
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Alternatively, you can also call [forTap] or [forLongPress] directly within
|
||||
/// your tap or long press handler:
|
||||
///
|
||||
/// ```dart
|
||||
/// class WidgetWithExplicitCall extends StatelessWidget {
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return new GestureDetector(
|
||||
/// onTap: () {
|
||||
/// // Do some work (e.g. check if the tap is valid)
|
||||
/// Feedback.forTap(context);
|
||||
/// // Do more work (e.g. respond to the tap)
|
||||
/// },
|
||||
/// onLongPress: () {
|
||||
/// // Do some work (e.g. check if the long press is valid)
|
||||
/// Feedback.forLongPress(context);
|
||||
/// // Do more work (e.g. respond to the long press)
|
||||
/// },
|
||||
/// child: const Text('X'),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
class Feedback {
|
||||
Feedback._();
|
||||
|
||||
/// Provides platform-specific feedback for a tap.
|
||||
///
|
||||
/// On Android the click system sound is played. On iOS this is a no-op.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [wrapForTap] to trigger platform-specific feedback before executing a
|
||||
/// [GestureTapCallback].
|
||||
static Future<Null> forTap(BuildContext context) async {
|
||||
switch (_platform(context)) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
return SystemSound.play(SystemSoundType.click);
|
||||
default:
|
||||
return new Future<Null>.value();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a [GestureTapCallback] to provide platform specific feedback for a
|
||||
/// tap before the provided callback is executed.
|
||||
///
|
||||
/// On Android the platform-typical click system sound is played. On iOS this
|
||||
/// is a no-op as that platform usually doesn't provide feedback for a tap.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [forTap] to just trigger the platform-specific feedback without wrapping
|
||||
/// a [GestureTapCallback].
|
||||
static GestureTapCallback wrapForTap(GestureTapCallback callback, BuildContext context) {
|
||||
if (callback == null)
|
||||
return null;
|
||||
return () {
|
||||
Feedback.forTap(context);
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
/// Provides platform-specific feedback for a long press.
|
||||
///
|
||||
/// On Android the platform-typical vibration is triggered. On iOS this is a
|
||||
/// no-op as that platform usually doesn't provide feedback for long presses.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [wrapForLongPress] to trigger platform-specific feedback before
|
||||
/// executing a [GestureLongPressCallback].
|
||||
static Future<Null> forLongPress(BuildContext context) {
|
||||
switch (_platform(context)) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
return HapticFeedback.vibrate();
|
||||
default:
|
||||
return new Future<Null>.value();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a [GestureLongPressCallback] to provide platform specific feedback
|
||||
/// for a long press before the provided callback is executed.
|
||||
///
|
||||
/// On Android the platform-typical vibration is triggered. On iOS this
|
||||
/// is a no-op as that platform usually doesn't provide feedback for a long
|
||||
/// press.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [forLongPress] to just trigger the platform-specific feedback without
|
||||
/// wrapping a [GestureLongPressCallback].
|
||||
static GestureLongPressCallback wrapForLongPress(GestureLongPressCallback callback, BuildContext context) {
|
||||
if (callback == null)
|
||||
return null;
|
||||
return () {
|
||||
Feedback.forLongPress(context);
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
static TargetPlatform _platform(BuildContext context) => Theme.of(context).platform;
|
||||
}
|
@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'debug.dart';
|
||||
import 'feedback.dart';
|
||||
import 'ink_highlight.dart';
|
||||
import 'ink_splash.dart';
|
||||
import 'material.dart';
|
||||
@ -70,6 +71,7 @@ import 'theme.dart';
|
||||
/// ```dart
|
||||
/// assert(debugCheckHasMaterial(context));
|
||||
/// ```
|
||||
/// The parameter [enableFeedback] must not be `null`.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
@ -93,7 +95,8 @@ class InkResponse extends StatefulWidget {
|
||||
this.borderRadius: BorderRadius.zero,
|
||||
this.highlightColor,
|
||||
this.splashColor,
|
||||
}) : super(key: key);
|
||||
this.enableFeedback: true,
|
||||
}) : assert(enableFeedback != null), super(key: key);
|
||||
|
||||
/// The widget below this widget in the tree.
|
||||
final Widget child;
|
||||
@ -179,6 +182,16 @@ class InkResponse extends StatefulWidget {
|
||||
/// * [highlightColor], the color of the highlight.
|
||||
final Color splashColor;
|
||||
|
||||
/// Whether detected gestures should provide acoustic and/or haptic feedback.
|
||||
///
|
||||
/// For example, on Android a tap will produce a clicking sound and a
|
||||
/// long-press will produce a short vibration, when feedback is enabled.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [Feedback] for providing platform-specific feedback to certain actions.
|
||||
final bool enableFeedback;
|
||||
|
||||
/// The rectangle to use for the highlight effect and for clipping
|
||||
/// the splash effects if [containedInkWell] is true.
|
||||
///
|
||||
@ -288,12 +301,15 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
|
||||
updateHighlight(true);
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
void _handleTap(BuildContext context) {
|
||||
_currentSplash?.confirm();
|
||||
_currentSplash = null;
|
||||
updateHighlight(false);
|
||||
if (widget.onTap != null)
|
||||
if (widget.onTap != null) {
|
||||
if (widget.enableFeedback)
|
||||
Feedback.forTap(context);
|
||||
widget.onTap();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
@ -309,11 +325,14 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
|
||||
widget.onDoubleTap();
|
||||
}
|
||||
|
||||
void _handleLongPress() {
|
||||
void _handleLongPress(BuildContext context) {
|
||||
_currentSplash?.confirm();
|
||||
_currentSplash = null;
|
||||
if (widget.onLongPress != null)
|
||||
if (widget.onLongPress != null) {
|
||||
if (widget.enableFeedback)
|
||||
Feedback.forLongPress(context);
|
||||
widget.onLongPress();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -340,10 +359,10 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
|
||||
final bool enabled = widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null;
|
||||
return new GestureDetector(
|
||||
onTapDown: enabled ? _handleTapDown : null,
|
||||
onTap: enabled ? _handleTap : null,
|
||||
onTap: enabled ? () => _handleTap(context) : null,
|
||||
onTapCancel: enabled ? _handleTapCancel : null,
|
||||
onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
|
||||
onLongPress: widget.onLongPress != null ? _handleLongPress : null,
|
||||
onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: widget.child
|
||||
);
|
||||
@ -392,6 +411,7 @@ class InkWell extends InkResponse {
|
||||
Color highlightColor,
|
||||
Color splashColor,
|
||||
BorderRadius borderRadius,
|
||||
bool enableFeedback: true,
|
||||
}) : super(
|
||||
key: key,
|
||||
child: child,
|
||||
@ -404,5 +424,6 @@ class InkWell extends InkResponse {
|
||||
highlightColor: highlightColor,
|
||||
splashColor: splashColor,
|
||||
borderRadius: borderRadius,
|
||||
enableFeedback: enableFeedback,
|
||||
);
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'feedback.dart';
|
||||
import 'input_decorator.dart';
|
||||
import 'text_selection.dart';
|
||||
import 'theme.dart';
|
||||
@ -220,6 +221,11 @@ class _TextFieldState extends State<TextField> {
|
||||
_editableTextKey.currentState?.requestKeyboard();
|
||||
}
|
||||
|
||||
void _onSelectionChanged(BuildContext context, bool longPress) {
|
||||
if (longPress)
|
||||
Feedback.forLongPress(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData themeData = Theme.of(context);
|
||||
@ -243,6 +249,7 @@ class _TextFieldState extends State<TextField> {
|
||||
selectionControls: materialTextSelectionControls,
|
||||
onChanged: widget.onChanged,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
onSelectionChanged: (TextSelection _, bool longPress) => _onSelectionChanged(context, longPress),
|
||||
inputFormatters: widget.inputFormatters,
|
||||
),
|
||||
);
|
||||
|
@ -13,6 +13,7 @@ import 'button.dart';
|
||||
import 'button_bar.dart';
|
||||
import 'colors.dart';
|
||||
import 'dialog.dart';
|
||||
import 'feedback.dart';
|
||||
import 'flat_button.dart';
|
||||
import 'theme.dart';
|
||||
import 'typography.dart';
|
||||
@ -289,7 +290,7 @@ class _TimePickerHeader extends StatelessWidget {
|
||||
);
|
||||
|
||||
final Widget dayPeriodPicker = new GestureDetector(
|
||||
onTap: _handleChangeDayPeriod,
|
||||
onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: new Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -302,12 +303,12 @@ class _TimePickerHeader extends StatelessWidget {
|
||||
);
|
||||
|
||||
final Widget hour = new GestureDetector(
|
||||
onTap: () => _handleChangeMode(_TimePickerMode.hour),
|
||||
onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.hour), context),
|
||||
child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle),
|
||||
);
|
||||
|
||||
final Widget minute = new GestureDetector(
|
||||
onTap: () => _handleChangeMode(_TimePickerMode.minute),
|
||||
onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.minute), context),
|
||||
child: new Text(selectedTime.minuteLabel, style: minuteStyle),
|
||||
);
|
||||
|
||||
|
@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'feedback.dart';
|
||||
import 'theme.dart';
|
||||
import 'theme_data.dart';
|
||||
|
||||
@ -110,12 +111,15 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
||||
_removeEntry();
|
||||
}
|
||||
|
||||
void ensureTooltipVisible() {
|
||||
/// Shows the tooltip if it is not already visible.
|
||||
///
|
||||
/// Returns `false` when the tooltip was already visible.
|
||||
bool ensureTooltipVisible() {
|
||||
if (_entry != null) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_controller.forward();
|
||||
return; // Already visible.
|
||||
return false; // Already visible.
|
||||
}
|
||||
final RenderBox box = context.findRenderObject();
|
||||
final Offset target = box.localToGlobal(box.size.center(Offset.zero));
|
||||
@ -138,6 +142,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
||||
Overlay.of(context, debugRequiredFor: widget).insert(_entry);
|
||||
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
|
||||
_controller.forward();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _removeEntry() {
|
||||
@ -177,7 +182,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
||||
assert(Overlay.of(context, debugRequiredFor: widget) != null);
|
||||
return new GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: ensureTooltipVisible,
|
||||
onLongPress: () {
|
||||
final bool tooltipCreated = ensureTooltipVisible();
|
||||
if (tooltipCreated)
|
||||
Feedback.forLongPress(context);
|
||||
},
|
||||
excludeFromSemantics: true,
|
||||
child: new Semantics(
|
||||
label: widget.message,
|
||||
|
@ -21,6 +21,10 @@ import 'text_selection.dart';
|
||||
|
||||
export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType;
|
||||
|
||||
/// Signature for the callback that reports when the user changes the selection
|
||||
/// (including the cursor location).
|
||||
typedef void SelectionChangedCallback(TextSelection selection, bool longPress);
|
||||
|
||||
const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500);
|
||||
|
||||
/// A controller for an editable text field.
|
||||
@ -150,6 +154,7 @@ class EditableText extends StatefulWidget {
|
||||
this.keyboardType,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onSelectionChanged,
|
||||
List<TextInputFormatter> inputFormatters,
|
||||
}) : assert(controller != null),
|
||||
assert(focusNode != null),
|
||||
@ -226,6 +231,10 @@ class EditableText extends StatefulWidget {
|
||||
/// Called when the user indicates that they are done editing the text in the field.
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
/// Called when the user changes the selection of text (including the cursor
|
||||
/// location).
|
||||
final SelectionChangedCallback onSelectionChanged;
|
||||
|
||||
/// Optional input validation and formatting overrides. Formatters are run
|
||||
/// in the provided order when the text input changes.
|
||||
final List<TextInputFormatter> inputFormatters;
|
||||
@ -447,6 +456,8 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
|
||||
_selectionOverlay.showHandles();
|
||||
if (longPress)
|
||||
_selectionOverlay.showToolbar();
|
||||
if (widget.onSelectionChanged != null)
|
||||
widget.onSelectionChanged(selection, longPress);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,8 +5,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Chip control test', (WidgetTester tester) async {
|
||||
final FeedbackTester feedback = new FeedbackTester();
|
||||
bool didDeleteChip = false;
|
||||
await tester.pumpWidget(
|
||||
new MaterialApp(
|
||||
@ -26,8 +29,15 @@ void main() {
|
||||
)
|
||||
);
|
||||
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
|
||||
expect(didDeleteChip, isFalse);
|
||||
await tester.tap(find.byType(Tooltip));
|
||||
expect(didDeleteChip, isTrue);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 1);
|
||||
|
||||
feedback.dispose();
|
||||
});
|
||||
}
|
||||
|
@ -3,10 +3,11 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
void main() {
|
||||
DateTime firstDate;
|
||||
DateTime lastDate;
|
||||
@ -273,34 +274,31 @@ void main() {
|
||||
|
||||
group('haptic feedback', () {
|
||||
const Duration kHapticFeedbackInterval = const Duration(milliseconds: 10);
|
||||
int hapticFeedbackCount;
|
||||
|
||||
setUpAll(() {
|
||||
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async {
|
||||
if (methodCall.method == "HapticFeedback.vibrate")
|
||||
hapticFeedbackCount++;
|
||||
});
|
||||
});
|
||||
FeedbackTester feedback;
|
||||
|
||||
setUp(() {
|
||||
hapticFeedbackCount = 0;
|
||||
feedback = new FeedbackTester();
|
||||
initialDate = new DateTime(2017, DateTime.JANUARY, 16);
|
||||
firstDate = new DateTime(2017, DateTime.JANUARY, 10);
|
||||
lastDate = new DateTime(2018, DateTime.JANUARY, 20);
|
||||
selectableDayPredicate = (DateTime date) => date.day.isEven;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
feedback?.dispose();
|
||||
});
|
||||
|
||||
testWidgets('tap-select date vibrates', (WidgetTester tester) async {
|
||||
await preparePicker(tester, (Future<DateTime> date) async {
|
||||
await tester.tap(find.text('10'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 1);
|
||||
expect(feedback.hapticCount, 1);
|
||||
await tester.tap(find.text('12'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 2);
|
||||
expect(feedback.hapticCount, 2);
|
||||
await tester.tap(find.text('14'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 3);
|
||||
expect(feedback.hapticCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
@ -308,13 +306,13 @@ void main() {
|
||||
await preparePicker(tester, (Future<DateTime> date) async {
|
||||
await tester.tap(find.text('11'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 0);
|
||||
expect(feedback.hapticCount, 0);
|
||||
await tester.tap(find.text('13'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 0);
|
||||
expect(feedback.hapticCount, 0);
|
||||
await tester.tap(find.text('15'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 0);
|
||||
expect(feedback.hapticCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -322,10 +320,10 @@ void main() {
|
||||
await preparePicker(tester, (Future<DateTime> date) async {
|
||||
await tester.tap(find.text('2017'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 1);
|
||||
expect(feedback.hapticCount, 1);
|
||||
await tester.tap(find.text('2018'));
|
||||
await tester.pump(kHapticFeedbackInterval);
|
||||
expect(hapticFeedbackCount, 2);
|
||||
expect(feedback.hapticCount, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
162
packages/flutter/test/material/feedback_test.dart
Normal file
162
packages/flutter/test/material/feedback_test.dart
Normal file
@ -0,0 +1,162 @@
|
||||
// Copyright 2017 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
void main () {
|
||||
const Duration kWaitDuration = const Duration(seconds: 1);
|
||||
|
||||
FeedbackTester feedback;
|
||||
|
||||
setUp(() {
|
||||
feedback = new FeedbackTester();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
feedback?.dispose();
|
||||
});
|
||||
|
||||
group('Feedback on Android', () {
|
||||
|
||||
testWidgets('forTap', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new TestWidget(
|
||||
tapHandler: (BuildContext context) {
|
||||
return () => Feedback.forTap(context);
|
||||
},
|
||||
));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
|
||||
await tester.tap(find.text('X'));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('forTap Wrapper', (WidgetTester tester) async {
|
||||
int callbackCount = 0;
|
||||
final VoidCallback callback = () {
|
||||
callbackCount++;
|
||||
};
|
||||
|
||||
await tester.pumpWidget(new TestWidget(
|
||||
tapHandler: (BuildContext context) {
|
||||
return Feedback.wrapForTap(callback, context);
|
||||
},
|
||||
));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(callbackCount, 0);
|
||||
|
||||
await tester.tap(find.text('X'));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 1);
|
||||
expect(callbackCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('forLongPress', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new TestWidget(
|
||||
longPressHandler: (BuildContext context) {
|
||||
return () => Feedback.forLongPress(context);
|
||||
},
|
||||
));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
|
||||
await tester.longPress(find.text('X'));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 1);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
});
|
||||
|
||||
testWidgets('forLongPress Wrapper', (WidgetTester tester) async {
|
||||
int callbackCount = 0;
|
||||
final VoidCallback callback = () {
|
||||
callbackCount++;
|
||||
};
|
||||
|
||||
await tester.pumpWidget(new TestWidget(
|
||||
longPressHandler: (BuildContext context) {
|
||||
return Feedback.wrapForLongPress(callback, context);
|
||||
},
|
||||
));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(callbackCount, 0);
|
||||
|
||||
await tester.longPress(find.text('X'));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 1);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(callbackCount, 1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
group('Feedback on iOS', () {
|
||||
testWidgets('forTap', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new Theme(
|
||||
data: new ThemeData(platform: TargetPlatform.iOS),
|
||||
child: new TestWidget(
|
||||
tapHandler: (BuildContext context) {
|
||||
return () => Feedback.forTap(context);
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.text('X'));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
});
|
||||
|
||||
testWidgets('forLongPress', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new Theme(
|
||||
data: new ThemeData(platform: TargetPlatform.iOS),
|
||||
child: new TestWidget(
|
||||
longPressHandler: (BuildContext context) {
|
||||
return () => Feedback.forLongPress(context);
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
await tester.longPress(find.text('X'));
|
||||
await tester.pumpAndSettle(kWaitDuration);
|
||||
expect(feedback.hapticCount, 0);
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class TestWidget extends StatelessWidget {
|
||||
|
||||
TestWidget({
|
||||
this.tapHandler: nullHandler,
|
||||
this.longPressHandler: nullHandler,
|
||||
});
|
||||
|
||||
final HandlerCreator tapHandler;
|
||||
final HandlerCreator longPressHandler;
|
||||
|
||||
static VoidCallback nullHandler(BuildContext context) => null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return new GestureDetector(
|
||||
onTap: tapHandler(context),
|
||||
onLongPress: longPressHandler(context),
|
||||
child: const Text('X'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef VoidCallback HandlerCreator(BuildContext context);
|
34
packages/flutter/test/material/feedback_tester.dart
Normal file
34
packages/flutter/test/material/feedback_tester.dart
Normal file
@ -0,0 +1,34 @@
|
||||
// Copyright 2017 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Tracks how often feedback has been requested since its instantiation.
|
||||
///
|
||||
/// It replaces the MockMethodCallHandler of [SystemChannels.platform] and
|
||||
/// cannot be used in combination with other classes that do the same.
|
||||
class FeedbackTester {
|
||||
FeedbackTester() {
|
||||
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) {
|
||||
if (methodCall.method == "HapticFeedback.vibrate")
|
||||
_hapticCount++;
|
||||
if (methodCall.method == "SystemSound.play" &&
|
||||
methodCall.arguments == SystemSoundType.click.toString())
|
||||
_clickSoundCount++;
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of times haptic feedback was requested (vibration).
|
||||
int get hapticCount => _hapticCount;
|
||||
int _hapticCount = 0;
|
||||
|
||||
/// Number of times the click sound was requested to play.
|
||||
int get clickSoundCount => _clickSoundCount;
|
||||
int _clickSoundCount = 0;
|
||||
|
||||
/// Stops tracking.
|
||||
void dispose() {
|
||||
SystemChannels.platform.setMockMethodCallHandler(null);
|
||||
}
|
||||
}
|
@ -5,6 +5,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('InkWell gestures control test', (WidgetTester tester) async {
|
||||
final List<String> log = <String>[];
|
||||
@ -44,4 +46,74 @@ void main() {
|
||||
|
||||
expect(log, equals(<String>['long-press']));
|
||||
});
|
||||
|
||||
testWidgets('long-press and tap on disabled should not throw', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const Material(
|
||||
child: const Center(
|
||||
child: const InkWell(),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.longPress(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
});
|
||||
|
||||
group('feedback', () {
|
||||
FeedbackTester feedback;
|
||||
|
||||
setUp(() {
|
||||
feedback = new FeedbackTester();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
feedback?.dispose();
|
||||
});
|
||||
|
||||
testWidgets('enabled (default)', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new Material(
|
||||
child: new Center(
|
||||
child: new InkWell(
|
||||
onTap: () {},
|
||||
onLongPress: () {},
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 1);
|
||||
expect(feedback.hapticCount, 0);
|
||||
|
||||
await tester.tap(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 2);
|
||||
expect(feedback.hapticCount, 0);
|
||||
|
||||
await tester.longPress(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 2);
|
||||
expect(feedback.hapticCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('disabled', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(new Material(
|
||||
child: new Center(
|
||||
child: new InkWell(
|
||||
onTap: () {},
|
||||
onLongPress: () {},
|
||||
enableFeedback: false,
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(feedback.hapticCount, 0);
|
||||
|
||||
await tester.longPress(find.byType(InkWell), pointer: 1);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(feedback.hapticCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
class MockClipboard {
|
||||
Object _clipboardData = <String, dynamic>{
|
||||
'text': null,
|
||||
@ -1504,6 +1506,38 @@ void main() {
|
||||
}
|
||||
);
|
||||
|
||||
testWidgets('haptic feedback', (WidgetTester tester) async {
|
||||
final FeedbackTester feedback = new FeedbackTester();
|
||||
final TextEditingController controller = new TextEditingController();
|
||||
|
||||
Widget builder() {
|
||||
return overlay(new Center(
|
||||
child: new Material(
|
||||
child: new Container(
|
||||
width: 100.0,
|
||||
child: new TextField(
|
||||
controller: controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
await tester.pumpWidget(builder());
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(feedback.hapticCount, 0);
|
||||
|
||||
await tester.longPress(find.byType(TextField));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
expect(feedback.clickSoundCount, 0);
|
||||
expect(feedback.hapticCount, 1);
|
||||
|
||||
feedback.dispose();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Text field drops selection when losing focus',
|
||||
(WidgetTester tester) async {
|
||||
|
@ -3,9 +3,10 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
class _TimePickerLauncher extends StatelessWidget {
|
||||
const _TimePickerLauncher({ Key key, this.onChanged }) : super(key: key);
|
||||
|
||||
@ -115,24 +116,21 @@ void main() {
|
||||
group('haptic feedback', () {
|
||||
const Duration kFastFeedbackInterval = const Duration(milliseconds: 10);
|
||||
const Duration kSlowFeedbackInterval = const Duration(milliseconds: 200);
|
||||
int hapticFeedbackCount;
|
||||
|
||||
setUpAll(() {
|
||||
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) {
|
||||
if (methodCall.method == "HapticFeedback.vibrate")
|
||||
hapticFeedbackCount++;
|
||||
});
|
||||
});
|
||||
FeedbackTester feedback;
|
||||
|
||||
setUp(() {
|
||||
hapticFeedbackCount = 0;
|
||||
feedback = new FeedbackTester();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
feedback?.dispose();
|
||||
});
|
||||
|
||||
testWidgets('tap-select vibrates once', (WidgetTester tester) async {
|
||||
final Offset center = await startPicker(tester, (TimeOfDay time) { });
|
||||
await tester.tapAt(new Offset(center.dx, center.dy - 50.0));
|
||||
await finishPicker(tester);
|
||||
expect(hapticFeedbackCount, 1);
|
||||
expect(feedback.hapticCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async {
|
||||
@ -141,7 +139,7 @@ void main() {
|
||||
await tester.pump(kFastFeedbackInterval);
|
||||
await tester.tapAt(new Offset(center.dx, center.dy + 50.0));
|
||||
await finishPicker(tester);
|
||||
expect(hapticFeedbackCount, 1);
|
||||
expect(feedback.hapticCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async {
|
||||
@ -152,7 +150,7 @@ void main() {
|
||||
await tester.pump(kSlowFeedbackInterval);
|
||||
await tester.tapAt(new Offset(center.dx, center.dy - 50.0));
|
||||
await finishPicker(tester);
|
||||
expect(hapticFeedbackCount, 3);
|
||||
expect(feedback.hapticCount, 3);
|
||||
});
|
||||
|
||||
testWidgets('drag-select vibrates once', (WidgetTester tester) async {
|
||||
@ -164,7 +162,7 @@ void main() {
|
||||
await gesture.moveBy(hour0 - hour3);
|
||||
await gesture.up();
|
||||
await finishPicker(tester);
|
||||
expect(hapticFeedbackCount, 1);
|
||||
expect(feedback.hapticCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('quick drag-select vibrates once', (WidgetTester tester) async {
|
||||
@ -180,7 +178,7 @@ void main() {
|
||||
await gesture.moveBy(hour0 - hour3);
|
||||
await gesture.up();
|
||||
await finishPicker(tester);
|
||||
expect(hapticFeedbackCount, 1);
|
||||
expect(feedback.hapticCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('slow drag-select vibrates once', (WidgetTester tester) async {
|
||||
@ -196,7 +194,7 @@ void main() {
|
||||
await gesture.moveBy(hour0 - hour3);
|
||||
await gesture.up();
|
||||
await finishPicker(tester);
|
||||
expect(hapticFeedbackCount, 3);
|
||||
expect(feedback.hapticCount, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../widgets/semantics_tester.dart';
|
||||
import 'feedback_tester.dart';
|
||||
|
||||
// This file uses "as dynamic" in a few places to defeat the static
|
||||
// analysis. In general you want to avoid using this style in your
|
||||
@ -501,4 +502,27 @@ void main() {
|
||||
expect(find.text(tooltipText), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Haptic feedback', (WidgetTester tester) async {
|
||||
final FeedbackTester feedback = new FeedbackTester();
|
||||
await tester.pumpWidget(new MaterialApp(
|
||||
home: new Center(
|
||||
child: new Tooltip(
|
||||
message: 'Foo',
|
||||
child: new Container(
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
color: Colors.green[500],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await tester.longPress(find.byType(Tooltip));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
expect(feedback.hapticCount, 1);
|
||||
|
||||
feedback.dispose();
|
||||
});
|
||||
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user