313 lines
9.7 KiB
Dart
313 lines
9.7 KiB
Dart
// 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 'dart:async';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'debug.dart';
|
|
import 'icon.dart';
|
|
import 'icons.dart';
|
|
import 'ink_well.dart';
|
|
import 'shadows.dart';
|
|
import 'theme.dart';
|
|
import 'material.dart';
|
|
|
|
const Duration _kDropDownMenuDuration = const Duration(milliseconds: 300);
|
|
const double _kMenuItemHeight = 48.0;
|
|
const EdgeDims _kMenuHorizontalPadding = const EdgeDims.only(left: 36.0, right: 36.0);
|
|
const double _kBaselineOffsetFromBottom = 20.0;
|
|
const Border _kDropDownUnderline = const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: 2.0));
|
|
|
|
class _DropDownMenuPainter extends CustomPainter {
|
|
const _DropDownMenuPainter({
|
|
this.color,
|
|
this.elevation,
|
|
this.menuTop,
|
|
this.menuBottom,
|
|
this.renderBox
|
|
});
|
|
|
|
final Color color;
|
|
final int elevation;
|
|
final double menuTop;
|
|
final double menuBottom;
|
|
final RenderBox renderBox;
|
|
|
|
void paint(Canvas canvas, Size size) {
|
|
final BoxPainter painter = new BoxDecoration(
|
|
backgroundColor: color,
|
|
borderRadius: 2.0,
|
|
boxShadow: elevationToShadow[elevation]
|
|
).createBoxPainter();
|
|
|
|
double top = renderBox.globalToLocal(new Point(0.0, menuTop)).y;
|
|
double bottom = renderBox.globalToLocal(new Point(0.0, menuBottom)).y;
|
|
painter.paint(canvas, new Rect.fromLTRB(0.0, top, size.width, bottom));
|
|
}
|
|
|
|
bool shouldRepaint(_DropDownMenuPainter oldPainter) {
|
|
return oldPainter.color != color
|
|
|| oldPainter.elevation != elevation
|
|
|| oldPainter.menuTop != menuTop
|
|
|| oldPainter.menuBottom != menuBottom
|
|
|| oldPainter.renderBox != renderBox;
|
|
}
|
|
}
|
|
|
|
class _DropDownMenu<T> extends StatusTransitionComponent {
|
|
_DropDownMenu({
|
|
Key key,
|
|
_DropDownRoute<T> route
|
|
}) : route = route, super(key: key, animation: route.animation);
|
|
|
|
final _DropDownRoute<T> route;
|
|
|
|
Widget build(BuildContext context) {
|
|
// The menu is shown in three stages (unit timing in brackets):
|
|
// [0s - 0.25s] - Fade in a rect-sized menu container with the selected item.
|
|
// [0.25s - 0.5s] - Grow the otherwise empty menu container from the center
|
|
// until it's big enough for as many items as we're going to show.
|
|
// [0.5s - 1.0s] Fade in the remaining visible items from top to bottom.
|
|
//
|
|
// When the menu is dismissed we just fade the entire thing out
|
|
// in the first 0.25s.
|
|
|
|
final double unit = 0.5 / (route.items.length + 1.5);
|
|
final List<Widget> children = <Widget>[];
|
|
for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) {
|
|
CurvedAnimation opacity;
|
|
if (itemIndex == route.selectedIndex) {
|
|
opacity = new CurvedAnimation(parent: route.animation, curve: const Interval(0.0, 0.001), reverseCurve: const Interval(0.75, 1.0));
|
|
} else {
|
|
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);
|
|
opacity = new CurvedAnimation(parent: route.animation, curve: new Interval(start, end), reverseCurve: const Interval(0.75, 1.0));
|
|
}
|
|
children.add(new FadeTransition(
|
|
opacity: opacity,
|
|
child: new InkWell(
|
|
child: new Container(
|
|
padding: _kMenuHorizontalPadding,
|
|
child: route.items[itemIndex]
|
|
),
|
|
onTap: () => Navigator.pop(
|
|
context,
|
|
new _DropDownRouteResult<T>(route.items[itemIndex].value)
|
|
)
|
|
)
|
|
));
|
|
}
|
|
|
|
final CurvedAnimation opacity = new CurvedAnimation(
|
|
parent: route.animation,
|
|
curve: const Interval(0.0, 0.25),
|
|
reverseCurve: const Interval(0.75, 1.0)
|
|
);
|
|
|
|
final CurvedAnimation resize = new CurvedAnimation(
|
|
parent: route.animation,
|
|
curve: const Interval(0.25, 0.5),
|
|
reverseCurve: const Interval(0.0, 0.001)
|
|
);
|
|
|
|
final Tween<double> menuTop = new Tween<double>(
|
|
begin: route.rect.top,
|
|
end: route.rect.top - route.selectedIndex * route.rect.height
|
|
);
|
|
final Tween<double> menuBottom = new Tween<double>(
|
|
begin: route.rect.bottom,
|
|
end: menuTop.end + route.items.length * route.rect.height
|
|
);
|
|
|
|
Widget child = new Material(
|
|
type: MaterialType.transparency,
|
|
child: new Block(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
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// We box the return value so that the return value can be null. Otherwise,
|
|
// canceling the route (which returns null) would get confused with actually
|
|
// returning a real null value.
|
|
class _DropDownRouteResult<T> {
|
|
const _DropDownRouteResult(this.result);
|
|
final T result;
|
|
bool operator ==(dynamic other) {
|
|
if (other is! _DropDownRouteResult<T>)
|
|
return false;
|
|
final _DropDownRouteResult<T> typedOther = other;
|
|
return result == typedOther.result;
|
|
}
|
|
int get hashCode => result.hashCode;
|
|
}
|
|
|
|
class _DropDownRoute<T> extends PopupRoute<_DropDownRouteResult<T>> {
|
|
_DropDownRoute({
|
|
Completer<_DropDownRouteResult<T>> completer,
|
|
this.items,
|
|
this.selectedIndex,
|
|
this.rect,
|
|
this.elevation: 8
|
|
}) : super(completer: completer);
|
|
|
|
final List<DropDownMenuItem<T>> items;
|
|
final int selectedIndex;
|
|
final Rect rect;
|
|
final int elevation;
|
|
|
|
Duration get transitionDuration => _kDropDownMenuDuration;
|
|
bool get barrierDismissable => true;
|
|
Color get barrierColor => null;
|
|
|
|
ModalPosition getPosition(BuildContext context) {
|
|
RenderBox overlayBox = Overlay.of(context).context.findRenderObject();
|
|
assert(overlayBox != null); // can't be null; routes get inserted by Navigator which has its own Overlay
|
|
Size overlaySize = overlayBox.size;
|
|
RelativeRect menuRect = new RelativeRect.fromSize(rect, overlaySize);
|
|
return new ModalPosition(
|
|
top: menuRect.top - selectedIndex * rect.height,
|
|
left: menuRect.left,
|
|
right: menuRect.right
|
|
);
|
|
}
|
|
|
|
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
|
|
return new _DropDownMenu<T>(route: this);
|
|
}
|
|
}
|
|
|
|
class DropDownMenuItem<T> extends StatelessComponent {
|
|
DropDownMenuItem({
|
|
Key key,
|
|
this.value,
|
|
this.child
|
|
}) : super(key: key);
|
|
|
|
final Widget child;
|
|
final T value;
|
|
|
|
Widget build(BuildContext context) {
|
|
return new Container(
|
|
height: _kMenuItemHeight,
|
|
padding: const EdgeDims.only(left: 8.0, right: 8.0, top: 6.0),
|
|
child: new DefaultTextStyle(
|
|
style: Theme.of(context).text.subhead,
|
|
child: new Baseline(
|
|
baseline: _kMenuItemHeight - _kBaselineOffsetFromBottom,
|
|
child: child
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
class DropDownButton<T> extends StatefulComponent {
|
|
DropDownButton({
|
|
Key key,
|
|
this.items,
|
|
this.value,
|
|
this.onChanged,
|
|
this.elevation: 8
|
|
}) : super(key: key) {
|
|
assert(items.where((DropDownMenuItem<T> item) => item.value == value).length == 1);
|
|
}
|
|
|
|
final List<DropDownMenuItem<T>> items;
|
|
final T value;
|
|
final ValueChanged<T> onChanged;
|
|
final int elevation;
|
|
|
|
_DropDownButtonState<T> createState() => new _DropDownButtonState<T>();
|
|
}
|
|
|
|
class _DropDownButtonState<T> extends State<DropDownButton<T>> {
|
|
final GlobalKey indexedStackKey = new GlobalKey(debugLabel: 'DropDownButton.IndexedStack');
|
|
|
|
void initState() {
|
|
super.initState();
|
|
_updateSelectedIndex();
|
|
assert(_selectedIndex != null);
|
|
}
|
|
|
|
void didUpdateConfig(DropDownButton<T> oldConfig) {
|
|
if (config.items[_selectedIndex].value != config.value)
|
|
_updateSelectedIndex();
|
|
}
|
|
|
|
int _selectedIndex;
|
|
|
|
void _updateSelectedIndex() {
|
|
for (int itemIndex = 0; itemIndex < config.items.length; itemIndex++) {
|
|
if (config.items[itemIndex].value == config.value) {
|
|
_selectedIndex = itemIndex;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleTap() {
|
|
final RenderBox renderBox = indexedStackKey.currentContext.findRenderObject();
|
|
final Rect rect = renderBox.localToGlobal(Point.origin) & renderBox.size;
|
|
final Completer<_DropDownRouteResult<T>> completer = new Completer<_DropDownRouteResult<T>>();
|
|
Navigator.push(context, new _DropDownRoute<T>(
|
|
completer: completer,
|
|
items: config.items,
|
|
selectedIndex: _selectedIndex,
|
|
rect: _kMenuHorizontalPadding.inflateRect(rect),
|
|
elevation: config.elevation
|
|
));
|
|
completer.future.then((_DropDownRouteResult<T> newValue) {
|
|
if (!mounted || newValue == null)
|
|
return;
|
|
if (config.onChanged != null)
|
|
config.onChanged(newValue.result);
|
|
});
|
|
}
|
|
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMaterial(context));
|
|
return new GestureDetector(
|
|
onTap: _handleTap,
|
|
child: new Container(
|
|
decoration: new BoxDecoration(border: _kDropDownUnderline),
|
|
child: new Row(
|
|
children: <Widget>[
|
|
new IndexedStack(
|
|
children: config.items,
|
|
key: indexedStackKey,
|
|
index: _selectedIndex,
|
|
alignment: const FractionalOffset(0.5, 0.0)
|
|
),
|
|
new Container(
|
|
child: new Icon(icon: Icons.arrow_drop_down, size: 36.0),
|
|
padding: const EdgeDims.only(top: 6.0)
|
|
)
|
|
],
|
|
justifyContent: FlexJustifyContent.collapse
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|