Skip to content

Commit 400136b

Browse files
authored
Fix Slider overlay and value indicator interactive behavior on desktop. (#113543)
1 parent 2a59bd5 commit 400136b

2 files changed

Lines changed: 249 additions & 9 deletions

File tree

packages/flutter/lib/src/material/slider.dart

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
609609
final double lerpValue = _lerp(value);
610610
if (lerpValue != widget.value) {
611611
widget.onChanged!(lerpValue);
612+
_focusNode?.requestFocus();
612613
}
613614
}
614615

@@ -1090,6 +1091,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
10901091
late TapGestureRecognizer _tap;
10911092
bool _active = false;
10921093
double _currentDragValue = 0.0;
1094+
Rect? overlayRect;
10931095

10941096
// This rect is used in gesture calculations, where the gesture coordinates
10951097
// are relative to the sliders origin. Therefore, the offset is passed as
@@ -1258,7 +1260,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
12581260
return;
12591261
}
12601262
_hasFocus = value;
1261-
_updateForFocusOrHover(_hasFocus);
1263+
_updateForFocus(_hasFocus);
12621264
markNeedsSemanticsUpdate();
12631265
}
12641266

@@ -1271,11 +1273,24 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
12711273
return;
12721274
}
12731275
_hovering = value;
1274-
_updateForFocusOrHover(_hovering);
1276+
_updateForHover(_hovering);
12751277
}
12761278

1277-
void _updateForFocusOrHover(bool hasFocusOrIsHovering) {
1278-
if (hasFocusOrIsHovering) {
1279+
/// True if the slider is interactive and the slider thumb is being
1280+
/// hovered over by a pointer.
1281+
bool _hoveringThumb = false;
1282+
bool get hoveringThumb => _hoveringThumb;
1283+
set hoveringThumb(bool value) {
1284+
assert(value != null);
1285+
if (value == _hoveringThumb) {
1286+
return;
1287+
}
1288+
_hoveringThumb = value;
1289+
_updateForHover(_hovering);
1290+
}
1291+
1292+
void _updateForFocus(bool focused) {
1293+
if (focused) {
12791294
_state.overlayController.forward();
12801295
if (showValueIndicator) {
12811296
_state.valueIndicatorController.forward();
@@ -1288,6 +1303,18 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
12881303
}
12891304
}
12901305

1306+
void _updateForHover(bool hovered) {
1307+
// Only show overlay when pointer is hovering the thumb.
1308+
if (hovered && hoveringThumb) {
1309+
_state.overlayController.forward();
1310+
} else {
1311+
// Only remove overlay when Slider is unfocused.
1312+
if (!hasFocus) {
1313+
_state.overlayController.reverse();
1314+
}
1315+
}
1316+
}
1317+
12911318
bool get showValueIndicator {
12921319
switch (_sliderTheme.showValueIndicator!) {
12931320
case ShowValueIndicator.onlyForDiscrete:
@@ -1404,7 +1431,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
14041431
_state.interactionTimer?.cancel();
14051432
_state.interactionTimer = Timer(_minimumInteractionTime * timeDilation, () {
14061433
_state.interactionTimer = null;
1407-
if (!_active &&
1434+
if (!_active && !hasFocus &&
14081435
_state.valueIndicatorController.status == AnimationStatus.completed) {
14091436
_state.valueIndicatorController.reverse();
14101437
}
@@ -1422,7 +1449,9 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
14221449
onChangeEnd?.call(_discretize(_currentDragValue));
14231450
_active = false;
14241451
_currentDragValue = 0.0;
1425-
_state.overlayController.reverse();
1452+
if (!hasFocus) {
1453+
_state.overlayController.reverse();
1454+
}
14261455

14271456
if (showValueIndicator && _state.interactionTimer == null) {
14281457
_state.valueIndicatorController.reverse();
@@ -1476,6 +1505,9 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
14761505
_drag.addPointer(event);
14771506
_tap.addPointer(event);
14781507
}
1508+
if (isInteractive && overlayRect != null) {
1509+
hoveringThumb = overlayRect!.contains(event.localPosition);
1510+
}
14791511
}
14801512

14811513
@override
@@ -1529,6 +1561,10 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
15291561
isDiscrete: isDiscrete,
15301562
);
15311563
final Offset thumbCenter = Offset(trackRect.left + visualPosition * trackRect.width, trackRect.center.dy);
1564+
if (isInteractive) {
1565+
final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isInteractive, false);
1566+
overlayRect = Rect.fromCircle(center: thumbCenter, radius: overlaySize.width / 2.0);
1567+
}
15321568
final Offset? secondaryOffset = (secondaryVisualPosition != null) ? Offset(trackRect.left + secondaryVisualPosition * trackRect.width, trackRect.center.dy) : null;
15331569

15341570
_sliderTheme.trackShape!.paint(

packages/flutter/test/material/slider_test.dart

Lines changed: 207 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,7 +1748,7 @@ void main() {
17481748
paints..circle(color: Colors.orange[500]),
17491749
);
17501750

1751-
// Check that the overlay does not show when focused and disabled.
1751+
// Check that the overlay does not show when unfocused and disabled.
17521752
await tester.pumpWidget(buildApp(enabled: false));
17531753
await tester.pumpAndSettle();
17541754
expect(focusNode.hasPrimaryFocus, isFalse);
@@ -1990,10 +1990,10 @@ void main() {
19901990
await drag.up();
19911991
await tester.pumpAndSettle();
19921992

1993-
// Slider does not have an overlay when stopped dragging.
1993+
// Slider still has overlay when stopped dragging.
19941994
expect(
19951995
Material.of(tester.element(find.byType(Slider))),
1996-
isNot(paints..circle(color: Colors.lime[500])),
1996+
paints..circle(color: Colors.lime[500]),
19971997
);
19981998
});
19991999

@@ -3177,4 +3177,208 @@ void main() {
31773177
expect(sliderEnd, true);
31783178
expect(dragStarted, false);
31793179
});
3180+
3181+
testWidgets('Overlay appear only when hovered on the thumb on desktop', (WidgetTester tester) async {
3182+
double value = 0.5;
3183+
const Color overlayColor = Color(0xffff0000);
3184+
3185+
Widget buildApp({bool enabled = true}) {
3186+
return MaterialApp(
3187+
home: Material(
3188+
child: Center(
3189+
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
3190+
return Slider(
3191+
value: value,
3192+
overlayColor: const MaterialStatePropertyAll<Color?>(overlayColor),
3193+
onChanged: enabled
3194+
? (double newValue) {
3195+
setState(() {
3196+
value = newValue;
3197+
});
3198+
}
3199+
: null,
3200+
);
3201+
}),
3202+
),
3203+
),
3204+
);
3205+
}
3206+
await tester.pumpWidget(buildApp());
3207+
3208+
// Slider does not have overlay when enabled and not hovered.
3209+
await tester.pumpAndSettle();
3210+
expect(
3211+
Material.of(tester.element(find.byType(Slider))),
3212+
isNot(paints..circle(color: overlayColor)),
3213+
);
3214+
3215+
// Hover on the slider but outside the thumb.
3216+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3217+
await gesture.addPointer();
3218+
await gesture.moveTo(tester.getTopLeft(find.byType(Slider)));
3219+
3220+
await tester.pumpWidget(buildApp());
3221+
await tester.pumpAndSettle();
3222+
expect(
3223+
Material.of(tester.element(find.byType(Slider))),
3224+
isNot(paints..circle(color: overlayColor)),
3225+
);
3226+
3227+
// Hover on the thumb.
3228+
await gesture.moveTo(tester.getCenter(find.byType(Slider)));
3229+
await tester.pumpAndSettle();
3230+
expect(
3231+
Material.of(tester.element(find.byType(Slider))),
3232+
paints..circle(color: overlayColor),
3233+
);
3234+
3235+
// Hover on the slider but outside the thumb.
3236+
await gesture.moveTo(tester.getBottomRight(find.byType(Slider)));
3237+
await tester.pumpAndSettle();
3238+
expect(
3239+
Material.of(tester.element(find.byType(Slider))),
3240+
isNot(paints..circle(color: overlayColor)),
3241+
);
3242+
}, variant: TargetPlatformVariant.desktop());
3243+
3244+
testWidgets('Overlay remains when Slider is in focus on desktop', (WidgetTester tester) async {
3245+
double value = 0.5;
3246+
const Color overlayColor = Color(0xffff0000);
3247+
final FocusNode focusNode = FocusNode();
3248+
3249+
Widget buildApp({bool enabled = true}) {
3250+
return MaterialApp(
3251+
home: Material(
3252+
child: Center(
3253+
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
3254+
return Slider(
3255+
value: value,
3256+
focusNode: focusNode,
3257+
overlayColor: const MaterialStatePropertyAll<Color?>(overlayColor),
3258+
onChanged: enabled
3259+
? (double newValue) {
3260+
setState(() {
3261+
value = newValue;
3262+
});
3263+
}
3264+
: null,
3265+
);
3266+
}),
3267+
),
3268+
),
3269+
);
3270+
}
3271+
await tester.pumpWidget(buildApp());
3272+
3273+
// Slider does not have overlay when enabled and not tapped.
3274+
await tester.pumpAndSettle();
3275+
expect(focusNode.hasFocus, false);
3276+
expect(
3277+
Material.of(tester.element(find.byType(Slider))),
3278+
isNot(paints..circle(color: overlayColor)),
3279+
);
3280+
3281+
final Offset sliderCenter = tester.getCenter(find.byType(Slider));
3282+
Offset tapLocation = Offset(sliderCenter.dx + 50, sliderCenter.dy);
3283+
3284+
// Tap somewhere to bring overlay.
3285+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3286+
await gesture.addPointer();
3287+
await gesture.down(tapLocation);
3288+
await gesture.up();
3289+
focusNode.requestFocus();
3290+
await tester.pumpAndSettle();
3291+
expect(focusNode.hasFocus, true);
3292+
expect(
3293+
Material.of(tester.element(find.byType(Slider))),
3294+
paints..circle(color: overlayColor),
3295+
);
3296+
3297+
tapLocation = Offset(sliderCenter.dx - 50, sliderCenter.dy);
3298+
await gesture.down(tapLocation);
3299+
await gesture.up();
3300+
await tester.pumpAndSettle();
3301+
expect(focusNode.hasFocus, true);
3302+
expect(
3303+
Material.of(tester.element(find.byType(Slider))),
3304+
paints..circle(color: overlayColor),
3305+
);
3306+
3307+
focusNode.unfocus();
3308+
await tester.pumpAndSettle();
3309+
expect(focusNode.hasFocus, false);
3310+
expect(
3311+
Material.of(tester.element(find.byType(Slider))),
3312+
isNot(paints..circle(color: overlayColor)),
3313+
);
3314+
}, variant: TargetPlatformVariant.desktop());
3315+
3316+
testWidgets('Value indicator remains when Slider is in focus on desktop', (WidgetTester tester) async {
3317+
double value = 0.5;
3318+
final FocusNode focusNode = FocusNode();
3319+
3320+
Widget buildApp({bool enabled = true}) {
3321+
return MaterialApp(
3322+
theme: ThemeData(
3323+
sliderTheme: const SliderThemeData(
3324+
showValueIndicator: ShowValueIndicator.always,
3325+
),
3326+
),
3327+
home: Material(
3328+
child: Center(
3329+
child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
3330+
return Slider(
3331+
value: value,
3332+
focusNode: focusNode,
3333+
divisions: 5,
3334+
label: value.toStringAsFixed(1),
3335+
onChanged: enabled
3336+
? (double newValue) {
3337+
setState(() {
3338+
value = newValue;
3339+
});
3340+
}
3341+
: null,
3342+
);
3343+
}),
3344+
),
3345+
),
3346+
);
3347+
}
3348+
await tester.pumpWidget(buildApp());
3349+
3350+
// Slider does not show value indicator without focus.
3351+
await tester.pumpAndSettle();
3352+
expect(focusNode.hasFocus, false);
3353+
RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay));
3354+
expect(
3355+
valueIndicatorBox,
3356+
isNot(paints..path(color: const Color(0xff000000))..paragraph()),
3357+
);
3358+
3359+
final Offset sliderCenter = tester.getCenter(find.byType(Slider));
3360+
final Offset tapLocation = Offset(sliderCenter.dx + 50, sliderCenter.dy);
3361+
3362+
// Tap somewhere to bring value indicator.
3363+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3364+
await gesture.addPointer();
3365+
await gesture.down(tapLocation);
3366+
await gesture.up();
3367+
focusNode.requestFocus();
3368+
await tester.pumpAndSettle();
3369+
expect(focusNode.hasFocus, true);
3370+
valueIndicatorBox = tester.renderObject(find.byType(Overlay));
3371+
expect(
3372+
valueIndicatorBox,
3373+
paints..path(color: const Color(0xff000000))..paragraph(),
3374+
);
3375+
3376+
focusNode.unfocus();
3377+
await tester.pumpAndSettle();
3378+
expect(focusNode.hasFocus, false);
3379+
expect(
3380+
valueIndicatorBox,
3381+
isNot(paints..path(color: const Color(0xff000000))..paragraph()),
3382+
);
3383+
}, variant: TargetPlatformVariant.desktop());
31803384
}

0 commit comments

Comments
 (0)