Commit 0139c69c authored by Adam Barth's avatar Adam Barth Committed by GitHub

Add GestureArenaTeam (#7481)

Previously, the Slider used a drag gesture recognizer to move the head
of the slider, but when the slider was in a vertical scroller, the
recognizer would wait until the user moved the pointer by enough pixels
to disambiguate between sliding the slider and scrolling the scroller.

That worked fine for actual drags, but the slider should also move when
the user taps the track. This patch introduces a tap recognizer to
handle that behavior.

To avoid the slider's drag and tap recognizers from competing with each
other in the arena, this patch introduces the notion of a
GestureArenaTeam, which lets several recognizers combine to form one
entry in the arena.  If that entry wins, the team picks the first of its
recognizers as the winner, avoiding latency.

Fixes #7454
parent 4955eef8
......@@ -22,4 +22,5 @@ export 'src/gestures/pointer_router.dart';
export 'src/gestures/recognizer.dart';
export 'src/gestures/scale.dart';
export 'src/gestures/tap.dart';
export 'src/gestures/team.dart';
export 'src/gestures/velocity_tracker.dart';
......@@ -13,6 +13,7 @@ import 'binding.dart';
import 'constants.dart';
import 'events.dart';
import 'pointer_router.dart';
import 'team.dart';
export 'pointer_router.dart' show PointerRouter;
......@@ -128,6 +129,31 @@ abstract class OneSequenceGestureRecognizer extends GestureRecognizer {
super.dispose();
}
/// The team that this recognizer belongs to, if any.
///
/// If [team] is null, this recognizer competes directly in the
/// [GestureArenaManager] to recognize a sequence of pointer events as a
/// gesture. If [team] is non-null, this recognizer competes in the arena in
/// a group with other recognizers on the same team.
///
/// A recognizer can be assigned to a team only when it is not participating
/// in the arena. For example, a common time to assign a recognizer to a team
/// is shortly after creating the recognizer.
GestureArenaTeam get team => _team;
GestureArenaTeam _team;
set team(GestureArenaTeam value) {
assert(_entries.isEmpty);
assert(_trackedPointers.isEmpty);
assert(_team == null);
_team = value;
}
GestureArenaEntry _addPointerToArena(int pointer) {
if (_team != null)
return _team.add(pointer, this);
return GestureBinding.instance.gestureArena.add(pointer, this);
}
/// Causes events related to the given pointer ID to be routed to this recognizer.
///
/// The pointer events are delivered to [handleEvent].
......@@ -138,7 +164,7 @@ abstract class OneSequenceGestureRecognizer extends GestureRecognizer {
GestureBinding.instance.pointerRouter.addRoute(pointer, handleEvent);
_trackedPointers.add(pointer);
assert(!_entries.containsValue(pointer));
_entries[pointer] = GestureBinding.instance.gestureArena.add(pointer, this);
_entries[pointer] = _addPointerToArena(pointer);
}
/// Stops events related to the given pointer ID from being routed to this recognizer.
......
// 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 'arena.dart';
import 'binding.dart';
class _CombiningGestureArenaEntry implements GestureArenaEntry {
_CombiningGestureArenaEntry(this._combiner, this._member);
final _CombiningGestureArenaMember _combiner;
final GestureArenaMember _member;
@override
void resolve(GestureDisposition disposition) {
_combiner._resolve(_member, disposition);
}
}
class _CombiningGestureArenaMember extends GestureArenaMember {
_CombiningGestureArenaMember(this._owner, this._pointer);
final GestureArenaTeam _owner;
final List<GestureArenaMember> _members = <GestureArenaMember>[];
final int _pointer;
bool _resolved = false;
GestureArenaMember _winner;
GestureArenaEntry _entry;
@override
void acceptGesture(int pointer) {
assert(_pointer == pointer);
assert(_winner != null || _members.isNotEmpty);
_close();
_winner ??= _members.removeAt(0);
for (GestureArenaMember member in _members)
member.rejectGesture(pointer);
_winner.acceptGesture(pointer);
}
@override
void rejectGesture(int pointer) {
assert(_pointer == pointer);
_close();
for (GestureArenaMember member in _members)
member.rejectGesture(pointer);
}
void _close() {
assert(!_resolved);
_resolved = true;
_CombiningGestureArenaMember combiner = _owner._combiners.remove(_pointer);
assert(combiner == this);
}
GestureArenaEntry _add(int pointer, GestureArenaMember member) {
assert(!_resolved);
assert(_pointer == pointer);
_members.add(member);
_entry ??= GestureBinding.instance.gestureArena.add(pointer, this);
return new _CombiningGestureArenaEntry(this, member);
}
void _resolve(GestureArenaMember member, GestureDisposition disposition) {
if (_resolved)
return;
if (disposition == GestureDisposition.rejected) {
_members.remove(member);
member.rejectGesture(_pointer);
if (_members.isEmpty)
_entry.resolve(disposition);
} else {
assert(disposition == GestureDisposition.accepted);
_winner ?? member;
_entry.resolve(disposition);
}
}
}
/// A group of [GestureArenaMember] objects that are competing as a unit in the [GestureArenaManager].
///
/// Normally, a recognizer competes directly in the [GestureArenaManager] to
/// recognize a sequence of pointer events as a gesture. With a
/// [GestureArenaTeam], recognizers can compete in the arena in a group with
/// other recognizers.
///
/// To assign a gesture recognizer to a team, see
/// [OneSequenceGestureRecognizer.team].
class GestureArenaTeam {
final Map<int, _CombiningGestureArenaMember> _combiners = new Map<int, _CombiningGestureArenaMember>();
/// Adds a new member to the arena on behalf of this team.
///
/// Used by [GestureRecognizer] subclasses that wish to compete in the arena
/// using this team.
///
/// To assign a gesture recognizer to a team, see
/// [OneSequenceGestureRecognizer.team].
GestureArenaEntry add(int pointer, GestureArenaMember member) {
_CombiningGestureArenaMember combiner = _combiners.putIfAbsent(
pointer, () => new _CombiningGestureArenaMember(this, pointer));
return combiner._add(pointer, member);
}
}
......@@ -269,10 +269,15 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
super(additionalConstraints: _getAdditionalConstraints(label)) {
assert(value != null && value >= 0.0 && value <= 1.0);
this.label = label;
GestureArenaTeam team = new GestureArenaTeam();
_drag = new HorizontalDragGestureRecognizer()
..team = team
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
_tap = new TapGestureRecognizer()
..team = team
..onTapUp = _handleTapUp;
_reactionController = new AnimationController(
duration: kRadialReactionDuration,
vsync: vsync,
......@@ -370,23 +375,28 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
final TextPainter _labelPainter = new TextPainter();
HorizontalDragGestureRecognizer _drag;
TapGestureRecognizer _tap;
bool _active = false;
double _currentDragValue = 0.0;
double get _discretizedCurrentDragValue {
double dragValue = _currentDragValue.clamp(0.0, 1.0);
if (divisions != null)
dragValue = (dragValue * divisions).round() / divisions;
return dragValue;
bool get isInteractive => onChanged != null;
double _getValueFromGlobalPosition(Point globalPosition) {
return (globalToLocal(globalPosition).x - _kReactionRadius) / _trackLength;
}
bool get isInteractive => onChanged != null;
double _discretize(double value) {
double result = value.clamp(0.0, 1.0);
if (divisions != null)
result = (result * divisions).round() / divisions;
return result;
}
void _handleDragStart(DragStartDetails details) {
if (isInteractive) {
_active = true;
_currentDragValue = (globalToLocal(details.globalPosition).x - _kReactionRadius) / _trackLength;
onChanged(_discretizedCurrentDragValue);
_currentDragValue = _getValueFromGlobalPosition(details.globalPosition);
onChanged(_discretize(_currentDragValue));
_reactionController.forward();
}
}
......@@ -394,7 +404,7 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
void _handleDragUpdate(DragUpdateDetails details) {
if (isInteractive) {
_currentDragValue += details.primaryDelta / _trackLength;
onChanged(_discretizedCurrentDragValue);
onChanged(_discretize(_currentDragValue));
}
}
......@@ -406,14 +416,22 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
}
}
void _handleTapUp(TapUpDetails details) {
if (isInteractive && !_active)
onChanged(_discretize(_getValueFromGlobalPosition(details.globalPosition)));
}
@override
bool hitTestSelf(Point position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent && isInteractive)
if (event is PointerDownEvent && isInteractive) {
// We need to add the drag first so that it has priority.
_drag.addPointer(event);
_tap.addPointer(event);
}
}
@override
......
......@@ -118,4 +118,52 @@ void main() {
await tester.pumpWidget(buildApp(true));
expect(getThumbPaint().style, equals(PaintingStyle.stroke));
});
testWidgets('Slider can tap in vertical scroller',
(WidgetTester tester) async {
double value = 0.0;
await tester.pumpWidget(new Material(
child: new Block(
children: <Widget>[
new Slider(
value: value,
onChanged: (double newValue) {
value = newValue;
},
),
new Container(
height: 2000.0,
),
],
),
));
await tester.tap(find.byType(Slider));
expect(value, equals(0.5));
});
testWidgets('Slider drags immediately', (WidgetTester tester) async {
double value = 0.0;
await tester.pumpWidget(new Material(
child: new Center(
child: new Slider(
value: value,
onChanged: (double newValue) {
value = newValue;
},
),
),
));
Point center = tester.getCenter(find.byType(Slider));
TestGesture gesture = await tester.startGesture(center);
expect(value, equals(0.5));
await gesture.moveBy(new Offset(1.0, 0.0));
expect(value, greaterThan(0.5));
await gesture.up();
});
}
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