Commit fe40eed3 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

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
parent 9adb4a78
...@@ -42,6 +42,7 @@ export 'src/material/dropdown.dart'; ...@@ -42,6 +42,7 @@ export 'src/material/dropdown.dart';
export 'src/material/expand_icon.dart'; export 'src/material/expand_icon.dart';
export 'src/material/expansion_panel.dart'; export 'src/material/expansion_panel.dart';
export 'src/material/expansion_tile.dart'; export 'src/material/expansion_tile.dart';
export 'src/material/feedback.dart';
export 'src/material/flat_button.dart'; export 'src/material/flat_button.dart';
export 'src/material/flexible_space_bar.dart'; export 'src/material/flexible_space_bar.dart';
export 'src/material/floating_action_button.dart'; export 'src/material/floating_action_button.dart';
......
...@@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; ...@@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart';
import 'colors.dart'; import 'colors.dart';
import 'debug.dart'; import 'debug.dart';
import 'feedback.dart';
import 'icons.dart'; import 'icons.dart';
import 'tooltip.dart'; import 'tooltip.dart';
...@@ -102,7 +103,7 @@ class Chip extends StatelessWidget { ...@@ -102,7 +103,7 @@ class Chip extends StatelessWidget {
if (deletable) { if (deletable) {
rightPadding = 0.0; rightPadding = 0.0;
children.add(new GestureDetector( children.add(new GestureDetector(
onTap: onDeleted, onTap: Feedback.wrapForTap(onDeleted, context),
child: new Tooltip( child: new Tooltip(
message: 'Delete "$label"', message: 'Delete "$label"',
child: new Container( child: new Container(
......
...@@ -17,6 +17,7 @@ import 'button_bar.dart'; ...@@ -17,6 +17,7 @@ import 'button_bar.dart';
import 'colors.dart'; import 'colors.dart';
import 'debug.dart'; import 'debug.dart';
import 'dialog.dart'; import 'dialog.dart';
import 'feedback.dart';
import 'flat_button.dart'; import 'flat_button.dart';
import 'icon_button.dart'; import 'icon_button.dart';
import 'icons.dart'; import 'icons.dart';
...@@ -120,11 +121,11 @@ class _DatePickerHeader extends StatelessWidget { ...@@ -120,11 +121,11 @@ class _DatePickerHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
new GestureDetector( new GestureDetector(
onTap: () => _handleChangeMode(_DatePickerMode.year), onTap: Feedback.wrapForTap(() => _handleChangeMode(_DatePickerMode.year), context),
child: new Text(new DateFormat('yyyy').format(selectedDate), style: yearStyle), child: new Text(new DateFormat('yyyy').format(selectedDate), style: yearStyle),
), ),
new GestureDetector( 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), child: new Text(new DateFormat('E, MMM\u00a0d').format(selectedDate), style: dayStyle),
), ),
], ],
......
// 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;
}
\ No newline at end of file
...@@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; ...@@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'debug.dart'; import 'debug.dart';
import 'feedback.dart';
import 'ink_highlight.dart'; import 'ink_highlight.dart';
import 'ink_splash.dart'; import 'ink_splash.dart';
import 'material.dart'; import 'material.dart';
...@@ -70,6 +71,7 @@ import 'theme.dart'; ...@@ -70,6 +71,7 @@ import 'theme.dart';
/// ```dart /// ```dart
/// assert(debugCheckHasMaterial(context)); /// assert(debugCheckHasMaterial(context));
/// ``` /// ```
/// The parameter [enableFeedback] must not be `null`.
/// ///
/// See also: /// See also:
/// ///
...@@ -93,7 +95,8 @@ class InkResponse extends StatefulWidget { ...@@ -93,7 +95,8 @@ class InkResponse extends StatefulWidget {
this.borderRadius: BorderRadius.zero, this.borderRadius: BorderRadius.zero,
this.highlightColor, this.highlightColor,
this.splashColor, this.splashColor,
}) : super(key: key); this.enableFeedback: true,
}) : assert(enableFeedback != null), super(key: key);
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
final Widget child; final Widget child;
...@@ -179,6 +182,16 @@ class InkResponse extends StatefulWidget { ...@@ -179,6 +182,16 @@ class InkResponse extends StatefulWidget {
/// * [highlightColor], the color of the highlight. /// * [highlightColor], the color of the highlight.
final Color splashColor; 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 rectangle to use for the highlight effect and for clipping
/// the splash effects if [containedInkWell] is true. /// the splash effects if [containedInkWell] is true.
/// ///
...@@ -288,12 +301,15 @@ class _InkResponseState<T extends InkResponse> extends State<T> { ...@@ -288,12 +301,15 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
updateHighlight(true); updateHighlight(true);
} }
void _handleTap() { void _handleTap(BuildContext context) {
_currentSplash?.confirm(); _currentSplash?.confirm();
_currentSplash = null; _currentSplash = null;
updateHighlight(false); updateHighlight(false);
if (widget.onTap != null) if (widget.onTap != null) {
if (widget.enableFeedback)
Feedback.forTap(context);
widget.onTap(); widget.onTap();
}
} }
void _handleTapCancel() { void _handleTapCancel() {
...@@ -309,11 +325,14 @@ class _InkResponseState<T extends InkResponse> extends State<T> { ...@@ -309,11 +325,14 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
widget.onDoubleTap(); widget.onDoubleTap();
} }
void _handleLongPress() { void _handleLongPress(BuildContext context) {
_currentSplash?.confirm(); _currentSplash?.confirm();
_currentSplash = null; _currentSplash = null;
if (widget.onLongPress != null) if (widget.onLongPress != null) {
if (widget.enableFeedback)
Feedback.forLongPress(context);
widget.onLongPress(); widget.onLongPress();
}
} }
@override @override
...@@ -340,10 +359,10 @@ class _InkResponseState<T extends InkResponse> extends State<T> { ...@@ -340,10 +359,10 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
final bool enabled = widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null; final bool enabled = widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null;
return new GestureDetector( return new GestureDetector(
onTapDown: enabled ? _handleTapDown : null, onTapDown: enabled ? _handleTapDown : null,
onTap: enabled ? _handleTap : null, onTap: enabled ? () => _handleTap(context) : null,
onTapCancel: enabled ? _handleTapCancel : null, onTapCancel: enabled ? _handleTapCancel : null,
onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null, onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
onLongPress: widget.onLongPress != null ? _handleLongPress : null, onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: widget.child child: widget.child
); );
...@@ -392,6 +411,7 @@ class InkWell extends InkResponse { ...@@ -392,6 +411,7 @@ class InkWell extends InkResponse {
Color highlightColor, Color highlightColor,
Color splashColor, Color splashColor,
BorderRadius borderRadius, BorderRadius borderRadius,
bool enableFeedback: true,
}) : super( }) : super(
key: key, key: key,
child: child, child: child,
...@@ -404,5 +424,6 @@ class InkWell extends InkResponse { ...@@ -404,5 +424,6 @@ class InkWell extends InkResponse {
highlightColor: highlightColor, highlightColor: highlightColor,
splashColor: splashColor, splashColor: splashColor,
borderRadius: borderRadius, borderRadius: borderRadius,
enableFeedback: enableFeedback,
); );
} }
...@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; ...@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'feedback.dart';
import 'input_decorator.dart'; import 'input_decorator.dart';
import 'text_selection.dart'; import 'text_selection.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -220,6 +221,11 @@ class _TextFieldState extends State<TextField> { ...@@ -220,6 +221,11 @@ class _TextFieldState extends State<TextField> {
_editableTextKey.currentState?.requestKeyboard(); _editableTextKey.currentState?.requestKeyboard();
} }
void _onSelectionChanged(BuildContext context, bool longPress) {
if (longPress)
Feedback.forLongPress(context);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context); final ThemeData themeData = Theme.of(context);
...@@ -243,6 +249,7 @@ class _TextFieldState extends State<TextField> { ...@@ -243,6 +249,7 @@ class _TextFieldState extends State<TextField> {
selectionControls: materialTextSelectionControls, selectionControls: materialTextSelectionControls,
onChanged: widget.onChanged, onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
onSelectionChanged: (TextSelection _, bool longPress) => _onSelectionChanged(context, longPress),
inputFormatters: widget.inputFormatters, inputFormatters: widget.inputFormatters,
), ),
); );
......
...@@ -13,6 +13,7 @@ import 'button.dart'; ...@@ -13,6 +13,7 @@ import 'button.dart';
import 'button_bar.dart'; import 'button_bar.dart';
import 'colors.dart'; import 'colors.dart';
import 'dialog.dart'; import 'dialog.dart';
import 'feedback.dart';
import 'flat_button.dart'; import 'flat_button.dart';
import 'theme.dart'; import 'theme.dart';
import 'typography.dart'; import 'typography.dart';
...@@ -289,7 +290,7 @@ class _TimePickerHeader extends StatelessWidget { ...@@ -289,7 +290,7 @@ class _TimePickerHeader extends StatelessWidget {
); );
final Widget dayPeriodPicker = new GestureDetector( final Widget dayPeriodPicker = new GestureDetector(
onTap: _handleChangeDayPeriod, onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: new Column( child: new Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
...@@ -302,12 +303,12 @@ class _TimePickerHeader extends StatelessWidget { ...@@ -302,12 +303,12 @@ class _TimePickerHeader extends StatelessWidget {
); );
final Widget hour = new GestureDetector( final Widget hour = new GestureDetector(
onTap: () => _handleChangeMode(_TimePickerMode.hour), onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.hour), context),
child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle), child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle),
); );
final Widget minute = new GestureDetector( final Widget minute = new GestureDetector(
onTap: () => _handleChangeMode(_TimePickerMode.minute), onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.minute), context),
child: new Text(selectedTime.minuteLabel, style: minuteStyle), child: new Text(selectedTime.minuteLabel, style: minuteStyle),
); );
......
...@@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; ...@@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'feedback.dart';
import 'theme.dart'; import 'theme.dart';
import 'theme_data.dart'; import 'theme_data.dart';
...@@ -110,12 +111,15 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -110,12 +111,15 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_removeEntry(); _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) { if (_entry != null) {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
_controller.forward(); _controller.forward();
return; // Already visible. return false; // Already visible.
} }
final RenderBox box = context.findRenderObject(); final RenderBox box = context.findRenderObject();
final Offset target = box.localToGlobal(box.size.center(Offset.zero)); final Offset target = box.localToGlobal(box.size.center(Offset.zero));
...@@ -138,6 +142,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -138,6 +142,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
Overlay.of(context, debugRequiredFor: widget).insert(_entry); Overlay.of(context, debugRequiredFor: widget).insert(_entry);
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent); GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
_controller.forward(); _controller.forward();
return true;
} }
void _removeEntry() { void _removeEntry() {
...@@ -177,7 +182,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -177,7 +182,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
assert(Overlay.of(context, debugRequiredFor: widget) != null); assert(Overlay.of(context, debugRequiredFor: widget) != null);
return new GestureDetector( return new GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onLongPress: ensureTooltipVisible, onLongPress: () {
final bool tooltipCreated = ensureTooltipVisible();
if (tooltipCreated)
Feedback.forLongPress(context);
},
excludeFromSemantics: true, excludeFromSemantics: true,
child: new Semantics( child: new Semantics(
label: widget.message, label: widget.message,
......
...@@ -21,6 +21,10 @@ import 'text_selection.dart'; ...@@ -21,6 +21,10 @@ import 'text_selection.dart';
export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType; 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); const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500);
/// A controller for an editable text field. /// A controller for an editable text field.
...@@ -150,6 +154,7 @@ class EditableText extends StatefulWidget { ...@@ -150,6 +154,7 @@ class EditableText extends StatefulWidget {
this.keyboardType, this.keyboardType,
this.onChanged, this.onChanged,
this.onSubmitted, this.onSubmitted,
this.onSelectionChanged,
List<TextInputFormatter> inputFormatters, List<TextInputFormatter> inputFormatters,
}) : assert(controller != null), }) : assert(controller != null),
assert(focusNode != null), assert(focusNode != null),
...@@ -226,6 +231,10 @@ class EditableText extends StatefulWidget { ...@@ -226,6 +231,10 @@ class EditableText extends StatefulWidget {
/// Called when the user indicates that they are done editing the text in the field. /// Called when the user indicates that they are done editing the text in the field.
final ValueChanged<String> onSubmitted; 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 /// Optional input validation and formatting overrides. Formatters are run
/// in the provided order when the text input changes. /// in the provided order when the text input changes.
final List<TextInputFormatter> inputFormatters; final List<TextInputFormatter> inputFormatters;
...@@ -447,6 +456,8 @@ class EditableTextState extends State<EditableText> implements TextInputClient { ...@@ -447,6 +456,8 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
_selectionOverlay.showHandles(); _selectionOverlay.showHandles();
if (longPress) if (longPress)
_selectionOverlay.showToolbar(); _selectionOverlay.showToolbar();
if (widget.onSelectionChanged != null)
widget.onSelectionChanged(selection, longPress);
} }
} }
......
...@@ -5,8 +5,11 @@ ...@@ -5,8 +5,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'feedback_tester.dart';
void main() { void main() {
testWidgets('Chip control test', (WidgetTester tester) async { testWidgets('Chip control test', (WidgetTester tester) async {
final FeedbackTester feedback = new FeedbackTester();
bool didDeleteChip = false; bool didDeleteChip = false;
await tester.pumpWidget( await tester.pumpWidget(
new MaterialApp( new MaterialApp(
...@@ -26,8 +29,15 @@ void main() { ...@@ -26,8 +29,15 @@ void main() {
) )
); );
expect(feedback.clickSoundCount, 0);
expect(didDeleteChip, isFalse); expect(didDeleteChip, isFalse);
await tester.tap(find.byType(Tooltip)); await tester.tap(find.byType(Tooltip));
expect(didDeleteChip, isTrue); expect(didDeleteChip, isTrue);
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(feedback.clickSoundCount, 1);
feedback.dispose();
}); });
} }
...@@ -3,10 +3,11 @@ ...@@ -3,10 +3,11 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'feedback_tester.dart';
void main() { void main() {
DateTime firstDate; DateTime firstDate;
DateTime lastDate; DateTime lastDate;
...@@ -273,34 +274,31 @@ void main() { ...@@ -273,34 +274,31 @@ void main() {
group('haptic feedback', () { group('haptic feedback', () {
const Duration kHapticFeedbackInterval = const Duration(milliseconds: 10); const Duration kHapticFeedbackInterval = const Duration(milliseconds: 10);
int hapticFeedbackCount; FeedbackTester feedback;
setUpAll(() {
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async {
if (methodCall.method == "HapticFeedback.vibrate")
hapticFeedbackCount++;
});
});
setUp(() { setUp(() {
hapticFeedbackCount = 0; feedback = new FeedbackTester();
initialDate = new DateTime(2017, DateTime.JANUARY, 16); initialDate = new DateTime(2017, DateTime.JANUARY, 16);
firstDate = new DateTime(2017, DateTime.JANUARY, 10); firstDate = new DateTime(2017, DateTime.JANUARY, 10);
lastDate = new DateTime(2018, DateTime.JANUARY, 20); lastDate = new DateTime(2018, DateTime.JANUARY, 20);
selectableDayPredicate = (DateTime date) => date.day.isEven; selectableDayPredicate = (DateTime date) => date.day.isEven;
}); });
tearDown(() {
feedback?.dispose();
});
testWidgets('tap-select date vibrates', (WidgetTester tester) async { testWidgets('tap-select date vibrates', (WidgetTester tester) async {
await preparePicker(tester, (Future<DateTime> date) async { await preparePicker(tester, (Future<DateTime> date) async {
await tester.tap(find.text('10')); await tester.tap(find.text('10'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 1); expect(feedback.hapticCount, 1);
await tester.tap(find.text('12')); await tester.tap(find.text('12'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 2); expect(feedback.hapticCount, 2);
await tester.tap(find.text('14')); await tester.tap(find.text('14'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 3); expect(feedback.hapticCount, 3);
}); });
}); });
...@@ -308,13 +306,13 @@ void main() { ...@@ -308,13 +306,13 @@ void main() {
await preparePicker(tester, (Future<DateTime> date) async { await preparePicker(tester, (Future<DateTime> date) async {
await tester.tap(find.text('11')); await tester.tap(find.text('11'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 0); expect(feedback.hapticCount, 0);
await tester.tap(find.text('13')); await tester.tap(find.text('13'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 0); expect(feedback.hapticCount, 0);
await tester.tap(find.text('15')); await tester.tap(find.text('15'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 0); expect(feedback.hapticCount, 0);
}); });
}); });
...@@ -322,10 +320,10 @@ void main() { ...@@ -322,10 +320,10 @@ void main() {
await preparePicker(tester, (Future<DateTime> date) async { await preparePicker(tester, (Future<DateTime> date) async {
await tester.tap(find.text('2017')); await tester.tap(find.text('2017'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 1); expect(feedback.hapticCount, 1);
await tester.tap(find.text('2018')); await tester.tap(find.text('2018'));
await tester.pump(kHapticFeedbackInterval); await tester.pump(kHapticFeedbackInterval);
expect(hapticFeedbackCount, 2); expect(feedback.hapticCount, 2);
}); });
}); });
}); });
......
// 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);
// 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);
}
}
\ No newline at end of file
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'feedback_tester.dart';
void main() { void main() {
testWidgets('InkWell gestures control test', (WidgetTester tester) async { testWidgets('InkWell gestures control test', (WidgetTester tester) async {
final List<String> log = <String>[]; final List<String> log = <String>[];
...@@ -44,4 +46,74 @@ void main() { ...@@ -44,4 +46,74 @@ void main() {
expect(log, equals(<String>['long-press'])); 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'; ...@@ -9,6 +9,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'feedback_tester.dart';
class MockClipboard { class MockClipboard {
Object _clipboardData = <String, dynamic>{ Object _clipboardData = <String, dynamic>{
'text': null, 'text': null,
...@@ -1504,6 +1506,38 @@ void main() { ...@@ -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( testWidgets(
'Text field drops selection when losing focus', 'Text field drops selection when losing focus',
(WidgetTester tester) async { (WidgetTester tester) async {
......
...@@ -3,9 +3,10 @@ ...@@ -3,9 +3,10 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'feedback_tester.dart';
class _TimePickerLauncher extends StatelessWidget { class _TimePickerLauncher extends StatelessWidget {
const _TimePickerLauncher({ Key key, this.onChanged }) : super(key: key); const _TimePickerLauncher({ Key key, this.onChanged }) : super(key: key);
...@@ -115,24 +116,21 @@ void main() { ...@@ -115,24 +116,21 @@ void main() {
group('haptic feedback', () { group('haptic feedback', () {
const Duration kFastFeedbackInterval = const Duration(milliseconds: 10); const Duration kFastFeedbackInterval = const Duration(milliseconds: 10);
const Duration kSlowFeedbackInterval = const Duration(milliseconds: 200); const Duration kSlowFeedbackInterval = const Duration(milliseconds: 200);
int hapticFeedbackCount; FeedbackTester feedback;
setUpAll(() { setUp(() {
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) { feedback = new FeedbackTester();
if (methodCall.method == "HapticFeedback.vibrate")
hapticFeedbackCount++;
});
}); });
setUp(() { tearDown(() {
hapticFeedbackCount = 0; feedback?.dispose();
}); });
testWidgets('tap-select vibrates once', (WidgetTester tester) async { testWidgets('tap-select vibrates once', (WidgetTester tester) async {
final Offset center = await startPicker(tester, (TimeOfDay time) { }); final Offset center = await startPicker(tester, (TimeOfDay time) { });
await tester.tapAt(new Offset(center.dx, center.dy - 50.0)); await tester.tapAt(new Offset(center.dx, center.dy - 50.0));
await finishPicker(tester); await finishPicker(tester);
expect(hapticFeedbackCount, 1); expect(feedback.hapticCount, 1);
}); });
testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async { testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async {
...@@ -141,7 +139,7 @@ void main() { ...@@ -141,7 +139,7 @@ void main() {
await tester.pump(kFastFeedbackInterval); await tester.pump(kFastFeedbackInterval);
await tester.tapAt(new Offset(center.dx, center.dy + 50.0)); await tester.tapAt(new Offset(center.dx, center.dy + 50.0));
await finishPicker(tester); await finishPicker(tester);
expect(hapticFeedbackCount, 1); expect(feedback.hapticCount, 1);
}); });
testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async { testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async {
...@@ -152,7 +150,7 @@ void main() { ...@@ -152,7 +150,7 @@ void main() {
await tester.pump(kSlowFeedbackInterval); await tester.pump(kSlowFeedbackInterval);
await tester.tapAt(new Offset(center.dx, center.dy - 50.0)); await tester.tapAt(new Offset(center.dx, center.dy - 50.0));
await finishPicker(tester); await finishPicker(tester);
expect(hapticFeedbackCount, 3); expect(feedback.hapticCount, 3);
}); });
testWidgets('drag-select vibrates once', (WidgetTester tester) async { testWidgets('drag-select vibrates once', (WidgetTester tester) async {
...@@ -164,7 +162,7 @@ void main() { ...@@ -164,7 +162,7 @@ void main() {
await gesture.moveBy(hour0 - hour3); await gesture.moveBy(hour0 - hour3);
await gesture.up(); await gesture.up();
await finishPicker(tester); await finishPicker(tester);
expect(hapticFeedbackCount, 1); expect(feedback.hapticCount, 1);
}); });
testWidgets('quick drag-select vibrates once', (WidgetTester tester) async { testWidgets('quick drag-select vibrates once', (WidgetTester tester) async {
...@@ -180,7 +178,7 @@ void main() { ...@@ -180,7 +178,7 @@ void main() {
await gesture.moveBy(hour0 - hour3); await gesture.moveBy(hour0 - hour3);
await gesture.up(); await gesture.up();
await finishPicker(tester); await finishPicker(tester);
expect(hapticFeedbackCount, 1); expect(feedback.hapticCount, 1);
}); });
testWidgets('slow drag-select vibrates once', (WidgetTester tester) async { testWidgets('slow drag-select vibrates once', (WidgetTester tester) async {
...@@ -196,7 +194,7 @@ void main() { ...@@ -196,7 +194,7 @@ void main() {
await gesture.moveBy(hour0 - hour3); await gesture.moveBy(hour0 - hour3);
await gesture.up(); await gesture.up();
await finishPicker(tester); await finishPicker(tester);
expect(hapticFeedbackCount, 3); expect(feedback.hapticCount, 3);
}); });
}); });
} }
...@@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart'; ...@@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
// This file uses "as dynamic" in a few places to defeat the static // 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 // analysis. In general you want to avoid using this style in your
...@@ -501,4 +502,27 @@ void main() { ...@@ -501,4 +502,27 @@ void main() {
expect(find.text(tooltipText), findsNothing); 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();
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment