dropdown.dart 10.1 KB
Newer Older
1 2 3 4 5 6 7 8
// 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';

9
import 'debug.dart';
10
import 'icon.dart';
11
import 'icons.dart';
12 13 14
import 'ink_well.dart';
import 'shadows.dart';
import 'theme.dart';
15
import 'material.dart';
16

Hixie's avatar
Hixie committed
17
const Duration _kDropDownMenuDuration = const Duration(milliseconds: 300);
18
const double _kMenuItemHeight = 48.0;
19
const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.only(left: 36.0, right: 36.0);
20
const double _kBaselineOffsetFromBottom = 20.0;
Hixie's avatar
Hixie committed
21
const Border _kDropDownUnderline = const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: 2.0));
22

Hixie's avatar
Hixie committed
23 24
class _DropDownMenuPainter extends CustomPainter {
  const _DropDownMenuPainter({
25 26 27 28 29 30 31 32 33 34 35 36 37
    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;

38
  @override
39
  void paint(Canvas canvas, Size size) {
40
    final BoxPainter painter = new BoxDecoration(
41 42 43
      backgroundColor: color,
      borderRadius: 2.0,
      boxShadow: elevationToShadow[elevation]
44
    ).createBoxPainter();
45 46 47 48 49 50

    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));
  }

51
  @override
Hixie's avatar
Hixie committed
52
  bool shouldRepaint(_DropDownMenuPainter oldPainter) {
53 54 55 56 57 58 59 60
    return oldPainter.color != color
        || oldPainter.elevation != elevation
        || oldPainter.menuTop != menuTop
        || oldPainter.menuBottom != menuBottom
        || oldPainter.renderBox != renderBox;
  }
}

61
class _DropDownMenu<T> extends StatusTransitionWidget {
Hixie's avatar
Hixie committed
62
  _DropDownMenu({
63
    Key key,
Hixie's avatar
Hixie committed
64
    _DropDownRoute<T> route
65
  }) : route = route, super(key: key, animation: route.animation);
66

Hixie's avatar
Hixie committed
67
  final _DropDownRoute<T> route;
68

69
  @override
70 71
  Widget build(BuildContext context) {
    // The menu is shown in three stages (unit timing in brackets):
Hixie's avatar
Hixie committed
72 73
    // [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
74
    //   until it's big enough for as many items as we're going to show.
Hixie's avatar
Hixie committed
75
    // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom.
76 77
    //
    // When the menu is dismissed we just fade the entire thing out
Hixie's avatar
Hixie committed
78
    // in the first 0.25s.
79

Adam Barth's avatar
Adam Barth committed
80
    final double unit = 0.5 / (route.items.length + 1.5);
81
    final List<Widget> children = <Widget>[];
Adam Barth's avatar
Adam Barth committed
82
    for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) {
83
      CurvedAnimation opacity;
Adam Barth's avatar
Adam Barth committed
84
      if (itemIndex == route.selectedIndex) {
85
        opacity = new CurvedAnimation(parent: route.animation, curve: const Interval(0.0, 0.001), reverseCurve: const Interval(0.75, 1.0));
86 87 88
      } 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);
89
        opacity = new CurvedAnimation(parent: route.animation, curve: new Interval(start, end), reverseCurve: const Interval(0.75, 1.0));
90
      }
91
      children.add(new FadeTransition(
92 93
        opacity: opacity,
        child: new InkWell(
94 95 96 97
          child: new Container(
            padding: _kMenuHorizontalPadding,
            child: route.items[itemIndex]
          ),
98 99 100 101
          onTap: () => Navigator.pop(
            context,
            new _DropDownRouteResult<T>(route.items[itemIndex].value)
          )
102 103 104 105
        )
      ));
    }

106 107
    final CurvedAnimation opacity = new CurvedAnimation(
      parent: route.animation,
108 109
      curve: const Interval(0.0, 0.25),
      reverseCurve: const Interval(0.75, 1.0)
110 111
    );

112 113
    final CurvedAnimation resize = new CurvedAnimation(
      parent: route.animation,
114
      curve: const Interval(0.25, 0.5),
115 116
      reverseCurve: const Interval(0.0, 0.001)
    );
117 118 119 120 121 122 123 124

    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
125 126
    );

127 128
    Widget child = new Material(
      type: MaterialType.transparency,
129
      child: new Block(children: children)
130 131 132 133 134
    );
    return new FadeTransition(
      opacity: opacity,
      child: new AnimatedBuilder(
        animation: resize,
135
        builder: (BuildContext context, Widget child) {
136 137 138 139
          return new CustomPaint(
            painter: new _DropDownMenuPainter(
              color: Theme.of(context).canvasColor,
              elevation: route.elevation,
140 141
              menuTop: menuTop.evaluate(resize),
              menuBottom: menuBottom.evaluate(resize),
142 143
              renderBox: context.findRenderObject()
            ),
144
            child: child
145
          );
146 147
        },
        child: child
148 149 150 151 152
      )
    );
  }
}

153 154 155 156 157
// 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);
158

159
  final T result;
160 161

  @override
162
  bool operator ==(dynamic other) {
163
    if (other is! _DropDownRouteResult<T>)
164 165 166 167
      return false;
    final _DropDownRouteResult<T> typedOther = other;
    return result == typedOther.result;
  }
168 169

  @override
170 171 172 173
  int get hashCode => result.hashCode;
}

class _DropDownRoute<T> extends PopupRoute<_DropDownRouteResult<T>> {
Hixie's avatar
Hixie committed
174
  _DropDownRoute({
175
    Completer<_DropDownRouteResult<T>> completer,
176 177 178
    this.items,
    this.selectedIndex,
    this.rect,
Hans Muller's avatar
Hans Muller committed
179
    this.elevation: 8
Hixie's avatar
Hixie committed
180
  }) : super(completer: completer);
181

Hixie's avatar
Hixie committed
182 183
  final List<DropDownMenuItem<T>> items;
  final int selectedIndex;
184
  final Rect rect;
Hans Muller's avatar
Hans Muller committed
185
  final int elevation;
186

187
  @override
Hixie's avatar
Hixie committed
188
  Duration get transitionDuration => _kDropDownMenuDuration;
189 190

  @override
Hixie's avatar
Hixie committed
191
  bool get barrierDismissable => true;
192 193

  @override
Hixie's avatar
Hixie committed
194
  Color get barrierColor => null;
195

196
  @override
197 198
  ModalPosition getPosition(BuildContext context) {
    RenderBox overlayBox = Overlay.of(context).context.findRenderObject();
199
    assert(overlayBox != null); // can't be null; routes get inserted by Navigator which has its own Overlay
200 201 202 203 204 205 206 207 208
    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
    );
  }

209
  @override
210
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation) {
211
    return new _DropDownMenu<T>(route: this);
212
  }
213 214
}

215
class DropDownMenuItem<T> extends StatelessWidget {
Hixie's avatar
Hixie committed
216
  DropDownMenuItem({
217 218 219 220 221
    Key key,
    this.value,
    this.child
  }) : super(key: key);

222
  /// The widget below this widget in the tree.
223
  final Widget child;
224 225 226 227

  /// The value to return if the user selects this menu item.
  ///
  /// Eventually returned in a call to [DropDownButton.onChanged].
228
  final T value;
229

230
  @override
231 232 233
  Widget build(BuildContext context) {
    return new Container(
      height: _kMenuItemHeight,
234
      padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 6.0),
235
      child: new DefaultTextStyle(
236
        style: Theme.of(context).textTheme.subhead,
237 238 239 240 241 242 243 244 245
        child: new Baseline(
          baseline: _kMenuItemHeight - _kBaselineOffsetFromBottom,
          child: child
        )
      )
    );
  }
}

246
class DropDownButton<T> extends StatefulWidget {
Hixie's avatar
Hixie committed
247
  DropDownButton({
248 249 250 251
    Key key,
    this.items,
    this.value,
    this.onChanged,
Hans Muller's avatar
Hans Muller committed
252
    this.elevation: 8
253 254 255
  }) : super(key: key) {
    assert(items.where((DropDownMenuItem<T> item) => item.value == value).length == 1);
  }
256

Hixie's avatar
Hixie committed
257
  final List<DropDownMenuItem<T>> items;
258
  final T value;
Hixie's avatar
Hixie committed
259
  final ValueChanged<T> onChanged;
Hans Muller's avatar
Hans Muller committed
260
  final int elevation;
261

262
  @override
263 264 265 266 267 268
  _DropDownButtonState<T> createState() => new _DropDownButtonState<T>();
}

class _DropDownButtonState<T> extends State<DropDownButton<T>> {
  final GlobalKey indexedStackKey = new GlobalKey(debugLabel: 'DropDownButton.IndexedStack');

269
  @override
270 271 272
  void initState() {
    super.initState();
    _updateSelectedIndex();
273
    assert(_selectedIndex != null);
274 275
  }

276
  @override
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293
  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() {
294 295
    final RenderBox renderBox = indexedStackKey.currentContext.findRenderObject();
    final Rect rect = renderBox.localToGlobal(Point.origin) & renderBox.size;
296
    final Completer<_DropDownRouteResult<T>> completer = new Completer<_DropDownRouteResult<T>>();
Hixie's avatar
Hixie committed
297
    Navigator.push(context, new _DropDownRoute<T>(
298
      completer: completer,
299 300
      items: config.items,
      selectedIndex: _selectedIndex,
301
      rect: _kMenuHorizontalPadding.inflateRect(rect),
302
      elevation: config.elevation
303
    ));
304 305
    completer.future.then((_DropDownRouteResult<T> newValue) {
      if (!mounted || newValue == null)
306 307
        return;
      if (config.onChanged != null)
308
        config.onChanged(newValue.result);
309 310 311
    });
  }

312
  @override
313
  Widget build(BuildContext context) {
314
    assert(debugCheckHasMaterial(context));
315
    return new GestureDetector(
316
      onTap: _handleTap,
317
      child: new Container(
Hixie's avatar
Hixie committed
318
        decoration: new BoxDecoration(border: _kDropDownUnderline),
319 320 321 322 323 324 325 326 327
        child: new Row(
          children: <Widget>[
            new IndexedStack(
              children: config.items,
              key: indexedStackKey,
              index: _selectedIndex,
              alignment: const FractionalOffset(0.5, 0.0)
            ),
            new Container(
328
              child: new Icon(icon: Icons.arrow_drop_down, size: 36.0),
329
              padding: const EdgeInsets.only(top: 6.0)
330 331
            )
          ],
332
          mainAxisAlignment: MainAxisAlignment.collapse
333
        )
334
      )
335 336 337
    );
  }
}