From a91bc0ba9c16b09676797e5e7330870a2e37b43d Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Fri, 15 Apr 2016 16:51:03 -0700 Subject: [PATCH] Material Data Tables (#3337) + Add new demo to gallery to show data tables. (This currently doesn't use a Card; I'll create a Card version in a subsequent patch.) + Fix checkbox alignment. It now centers in its box regardless. + Add Colors.black54. + Some minor fixes to dartdocs. + DataTable, DataColumn, DataRow, DataCell + RowInkWell + Augment dartdocs of materia/debug.dart. + DropDownButtonHideUnderline to hide the underline in a drop-down when used in a DataTable. + Add new capabilities to InkResponse to support RowInkWell. + Augment dartdocs of materia/material.dart. + Add an assert to catch nested Blocks. + Fix a crash in RenderBox when you remove an object and an ancestor used its baseline. (https://github.com/flutter/flutter/issues/2874) + Fix (and redocument) RenderBaseline/Baseline. + Add flex support to IntrinsicColumnWidth. + Document more stuff on the RenderTable side. + Fix a bug with parentData handling on RenderTable children. + Completely rewrite the column width computations. The old logic made no sense at all. + Add dartdocs to widgets/debug.dart. + Add a toString for TableRow. --- .../lib/demo/data_table_demo.dart | 145 ++++ .../material_gallery/lib/gallery/home.dart | 2 + packages/flutter/lib/material.dart | 5 +- .../flutter/lib/src/material/checkbox.dart | 16 +- packages/flutter/lib/src/material/colors.dart | 16 +- .../flutter/lib/src/material/data_table.dart | 766 ++++++++++++++++++ packages/flutter/lib/src/material/debug.dart | 31 +- .../flutter/lib/src/material/drop_down.dart | 77 +- packages/flutter/lib/src/material/icon.dart | 2 +- .../flutter/lib/src/material/ink_well.dart | 32 +- .../flutter/lib/src/material/material.dart | 106 ++- packages/flutter/lib/src/rendering/block.dart | 26 + packages/flutter/lib/src/rendering/box.dart | 14 +- .../flutter/lib/src/rendering/proxy_box.dart | 3 + .../lib/src/rendering/shifted_box.dart | 20 +- packages/flutter/lib/src/rendering/table.dart | 267 +++++- packages/flutter/lib/src/widgets/basic.dart | 12 +- packages/flutter/lib/src/widgets/debug.dart | 59 +- .../flutter/lib/src/widgets/framework.dart | 1 + packages/flutter/lib/src/widgets/table.dart | 25 +- .../flutter/test/rendering/table_test.dart | 8 +- 21 files changed, 1510 insertions(+), 123 deletions(-) create mode 100644 examples/material_gallery/lib/demo/data_table_demo.dart create mode 100644 packages/flutter/lib/src/material/data_table.dart diff --git a/examples/material_gallery/lib/demo/data_table_demo.dart b/examples/material_gallery/lib/demo/data_table_demo.dart new file mode 100644 index 0000000000..282b1e0da6 --- /dev/null +++ b/examples/material_gallery/lib/demo/data_table_demo.dart @@ -0,0 +1,145 @@ +// Copyright 2016 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/rendering.dart'; +import 'package:flutter/material.dart'; + +class Desert { + Desert(this.name, this.calories, this.fat, this.carbs, this.protein, this.sodium, this.calcium, this.iron); + final String name; + final int calories; + final double fat; + final int carbs; + final double protein; + final int sodium; + final int calcium; + final int iron; + + bool selected = false; +} + +class DataTableDemo extends StatefulWidget { + @override + _DataTableDemoState createState() => new _DataTableDemoState(); +} + +class _DataTableDemoState extends State { + + int _sortColumnIndex; + bool _sortAscending = true; + + final List _deserts = [ + new Desert('Frozen yogurt', 159, 6.0, 24, 4.0, 87, 14, 1), + new Desert('Ice cream sandwich', 237, 9.0, 37, 4.3, 129, 8, 1), + new Desert('Eclair', 262, 16.0, 24, 6.0, 337, 6, 7), + new Desert('Cupcake', 305, 3.7, 67, 4.3, 413, 3, 8), + new Desert('Gingerbread', 356, 16.0, 49, 3.9, 327, 7, 16), + new Desert('Jelly bean', 375, 0.0, 94, 0.0, 50, 0, 0), + new Desert('Lollipop', 392, 0.2, 98, 0.0, 38, 0, 2), + new Desert('Honeycomb', 408, 3.2, 87, 6.5, 562, 0, 45), + new Desert('Donut', 452, 25.0, 51, 4.9, 326, 2, 22), + new Desert('KitKat', 518, 26.0, 65, 7.0, 54, 12, 6), + ]; + + void _sort/**/(Comparable getField(Desert d), int columnIndex, bool ascending) { + setState(() { + _deserts.sort((Desert a, Desert b) { + if (!ascending) { + final Desert c = a; + a = b; + b = c; + } + final Comparable aValue = getField(a); + final Comparable bValue = getField(b); + return Comparable.compare(aValue, bValue); + }); + _sortColumnIndex = columnIndex; + _sortAscending = ascending; + }); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: new AppBar(title: new Text('Data tables')), + body: new Block( + children: [ + new Material( + child: new IntrinsicHeight( + child: new Block( + scrollDirection: Axis.horizontal, + children: [ + new DataTable( + sortColumnIndex: _sortColumnIndex, + sortAscending: _sortAscending, + columns: [ + new DataColumn( + label: new Text('Dessert (100g serving)'), + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.name, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Calories'), + tooltip: 'The total amount of food energy in the given serving size.', + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.calories, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Fat (g)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.fat, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Carbs (g)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.carbs, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Protein (g)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.protein, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Sodium (mg)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.sodium, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Calcium (%)'), + tooltip: 'The amount of calcium as a percentage of the recommended daily amount.', + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.calcium, columnIndex, ascending) + ), + new DataColumn( + label: new Text('Iron (%)'), + numeric: true, + onSort: (int columnIndex, bool ascending) => _sort/**/((Desert d) => d.iron, columnIndex, ascending) + ), + ], + rows: _deserts.map/**/((Desert desert) { + return new DataRow( + key: new ValueKey(desert), + selected: desert.selected, + onSelectChanged: (bool selected) { setState(() { desert.selected = selected; }); }, + cells: [ + new DataCell(new Text('${desert.name}')), + new DataCell(new Text('${desert.calories}')), + new DataCell(new Text('${desert.fat.toStringAsFixed(1)}')), + new DataCell(new Text('${desert.carbs}')), + new DataCell(new Text('${desert.protein.toStringAsFixed(1)}')), + new DataCell(new Text('${desert.sodium}')), + new DataCell(new Text('${desert.calcium}%')), + new DataCell(new Text('${desert.iron}%')), + ] + ); + }).toList(growable: false) + ) + ] + ) + ) + ) + ] + ) + ); + } +} diff --git a/examples/material_gallery/lib/gallery/home.dart b/examples/material_gallery/lib/gallery/home.dart index 139957da9c..cd4647dc84 100644 --- a/examples/material_gallery/lib/gallery/home.dart +++ b/examples/material_gallery/lib/gallery/home.dart @@ -13,6 +13,7 @@ import '../demo/buttons_demo.dart'; import '../demo/cards_demo.dart'; import '../demo/colors_demo.dart'; import '../demo/chip_demo.dart'; +import '../demo/data_table_demo.dart'; import '../demo/date_picker_demo.dart'; import '../demo/dialog_demo.dart'; import '../demo/drop_down_demo.dart'; @@ -122,6 +123,7 @@ class GalleryHomeState extends State { new GalleryItem(title: 'Cards', builder: () => new CardsDemo()), new GalleryItem(title: 'Chips', builder: () => new ChipDemo()), new GalleryItem(title: 'Date picker', builder: () => new DatePickerDemo()), + new GalleryItem(title: 'Data tables', builder: () => new DataTableDemo()), new GalleryItem(title: 'Dialog', builder: () => new DialogDemo()), new GalleryItem(title: 'Drop-down button', builder: () => new DropDownDemo()), new GalleryItem(title: 'Expand/collapse list control', builder: () => new TwoLevelListDemo()), diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index d158559e24..7cc5c5d183 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -19,13 +19,14 @@ export 'src/material/chip.dart'; export 'src/material/circle_avatar.dart'; export 'src/material/colors.dart'; export 'src/material/constants.dart'; +export 'src/material/data_table.dart'; export 'src/material/date_picker.dart'; export 'src/material/date_picker_dialog.dart'; export 'src/material/dialog.dart'; +export 'src/material/divider.dart'; export 'src/material/drawer.dart'; export 'src/material/drawer_header.dart'; export 'src/material/drawer_item.dart'; -export 'src/material/divider.dart'; export 'src/material/drop_down.dart'; export 'src/material/flat_button.dart'; export 'src/material/flexible_space_bar.dart'; @@ -33,10 +34,10 @@ export 'src/material/floating_action_button.dart'; export 'src/material/grid_tile.dart'; export 'src/material/grid_tile_bar.dart'; export 'src/material/icon.dart'; -export 'src/material/icons.dart'; export 'src/material/icon_button.dart'; export 'src/material/icon_theme.dart'; export 'src/material/icon_theme_data.dart'; +export 'src/material/icons.dart'; export 'src/material/ink_well.dart'; export 'src/material/input.dart'; export 'src/material/list.dart'; diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index 662a16a8cc..3856acfcaf 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart @@ -64,6 +64,9 @@ class Checkbox extends StatelessWidget { /// If null, the checkbox will be displayed as disabled. final ValueChanged onChanged; + /// The width of a checkbox widget. + static const double width = 18.0; + @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); @@ -114,10 +117,9 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget { } const double _kMidpoint = 0.5; -const double _kEdgeSize = 18.0; +const double _kEdgeSize = Checkbox.width; const double _kEdgeRadius = 1.0; const double _kStrokeWidth = 2.0; -const double _kOffset = kRadialReactionRadius - _kEdgeSize / 2.0; class _RenderCheckbox extends RenderToggleable { _RenderCheckbox({ @@ -135,11 +137,13 @@ class _RenderCheckbox extends RenderToggleable { @override void paint(PaintingContext context, Offset offset) { - final Canvas canvas = context.canvas; - final double offsetX = _kOffset + offset.dx; - final double offsetY = _kOffset + offset.dy; - paintRadialReaction(canvas, offset, const Point(kRadialReactionRadius, kRadialReactionRadius)); + final Canvas canvas = context.canvas; + + final double offsetX = offset.dx + (size.width - _kEdgeSize) / 2.0; + final double offsetY = offset.dy + (size.height - _kEdgeSize) / 2.0; + + paintRadialReaction(canvas, offset, size.center(Point.origin)); double t = position.value; diff --git a/packages/flutter/lib/src/material/colors.dart b/packages/flutter/lib/src/material/colors.dart index 253ce57698..b6bb1e8c1f 100644 --- a/packages/flutter/lib/src/material/colors.dart +++ b/packages/flutter/lib/src/material/colors.dart @@ -12,6 +12,7 @@ class Colors { /// Completely invisible. static const Color transparent = const Color(0x00000000); + /// Completely opaque black. static const Color black = const Color(0xFF000000); @@ -21,6 +22,11 @@ class Colors { /// Black with 54% opacity. static const Color black54 = const Color(0x8A000000); + /// Black with 38% opacity. + /// + /// Used for the placeholder text in data tables in light themes. + static const Color black38 = const Color(0x61000000); + /// Black with 45% opacity. /// /// Used for modal barriers. @@ -28,14 +34,15 @@ class Colors { /// Black with 26% opacity. /// - /// Used for disabled radio buttons and text of disabled flat buttons in the light theme. + /// Used for disabled radio buttons and the text of disabled flat buttons in light themes. static const Color black26 = const Color(0x42000000); /// Black with 12% opacity. /// - /// Used for the background of disabled raised buttons in the light theme. + /// Used for the background of disabled raised buttons in light themes. static const Color black12 = const Color(0x1F000000); + /// Completely opaque white. static const Color white = const Color(0xFFFFFFFF); @@ -44,17 +51,18 @@ class Colors { /// White with 32% opacity. /// - /// Used for disabled radio buttons and text of disabled flat buttons in the dark theme. + /// Used for disabled radio buttons and the text of disabled flat buttons in dark themes. static const Color white30 = const Color(0x4DFFFFFF); /// White with 12% opacity. /// - /// Used for the background of disabled raised buttons in the dark theme. + /// Used for the background of disabled raised buttons in dark themes. static const Color white12 = const Color(0x1FFFFFFF); /// White with 10% opacity. static const Color white10 = const Color(0x1AFFFFFF); + /// The red primary swatch. static const Map red = const { 50: const Color(0xFFFFEBEE), diff --git a/packages/flutter/lib/src/material/data_table.dart b/packages/flutter/lib/src/material/data_table.dart new file mode 100644 index 0000000000..cdd3f1d07f --- /dev/null +++ b/packages/flutter/lib/src/material/data_table.dart @@ -0,0 +1,766 @@ +// Copyright 2016 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:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'checkbox.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'drop_down.dart'; +import 'icon.dart'; +import 'icon_theme.dart'; +import 'icon_theme_data.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'tooltip.dart'; + +typedef void DataColumnSortCallback(int columnIndex, bool ascending); + +typedef void DataCellEditCallback(Rect cell); + +/// Column configuration for a [DataTable]. +/// +/// One column configuration must be provided for each column to +/// display in the table. The list of [DataColumn] objects is passed +/// as the `columns` argument to the [new DataTable] constructor. +class DataColumn { + /// Creates the configuration for a column of a [DataTable]. + /// + /// The [label] argument must not be null. + const DataColumn({ + this.label, + this.tooltip, + this.numeric: false, + this.onSort + }); + + /// The column heading. + /// + /// Typically, this will be a [Text] widget. It could also be an + /// [Icon] (typically using size 18), or a [Row] with an icon and + /// some text. + /// + /// The label should not include the sort indicator. + final Widget label; + + /// The column heading's tooltip. + /// + /// This is a longer description of the column heading, for cases + /// where the heading might have been abbreviated to keep the column + /// width to a reasonable size. + final String tooltip; + + /// Whether this column represents numeric data or not. + /// + /// The contents of cells of columns containing numeric data are + /// right-aligned. + final bool numeric; + + /// Invoked when the user asks to sort the table using this column. + /// + /// If null, the column will not be considered sortable. + /// + /// See [DataTable.sortColumnIndex] and [DataTable.sortAscending]. + final DataColumnSortCallback onSort; + + bool get _debugInteractive => onSort != null; +} + +/// Row configuration and cell data for a [DataTable]. +/// +/// One row configuration must be provided for each row to +/// display in the table. The list of [DataRow] objects is passed +/// as the `rows` argument to the [new DataTable] constructor. +/// +/// The data for this row of the table is provided in the [cells] +/// property of the [DataRow] object. +class DataRow { + /// Creates the configuration for a row of a [DataTable]. + /// + /// The [cells] argument must not be null. + const DataRow({ + this.key, + this.selected: false, + this.onSelectChanged, + this.cells + }); + + /// A [Key] that uniquely identifies this row. This is used to + /// ensure that if a row is added or removed, any stateful widgets + /// related to this row (e.g. an in-progress checkbox animation) + /// remain on the right row visually. + /// + /// If the table never changes once created, no key is necessary. + final LocalKey key; + + /// Invoked when the user selects or unselects a selectable row. + /// + /// If this is not null, then the row is selectable. The current + /// selection state of the row is given by [selected]. + /// + /// If any row is selectable, then the table's heading row will have + /// a checkbox that can be checked to select all selectable rows + /// (and which is checked if all the rows are selected), and each + /// subsequent row will have a checkbox to toggle just that row. + /// + /// A row whose [onSelectChanged] callback is null is ignored for + /// the purposes of determining the state of the "all" checkbox, + /// and its checkbox is disabled. + final ValueChanged onSelectChanged; + + /// Whether the row is selected. + /// + /// If [onSelectChanged] is non-null for any row in the table, then + /// a checkbox is shown at the start of each row. If the row is + /// selected (true), the checkbox will be checked and the row will + /// be highlighted. + /// + /// Otherwise, the checkbox, if present, will not be checked. + final bool selected; + + /// The data for this row. + /// + /// There must be exactly as many cells as there are columns in the + /// table. + final List cells; + + bool get _debugInteractive => onSelectChanged != null || cells.any((DataCell cell) => cell._debugInteractive); +} + +/// The data for a cell of a [DataTable]. +/// +/// One list of [DataCell] objects must be provided for each [DataRow] +/// in the [DataTable], in the [new DataRow] constructor's `cells` +/// argument. +class DataCell { + /// Creates an object to hold the data for a cell in a [DataTable]. + /// + /// The first argument is the widget to show for the cell, typically + /// a [Text] or [DropDownButton] widget; this becomes the [widget] + /// property and must not be null. + /// + /// If the cell has no data, then a [Text] widget with placeholder + /// text should be provided instead, and then the [placeholder] + /// argument should be set to true. + const DataCell(this.widget, { + this.placeholder: false, + this.showEditIcon: false, + this.onTap + }); + + /// The data for the row. + /// + /// Typically a [Text] widget or a [DropDownButton] widget. + /// + /// If the cell has no data, then a [Text] widget with placeholder + /// text should be provided instead, and [placeholder] should be set + /// to true. + final Widget widget; + + /// Whether the [widget] is actually a placeholder. + /// + /// If this is true, the default text style for the cell is changed + /// to be appropriate for placeholder text. + final bool placeholder; + + /// Whether to show an edit icon at the end of the cell. + /// + /// This does not make the cell actually editable; the caller must + /// implement editing behavior if desired (initiated from the + /// [onTap] callback). + /// + /// If this is set, [onTap] should also be set, otherwise tapping + /// the icon will have no effect. + final bool showEditIcon; + + /// Invoked if the cell is tapped. + /// + /// If non-null, tapping the cell will invoke this callback. If + /// null, tapping the cell will attempt to select the row (if + /// [TableRow.onSelectChanged] is provided). + final VoidCallback onTap; + + bool get _debugInteractive => onTap != null; +} + +/// A material design data table. +/// +/// Displaying data in a table is expensive, because to lay out the +/// table all the data must be measured twice, once to negotiate the +/// dimensions to use for each column, and once to actually lay out +/// the table given the results of the negotiation. +/// +// /// For this reason, if you have a lot of data (say, more than a dozen +// /// rows with a dozen columns, though the precise limits depend on the +// /// target device), it is suggested that you use a [DataCard] which +// /// automatically splits the data into multiple pages. +// /// +/// See also: +/// +/// * [DataColumn] +/// * [DataRow] +/// * [DataCell] +/// * +class DataTable extends StatelessWidget { + /// Creates a widget describing a data table. + /// + /// The [columns] argument must be a list of as many [DataColumn] + /// objects as the table is to have columns, ignoring the leading + /// checkbox column if any. The [columns] argument must have a + /// length greater than zero. + /// + /// The [rows] argument must be a list of as many [DataRow] objects + /// as the table is to have rows, ignoring the leading heading row + /// that contains the column headings (derived from the [columns] + /// argument). There may be zero rows, but the rows argument must + /// not be null. + /// + /// Each [DataRow] object in [rows] must have as many [DataCell] + /// objects in the [DataRow.cells] list as the table has columns. + /// + /// If the table is sorted, the column that provides the current + /// primary key should be specified by index in [sortColumnIndex], 0 + /// meaning the first column in [columns], 1 being the next one, and + /// so forth. + /// + /// The actual sort order can be specified using [sortAscending]; if + /// the sort order is ascending, this should be true (the default), + /// otherwise it should be false. + DataTable({ + Key key, + List columns, + this.sortColumnIndex, + this.sortAscending: true, + this.rows + }) : columns = columns, + _onlyTextColumn = _initOnlyTextColumn(columns), super(key: key) { + assert(columns != null); + assert(columns.length > 0); + assert(sortAscending != null); + assert(rows != null); + assert(!rows.any((DataRow row) => row.cells.length != columns.length)); + assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)); + } + + /// The configuration and labels for the columns in the table. + final List columns; + + /// The current primary sort key's column. + /// + /// If non-null, indicates that the indicated column is the column + /// by which the data is sorted. The number must correspond to the + /// index of the relevant column in [columns]. + /// + /// Setting this will cause the relevant column to have a sort + /// indicator displayed. + /// + /// When this is null, it implies that the table's sort order does + /// not correspond to any of the columns. + final int sortColumnIndex; + + /// Whether the column mentioned in [sortColumnIndex], if any, is sorted + /// in ascending order. + /// + /// If true, the order is ascending (meaning the rows with the + /// smallest values for the current sort column are first in the + /// table). + /// + /// If false, the order is descending (meaning the rows with the + /// smallest values for the current sort column are last in the + /// table). + final bool sortAscending; + + /// The data to show in each row (excluding the row that contains + /// the column headings). Must be non-null, but may be empty. + final List rows; + + // Set by the constructor to the index of the only Column that is + // non-numeric, if there is exactly one, otherwise null. + final int _onlyTextColumn; + static int _initOnlyTextColumn(List columns) { + int result; + for (int index = 0; index < columns.length; index += 1) { + DataColumn column = columns[index]; + if (!column.numeric) { + if (result != null) + return null; + result = index; + } + } + return result; + } + + bool get _debugInteractive { + return columns.any((DataColumn column) => column._debugInteractive) + || rows.any((DataRow row) => row._debugInteractive); + } + + static final LocalKey _headingRowKey = new UniqueKey(); + + void _handleSelectAll(bool checked) { + for (DataRow row in rows) { + if ((row.onSelectChanged != null) && (row.selected != checked)) + row.onSelectChanged(checked); + } + } + + static const double _kHeadingRowHeight = 56.0; + static const double _kDataRowHeight = 48.0; + static const double _kTablePadding = 24.0; + static const double _kColumnSpacing = 56.0; + static const double _kSortArrowPadding = 2.0; + static const double _kHeadingFontSize = 12.0; + static const Duration _kSortArrowAnimationDuration = const Duration(milliseconds: 150); + static const Color _kGrey100Opacity = const Color(0x0A000000); // Grey 100 as opacity instead of solid color + + Widget _buildCheckbox({ + Color color, + bool checked, + VoidCallback onRowTap, + ValueChanged onCheckboxChanged + }) { + Widget contents = new Padding( + padding: const EdgeInsets.fromLTRB(_kTablePadding, 0.0, _kTablePadding / 2.0, 0.0), + child: new Center( + child: new Checkbox( + activeColor: color, + value: checked, + onChanged: onCheckboxChanged + ) + ) + ); + if (onRowTap != null) { + contents = new RowInkWell( + onTap: onRowTap, + child: contents + ); + } + return new TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: contents + ); + } + + Widget _buildHeadingCell({ + EdgeInsets padding, + Widget label, + String tooltip, + bool numeric, + VoidCallback onSort, + bool sorted, + bool ascending + }) { + if (onSort != null) { + final Widget arrow = new _SortArrow( + visible: sorted, + down: sorted ? ascending : null, + duration: _kSortArrowAnimationDuration + ); + final Widget arrowPadding = new SizedBox(width: _kSortArrowPadding); + label = new Row( + children: numeric ? [ arrow, arrowPadding, label ] + : [ label, arrowPadding, arrow ] + ); + } + label = new Container( + padding: padding, + height: _kHeadingRowHeight, + child: new Align( + alignment: new FractionalOffset(numeric ? 1.0 : 0.0, 0.5), // TODO(ianh): RTL for non-numeric + child: new AnimatedDefaultTextStyle( + style: new TextStyle( + // TODO(ianh): font family should be Roboto; see https://github.com/flutter/flutter/issues/3116 + fontWeight: FontWeight.w500, + fontSize: _kHeadingFontSize, + color: onSort != null && sorted ? Colors.black87 : Colors.black54, + height: _kHeadingRowHeight / _kHeadingFontSize + ), + duration: _kSortArrowAnimationDuration, + child: label + ) + ) + ); + if (tooltip != null) { + label = new Tooltip( + message: tooltip, + child: label + ); + } + if (onSort != null) { + label = new InkWell( + onTap: onSort, + // TODO(ianh): When we do RTL, we need to use 'end' ordering for the non-numeric case + child: label + ); + } + return label; + } + + Widget _buildDataCell({ + EdgeInsets padding, + Widget label, + bool numeric, + bool placeholder, + bool showEditIcon, + VoidCallback onTap, + VoidCallback onSelectChanged + }) { + if (showEditIcon) { + final Widget icon = new Icon(icon: Icons.edit, size: 18.0); + label = new Flexible(child: label); + label = new Row(children: numeric ? [ icon, label ] : [ label, icon ]); + } + label = new Container( + padding: padding, + height: _kDataRowHeight, + child: new Align( + alignment: new FractionalOffset(numeric ? 1.0 : 0.0, 0.5), // TODO(ianh): RTL for non-numeric + child: new DefaultTextStyle( + style: new TextStyle( + // TODO(ianh): font family should be Roboto; see https://github.com/flutter/flutter/issues/3116 + fontSize: 13.0, + color: placeholder ? Colors.black38 : Colors.black87 // TODO(ianh): defer to theme, since this won't work in e.g. the dark theme + ), + child: new IconTheme( + data: new IconThemeData( + color: Colors.black54 + ), + child: new DropDownButtonHideUnderline(child: label) + ) + ) + ) + ); + if (onTap != null) { + label = new InkWell( + onTap: onTap, + child: label + ); + } else if (onSelectChanged != null) { + label = new RowInkWell( + onTap: onSelectChanged, + child: label + ); + } + return label; + } + + @override + Widget build(BuildContext context) { + assert(!_debugInteractive || debugCheckHasMaterial(context)); + + final ThemeData theme = Theme.of(context); + final BoxDecoration _kSelectedDecoration = new BoxDecoration( + backgroundColor: _kGrey100Opacity, // has to be transparent so you can see the ink on the material + border: new Border(bottom: new BorderSide(color: theme.dividerColor)) + ); + final BoxDecoration _kUnselectedDecoration = new BoxDecoration( + border: new Border(bottom: new BorderSide(color: theme.dividerColor)) + ); + + final bool showCheckboxColumn = rows.any((DataRow row) => row.onSelectChanged != null); + final bool allChecked = showCheckboxColumn && !rows.any((DataRow row) => row.onSelectChanged != null && !row.selected); + + List tableColumns = new List(columns.length + (showCheckboxColumn ? 1 : 0)); + List tableRows = new List.generate( + rows.length + 1, // the +1 is for the header row + (int index) { + return new TableRow( + key: index == 0 ? _headingRowKey : rows[index - 1].key, + decoration: index > 0 && rows[index - 1].selected ? _kSelectedDecoration + : _kUnselectedDecoration, + children: new List(tableColumns.length) + ); + } + ); + + int rowIndex; + + int displayColumnIndex = 0; + if (showCheckboxColumn) { + tableColumns[0] = new FixedColumnWidth(_kTablePadding + Checkbox.width + _kTablePadding / 2.0); + tableRows[0].children[0] = _buildCheckbox( + color: theme.accentColor, + checked: allChecked, + onCheckboxChanged: _handleSelectAll + ); + rowIndex = 1; + for (DataRow row in rows) { + tableRows[rowIndex].children[0] = _buildCheckbox( + color: theme.accentColor, + checked: row.selected, + onRowTap: () => row.onSelectChanged(!row.selected), + onCheckboxChanged: row.onSelectChanged + ); + rowIndex += 1; + } + displayColumnIndex += 1; + } + + for (int dataColumnIndex = 0; dataColumnIndex < columns.length; dataColumnIndex += 1) { + DataColumn column = columns[dataColumnIndex]; + final EdgeInsets padding = new EdgeInsets.fromLTRB( + dataColumnIndex == 0 ? showCheckboxColumn ? _kTablePadding / 2.0 : _kTablePadding : _kColumnSpacing / 2.0, + 0.0, + dataColumnIndex == columns.length - 1 ? _kTablePadding : _kColumnSpacing / 2.0, + 0.0 + ); + if (dataColumnIndex == _onlyTextColumn) { + tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(flex: 1.0); + } else { + tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(); + } + tableRows[0].children[displayColumnIndex] = _buildHeadingCell( + padding: padding, + label: column.label, + tooltip: column.tooltip, + numeric: column.numeric, + onSort: () => column.onSort(dataColumnIndex, sortColumnIndex == dataColumnIndex ? !sortAscending : true), + sorted: dataColumnIndex == sortColumnIndex, + ascending: sortAscending + ); + rowIndex = 1; + for (DataRow row in rows) { + DataCell cell = row.cells[dataColumnIndex]; + tableRows[rowIndex].children[displayColumnIndex] = _buildDataCell( + padding: padding, + label: cell.widget, + numeric: column.numeric, + placeholder: cell.placeholder, + showEditIcon: cell.showEditIcon, + onTap: cell.onTap, + onSelectChanged: () => row.onSelectChanged(!row.selected) + ); + rowIndex += 1; + } + displayColumnIndex += 1; + } + + return new Table( + columnWidths: tableColumns.asMap(), + children: tableRows + ); + } +} + +/// A rectangular area of a Material that responds to touch but clips +/// its ink splashes to the current table row of the nearest table. +/// +/// Must have an ancestor [Material] widget in which to cause ink +/// reactions and an ancestor [Table] widget to establish a row. +/// +/// The RowInkWell must be in the same coordinate space (modulo +/// translations) as the [Table]. If it's rotated or scaled or +/// otherwise transformed, it will not be able to describe the +/// rectangle of the row in its own coordinate system as a [Rect], and +/// thus the splash will not occur. (In general, this is easy to +/// achieve: just put the RowInkWell as the direct child of the +/// [Table], and put the other contents of the cell inside it.) +class RowInkWell extends InkResponse { + RowInkWell({ + Key key, + Widget child, + GestureTapCallback onTap, + GestureTapCallback onDoubleTap, + GestureLongPressCallback onLongPress, + ValueChanged onHighlightChanged + }) : super( + key: key, + child: child, + onTap: onTap, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + onHighlightChanged: onHighlightChanged, + containedInkWell: true, + highlightShape: BoxShape.rectangle + ); + + @override + RectCallback getRectCallback(RenderBox referenceBox) { + return () { + RenderObject cell = referenceBox; + AbstractNode table = cell.parent; + Matrix4 transform = new Matrix4.identity(); + while (table is RenderObject && table is! RenderTable) { + RenderTable parentBox = table; + parentBox.applyPaintTransform(cell, transform); + assert(table == cell.parent); + cell = table; + table = table.parent; + } + if (table is RenderTable) { + TableCellParentData cellParentData = cell.parentData; + assert(cellParentData.y != null); + Rect rect = table.getRowBox(cellParentData.y); + // The rect is in the table's coordinate space. We need to change it to the + // RowInkWell's coordinate space. + table.applyPaintTransform(cell, transform); + Offset offset = MatrixUtils.getAsTranslation(transform); + if (offset != null) + return rect.shift(-offset); + } + return Rect.zero; + }; + } + + @override + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasTable(context)); + return super.debugCheckContext(context); + } +} + +class _SortArrow extends StatefulWidget { + _SortArrow({ + Key key, + this.visible, + this.down, + this.duration + }) : super(key: key); + + final bool visible; + + final bool down; + + final Duration duration; + + @override + _SortArrowState createState() => new _SortArrowState(); +} + +class _SortArrowState extends State<_SortArrow> { + + AnimationController _opacityController; + Animation _opacityAnimation; + + AnimationController _orientationController; + Animation _orientationAnimation; + double _orientationOffset = 0.0; + + bool _down; + + @override + void initState() { + super.initState(); + _opacityAnimation = new CurvedAnimation( + parent: _opacityController = new AnimationController( + duration: config.duration + ), + curve: Curves.ease + ) + ..addListener(_rebuild); + _opacityController.value = config.visible ? 1.0 : 0.0; + _orientationAnimation = new Tween( + begin: 0.0, + end: math.PI + ).animate(new CurvedAnimation( + parent: _orientationController = new AnimationController( + duration: config.duration + ), + curve: Curves.easeIn + )) + ..addListener(_rebuild) + ..addStatusListener(_resetOrientationAnimation); + if (config.visible) + _orientationOffset = config.down ? 0.0 : math.PI; + } + + void _rebuild() { + setState(() { + // The animations changed, so we need to rebuild. + }); + } + + void _resetOrientationAnimation(AnimationStatus status) { + if (status == AnimationStatus.completed) { + assert(_orientationAnimation.value == math.PI); + _orientationOffset += math.PI; + _orientationController.value = 0.0; // TODO(ianh): This triggers a pointless rebuild. + } + } + + @override + void didUpdateConfig(_SortArrow oldConfig) { + super.didUpdateConfig(oldConfig); + bool skipArrow = false; + bool newDown = config.down != null ? config.down : _down; + if (oldConfig.visible != config.visible) { + if (config.visible && (_opacityController.status == AnimationStatus.dismissed)) { + _orientationController.stop(); + _orientationController.value = 0.0; + _orientationOffset = newDown ? 0.0 : math.PI; + skipArrow = true; + } + if (config.visible) { + _opacityController.forward(); + } else { + _opacityController.reverse(); + } + } + if ((_down != newDown) && !skipArrow) { + if (_orientationController.status == AnimationStatus.dismissed) { + _orientationController.forward(); + } else { + _orientationController.reverse(); + } + } + _down = newDown; + } + + @override + void dispose() { + _opacityController.dispose(); + _orientationController.dispose(); + super.dispose(); + } + + static const double _kArrowIconBaselineOffset = -1.5; + static const double _kArrowIconSize = 16.0; + + @override + Widget build(BuildContext context) { + return new Opacity( + opacity: _opacityAnimation.value, + child: new Transform( + transform: new Matrix4.rotationZ(_orientationOffset + _orientationAnimation.value) + ..setTranslationRaw(0.0, _kArrowIconBaselineOffset, 0.0), + alignment: FractionalOffset.center, + child: new Icon( + icon: Icons.arrow_downward, + size: _kArrowIconSize, + color: Colors.black87 + ) + ) + ); + } + +} + +/* + +TODO(ianh): implement DataTableCard + +DataTableCard + - top: 64px + - caption, top left + - 20px Roboto Regular, black87 + - persistent actions, top left + - header when there's a selection + - accent 50? + - show number of selected items + - different actions + - actions, top right + - 24px icons, black54 + +bottom: + - 56px + - handles pagination + - 12px Roboto Regular, black54 + +*/ diff --git a/packages/flutter/lib/src/material/debug.dart b/packages/flutter/lib/src/material/debug.dart index 6fee53708f..f645a362fb 100644 --- a/packages/flutter/lib/src/material/debug.dart +++ b/packages/flutter/lib/src/material/debug.dart @@ -7,9 +7,19 @@ import 'package:flutter/widgets.dart'; import 'material.dart'; import 'scaffold.dart'; -/// Throws an exception of the given build context is not contained in a [Material] widget. +/// Asserts that the given context has a [Material] ancestor. /// -/// Does nothing if asserts are disabled. +/// Used by many material design widgets to make sure that they are +/// only used in contexts where they can print ink onto some material. +/// +/// To invoke this function, use the following pattern, typically in the +/// relevant Widget's [build] method: +/// +/// ```dart +/// assert(debugCheckHasMaterial(context)); +/// ``` +/// +/// Does nothing if asserts are disabled. Always returns true. bool debugCheckHasMaterial(BuildContext context) { assert(() { if (context.widget is! Material && context.ancestorWidgetOfExactType(Material) == null) { @@ -33,9 +43,22 @@ bool debugCheckHasMaterial(BuildContext context) { return true; } -/// Throws an exception of the given build context is not contained in a [Scaffold] widget. +/// Asserts that the given context has a [Scaffold] ancestor. /// -/// Does nothing if asserts are disabled. +/// Used by some material design widgets to make sure that they are +/// only used in contexts where they can communicate with a Scaffold. +/// +/// For example, the [AppBar] in some situations requires a Scaffold +/// to do the right thing with scrolling. +/// +/// To invoke this function, use the following pattern, typically in the +/// relevant Widget's [build] method: +/// +/// ```dart +/// assert(debugCheckHasScaffold(context)); +/// ``` +/// +/// Does nothing if asserts are disabled. Always returns true. bool debugCheckHasScaffold(BuildContext context) { assert(() { if (Scaffold.of(context) == null) { diff --git a/packages/flutter/lib/src/material/drop_down.dart b/packages/flutter/lib/src/material/drop_down.dart index c4ec203de6..a8f84e5d94 100644 --- a/packages/flutter/lib/src/material/drop_down.dart +++ b/packages/flutter/lib/src/material/drop_down.dart @@ -15,11 +15,13 @@ import 'theme.dart'; import 'material.dart'; const Duration _kDropDownMenuDuration = const Duration(milliseconds: 300); +const double _kTopMargin = 6.0; const double _kMenuItemHeight = 48.0; const EdgeInsets _kMenuVerticalPadding = const EdgeInsets.symmetric(vertical: 8.0); const EdgeInsets _kMenuHorizontalPadding = const EdgeInsets.symmetric(horizontal: 36.0); const double _kBaselineOffsetFromBottom = 20.0; -const Border _kDropDownUnderline = const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: 2.0)); +const double _kBottomBorderHeight = 2.0; +const Border _kDropDownUnderline = const Border(bottom: const BorderSide(color: const Color(0xFFBDBDBD), width: _kBottomBorderHeight)); class _DropDownMenuPainter extends CustomPainter { const _DropDownMenuPainter({ @@ -235,7 +237,7 @@ class DropDownMenuItem extends StatelessWidget { Widget build(BuildContext context) { return new Container( height: _kMenuItemHeight, - padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 6.0), + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: _kTopMargin), child: new DefaultTextStyle( style: Theme.of(context).textTheme.subhead, child: new Baseline( @@ -247,6 +249,32 @@ class DropDownMenuItem extends StatelessWidget { } } +/// An inherited widget that causes any descendant [DropDownButton] +/// widgets to not include their regular underline. +/// +/// This is used by [DataTable] to remove the underline from any +/// [DropDownButton] widgets placed within material data tables, as +/// required by the material design specification. +class DropDownButtonHideUnderline extends InheritedWidget { + /// Creates a [DropDownButtonHideUnderline]. A non-null [child] must + /// be given. + DropDownButtonHideUnderline({ + Key key, + Widget child + }) : super(key: key, child: child) { + assert(child != null); + } + + /// Returns whether the underline of [DropDownButton] widgets should + /// be hidden. + static bool at(BuildContext context) { + return context.inheritFromWidgetOfExactType(DropDownButtonHideUnderline) != null; + } + + @override + bool updateShouldNotify(DropDownButtonHideUnderline old) => false; +} + /// A material design button for selecting from a list of items. /// /// A dropdown button lets the user select from a number of items. The button @@ -336,26 +364,35 @@ class _DropDownButtonState extends State> { @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); + Widget result = new Row( + children: [ + new IndexedStack( + children: config.items, + key: indexedStackKey, + index: _selectedIndex, + alignment: FractionalOffset.topCenter + ), + new Container( + child: new Icon(icon: Icons.arrow_drop_down, size: 36.0), + padding: const EdgeInsets.only(top: _kTopMargin) + ) + ], + mainAxisAlignment: MainAxisAlignment.collapse + ); + if (DropDownButtonHideUnderline.at(context)) { + result = new Padding( + padding: const EdgeInsets.only(bottom: _kBottomBorderHeight), + child: result + ); + } else { + result = new Container( + decoration: const BoxDecoration(border: _kDropDownUnderline), + child: result + ); + } return new GestureDetector( onTap: _handleTap, - child: new Container( - decoration: new BoxDecoration(border: _kDropDownUnderline), - child: new Row( - children: [ - new IndexedStack( - children: config.items, - key: indexedStackKey, - index: _selectedIndex, - alignment: FractionalOffset.topCenter - ), - new Container( - child: new Icon(icon: Icons.arrow_drop_down, size: 36.0), - padding: const EdgeInsets.only(top: 6.0) - ) - ], - mainAxisAlignment: MainAxisAlignment.collapse - ) - ) + child: result ); } } diff --git a/packages/flutter/lib/src/material/icon.dart b/packages/flutter/lib/src/material/icon.dart index 7f18d42a70..e76477eeb9 100644 --- a/packages/flutter/lib/src/material/icon.dart +++ b/packages/flutter/lib/src/material/icon.dart @@ -46,7 +46,7 @@ class Icon extends StatelessWidget { /// Icons occupy a square with width and height equal to size. final double size; - /// The icon to display. + /// The icon to display. The available icons are described in [Icons]. final IconData icon; /// The color to use when drawing the icon. diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index 08717233d6..476dfe0504 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -64,6 +64,31 @@ class InkResponse extends StatefulWidget { /// The shape (e.g., circle, rectangle) to use for the highlight drawn around this part of the material. final BoxShape highlightShape; + /// The rectangle to use for the highlight effect and for clipping + /// the splash effects if [containedInkWell] is true. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [RowInkWell] implements this method to return the rectangle + /// corresponding to the row that the widget is in. + /// + /// The default behavior returns null, which is equivalent to + /// returning the referenceBox argument's bounding box (though + /// slightly more efficient). + RectCallback getRectCallback(RenderBox referenceBox) => null; + + /// Asserts that the given context satisfies the prerequisites for + /// this class. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [RowInkWell] implements this method to verify that the widget is + /// in a table. + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasMaterial(context)); + return true; + } + @override _InkResponseState createState() => new _InkResponseState(); } @@ -85,6 +110,7 @@ class _InkResponseState extends State { referenceBox: referenceBox, color: Theme.of(context).highlightColor, shape: config.highlightShape, + rectCallback: config.getRectCallback(referenceBox), onRemoved: () { assert(_lastHighlight != null); _lastHighlight = null; @@ -105,11 +131,13 @@ class _InkResponseState extends State { RenderBox referenceBox = context.findRenderObject(); assert(Material.of(context) != null); InkSplash splash; + RectCallback rectCallback = config.getRectCallback(referenceBox); splash = Material.of(context).splashAt( referenceBox: referenceBox, position: referenceBox.globalToLocal(position), color: Theme.of(context).splashColor, - containedInWell: config.containedInkWell, + containedInkWell: config.containedInkWell, + rectCallback: config.containedInkWell ? rectCallback : null, onRemoved: () { if (_splashes != null) { assert(_splashes.contains(splash)); @@ -176,7 +204,7 @@ class _InkResponseState extends State { @override Widget build(BuildContext context) { - assert(debugCheckHasMaterial(context)); + assert(config.debugCheckContext(context)); final bool enabled = config.onTap != null || config.onDoubleTap != null || config.onLongPress != null; return new GestureDetector( onTapDown: enabled ? _handleTapDown : null, diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart index ebcdcfa57e..35adf89921 100644 --- a/packages/flutter/lib/src/material/material.dart +++ b/packages/flutter/lib/src/material/material.dart @@ -12,10 +12,15 @@ import 'constants.dart'; import 'shadows.dart'; import 'theme.dart'; -/// The various kinds of material in material design. +/// Signature for callback used by ink effects to obtain the rectangle for the effect. +typedef Rect RectCallback(); + +/// The various kinds of material in material design. Used to +/// configure the default behavior of [Material] widgets. /// /// See also: -/// * [Material] +/// +/// * [Material], in particular [Material.type] /// * [kMaterialEdges] enum MaterialType { /// Infinite extent using default theme canvas color. @@ -27,7 +32,7 @@ enum MaterialType { /// A circle, no color by default (used for floating action buttons). circle, - /// Rounded edges, no color by default (used for MaterialButton buttons). + /// Rounded edges, no color by default (used for [MaterialButton] buttons). button, /// A transparent piece of material that draws ink splashes and highlights. @@ -95,13 +100,36 @@ abstract class MaterialInkController { Color get color; /// Begin a splash, centered at position relative to referenceBox. - /// If containedInWell is true, then the splash will be sized to fit - /// the referenceBox, then clipped to it when drawn. + /// + /// If containedInkWell is true, then the splash will be sized to fit + /// the well rectangle, then clipped to it when drawn. The well + /// rectangle is the box returned by rectCallback, if provided, or + /// otherwise is the bounds of the referenceBox. + /// + /// If containedInkWell is false, then rectCallback should be null. + /// The ink splash is clipped only to the edges of the [Material]. + /// This is the default. + /// /// When the splash is removed, onRemoved will be invoked. - InkSplash splashAt({ RenderBox referenceBox, Point position, Color color, bool containedInWell, VoidCallback onRemoved }); + InkSplash splashAt({ + RenderBox referenceBox, + Point position, + Color color, + bool containedInkWell: false, + RectCallback rectCallback, + VoidCallback onRemoved + }); - /// Begin a highlight, coincident with the referenceBox. - InkHighlight highlightAt({ RenderBox referenceBox, Color color, BoxShape shape: BoxShape.rectangle, VoidCallback onRemoved }); + /// Begin a highlight animation. If a rectCallback is given, then it + /// provides the highlight rectangle, otherwise, the highlight + /// rectangle is coincident with the referenceBox. + InkHighlight highlightAt({ + RenderBox referenceBox, + Color color, + BoxShape shape: BoxShape.rectangle, + RectCallback rectCallback, + VoidCallback onRemoved + }); /// Add an arbitrary InkFeature to this InkController. void addInkFeature(InkFeature feature); @@ -147,22 +175,27 @@ class Material extends StatefulWidget { /// The widget below this widget in the tree. final Widget child; - /// The kind of material (e.g., card or canvas). + /// The kind of material to show (e.g., card or canvas). This + /// affects the shape of the widget, the roundness of its corners if + /// the shape is rectangular, and the default color. final MaterialType type; /// The z-coordinate at which to place this material. final int elevation; - /// The color of the material. + /// The color to paint the material. /// /// Must be opaque. To create a transparent piece of material, use /// [MaterialType.transparency]. + /// + /// By default, the color is derived from the [type] of material. final Color color; /// The typographical style to use for text within this material. final TextStyle textStyle; - /// The ink controller from the closest instance of this class that encloses the given context. + /// The ink controller from the closest instance of this class that + /// encloses the given context. static MaterialInkController of(BuildContext context) { final _RenderInkFeatures result = context.ancestorRenderObjectOfType(const TypeMatcher<_RenderInkFeatures>()); return result; @@ -273,13 +306,24 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController RenderBox referenceBox, Point position, Color color, - bool containedInWell, + bool containedInkWell: false, + RectCallback rectCallback, VoidCallback onRemoved }) { double radius; - if (containedInWell) { - radius = _getSplashTargetSize(referenceBox.size, position); + RectCallback clipCallback; + if (containedInkWell) { + Size size; + if (rectCallback != null) { + size = rectCallback().size; + clipCallback = rectCallback; + } else { + size = referenceBox.size; + clipCallback = () => Point.origin & size; + } + radius = _getSplashTargetSize(size, position); } else { + assert(rectCallback == null); radius = _kDefaultSplashRadius; } _InkSplash splash = new _InkSplash( @@ -288,8 +332,8 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController position: position, color: color, targetRadius: radius, - clipToReferenceBox: containedInWell, - repositionToReferenceBox: !containedInWell, + clipCallback: clipCallback, + repositionToReferenceBox: !containedInkWell, onRemoved: onRemoved ); addInkFeature(splash); @@ -309,6 +353,7 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController RenderBox referenceBox, Color color, BoxShape shape: BoxShape.rectangle, + RectCallback rectCallback, VoidCallback onRemoved }) { _InkHighlight highlight = new _InkHighlight( @@ -316,6 +361,7 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController referenceBox: referenceBox, color: color, shape: shape, + rectCallback: rectCallback, onRemoved: onRemoved ); addInkFeature(highlight); @@ -436,7 +482,7 @@ class _InkSplash extends InkFeature implements InkSplash { this.position, this.color, this.targetRadius, - this.clipToReferenceBox, + this.clipCallback, this.repositionToReferenceBox, VoidCallback onRemoved }) : super(renderer: renderer, referenceBox: referenceBox, onRemoved: onRemoved) { @@ -460,7 +506,7 @@ class _InkSplash extends InkFeature implements InkSplash { final Point position; final Color color; final double targetRadius; - final bool clipToReferenceBox; + final RectCallback clipCallback; final bool repositionToReferenceBox; Animation _radius; @@ -499,25 +545,23 @@ class _InkSplash extends InkFeature implements InkSplash { void paintFeature(Canvas canvas, Matrix4 transform) { Paint paint = new Paint()..color = color.withAlpha(_alpha.value); Point center = position; + if (repositionToReferenceBox) + center = Point.lerp(center, referenceBox.size.center(Point.origin), _radiusController.value); Offset originOffset = MatrixUtils.getAsTranslation(transform); if (originOffset == null) { canvas.save(); canvas.transform(transform.storage); - if (clipToReferenceBox) - canvas.clipRect(Point.origin & referenceBox.size); - if (repositionToReferenceBox) - center = Point.lerp(center, Point.origin, _radiusController.value); + if (clipCallback != null) + canvas.clipRect(clipCallback()); canvas.drawCircle(center, _radius.value, paint); canvas.restore(); } else { - if (clipToReferenceBox) { + if (clipCallback != null) { canvas.save(); - canvas.clipRect(originOffset.toPoint() & referenceBox.size); + canvas.clipRect(clipCallback().shift(originOffset)); } - if (repositionToReferenceBox) - center = Point.lerp(center, referenceBox.size.center(Point.origin), _radiusController.value); canvas.drawCircle(center + originOffset, _radius.value, paint); - if (clipToReferenceBox) + if (clipCallback != null) canvas.restore(); } } @@ -527,6 +571,7 @@ class _InkHighlight extends InkFeature implements InkHighlight { _InkHighlight({ _RenderInkFeatures renderer, RenderBox referenceBox, + this.rectCallback, Color color, this.shape, VoidCallback onRemoved @@ -542,6 +587,8 @@ class _InkHighlight extends InkFeature implements InkHighlight { ).animate(_alphaController); } + final RectCallback rectCallback; + @override Color get color => _color; Color _color; @@ -597,13 +644,14 @@ class _InkHighlight extends InkFeature implements InkHighlight { void paintFeature(Canvas canvas, Matrix4 transform) { Paint paint = new Paint()..color = color.withAlpha(_alpha.value); Offset originOffset = MatrixUtils.getAsTranslation(transform); + final Rect rect = (rectCallback != null ? rectCallback() : Point.origin & referenceBox.size); if (originOffset == null) { canvas.save(); canvas.transform(transform.storage); - _paintHighlight(canvas, Point.origin & referenceBox.size, paint); + _paintHighlight(canvas, rect, paint); canvas.restore(); } else { - _paintHighlight(canvas, originOffset.toPoint() & referenceBox.size, paint); + _paintHighlight(canvas, rect.shift(originOffset), paint); } } diff --git a/packages/flutter/lib/src/rendering/block.dart b/packages/flutter/lib/src/rendering/block.dart index 154ea1e3da..58f592f9b0 100644 --- a/packages/flutter/lib/src/rendering/block.dart +++ b/packages/flutter/lib/src/rendering/block.dart @@ -93,6 +93,32 @@ class RenderBlock extends RenderBox ); return false; }); + assert(() { + switch (mainAxis) { + case Axis.horizontal: + if (!constraints.maxHeight.isInfinite) + return true; + break; + case Axis.vertical: + if (!constraints.maxWidth.isInfinite) + return true; + break; + } + // TODO(ianh): Detect if we're actually nested blocks and say something + // more specific to the exact situation in that case, and don't mention + // nesting blocks in the negative case. + throw new FlutterError( + 'RenderBlock must have a bounded constraint for its cross axis.\n' + 'RenderBlock forces its children to expand to fit the block\'s container, ' + 'so it must be placed in a parent that does constrain the block\'s cross ' + 'axis to a finite dimension. If you are attempting to nest a block with ' + 'one direction inside a block of another direction, you will want to ' + 'wrap the inner one inside a box that fixes the dimension in that direction, ' + 'for example, a RenderIntrinsicWidth or RenderIntrinsicHeight object. ' + 'This is relatively expensive, however.' // (that's why we don't do it automatically) + ); + return false; + }); BoxConstraints innerConstraints = _getInnerConstraints(constraints); double position = 0.0; RenderBox child = firstChild; diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index 60a17399d6..19201885b6 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -609,12 +609,12 @@ abstract class RenderBox extends RenderObject { /// baseline, regardless of padding, font size differences, etc. If there is /// no baseline, this function returns the distance from the y-coordinate of /// the position of the box to the y-coordinate of the bottom of the box - /// (i.e., the height of the box) unless the the caller passes true + /// (i.e., the height of the box) unless the caller passes true /// for `onlyReal`, in which case the function returns null. /// - /// Only call this function calling [layout] on this box. You are only - /// allowed to call this from the parent of this box during that parent's - /// [performLayout] or [paint] functions. + /// Only call this function after calling [layout] on this box. You + /// are only allowed to call this from the parent of this box during + /// that parent's [performLayout] or [paint] functions. double getDistanceToBaseline(TextBaseline baseline, { bool onlyReal: false }) { assert(!needsLayout); assert(!_debugDoingBaseline); @@ -724,6 +724,10 @@ abstract class RenderBox extends RenderObject { 'as big as possible, but it was put inside another render object ' 'that allows its children to pick their own size.\n' '$information' + 'The constraints that applied to the $runtimeType were:\n' + ' $constraints\n' + 'The exact size it was given was:\n' + ' $_size\n' 'See https://flutter.io/layout/ for more information.' ); } @@ -788,7 +792,7 @@ abstract class RenderBox extends RenderObject { // if we have cached data, then someone must have used our data assert(_ancestorUsesBaseline); final RenderObject parent = this.parent; - parent.markNeedsLayout(); + parent?.markNeedsLayout(); assert(parent == this.parent); // Now that they're dirty, we can forget that they used the // baseline. If they use it again, then we'll set the bit diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index b42b80ff6f..0fff284aed 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -456,6 +456,7 @@ class RenderIntrinsicWidth extends RenderProxyBox { if (child == null) return constraints.constrainWidth(0.0); double childResult = child.getMaxIntrinsicWidth(constraints); + assert(!childResult.isInfinite); return constraints.constrainWidth(_applyStep(childResult, _stepWidth)); } @@ -465,6 +466,7 @@ class RenderIntrinsicWidth extends RenderProxyBox { if (child == null) return constraints.constrainHeight(0.0); double childResult = child.getMinIntrinsicHeight(_getInnerConstraints(constraints)); + assert(!childResult.isInfinite); return constraints.constrainHeight(_applyStep(childResult, _stepHeight)); } @@ -474,6 +476,7 @@ class RenderIntrinsicWidth extends RenderProxyBox { if (child == null) return constraints.constrainHeight(0.0); double childResult = child.getMaxIntrinsicHeight(_getInnerConstraints(constraints)); + assert(!childResult.isInfinite); return constraints.constrainHeight(_applyStep(childResult, _stepHeight)); } diff --git a/packages/flutter/lib/src/rendering/shifted_box.dart b/packages/flutter/lib/src/rendering/shifted_box.dart index 83da42326a..9c085cca07 100644 --- a/packages/flutter/lib/src/rendering/shifted_box.dart +++ b/packages/flutter/lib/src/rendering/shifted_box.dart @@ -821,9 +821,16 @@ class RenderCustomSingleChildLayoutBox extends RenderShiftedBox { } } -/// Positions its child vertically according to the child's baseline. +/// Shifts the child down such that the child's baseline (or the +/// bottom of the child, if the child has no baseline) is [baseline] +/// logical pixels below the top of this box, then sizes this box to +/// contain the child. If [baseline] is less than the distance from +/// the top of the child to the baseline of the child, then the child +/// is top-aligned instead. class RenderBaseline extends RenderShiftedBox { - + /// Creates a [RenderBaseline] object. + /// + /// The [baseline] and [baselineType] arguments are required. RenderBaseline({ RenderBox child, double baseline, @@ -862,10 +869,13 @@ class RenderBaseline extends RenderShiftedBox { void performLayout() { if (child != null) { child.layout(constraints.loosen(), parentUsesSize: true); - size = constraints.constrain(child.size); - double delta = baseline - child.getDistanceToBaseline(baselineType); + final double childBaseline = child.getDistanceToBaseline(baselineType); + final double actualBaseline = math.max(baseline, childBaseline); + final double top = actualBaseline - childBaseline; final BoxParentData childParentData = child.parentData; - childParentData.offset = new Offset(0.0, delta); + childParentData.offset = new Offset(0.0, top); + final Size childSize = child.size; + size = constraints.constrain(new Size(childSize.width, top + childSize.height)); } else { performResize(); } diff --git a/packages/flutter/lib/src/rendering/table.dart b/packages/flutter/lib/src/rendering/table.dart index 3e6dda3a78..6103722ee9 100644 --- a/packages/flutter/lib/src/rendering/table.dart +++ b/packages/flutter/lib/src/rendering/table.dart @@ -19,17 +19,47 @@ class TableCellParentData extends BoxParentData { int y; @override - String toString() => '${super.toString()}; $verticalAlignment'; + String toString() => '${super.toString()}; ${verticalAlignment == null ? "default vertical alignment" : "$verticalAlignment"}'; } /// Base class to describe how wide a column in a [RenderTable] should be. abstract class TableColumnWidth { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. const TableColumnWidth(); + /// The smallest width that the column can have. + /// + /// The `cells` argument is an iterable that provides all the cells + /// in the table for this column. Walking the cells is by definition + /// O(N), so algorithms that do that should be considered expensive. + /// + /// The `containerWidth` argument is the `maxWidth` of the incoming + /// constraints for the table, and might be infinite. double minIntrinsicWidth(Iterable cells, double containerWidth); + /// The ideal width that the column should have. This must be equal + /// to or greater than the [minIntrinsicWidth]. The column might be + /// bigger than this width, e.g. if the column is flexible or if the + /// table's width ends up being forced to be bigger than the sum of + /// all the maxIntrinsicWidth values. + /// + /// The `cells` argument is an iterable that provides all the cells + /// in the table for this column. Walking the cells is by definition + /// O(N), so algorithms that do that should be considered expensive. + /// + /// The `containerWidth` argument is the `maxWidth` of the incoming + /// constraints for the table, and might be infinite. double maxIntrinsicWidth(Iterable cells, double containerWidth); + /// The flex factor to apply to the cell if there is any room left + /// over when laying out the table. The remaining space is + /// distributed to any columns with flex in proportion to their flex + /// value (higher values get more space). + /// + /// The `cells` argument is an iterable that provides all the cells + /// in the table for this column. Walking the cells is by definition + /// O(N), so algorithms that do that should be considered expensive. double flex(Iterable cells) => null; @override @@ -40,8 +70,12 @@ abstract class TableColumnWidth { /// cells in that column. /// /// This is a very expensive way to size a column. +/// +/// A flex value can be provided. If specified (and non-null), the +/// column will participate in the distribution of remaining space +/// once all the non-flexible columns have been sized. class IntrinsicColumnWidth extends TableColumnWidth { - const IntrinsicColumnWidth(); + const IntrinsicColumnWidth({ double flex }) : _flex = flex; @override double minIntrinsicWidth(Iterable cells, double containerWidth) { @@ -58,6 +92,11 @@ class IntrinsicColumnWidth extends TableColumnWidth { result = math.max(result, cell.getMaxIntrinsicWidth(const BoxConstraints())); return result; } + + final double _flex; + + @override + double flex(Iterable cells) => _flex; } /// Sizes the column to a specific number of pixels. @@ -169,10 +208,13 @@ class MaxColumnWidth extends TableColumnWidth { @override double flex(Iterable cells) { - double aFlex = a.flex(cells); + final double aFlex = a.flex(cells); if (aFlex == null) return b.flex(cells); - return math.max(aFlex, b.flex(cells)); + final double bFlex = b.flex(cells); + if (bFlex == null) + return null; + return math.max(aFlex, bFlex); } @override @@ -215,7 +257,10 @@ class MinColumnWidth extends TableColumnWidth { double aFlex = a.flex(cells); if (aFlex == null) return b.flex(cells); - return math.min(aFlex, b.flex(cells)); + double bFlex = b.flex(cells); + if (bFlex == null) + return null; + return math.min(aFlex, bFlex); } @override @@ -535,26 +580,34 @@ class RenderTable extends RenderBox { } assert(cells != null); assert(cells.length % columns == 0); - // remove cells that are moving away + // fill a set with the cells that are moving (it's important not + // to dropChild a child that's remaining with us, because that + // would clear their parentData field) + final Set lostChildren = new HashSet(); for (int y = 0; y < _rows; y += 1) { for (int x = 0; x < _columns; x += 1) { int xyOld = x + y * _columns; int xyNew = x + y * columns; if (_children[xyOld] != null && (x >= columns || xyNew >= cells.length || _children[xyOld] != cells[xyNew])) - dropChild(_children[xyOld]); + lostChildren.add(_children[xyOld]); } } - // adopt cells that are arriving + // adopt cells that are arriving, and cross cells that are just moving off our list of lostChildren int y = 0; while (y * columns < cells.length) { for (int x = 0; x < columns; x += 1) { int xyNew = x + y * columns; int xyOld = x + y * _columns; - if (cells[xyNew] != null && (x >= _columns || y >= _rows || _children[xyOld] != cells[xyNew])) - adoptChild(cells[xyNew]); + if (cells[xyNew] != null && (x >= _columns || y >= _rows || _children[xyOld] != cells[xyNew])) { + if (!lostChildren.remove(cells[xyNew])) + adoptChild(cells[xyNew]); + } } y += 1; } + // drop all the lost children + for (RenderBox oldChild in lostChildren) + dropChild(oldChild); // update our internal values _columns = columns; _rows = cells.length ~/ columns; @@ -666,7 +719,7 @@ class RenderTable extends RenderBox { // honorable mention, most likely to improve if taught about memoization award assert(constraints.debugAssertIsValid()); assert(_children.length == rows * columns); - final List widths = computeColumnWidths(constraints); + final List widths = _computeColumnWidths(constraints); double rowTop = 0.0; for (int y = 0; y < rows; y += 1) { double rowHeight = 0.0; @@ -694,6 +747,10 @@ class RenderTable extends RenderBox { return _baselineDistance; } + /// Returns the list of [RenderBox] objects that are in the given + /// column, in row order, starting from the first row. + /// + /// This is a lazily-evaluated iterable. Iterable column(int x) sync* { for (int y = 0; y < rows; y += 1) { final int xy = x + y * columns; @@ -703,6 +760,10 @@ class RenderTable extends RenderBox { } } + /// Returns the list of [RenderBox] objects that are on the given + /// row, in column order, starting with the first column. + /// + /// This is a lazily-evaluated iterable. Iterable row(int y) sync* { final int start = y * columns; final int end = (y + 1) * columns; @@ -713,48 +774,166 @@ class RenderTable extends RenderBox { } } - List computeColumnWidths(BoxConstraints constraints) { + List _computeColumnWidths(BoxConstraints constraints) { assert(_children.length == rows * columns); + // We apply the constraints to the column widths in the order of + // least important to most important: + // 1. apply the ideal widths (maxIntrinsicWidth) + // 2. grow the flex columns so that the table has the maxWidth (if + // finite) or the minWidth (if not) + // 3. if there were no flex columns, then grow the table to the + // minWidth. + // 4. apply the maximum width of the table, shrinking columns as + // necessary, applying minimum column widths as we go + + // 1. apply ideal widths, and collect information we'll need later final List widths = new List(columns); + final List minWidths = new List(columns); final List flexes = new List(columns); - double totalMinWidth = 0.0; - double totalMaxWidth = constraints.maxWidth.isFinite ? constraints.maxWidth : 0.0; + double tableWidth = 0.0; // running tally of the sum of widths[x] for all x + double unflexedTableWidth = 0.0; // sum of the maxIntrinsicWidths of any column that has null flex double totalFlex = 0.0; for (int x = 0; x < columns; x += 1) { TableColumnWidth columnWidth = _columnWidths[x] ?? defaultColumnWidth; Iterable columnCells = column(x); - double minIntrinsicWidth = columnWidth.minIntrinsicWidth(columnCells, constraints.maxWidth); - widths[x] = minIntrinsicWidth; - totalMinWidth += minIntrinsicWidth; - if (!constraints.maxWidth.isFinite) { - double maxIntrinsicWidth = columnWidth.maxIntrinsicWidth(columnCells, constraints.maxWidth); - assert(minIntrinsicWidth <= maxIntrinsicWidth); - totalMaxWidth += maxIntrinsicWidth; - } + // apply ideal width (maxIntrinsicWidth) + final double maxIntrinsicWidth = columnWidth.maxIntrinsicWidth(columnCells, constraints.maxWidth); + assert(maxIntrinsicWidth.isFinite); + assert(maxIntrinsicWidth >= 0.0); + widths[x] = maxIntrinsicWidth; + tableWidth += maxIntrinsicWidth; + // collect min width information while we're at it + final double minIntrinsicWidth = columnWidth.minIntrinsicWidth(columnCells, constraints.maxWidth); + assert(minIntrinsicWidth.isFinite); + assert(minIntrinsicWidth >= 0.0); + minWidths[x] = minIntrinsicWidth; + assert(maxIntrinsicWidth >= minIntrinsicWidth); + // collect flex information while we're at it double flex = columnWidth.flex(columnCells); if (flex != null) { - assert(flex != 0.0); + assert(flex.isFinite); + assert(flex > 0.0); flexes[x] = flex; totalFlex += flex; + } else { + unflexedTableWidth += maxIntrinsicWidth; } } assert(!widths.any((double value) => value == null)); - // table is going to be the biggest of: - // - the incoming minimum width - // - the sum of the cells' minimum widths - // - the incoming maximum width if it is finite, or else the table's ideal shrink-wrap width - double tableWidth = math.max(constraints.minWidth, math.max(totalMinWidth, totalMaxWidth)); - double remainingWidth = tableWidth - totalMinWidth; - if (remainingWidth > 0.0) { - if (totalFlex > 0.0) { + final double maxWidthConstraint = constraints.maxWidth; + final double minWidthConstraint = constraints.minWidth; + + // 2. grow the flex columns so that the table has the maxWidth (if + // finite) or the minWidth (if not) + if (totalFlex > 0.0) { + // this can only grow the table, but it _will_ grow the table at + // least as big as the target width. + double targetWidth; + if (maxWidthConstraint.isFinite) { + targetWidth = maxWidthConstraint; + } else { + targetWidth = minWidthConstraint; + } + if (tableWidth < targetWidth) { + final double remainingWidth = targetWidth - unflexedTableWidth; + assert(remainingWidth.isFinite); + assert(remainingWidth >= 0.0); for (int x = 0; x < columns; x += 1) { if (flexes[x] != null) { - widths[x] += math.max((flexes[x] / totalFlex) * remainingWidth - widths[x], 0.0); + final double flexedWidth = remainingWidth * flexes[x] / totalFlex; + assert(flexedWidth.isFinite); + assert(flexedWidth >= 0.0); + if (widths[x] < flexedWidth) { + final double delta = flexedWidth - widths[x]; + tableWidth += delta; + widths[x] = flexedWidth; + } } } - } else { - for (int x = 0; x < columns; x += 1) - widths[x] += remainingWidth / columns; + assert(tableWidth >= targetWidth); + } + } else // step 2 and 3 are mutually exclusive + + // 3. if there were no flex columns, then grow the table to the + // minWidth. + if (tableWidth < minWidthConstraint) { + final double delta = (minWidthConstraint - tableWidth) / columns; + for (int x = 0; x < columns; x += 1) + widths[x] += delta; + tableWidth = minWidthConstraint; + } + + // beyond this point, unflexedTableWidth is no longer valid + assert(() { unflexedTableWidth = null; return true; }); + + // 4. apply the maximum width of the table, shrinking columns as + // necessary, applying minimum column widths as we go + if (tableWidth > maxWidthConstraint) { + double deficit = tableWidth - maxWidthConstraint; + // Some columns may have low flex but have all the free space. + // (Consider a case with a 1px wide column of flex 1000.0 and + // a 1000px wide column of flex 1.0; the sizes coming from the + // maxIntrinsicWidths. If the maximum table width is 2px, then + // just applying the flexes to the deficit would result in a + // table with one column at -998px and one column at 990px, + // which is wildly unhelpful.) + // Similarly, some columns may be flexible, but not actually + // be shrinkable due to a large minimum width. (Consider a + // case with two columns, one is flex and one isn't, both have + // 1000px maxIntrinsicWidths, but the flex one has 1000px + // minIntrinsicWidth also. The whole deficit will have to come + // from the non-flex column.) + // So what we do is we repeatedly iterate through the flexible + // columns shrinking them proportionally until we have no + // available columns, then do the same to the non-flexible ones. + int availableColumns = columns; + while (deficit > 0.0 && totalFlex > 0.0) { + double newTotalFlex = 0.0; + for (int x = 0; x < columns; x += 1) { + if (flexes[x] != null) { + final double newWidth = widths[x] - deficit * flexes[x] / totalFlex; + assert(newWidth.isFinite); + assert(newWidth >= 0.0); + if (newWidth <= minWidths[x]) { + // shrank to minimum + deficit -= widths[x] - minWidths[x]; + widths[x] = minWidths[x]; + flexes[x] = null; + availableColumns -= 1; + } else { + deficit -= widths[x] - newWidth; + widths[x] = newWidth; + newTotalFlex += flexes[x]; + } + } + } + totalFlex = newTotalFlex; + } + if (deficit > 0.0) { + // Now we have to take out the remaining space from the + // columns that aren't minimum sized. + // To make this fair, we repeatedly remove equal amounts from + // each column, clamped to the minimum width, until we run out + // of columns that aren't at their minWidth. + do { + final double delta = deficit / availableColumns; + int newAvailableColumns = 0; + for (int x = 0; x < columns; x += 1) { + double availableDelta = widths[x] - minWidths[x]; + if (availableDelta > 0.0) { + if (availableDelta <= delta) { + // shrank to minimum + deficit -= widths[x] - minWidths[x]; + widths[x] = minWidths[x]; + } else { + deficit -= availableDelta; + widths[x] -= availableDelta; + newAvailableColumns += 1; + } + } + } + availableColumns = newAvailableColumns; + } while (deficit > 0.0 && availableColumns > 0); } } return widths; @@ -764,16 +943,30 @@ class RenderTable extends RenderBox { List _rowTops = []; List _columnLefts; + /// Returns the position and dimensions of the box that the given + /// row covers, in this render object's coordinate space (so the + /// left coordinate is always 0.0). + /// + /// The row being queried must exist. + /// + /// This is only valid after layout. + Rect getRowBox(int row) { + assert(row >= 0); + assert(row < rows); + assert(!needsLayout); + return new Rect.fromLTRB(0.0, _rowTops[row], size.width, _rowTops[row + 1]); + } + @override void performLayout() { assert(_children.length == rows * columns); if (rows * columns == 0) { // TODO(ianh): if columns is zero, this should be zero width // TODO(ianh): if columns is not zero, this should be based on the column width specifications - size = constraints.constrain(const Size(double.INFINITY, 0.0)); + size = constraints.constrain(const Size(0.0, 0.0)); return; } - final List widths = computeColumnWidths(constraints); + final List widths = _computeColumnWidths(constraints); final List positions = new List(columns); _rowTops.clear(); positions[0] = 0.0; diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index aaa476797d..178a182cb9 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -957,9 +957,17 @@ class IntrinsicHeight extends SingleChildRenderObjectWidget { RenderIntrinsicHeight createRenderObject(BuildContext context) => new RenderIntrinsicHeight(); } -/// Positions its child vertically according to the child's baseline. +/// Shifts the child down such that the child's baseline (or the +/// bottom of the child, if the child has no baseline) is [baseline] +/// logical pixels below the top of this box, then sizes this box to +/// contain the child. If [baseline] is less than the distance from +/// the top of the child to the baseline of the child, then the child +/// is top-aligned instead. class Baseline extends SingleChildRenderObjectWidget { - Baseline({ Key key, this.baseline, this.baselineType: TextBaseline.alphabetic, Widget child }) + /// Creates a [Baseline] object. + /// + /// The [baseline] and [baselineType] arguments are required. + Baseline({ Key key, this.baseline, this.baselineType, Widget child }) : super(key: key, child: child) { assert(baseline != null); assert(baselineType != null); diff --git a/packages/flutter/lib/src/widgets/debug.dart b/packages/flutter/lib/src/widgets/debug.dart index 4e52d03aa6..0f42832859 100644 --- a/packages/flutter/lib/src/widgets/debug.dart +++ b/packages/flutter/lib/src/widgets/debug.dart @@ -5,6 +5,7 @@ import 'dart:collection'; import 'framework.dart'; +import 'table.dart'; Key _firstNonUniqueKey(Iterable widgets) { Set keySet = new HashSet(); @@ -18,6 +19,20 @@ Key _firstNonUniqueKey(Iterable widgets) { return null; } +/// Asserts if the given child list contains any duplicate non-null keys. +/// +/// To invoke this function, use the following pattern, typically in the +/// relevant Widget's constructor: +/// +/// ```dart +/// assert(!debugChildrenHaveDuplicateKeys(this, children)); +/// ``` +/// +/// For a version of this function that can be used in contexts where +/// the list of items does not have a particular parent, see +/// [debugItemsHaveDuplicateKeys]. +/// +/// Does nothing if asserts are disabled. Always returns true. bool debugChildrenHaveDuplicateKeys(Widget parent, Iterable children) { assert(() { final Key nonUniqueKey = _firstNonUniqueKey(children); @@ -33,12 +48,54 @@ bool debugChildrenHaveDuplicateKeys(Widget parent, Iterable children) { return false; } +/// Asserts if the given list of items contains any duplicate non-null keys. +/// +/// To invoke this function, use the following pattern: +/// +/// ```dart +/// assert(!debugItemsHaveDuplicateKeys(items)); +/// ``` +/// +/// For a version of this function specifically intended for parents +/// checking their children lists, see [debugChildrenHaveDuplicateKeys]. +/// +/// Does nothing if asserts are disabled. Always returns true. bool debugItemsHaveDuplicateKeys(Iterable items) { assert(() { final Key nonUniqueKey = _firstNonUniqueKey(items); if (nonUniqueKey != null) - throw new FlutterError('Duplicate key found: $nonUniqueKey.\n'); + throw new FlutterError('Duplicate key found: $nonUniqueKey.'); return true; }); return false; } + +/// Asserts that the given context has a [Table] ancestor. +/// +/// Used by [RowInkWell] to make sure that it is only used in an appropriate context. +/// +/// To invoke this function, use the following pattern, typically in the +/// relevant Widget's [build] method: +/// +/// ```dart +/// assert(debugCheckHasTable(context)); +/// ``` +/// +/// Does nothing if asserts are disabled. Always returns true. +bool debugCheckHasTable(BuildContext context) { + assert(() { + if (context.widget is! Table && context.ancestorWidgetOfExactType(Table) == null) { + Element element = context; + throw new FlutterError( + 'No Table widget found.\n' + '${context.widget.runtimeType} widgets require a Table widget ancestor.\n' + 'The specific widget that could not find a Table ancestor was:\n' + ' ${context.widget}\n' + 'The ownership chain for the affected widget is:\n' + ' ${element.debugGetCreatorChain(10)}' + ); + } + return true; + }); + return true; +} diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index fb129cf1a0..3fb21a4a0e 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -1009,6 +1009,7 @@ abstract class Element implements BuildContext { } Element inflateWidget(Widget newWidget, dynamic newSlot) { + assert(newWidget != null); Key key = newWidget.key; if (key is GlobalKey) { Element newChild = _retakeInactiveElement(key, newWidget); diff --git a/packages/flutter/lib/src/widgets/table.dart b/packages/flutter/lib/src/widgets/table.dart index 15bbf20d68..50400a4860 100644 --- a/packages/flutter/lib/src/widgets/table.dart +++ b/packages/flutter/lib/src/widgets/table.dart @@ -25,6 +25,25 @@ class TableRow { final LocalKey key; final Decoration decoration; final List children; + + @override + String toString() { + StringBuffer result = new StringBuffer(); + result.write('TableRow('); + if (key != null) + result.write('$key, '); + if (decoration != null) + result.write('$decoration, '); + if (children != null) { + result.write('child list is null'); + } else if (children.length == 0) { + result.write('no children'); + } else { + result.write('$children'); + } + result.write(')'); + return result.toString(); + } } class _TableElementRow { @@ -54,6 +73,7 @@ class Table extends RenderObjectWidget { assert(children != null); assert(defaultColumnWidth != null); assert(defaultVerticalAlignment != null); + assert(!children.any((TableRow row) => row.children.any((Widget cell) => cell == null))); assert(() { List flatChildren = children.expand((TableRow row) => row.children).toList(growable: false); return !debugChildrenHaveDuplicateKeys(this, flatChildren); @@ -125,7 +145,10 @@ class _TableElement extends RenderObjectElement { _children = widget.children.map((TableRow row) { return new _TableElementRow( key: row.key, - children: row.children.map((Widget child) => inflateWidget(child, null)).toList(growable: false) + children: row.children.map/**/((Widget child) { + assert(child != null); + return inflateWidget(child, null); + }).toList(growable: false) ); }).toList(growable: false); assert(() { _debugWillReattachChildren = false; return true; }); diff --git a/packages/flutter/test/rendering/table_test.dart b/packages/flutter/test/rendering/table_test.dart index 2d5b714c3c..eda02f86d0 100644 --- a/packages/flutter/test/rendering/table_test.dart +++ b/packages/flutter/test/rendering/table_test.dart @@ -26,7 +26,7 @@ void main() { RenderTable table; layout(new RenderPositionedBox(child: table = new RenderTable())); - expect(table.size, equals(const Size(800.0, 0.0))); + expect(table.size, equals(const Size(0.0, 0.0))); }); test('Table test: combinations', () { @@ -39,13 +39,13 @@ void main() { textBaseline: TextBaseline.alphabetic ))); - expect(table.size, equals(const Size(800.0, 0.0))); + expect(table.size, equals(const Size(0.0, 0.0))); table.setChild(2, 4, sizedBox(100.0, 200.0)); pumpFrame(); - expect(table.size, equals(new Size(800.0, 200.0))); + expect(table.size, equals(new Size(100.0, 200.0))); table.setChild(0, 0, sizedBox(10.0, 30.0)); table.setChild(1, 0, sizedBox(20.0, 20.0)); @@ -53,7 +53,7 @@ void main() { pumpFrame(); - expect(table.size, equals(new Size(800.0, 230.0))); + expect(table.size, equals(new Size(130.0, 230.0))); }); test('Table test: removing cells', () {