Unverified Commit 93de096e authored by Matan Shukry's avatar Matan Shukry Committed by GitHub

Add TooltipTriggerMode and provideTriggerFeedback to allow users to c… (#84434)

parent cc03623c
...@@ -112,6 +112,8 @@ class Tooltip extends StatefulWidget { ...@@ -112,6 +112,8 @@ class Tooltip extends StatefulWidget {
this.waitDuration, this.waitDuration,
this.showDuration, this.showDuration,
this.child, this.child,
this.triggerMode,
this.enableFeedback,
}) : assert(message != null), }) : assert(message != null),
super(key: key); super(key: key);
...@@ -201,6 +203,25 @@ class Tooltip extends StatefulWidget { ...@@ -201,6 +203,25 @@ class Tooltip extends StatefulWidget {
/// pointer exits the widget. /// pointer exits the widget.
final Duration? showDuration; final Duration? showDuration;
/// The [TooltipTriggerMode] that will show the tooltip.
///
/// If this property is null, then [TooltipThemeData.triggerMode] is used.
/// If [TooltipThemeData.triggerMode] is also null, the default mode is
/// [TooltipTriggerMode.longPress].
final TooltipTriggerMode? triggerMode;
/// Whether the tooltip 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.
///
/// When null, the default value is true.
///
/// See also:
///
/// * [Feedback], for providing platform-specific feedback to certain actions.
final bool? enableFeedback;
static final Set<_TooltipState> _openedToolTips = <_TooltipState>{}; static final Set<_TooltipState> _openedToolTips = <_TooltipState>{};
/// Dismiss all of the tooltips that are currently shown on the screen. /// Dismiss all of the tooltips that are currently shown on the screen.
...@@ -234,6 +255,8 @@ class Tooltip extends StatefulWidget { ...@@ -234,6 +255,8 @@ class Tooltip extends StatefulWidget {
properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true, defaultValue: null)); properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null)); properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null)); properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true, defaultValue: null));
} }
} }
...@@ -247,6 +270,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -247,6 +270,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100); static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100);
static const Duration _defaultWaitDuration = Duration.zero; static const Duration _defaultWaitDuration = Duration.zero;
static const bool _defaultExcludeFromSemantics = false; static const bool _defaultExcludeFromSemantics = false;
static const TooltipTriggerMode _defaultTriggerMode = TooltipTriggerMode.longPress;
static const bool _defaultEnableFeedback = true;
late double height; late double height;
late EdgeInsetsGeometry padding; late EdgeInsetsGeometry padding;
...@@ -264,7 +289,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -264,7 +289,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late Duration hoverShowDuration; late Duration hoverShowDuration;
late Duration waitDuration; late Duration waitDuration;
late bool _mouseIsConnected; late bool _mouseIsConnected;
bool _longPressActivated = false; bool _pressActivated = false;
late TooltipTriggerMode triggerMode;
late bool enableFeedback;
@override @override
void initState() { void initState() {
...@@ -346,12 +373,12 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -346,12 +373,12 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_removeEntry(); _removeEntry();
return; return;
} }
if (_longPressActivated) { if (_pressActivated) {
_hideTimer ??= Timer(showDuration, _controller.reverse); _hideTimer ??= Timer(showDuration, _controller.reverse);
} else { } else {
_hideTimer ??= Timer(hoverShowDuration, _controller.reverse); _hideTimer ??= Timer(hoverShowDuration, _controller.reverse);
} }
_longPressActivated = false; _pressActivated = false;
} }
void _showTooltip({ bool immediately = false }) { void _showTooltip({ bool immediately = false }) {
...@@ -463,11 +490,15 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -463,11 +490,15 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
super.dispose(); super.dispose();
} }
void _handleLongPress() { void _handlePress() {
_longPressActivated = true; _pressActivated = true;
final bool tooltipCreated = ensureTooltipVisible(); final bool tooltipCreated = ensureTooltipVisible();
if (tooltipCreated) if (tooltipCreated && enableFeedback) {
if (triggerMode == TooltipTriggerMode.longPress)
Feedback.forLongPress(context); Feedback.forLongPress(context);
else
Feedback.forTap(context);
}
} }
@override @override
...@@ -508,10 +539,14 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -508,10 +539,14 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration; waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration;
showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration; showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration;
hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration; hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration;
triggerMode = widget.triggerMode ?? tooltipTheme.triggerMode ?? _defaultTriggerMode;
enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
Widget result = GestureDetector( Widget result = GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onLongPress: _handleLongPress, onLongPress: (triggerMode == TooltipTriggerMode.longPress) ?
_handlePress : null,
onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null,
excludeFromSemantics: true, excludeFromSemantics: true,
child: Semantics( child: Semantics(
label: excludeFromSemantics ? null : widget.message, label: excludeFromSemantics ? null : widget.message,
......
...@@ -37,6 +37,8 @@ class TooltipThemeData with Diagnosticable { ...@@ -37,6 +37,8 @@ class TooltipThemeData with Diagnosticable {
this.textStyle, this.textStyle,
this.waitDuration, this.waitDuration,
this.showDuration, this.showDuration,
this.triggerMode,
this.enableFeedback,
}); });
/// The height of [Tooltip.child]. /// The height of [Tooltip.child].
...@@ -84,6 +86,22 @@ class TooltipThemeData with Diagnosticable { ...@@ -84,6 +86,22 @@ class TooltipThemeData with Diagnosticable {
/// The length of time that the tooltip will be shown once it has appeared. /// The length of time that the tooltip will be shown once it has appeared.
final Duration? showDuration; final Duration? showDuration;
/// The [TooltipTriggerMode] that will show the tooltip.
final TooltipTriggerMode? triggerMode;
/// Whether the tooltip 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.
///
/// This value is used if [Tooltip.enableFeedback] is null.
/// If this value is null, the default is true.
///
/// See also:
///
/// * [Feedback], for providing platform-specific feedback to certain actions.
final bool? enableFeedback;
/// Creates a copy of this object but with the given fields replaced with the /// Creates a copy of this object but with the given fields replaced with the
/// new values. /// new values.
TooltipThemeData copyWith({ TooltipThemeData copyWith({
...@@ -97,6 +115,8 @@ class TooltipThemeData with Diagnosticable { ...@@ -97,6 +115,8 @@ class TooltipThemeData with Diagnosticable {
TextStyle? textStyle, TextStyle? textStyle,
Duration? waitDuration, Duration? waitDuration,
Duration? showDuration, Duration? showDuration,
TooltipTriggerMode? triggerMode,
bool? enableFeedback,
}) { }) {
return TooltipThemeData( return TooltipThemeData(
height: height ?? this.height, height: height ?? this.height,
...@@ -109,6 +129,8 @@ class TooltipThemeData with Diagnosticable { ...@@ -109,6 +129,8 @@ class TooltipThemeData with Diagnosticable {
textStyle: textStyle ?? this.textStyle, textStyle: textStyle ?? this.textStyle,
waitDuration: waitDuration ?? this.waitDuration, waitDuration: waitDuration ?? this.waitDuration,
showDuration: showDuration ?? this.showDuration, showDuration: showDuration ?? this.showDuration,
triggerMode: triggerMode ?? this.triggerMode,
enableFeedback: enableFeedback ?? this.enableFeedback,
); );
} }
...@@ -146,6 +168,8 @@ class TooltipThemeData with Diagnosticable { ...@@ -146,6 +168,8 @@ class TooltipThemeData with Diagnosticable {
textStyle, textStyle,
waitDuration, waitDuration,
showDuration, showDuration,
triggerMode,
enableFeedback
); );
} }
...@@ -165,7 +189,9 @@ class TooltipThemeData with Diagnosticable { ...@@ -165,7 +189,9 @@ class TooltipThemeData with Diagnosticable {
&& other.decoration == decoration && other.decoration == decoration
&& other.textStyle == textStyle && other.textStyle == textStyle
&& other.waitDuration == waitDuration && other.waitDuration == waitDuration
&& other.showDuration == showDuration; && other.showDuration == showDuration
&& other.triggerMode == triggerMode
&& other.enableFeedback == enableFeedback;
} }
@override @override
...@@ -181,6 +207,8 @@ class TooltipThemeData with Diagnosticable { ...@@ -181,6 +207,8 @@ class TooltipThemeData with Diagnosticable {
properties.add(DiagnosticsProperty<TextStyle>('textStyle', textStyle, defaultValue: null)); properties.add(DiagnosticsProperty<TextStyle>('textStyle', textStyle, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null)); properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null)); properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true, defaultValue: null));
} }
} }
...@@ -250,3 +278,26 @@ class TooltipTheme extends InheritedTheme { ...@@ -250,3 +278,26 @@ class TooltipTheme extends InheritedTheme {
@override @override
bool updateShouldNotify(TooltipTheme oldWidget) => data != oldWidget.data; bool updateShouldNotify(TooltipTheme oldWidget) => data != oldWidget.data;
} }
/// The method of interaction that will trigger a tooltip.
/// Used in [Tooltip.triggerMode] and [TooltipThemeData.triggerMode].
enum TooltipTriggerMode {
/// Tooltip will only be shown by calling `ensureTooltipVisible`.
manual,
/// Tooltip will be shown after a long press.
///
/// See also:
///
/// * [GestureDetector.onLongPress], the event that is used for trigger.
/// * [Feedback.forLongPress], the feedback method called when feedback is enabled.
longPress,
/// Tooltip will be shown after a single tap.
///
/// See also:
///
/// * [GestureDetector.onTap], the event that is used for trigger.
/// * [Feedback.forTap], the feedback method called when feedback is enabled.
tap,
}
...@@ -1429,6 +1429,8 @@ void main() { ...@@ -1429,6 +1429,8 @@ void main() {
excludeFromSemantics: true, excludeFromSemantics: true,
preferBelow: false, preferBelow: false,
verticalOffset: 50.0, verticalOffset: 50.0,
triggerMode: TooltipTriggerMode.manual,
enableFeedback: true,
).debugFillProperties(builder); ).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties
...@@ -1445,8 +1447,81 @@ void main() { ...@@ -1445,8 +1447,81 @@ void main() {
'semantics: excluded', 'semantics: excluded',
'wait duration: 0:00:01.000000', 'wait duration: 0:00:01.000000',
'show duration: 0:00:02.000000', 'show duration: 0:00:02.000000',
'triggerMode: TooltipTriggerMode.manual',
'enableFeedback: true',
]); ]);
}); });
testWidgets('Tooltip triggers on tap when trigger mode is tap', (WidgetTester tester) async {
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap);
final Finder tooltip = find.byType(Tooltip);
expect(find.text(tooltipText), findsNothing);
await testGestureTap(tester, tooltip);
expect(find.text(tooltipText), findsOneWidget);
});
testWidgets('Tooltip triggers on long press when mode is long press', (WidgetTester tester) async {
await setWidgetForTooltipMode(tester, TooltipTriggerMode.longPress);
final Finder tooltip = find.byType(Tooltip);
expect(find.text(tooltipText), findsNothing);
await testGestureTap(tester, tooltip);
expect(find.text(tooltipText), findsNothing);
await testGestureLongPress(tester, tooltip);
expect(find.text(tooltipText), findsOneWidget);
});
testWidgets('Tooltip does not trigger on tap when trigger mode is longPress', (WidgetTester tester) async {
await setWidgetForTooltipMode(tester, TooltipTriggerMode.longPress);
final Finder tooltip = find.byType(Tooltip);
expect(find.text(tooltipText), findsNothing);
await testGestureTap(tester, tooltip);
expect(find.text(tooltipText), findsNothing);
});
testWidgets('Tooltip does not trigger when trigger mode is manual', (WidgetTester tester) async {
await setWidgetForTooltipMode(tester, TooltipTriggerMode.manual);
final Finder tooltip = find.byType(Tooltip);
expect(find.text(tooltipText), findsNothing);
await testGestureTap(tester, tooltip);
expect(find.text(tooltipText), findsNothing);
await testGestureLongPress(tester, tooltip);
expect(find.text(tooltipText), findsNothing);
});
}
Future<void> setWidgetForTooltipMode(WidgetTester tester, TooltipTriggerMode triggerMode) async {
await tester.pumpWidget(
MaterialApp(
home: Tooltip(
message: tooltipText,
triggerMode: triggerMode,
child: const SizedBox(width: 100.0, height: 100.0),
),
),
);
}
Future<void> testGestureLongPress(WidgetTester tester, Finder tooltip) async {
final TestGesture gestureLongPress = await tester.startGesture(tester.getCenter(tooltip));
await tester.pump();
await tester.pump(kLongPressTimeout);
await gestureLongPress.up();
await tester.pump();
}
Future<void> testGestureTap(WidgetTester tester, Finder tooltip) async {
await tester.tap(tooltip);
await tester.pump(const Duration(milliseconds: 10));
} }
SemanticsNode findDebugSemantics(RenderObject object) { SemanticsNode findDebugSemantics(RenderObject object) {
......
...@@ -48,6 +48,8 @@ void main() { ...@@ -48,6 +48,8 @@ void main() {
expect(theme.textStyle, null); expect(theme.textStyle, null);
expect(theme.waitDuration, null); expect(theme.waitDuration, null);
expect(theme.showDuration, null); expect(theme.showDuration, null);
expect(theme.triggerMode, null);
expect(theme.enableFeedback, null);
}); });
testWidgets('Default TooltipThemeData debugFillProperties', (WidgetTester tester) async { testWidgets('Default TooltipThemeData debugFillProperties', (WidgetTester tester) async {
...@@ -66,6 +68,8 @@ void main() { ...@@ -66,6 +68,8 @@ void main() {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Duration wait = Duration(milliseconds: 100); const Duration wait = Duration(milliseconds: 100);
const Duration show = Duration(milliseconds: 200); const Duration show = Duration(milliseconds: 200);
const TooltipTriggerMode triggerMode = TooltipTriggerMode.longPress;
const bool enableFeedback = true;
const TooltipThemeData( const TooltipThemeData(
height: 15.0, height: 15.0,
padding: EdgeInsets.all(20.0), padding: EdgeInsets.all(20.0),
...@@ -76,6 +80,8 @@ void main() { ...@@ -76,6 +80,8 @@ void main() {
textStyle: TextStyle(decoration: TextDecoration.underline), textStyle: TextStyle(decoration: TextDecoration.underline),
waitDuration: wait, waitDuration: wait,
showDuration: show, showDuration: show,
triggerMode: triggerMode,
enableFeedback: enableFeedback,
).debugFillProperties(builder); ).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties
...@@ -93,6 +99,8 @@ void main() { ...@@ -93,6 +99,8 @@ void main() {
'textStyle: TextStyle(inherit: true, decoration: TextDecoration.underline)', 'textStyle: TextStyle(inherit: true, decoration: TextDecoration.underline)',
'wait duration: ${wait.toString()}', 'wait duration: ${wait.toString()}',
'show duration: ${show.toString()}', 'show duration: ${show.toString()}',
'triggerMode: $triggerMode',
'enableFeedback: true',
]); ]);
}); });
...@@ -985,6 +993,54 @@ void main() { ...@@ -985,6 +993,54 @@ void main() {
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });
testWidgets('Tooltip triggerMode - ThemeData.triggerMode', (WidgetTester tester) async {
const TooltipTriggerMode triggerMode = TooltipTriggerMode.tap;
await tester.pumpWidget(
MaterialApp(
home: Theme(
data: ThemeData(
tooltipTheme: const TooltipThemeData(triggerMode: triggerMode),
),
child: const Center(
child: Tooltip(
message: tooltipText,
child: SizedBox(width: 100.0, height: 100.0),
),
),
),
),
);
final Finder tooltip = find.byType(Tooltip);
final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip));
await gesture.up();
await tester.pump();
expect(find.text(tooltipText), findsOneWidget); // Tooltip should show immediately after tap
});
testWidgets('Tooltip triggerMode - TooltipTheme', (WidgetTester tester) async {
const TooltipTriggerMode triggerMode = TooltipTriggerMode.tap;
await tester.pumpWidget(
const MaterialApp(
home: TooltipTheme(
data: TooltipThemeData(triggerMode: triggerMode),
child: Center(
child: Tooltip(
message: tooltipText,
child: SizedBox(width: 100.0, height: 100.0),
),
),
),
),
);
final Finder tooltip = find.byType(Tooltip);
final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip));
await gesture.up();
await tester.pump();
expect(find.text(tooltipText), findsOneWidget); // Tooltip should show immediately after tap
});
testWidgets('Semantics included by default - ThemeData.tooltipTheme', (WidgetTester tester) async { testWidgets('Semantics included by default - ThemeData.tooltipTheme', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
......
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