Commit 6a46bf2e authored by Adam Barth's avatar Adam Barth

Ensure that DropDownMenus are always onscreen (#3742)

This patch sizes the menu such that it is always on screen, but doesn't scroll
the menu to ensure that the currently selected item is always visible and on
top of the button. That will need to wait for a later patch.

Also, teach CustomPaint how to repaint animations more efficiently.

Fixes #3720
parent 0e515631
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -24,40 +25,52 @@ const double _kBottomBorderHeight = 2.0; ...@@ -24,40 +25,52 @@ const double _kBottomBorderHeight = 2.0;
const Border _kDropDownUnderline = const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: _kBottomBorderHeight)); const Border _kDropDownUnderline = const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: _kBottomBorderHeight));
class _DropDownMenuPainter extends CustomPainter { class _DropDownMenuPainter extends CustomPainter {
const _DropDownMenuPainter({ _DropDownMenuPainter({
this.color, Color color,
this.elevation, int elevation,
this.menuTop, this.buttonRect,
this.menuBottom, this.selectedIndex,
this.renderBox Animation<double> resize
}); }) : color = color,
elevation = elevation,
resize = resize,
_painter = new BoxDecoration(
backgroundColor: color,
borderRadius: 2.0,
boxShadow: kElevationToShadow[elevation]
).createBoxPainter(),
super(repaint: resize);
final Color color; final Color color;
final int elevation; final int elevation;
final double menuTop; final Rect buttonRect;
final double menuBottom; final int selectedIndex;
final RenderBox renderBox; final Animation<double> resize;
final BoxPainter _painter;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final BoxPainter painter = new BoxDecoration( final Tween<double> top = new Tween<double>(
backgroundColor: color, begin: (selectedIndex * buttonRect.height + _kMenuVerticalPadding.top).clamp(0.0, size.height - buttonRect.height),
borderRadius: 2.0, end: 0.0
boxShadow: kElevationToShadow[elevation] );
).createBoxPainter();
final Tween<double> bottom = new Tween<double>(
begin: (top.begin + buttonRect.height).clamp(buttonRect.height, size.height),
end: size.height
);
double top = renderBox.globalToLocal(new Point(0.0, menuTop)).y; _painter.paint(canvas, new Rect.fromLTRB(0.0, top.evaluate(resize), size.width, bottom.evaluate(resize)));
double bottom = renderBox.globalToLocal(new Point(0.0, menuBottom)).y;
painter.paint(canvas, new Rect.fromLTRB(0.0, top, size.width, bottom));
} }
@override @override
bool shouldRepaint(_DropDownMenuPainter oldPainter) { bool shouldRepaint(_DropDownMenuPainter oldPainter) {
return oldPainter.color != color return oldPainter.color != color
|| oldPainter.elevation != elevation || oldPainter.elevation != elevation
|| oldPainter.menuTop != menuTop || oldPainter.buttonRect != buttonRect
|| oldPainter.menuBottom != menuBottom || oldPainter.selectedIndex != selectedIndex
|| oldPainter.renderBox != renderBox; || oldPainter.resize != resize;
} }
} }
...@@ -84,12 +97,13 @@ class _DropDownMenu<T> extends StatusTransitionWidget { ...@@ -84,12 +97,13 @@ class _DropDownMenu<T> extends StatusTransitionWidget {
final List<Widget> children = <Widget>[]; final List<Widget> children = <Widget>[];
for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) { for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) {
CurvedAnimation opacity; CurvedAnimation opacity;
Interval reverseCurve = const Interval(0.75, 1.0);
if (itemIndex == route.selectedIndex) { if (itemIndex == route.selectedIndex) {
opacity = new CurvedAnimation(parent: route.animation, curve: const Step(0.0), reverseCurve: const Interval(0.75, 1.0)); opacity = new CurvedAnimation(parent: route.animation, curve: const Step(0.0), reverseCurve: reverseCurve);
} else { } else {
final double start = (0.5 + (itemIndex + 1) * unit).clamp(0.0, 1.0); final double start = (0.5 + (itemIndex + 1) * unit).clamp(0.0, 1.0);
final double end = (start + 1.5 * unit).clamp(0.0, 1.0); final double end = (start + 1.5 * unit).clamp(0.0, 1.0);
opacity = new CurvedAnimation(parent: route.animation, curve: new Interval(start, end), reverseCurve: const Interval(0.75, 1.0)); opacity = new CurvedAnimation(parent: route.animation, curve: new Interval(start, end), reverseCurve: reverseCurve);
} }
children.add(new FadeTransition( children.add(new FadeTransition(
opacity: opacity, opacity: opacity,
...@@ -106,51 +120,31 @@ class _DropDownMenu<T> extends StatusTransitionWidget { ...@@ -106,51 +120,31 @@ class _DropDownMenu<T> extends StatusTransitionWidget {
)); ));
} }
final CurvedAnimation opacity = new CurvedAnimation( return new FadeTransition(
opacity: new CurvedAnimation(
parent: route.animation, parent: route.animation,
curve: const Interval(0.0, 0.25), curve: const Interval(0.0, 0.25),
reverseCurve: const Interval(0.75, 1.0) reverseCurve: const Interval(0.75, 1.0)
); ),
child: new CustomPaint(
final CurvedAnimation resize = new CurvedAnimation( painter: new _DropDownMenuPainter(
color: Theme.of(context).canvasColor,
elevation: route.elevation,
buttonRect: route.buttonRect,
selectedIndex: route.selectedIndex,
resize: new CurvedAnimation(
parent: route.animation, parent: route.animation,
curve: const Interval(0.25, 0.5), curve: const Interval(0.25, 0.5),
reverseCurve: const Step(0.0) reverseCurve: const Step(0.0)
); )
),
final Tween<double> menuTop = new Tween<double>( child: new Material(
begin: route.buttonRect.top,
end: route.buttonRect.top - route.selectedIndex * route.buttonRect.height - _kMenuVerticalPadding.top
);
final Tween<double> menuBottom = new Tween<double>(
begin: route.buttonRect.bottom,
end: menuTop.end + route.items.length * route.buttonRect.height + _kMenuVerticalPadding.vertical
);
Widget child = new Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: new Block( child: new Block(
padding: _kMenuVerticalPadding, padding: _kMenuVerticalPadding,
children: children children: children
) )
); )
return new FadeTransition(
opacity: opacity,
child: new AnimatedBuilder(
animation: resize,
builder: (BuildContext context, Widget child) {
return new CustomPaint(
painter: new _DropDownMenuPainter(
color: Theme.of(context).canvasColor,
elevation: route.elevation,
menuTop: menuTop.evaluate(resize),
menuBottom: menuBottom.evaluate(resize),
renderBox: context.findRenderObject()
),
child: child
);
},
child: child
) )
); );
} }
...@@ -164,20 +158,34 @@ class _DropDownMenuRouteLayout extends SingleChildLayoutDelegate { ...@@ -164,20 +158,34 @@ class _DropDownMenuRouteLayout extends SingleChildLayoutDelegate {
@override @override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) { BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
// The maximum height of a simple menu should be one or more rows less than
// the view height. This ensures a tappable area outside of the simple menu
// with which to dismiss the menu.
// -- https://www.google.com/design/spec/components/menus.html#menus-simple-menus
final double maxHeight = math.max(0.0, constraints.maxHeight - 2 * buttonRect.height);
return new BoxConstraints( return new BoxConstraints(
minWidth: buttonRect.width, minWidth: buttonRect.width,
maxWidth: buttonRect.width, maxWidth: buttonRect.width,
minHeight: 0.0, minHeight: 0.0,
maxHeight: constraints.maxHeight maxHeight: maxHeight
); );
} }
@override @override
Offset getPositionForChild(Size size, Size childSize) { Offset getPositionForChild(Size size, Size childSize) {
return new Offset( double top = buttonRect.top - selectedIndex * buttonRect.height - _kMenuVerticalPadding.top;
buttonRect.left, double topPreferredLimit = buttonRect.height;
buttonRect.top - selectedIndex * buttonRect.height - _kMenuVerticalPadding.top if (top < topPreferredLimit)
); top = math.min(buttonRect.top, topPreferredLimit);
double bottom = top + childSize.height;
double bottomPreferredLimit = size.height - buttonRect.height;
if (bottom > bottomPreferredLimit) {
bottom = math.max(buttonRect.bottom, bottomPreferredLimit);
top = bottom - childSize.height;
}
assert(top >= 0.0);
assert(top + childSize.height <= size.height);
return new Offset(buttonRect.left, top);
} }
@override @override
...@@ -256,6 +264,8 @@ class DropDownMenuItem<T> extends StatelessWidget { ...@@ -256,6 +264,8 @@ class DropDownMenuItem<T> extends StatelessWidget {
} }
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child; final Widget child;
/// The value to return if the user selects this menu item. /// The value to return if the user selects this menu item.
...@@ -330,6 +340,7 @@ class DropDownButton<T> extends StatefulWidget { ...@@ -330,6 +340,7 @@ class DropDownButton<T> extends StatefulWidget {
this.onChanged, this.onChanged,
this.elevation: 8 this.elevation: 8
}) : super(key: key) { }) : super(key: key) {
assert(items != null);
assert(items.where((DropDownMenuItem<T> item) => item.value == value).length == 1); assert(items.where((DropDownMenuItem<T> item) => item.value == value).length == 1);
} }
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:ui' as ui show ImageFilter; import 'dart:ui' as ui show ImageFilter;
import 'package:flutter/animation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
...@@ -1382,12 +1383,20 @@ class RenderFractionalTranslation extends RenderProxyBox { ...@@ -1382,12 +1383,20 @@ class RenderFractionalTranslation extends RenderProxyBox {
/// is provided, to check if the new instance actually represents different /// is provided, to check if the new instance actually represents different
/// information. /// information.
/// ///
/// The most efficient way to trigger a repaint is to supply a repaint argument
/// to the constructor of the [CustomPainter]. The custom object will listen to
/// this animation and repaint whenever the animation ticks, avoiding both the
/// build and layout phases of the pipeline.
///
/// The [hitTest] method is invoked when the user interacts with the underlying /// The [hitTest] method is invoked when the user interacts with the underlying
/// render object, to determine if the user hit the object or missed it. /// render object, to determine if the user hit the object or missed it.
abstract class CustomPainter { abstract class CustomPainter {
/// Abstract const constructor. This constructor enables subclasses to provide /// Creates a custom painter.
/// const constructors so that they can be used in const expressions. ///
const CustomPainter(); /// The painter will repaint whenever the [repaint] animation ticks.
const CustomPainter({ Animation<dynamic> repaint }) : _repaint = repaint;
final Animation<dynamic> _repaint;
/// Called whenever the object needs to paint. The given [Canvas] has its /// Called whenever the object needs to paint. The given [Canvas] has its
/// coordinate space configured such that the origin is at the top left of the /// coordinate space configured such that the origin is at the top left of the
...@@ -1500,7 +1509,7 @@ class RenderCustomPaint extends RenderProxyBox { ...@@ -1500,7 +1509,7 @@ class RenderCustomPaint extends RenderProxyBox {
return; return;
CustomPainter oldPainter = _painter; CustomPainter oldPainter = _painter;
_painter = newPainter; _painter = newPainter;
_checkForRepaint(_painter, oldPainter); _didUpdatePainter(_painter, oldPainter);
} }
/// The foreground custom paint delegate. /// The foreground custom paint delegate.
...@@ -1525,10 +1534,10 @@ class RenderCustomPaint extends RenderProxyBox { ...@@ -1525,10 +1534,10 @@ class RenderCustomPaint extends RenderProxyBox {
return; return;
CustomPainter oldPainter = _foregroundPainter; CustomPainter oldPainter = _foregroundPainter;
_foregroundPainter = newPainter; _foregroundPainter = newPainter;
_checkForRepaint(_foregroundPainter, oldPainter); _didUpdatePainter(_foregroundPainter, oldPainter);
} }
void _checkForRepaint(CustomPainter newPainter, CustomPainter oldPainter) { void _didUpdatePainter(CustomPainter newPainter, CustomPainter oldPainter) {
if (newPainter == null) { if (newPainter == null) {
assert(oldPainter != null); // We should be called only for changes. assert(oldPainter != null); // We should be called only for changes.
markNeedsPaint(); markNeedsPaint();
...@@ -1537,6 +1546,24 @@ class RenderCustomPaint extends RenderProxyBox { ...@@ -1537,6 +1546,24 @@ class RenderCustomPaint extends RenderProxyBox {
newPainter.shouldRepaint(oldPainter)) { newPainter.shouldRepaint(oldPainter)) {
markNeedsPaint(); markNeedsPaint();
} }
if (attached) {
oldPainter._repaint?.removeListener(markNeedsPaint);
newPainter._repaint?.addListener(markNeedsPaint);
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_painter?._repaint?.addListener(markNeedsPaint);
_foregroundPainter?._repaint?.addListener(markNeedsPaint);
}
@override
void detach() {
_painter?._repaint?.removeListener(markNeedsPaint);
_foregroundPainter?._repaint?.removeListener(markNeedsPaint);
super.detach();
} }
@override @override
......
// Copyright 2015 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:test/test.dart';
void main() {
testWidgets('Drop down screen edges', (WidgetTester tester) {
int value = 4;
List<DropDownMenuItem<int>> items = <DropDownMenuItem<int>>[];
for (int i = 0; i < 20; ++i)
items.add(new DropDownMenuItem<int>(value: i, child: new Text('$i')));
void handleChanged(int newValue) {
value = newValue;
}
DropDownButton<int> button = new DropDownButton<int>(
value: value,
onChanged: handleChanged,
items: items
);
tester.pumpWidget(
new MaterialApp(
home: new Material(
child: new Align(
alignment: FractionalOffset.topCenter,
child:button
)
)
)
);
tester.tap(find.text('4'));
tester.pump();
tester.pump(const Duration(seconds: 1)); // finish the menu animation
tester.tap(find.byConfig(button));
// Ideally this would be 4 because the menu would be overscrolled to the
// correct position, but currently we just reposition the menu so that it
// is visible on screen.
expect(value, 0);
// TODO(abarth): Remove these calls to pump once navigator cleans up its
// pop transitions.
tester.pump();
tester.pump(const Duration(seconds: 1)); // finish the menu animation
});
}
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