Commit a91dd07c authored by Hixie's avatar Hixie

Draggable

Introduce a Draggable class that wraps all the logic of dragging
something and dropping it on a DragTarget.

Update examples/widgets/drag_and_drop.dart accordingly.

Make the performance/transition part of routes optional.
parent 4150615e
......@@ -2,14 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:sky' as sky;
import 'package:sky/material.dart';
import 'package:sky/painting.dart';
import 'package:sky/rendering.dart';
import 'package:sky/src/fn3.dart';
final double kTop = 10.0 + sky.view.paddingTop;
final double kLeft = 10.0;
class DragData {
DragData(this.text);
......@@ -21,11 +18,11 @@ class ExampleDragTarget extends StatefulComponent {
}
class ExampleDragTargetState extends State<ExampleDragTarget> {
String _text = 'ready';
String _text = 'Drag Target';
void _handleAccept(DragData data) {
setState(() {
_text = data.text;
_text = 'dropped: ${data.text}';
});
}
......@@ -34,7 +31,6 @@ class ExampleDragTargetState extends State<ExampleDragTarget> {
onAccept: _handleAccept,
builder: (BuildContext context, List<DragData> data, _) {
return new Container(
width: 100.0,
height: 100.0,
margin: new EdgeDims.all(10.0),
decoration: new BoxDecoration(
......@@ -54,100 +50,76 @@ class ExampleDragTargetState extends State<ExampleDragTarget> {
}
class Dot extends StatelessComponent {
Dot({ Key key, this.color }): super(key: key);
final Color color;
Widget build(BuildContext context) {
return new Container(
width: 50.0,
height: 50.0,
decoration: new BoxDecoration(
backgroundColor: Colors.deepOrange[500]
backgroundColor: color
)
);
}
}
class ExampleDragSource extends StatelessComponent {
ExampleDragSource({ Key key, this.navigator, this.name, this.color }): super(key: key);
final NavigatorState navigator;
final String name;
final Color color;
Widget build(BuildContext context) {
return new Draggable(
navigator: navigator,
data: new DragData(name),
child: new Dot(color: color),
feedback: new Dot(color: color)
);
}
}
class DragAndDropApp extends StatefulComponent {
DragAndDropApp({ this.navigator });
final NavigatorState navigator;
DragAndDropAppState createState() => new DragAndDropAppState();
}
class DragAndDropAppState extends State<DragAndDropApp> {
DragController _dragController;
Offset _displacement = Offset.zero;
void _startDrag(sky.PointerEvent event) {
setState(() {
_dragController = new DragController(new DragData("Orange"));
_dragController.update(new Point(event.x, event.y));
_displacement = Offset.zero;
});
}
void _updateDrag(sky.PointerEvent event) {
setState(() {
_dragController.update(new Point(event.x, event.y));
_displacement += new Offset(event.dx, event.dy);
});
}
void _cancelDrag(sky.PointerEvent event) {
setState(() {
_dragController.cancel();
_dragController = null;
});
}
void _drop(sky.PointerEvent event) {
setState(() {
_dragController.update(new Point(event.x, event.y));
_dragController.drop();
_dragController = null;
_displacement = Offset.zero;
});
}
Widget build(BuildContext context) {
List<Widget> layers = <Widget>[
new Row([
new ExampleDragTarget(),
new ExampleDragTarget(),
new ExampleDragTarget(),
new ExampleDragTarget(),
]),
new Positioned(
top: kTop,
left: kLeft,
// TODO(abarth): We should be using a GestureDetector
child: new Listener(
onPointerDown: _startDrag,
onPointerMove: _updateDrag,
onPointerCancel: _cancelDrag,
onPointerUp: _drop,
child: new Dot()
)
return new Scaffold(
toolbar: new ToolBar(
center: new Text('Drag and Drop Flutter Demo')
),
];
if (_dragController != null) {
layers.add(
new Positioned(
top: kTop + _displacement.dy,
left: kLeft + _displacement.dx,
child: new IgnorePointer(
child: new Opacity(
opacity: 0.5,
child: new Dot()
body: new Material(
child: new DefaultTextStyle(
style: Theme.of(context).text.body1.copyWith(textAlign: TextAlign.center),
child: new Column([
new Flexible(child: new Row([
new ExampleDragSource(navigator: config.navigator, name: 'Orange', color: const Color(0xFFFF9000)),
new ExampleDragSource(navigator: config.navigator, name: 'Teal', color: const Color(0xFF00FFFF)),
new ExampleDragSource(navigator: config.navigator, name: 'Yellow', color: const Color(0xFFFFF000)),
],
alignItems: FlexAlignItems.center,
justifyContent: FlexJustifyContent.spaceAround
)),
new Flexible(child: new Row([
new Flexible(child: new ExampleDragTarget()),
new Flexible(child: new ExampleDragTarget()),
new Flexible(child: new ExampleDragTarget()),
new Flexible(child: new ExampleDragTarget()),
])),
])
)
)
)
);
}
return new Container(
decoration: new BoxDecoration(backgroundColor: Colors.pink[500]),
child: new Stack(layers)
);
}
}
void main() {
runApp(new DragAndDropApp());
runApp(new App(
title: 'Drag and Drop Flutter Demo',
routes: {
'/': (NavigatorState navigator, Route route) => new DragAndDropApp(navigator: navigator)
}
));
}
......@@ -69,7 +69,7 @@ class AppState extends State<App> {
Widget build(BuildContext context) {
return new Theme(
data: config.theme,
data: config.theme ?? new ThemeData.fallback(),
child: new DefaultTextStyle(
style: _errorTextStyle,
child: new Title(
......
......@@ -139,8 +139,8 @@ class DialogRoute extends Route {
final RouteBuilder builder;
Duration get transitionDuration => _kTransitionDuration;
bool get isOpaque => false;
Widget build(Key key, NavigatorState navigator, WatchableAnimationPerformance performance) {
bool get opaque => false;
Widget build(Key key, NavigatorState navigator) {
return new FadeTransition(
performance: performance,
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: easeOut),
......@@ -148,8 +148,9 @@ class DialogRoute extends Route {
);
}
void popState([dynamic result]) {
void didPop([dynamic result]) {
completer.complete(result);
super.didPop(result);
}
}
......
......@@ -3,15 +3,87 @@
// found in the LICENSE file.
import 'dart:collection';
import 'dart:sky' as sky;
import 'package:sky/rendering.dart';
import 'package:sky/src/fn3/basic.dart';
import 'package:sky/src/fn3/binding.dart';
import 'package:sky/src/fn3/framework.dart';
import 'package:sky/src/fn3/navigator.dart';
typedef bool DragTargetWillAccept<T>(T data);
typedef void DragTargetAccept<T>(T data);
typedef Widget DragTargetBuilder<T>(BuildContext context, List<T> candidateData, List<dynamic> rejectedData);
typedef void DragFinishedNotification();
class Draggable extends StatefulComponent {
Draggable({ Key key, this.navigator, this.data, this.child, this.feedback }): super(key: key) {
assert(navigator != null);
}
final NavigatorState navigator;
final dynamic data;
final Widget child;
final Widget feedback;
DraggableState createState() => new DraggableState();
}
class DraggableState extends State<Draggable> {
DragRoute _route;
void _startDrag(sky.PointerEvent event) {
if (_route != null)
return; // TODO(ianh): once we switch to using gestures, just hand the gesture to the route so it can do everything itself. then we can have multiple drags at the same time.
Point point = new Point(event.x, event.y);
RenderBox renderObject = context.findRenderObject();
_route = new DragRoute(
data: config.data,
dragStartPoint: renderObject.globalToLocal(point),
feedback: config.feedback,
onDragFinished: () {
_route = null;
}
);
_route.update(point);
config.navigator.push(_route);
}
void _updateDrag(sky.PointerEvent event) {
if (_route != null) {
config.navigator.setState(() {
_route.update(new Point(event.x, event.y));
});
}
}
void _cancelDrag(sky.PointerEvent event) {
if (_route != null) {
config.navigator.popRoute(_route, DragEndKind.canceled);
assert(_route == null);
}
}
void _drop(sky.PointerEvent event) {
if (_route != null) {
_route.update(new Point(event.x, event.y));
config.navigator.popRoute(_route, DragEndKind.dropped);
assert(_route == null);
}
}
Widget build(BuildContext context) {
// TODO(abarth): We should be using a GestureDetector
return new Listener(
onPointerDown: _startDrag,
onPointerMove: _updateDrag,
onPointerCancel: _cancelDrag,
onPointerUp: _drop,
child: config.child
);
}
}
class DragTarget<T> extends StatefulComponent {
const DragTarget({
......@@ -72,27 +144,23 @@ class DragTargetState<T> extends State<DragTarget<T>> {
}
}
class DragController {
DragController(this.data);
enum DragEndKind { dropped, canceled }
class DragRoute extends Route {
DragRoute({ this.data, this.dragStartPoint: Point.origin, this.feedback, this.onDragFinished });
final dynamic data;
final Point dragStartPoint;
final Widget feedback;
final DragFinishedNotification onDragFinished;
DragTargetState _activeTarget;
bool _activeTargetWillAcceptDrop = false;
DragTargetState _getDragTarget(List<HitTestEntry> path) {
// TODO(abarth): Why to we reverse the path here?
for (HitTestEntry entry in path.reversed) {
if (entry.target is RenderMetaData) {
RenderMetaData renderMetaData = entry.target;
if (renderMetaData.metaData is DragTargetState)
return renderMetaData.metaData;
}
}
return null;
}
Offset _lastOffset;
void update(Point globalPosition) {
_lastOffset = globalPosition - dragStartPoint;
HitTestResult result = WidgetFlutterBinding.instance.hitTest(globalPosition);
DragTargetState target = _getDragTarget(result.path);
if (target == _activeTarget)
......@@ -103,21 +171,47 @@ class DragController {
_activeTargetWillAcceptDrop = _activeTarget != null && _activeTarget.didEnter(data);
}
void cancel() {
if (_activeTarget != null)
_activeTarget.didLeave(data);
_activeTarget = null;
_activeTargetWillAcceptDrop = false;
DragTargetState _getDragTarget(List<HitTestEntry> path) {
// TODO(abarth): Why do we reverse the path here?
for (HitTestEntry entry in path.reversed) {
if (entry.target is RenderMetaData) {
RenderMetaData renderMetaData = entry.target;
if (renderMetaData.metaData is DragTargetState)
return renderMetaData.metaData;
}
}
return null;
}
void drop() {
if (_activeTarget == null)
return;
if (_activeTargetWillAcceptDrop)
void didPop([DragEndKind endKind]) {
if (_activeTarget != null) {
if (endKind == DragEndKind.dropped && _activeTargetWillAcceptDrop)
_activeTarget.didDrop(data);
else
_activeTarget.didLeave(data);
}
_activeTarget = null;
_activeTargetWillAcceptDrop = false;
if (onDragFinished != null)
onDragFinished();
super.didPop(endKind);
}
bool get ephemeral => true;
bool get modal => false;
Duration get transitionDuration => const Duration();
bool get opaque => false;
Widget build(Key key, NavigatorState navigator) {
return new Positioned(
left: _lastOffset.dx,
top: _lastOffset.dy,
child: new IgnorePointer(
child: new Opacity(
opacity: 0.5,
child: feedback
)
)
);
}
}
......@@ -9,102 +9,8 @@ import 'package:sky/src/fn3/framework.dart';
import 'package:sky/src/fn3/transitions.dart';
typedef Widget RouteBuilder(NavigatorState navigator, Route route);
typedef void NotificationCallback();
abstract class Route {
AnimationPerformance _performance;
NotificationCallback onDismissed;
NotificationCallback onCompleted;
AnimationPerformance createPerformance() {
AnimationPerformance result = new AnimationPerformance(duration: transitionDuration);
result.addStatusListener((AnimationStatus status) {
switch (status) {
case AnimationStatus.dismissed:
if (onDismissed != null)
onDismissed();
break;
case AnimationStatus.completed:
if (onCompleted != null)
onCompleted();
break;
default:
;
}
});
return result;
}
WatchableAnimationPerformance ensurePerformance({ Direction direction }) {
assert(direction != null);
if (_performance == null)
_performance = createPerformance();
AnimationStatus desiredStatus = direction == Direction.forward ? AnimationStatus.forward : AnimationStatus.reverse;
if (_performance.status != desiredStatus)
_performance.play(direction);
return _performance.view;
}
bool get isActuallyOpaque => _performance != null && _performance.isCompleted && isOpaque;
bool get hasContent => true; // set to false if you have nothing useful to return from build()
Duration get transitionDuration;
bool get isOpaque;
Widget build(Key key, NavigatorState navigator, WatchableAnimationPerformance performance);
void popState([dynamic result]) { assert(result == null); }
String toString() => '$runtimeType()';
}
const Duration _kTransitionDuration = const Duration(milliseconds: 150);
const Point _kTransitionStartPoint = const Point(0.0, 75.0);
class PageRoute extends Route {
PageRoute(this.builder);
final RouteBuilder builder;
bool get isOpaque => true;
Duration get transitionDuration => _kTransitionDuration;
Widget build(Key key, NavigatorState navigator, WatchableAnimationPerformance performance) {
// TODO(jackson): Hit testing should ignore transform
// TODO(jackson): Block input unless content is interactive
return new SlideTransition(
key: key,
performance: performance,
position: new AnimatedValue<Point>(_kTransitionStartPoint, end: Point.origin, curve: easeOut),
child: new FadeTransition(
performance: performance,
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: easeOut),
child: builder(navigator, this)
)
);
}
}
typedef void RouteStateCallback(RouteState route);
class RouteState extends Route {
RouteState({ this.route, this.owner, this.callback });
Route route;
State owner;
RouteStateCallback callback;
bool get isOpaque => false;
void popState([dynamic result]) {
assert(result == null);
if (callback != null)
callback(this);
}
bool get hasContent => false;
Duration get transitionDuration => const Duration();
Widget build(Key key, NavigatorState navigator, WatchableAnimationPerformance performance) => null;
}
class Navigator extends StatefulComponent {
Navigator({ this.routes, Key key }) : super(key: key) {
// To use a navigator, you must at a minimum define the route with the name '/'.
......@@ -128,6 +34,7 @@ class NavigatorState extends State<Navigator> {
super.initState(context);
PageRoute route = new PageRoute(config.routes['/']);
assert(route != null);
assert(!route.ephemeral);
_history.add(route);
}
......@@ -147,53 +54,223 @@ class NavigatorState extends State<Navigator> {
void push(Route route) {
assert(!_debugCurrentlyHaveRoute(route));
_history.insert(_currentPosition + 1, route);
setState(() {
while (currentRoute.ephemeral) {
assert(currentRoute.ephemeral);
currentRoute.didPop(null);
_currentPosition -= 1;
}
_history.insert(_currentPosition + 1, route);
_currentPosition += 1;
});
}
void pop([dynamic result]) {
if (_currentPosition > 0) {
Route route = _history[_currentPosition];
route.popState(result);
void popRoute(Route route, [dynamic result]) {
assert(_debugCurrentlyHaveRoute(route));
assert(_currentPosition > 0);
setState(() {
while (currentRoute != route) {
assert(currentRoute.ephemeral);
currentRoute.didPop(null);
_currentPosition -= 1;
}
assert(_currentPosition > 0);
currentRoute.didPop(result);
_currentPosition -= 1;
});
assert(!_debugCurrentlyHaveRoute(route));
}
void pop([dynamic result]) {
setState(() {
while (currentRoute.ephemeral) {
currentRoute.didPop(null);
_currentPosition -= 1;
}
assert(_currentPosition > 0);
currentRoute.didPop(result);
_currentPosition -= 1;
});
}
bool _debugCurrentlyHaveRoute(Route route) {
return _history.any((candidate) => candidate == route);
int index = _history.indexOf(route);
return index >= 0 && index <= _currentPosition;
}
Widget build(BuildContext context) {
List<Widget> visibleRoutes = new List<Widget>();
bool alreadyInsertModalBarrier = false;
for (int i = _history.length-1; i >= 0; i -= 1) {
Route route = _history[i];
if (!route.hasContent)
if (!route.hasContent) {
assert(!route.modal);
continue;
WatchableAnimationPerformance performance = route.ensurePerformance(
}
route.ensurePerformance(
direction: (i <= _currentPosition) ? Direction.forward : Direction.reverse
);
route.onDismissed = () {
route._onDismissed = () {
setState(() {
assert(_history.contains(route));
_history.remove(route);
});
};
Key key = new ObjectKey(route);
Widget widget = route.build(key, this, performance);
Widget widget = route.build(key, this);
visibleRoutes.add(widget);
if (route.isActuallyOpaque)
break;
}
if (visibleRoutes.length > 1) {
visibleRoutes.insert(1, new Listener(
assert(route.modal || route.ephemeral);
if (route.modal && i > 0 && !alreadyInsertModalBarrier) {
visibleRoutes.add(new Listener(
onPointerDown: (_) { pop(); },
child: new Container()
));
alreadyInsertModalBarrier = true;
}
}
return new Focus(child: new Stack(visibleRoutes.reversed.toList()));
}
}
abstract class Route {
WatchableAnimationPerformance get performance => _performance?.view;
AnimationPerformance _performance;
NotificationCallback _onDismissed;
AnimationPerformance createPerformance() {
Duration duration = transitionDuration;
if (duration > Duration.ZERO) {
return new AnimationPerformance(duration: duration)
..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.dismissed && _onDismissed != null)
_onDismissed();
});
}
return null;
}
void ensurePerformance({ Direction direction }) {
assert(direction != null);
if (_performance == null)
_performance = createPerformance();
if (_performance != null) {
AnimationStatus desiredStatus = direction == Direction.forward ? AnimationStatus.forward : AnimationStatus.reverse;
if (_performance.status != desiredStatus)
_performance.play(direction);
}
}
/// If hasContent is true, then the route represents some on-screen state.
///
/// If hasContent is false, then no performance will be created, and the values of
/// ephemeral, modal, and opaque are ignored. This is useful if the route
/// represents some state handled by another widget. See
/// NavigatorState.pushState().
///
/// Set hasContent to false if you have nothing useful to return from build().
bool get hasContent => true;
/// If ephemeral is true, then to explicitly pop the route you have to use
/// navigator.popRoute() with a reference to this route. navigator.pop()
/// automatically pops all ephemeral routes before popping the current
/// top-most non-ephemeral route.
///
/// If ephemeral is false, then the route can be popped with navigator.pop().
///
/// Set ephemeral to true if you want to be automatically popped when another
/// route is pushed or popped.
///
/// modal must be true if ephemeral is false.
bool get ephemeral => false;
/// If modal is true, a hidden layer is inserted in the widget tree that
/// catches all touches to widgets created by routes below this one, even if
/// this one is transparent.
///
/// If modal is false, then earlier routes can be interacted with, including
/// causing new routes to be pushed and/or this route (and maybe others) to be
/// popped.
///
/// ephemeral must be true if modal is false.
bool get modal => true;
/// If opaque is true, then routes below this one will not be built or painted
/// when the transition to this route is complete.
///
/// If opaque is false, then the previous route will always be painted even if
/// this route's transition is complete.
///
/// Set this to true if there's no reason to build and paint the route behind
/// you when your transition is finished, and set it to false if you do not
/// cover the entire application surface or are in any way semi-transparent.
bool get opaque => false;
/// If this is set to a non-zero [Duration], then an [AnimationPerformance]
/// object, available via the performance field, will be created when the
/// route is first built, using the duration described here.
Duration get transitionDuration => Duration.ZERO;
bool get isActuallyOpaque => (performance == null || _performance.isCompleted) && opaque;
Widget build(Key key, NavigatorState navigator);
void didPop([dynamic result]) {
if (performance == null && _onDismissed != null)
_onDismissed();
}
String toString() => '$runtimeType()';
}
const Duration _kTransitionDuration = const Duration(milliseconds: 150);
const Point _kTransitionStartPoint = const Point(0.0, 75.0);
class PageRoute extends Route {
PageRoute(this.builder);
final RouteBuilder builder;
bool get opaque => true;
Duration get transitionDuration => _kTransitionDuration;
Widget build(Key key, NavigatorState navigator) {
// TODO(jackson): Hit testing should ignore transform
// TODO(jackson): Block input unless content is interactive
return new SlideTransition(
key: key,
performance: performance,
position: new AnimatedValue<Point>(_kTransitionStartPoint, end: Point.origin, curve: easeOut),
child: new FadeTransition(
performance: performance,
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: easeOut),
child: builder(navigator, this)
)
);
}
}
typedef void RouteStateCallback(RouteState route);
class RouteState extends Route {
RouteState({ this.route, this.owner, this.callback });
Route route;
State owner;
RouteStateCallback callback;
bool get opaque => false;
void didPop([dynamic result]) {
assert(result == null);
if (callback != null)
callback(this);
super.didPop(result);
}
bool get hasContent => false;
Widget build(Key key, NavigatorState navigator) => null;
}
......@@ -167,9 +167,12 @@ class MenuRoute extends Route {
return result;
}
bool get ephemeral => true;
bool get modal => true;
Duration get transitionDuration => _kMenuDuration;
bool get isOpaque => false;
Widget build(Key key, NavigatorState navigator, WatchableAnimationPerformance performance) {
bool get opaque => false;
Widget build(Key key, NavigatorState navigator) {
return new Positioned(
top: position?.top,
right: position?.right,
......@@ -189,8 +192,9 @@ class MenuRoute extends Route {
);
}
void popState([dynamic result]) {
void didPop([dynamic result]) {
completer.complete(result);
super.didPop(result);
}
}
......
import 'package:sky/src/fn3.dart';
import 'package:test/test.dart';
import '../engine/mock_events.dart';
import '../fn3/widget_tester.dart';
void main() {
test('Drag and drop - control test', () {
WidgetTester tester = new WidgetTester();
TestPointer pointer = new TestPointer(7);
List accepted = [];
tester.pumpFrame(new Navigator(
routes: {
'/': (NavigatorState navigator, Route route) { return new Column([
new Draggable(
navigator: navigator,
data: 1,
child: new Text('Source'),
feedback: new Text('Dragging')
),
new DragTarget(
builder: (context, data, rejects) {
return new Container(
height: 100.0,
child: new Text('Target')
);
},
onAccept: (data) {
accepted.add(data);
}
),
]);
},
}
));
expect(accepted, isEmpty);
expect(tester.findText('Source'), isNotNull);
expect(tester.findText('Dragging'), isNull);
expect(tester.findText('Target'), isNotNull);
Point firstLocation = tester.getCenter(tester.findText('Source'));
tester.dispatchEvent(pointer.down(firstLocation), firstLocation);
tester.pumpFrameWithoutChange();
expect(accepted, isEmpty);
expect(tester.findText('Source'), isNotNull);
expect(tester.findText('Dragging'), isNotNull);
expect(tester.findText('Target'), isNotNull);
Point secondLocation = tester.getCenter(tester.findText('Target'));
tester.dispatchEvent(pointer.move(secondLocation), firstLocation);
tester.pumpFrameWithoutChange();
expect(accepted, isEmpty);
expect(tester.findText('Source'), isNotNull);
expect(tester.findText('Dragging'), isNotNull);
expect(tester.findText('Target'), isNotNull);
tester.dispatchEvent(pointer.up(), firstLocation);
tester.pumpFrameWithoutChange();
expect(accepted, equals([1]));
expect(tester.findText('Source'), isNotNull);
expect(tester.findText('Dragging'), isNull);
expect(tester.findText('Target'), isNotNull);
});
}
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