Unverified Commit c4e84380 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Added Material OutlineButton (#14939)

parent da24ad0b
......@@ -65,6 +65,7 @@ export 'src/material/list_tile.dart';
export 'src/material/material.dart';
export 'src/material/material_localizations.dart';
export 'src/material/mergeable_material.dart';
export 'src/material/outline_button.dart';
export 'src/material/page.dart';
export 'src/material/paginated_data_table.dart';
export 'src/material/popup_menu.dart';
......
......@@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart';
import 'button_theme.dart';
import 'colors.dart';
import 'constants.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';
......@@ -29,6 +30,7 @@ class RawMaterialButton extends StatefulWidget {
const RawMaterialButton({
Key key,
@required this.onPressed,
this.onHighlightChanged,
this.textStyle,
this.fillColor,
this.highlightColor,
......@@ -39,6 +41,7 @@ class RawMaterialButton extends StatefulWidget {
this.padding: EdgeInsets.zero,
this.constraints: const BoxConstraints(minWidth: 88.0, minHeight: 36.0),
this.shape: const RoundedRectangleBorder(),
this.animationDuration: kThemeChangeDuration,
this.child,
}) : assert(shape != null),
assert(elevation != null),
......@@ -46,6 +49,7 @@ class RawMaterialButton extends StatefulWidget {
assert(disabledElevation != null),
assert(padding != null),
assert(constraints != null),
assert(animationDuration != null),
super(key: key);
/// Called when the button is tapped or otherwise activated.
......@@ -53,6 +57,10 @@ class RawMaterialButton extends StatefulWidget {
/// If this is set to null, the button will be disabled, see [enabled].
final VoidCallback onPressed;
/// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
/// callback.
final ValueChanged<bool> onHighlightChanged;
/// Defines the default text style, with [Material.textStyle], for the
/// button's [child].
final TextStyle textStyle;
......@@ -111,6 +119,11 @@ class RawMaterialButton extends StatefulWidget {
/// button has an elevation, then its drop shadow is defined by this shape.
final ShapeBorder shape;
/// Defines the duration of animated changes for [shape] and [elevation].
///
/// The default value is [kThemeChangeDuration].
final Duration animationDuration;
/// Typically the button's label.
final Widget child;
......@@ -129,6 +142,8 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
void _handleHighlightChanged(bool value) {
setState(() {
_highlight = value;
if (widget.onHighlightChanged != null)
widget.onHighlightChanged(value);
});
}
......@@ -150,6 +165,7 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
shape: widget.shape,
color: widget.fillColor,
type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
animationDuration: widget.animationDuration,
child: new InkWell(
onHighlightChanged: _handleHighlightChanged,
splashColor: widget.splashColor,
......
......@@ -34,7 +34,8 @@ import 'theme.dart';
/// trying to change the button's [color] and it is not having any effect, check
/// that you are passing a non-null [onPressed] handler.
///
/// Flat buttons will expand to fit the child widget, if necessary.
/// Flat buttons have a minimum size of 88.0 by 36.0 which can be overidden
/// with [ButtonTheme].
///
/// See also:
///
......@@ -43,13 +44,14 @@ import 'theme.dart';
/// * [SimpleDialogOption], which is used in [SimpleDialog]s.
/// * [IconButton], to create buttons that just contain icons.
/// * [InkWell], which implements the ink splash part of a flat button.
//// * [RawMaterialButton], the widget this widget is based on.
/// * [RawMaterialButton], the widget this widget is based on.
/// * <https://material.google.com/components/buttons.html>
class FlatButton extends StatelessWidget {
/// Create a simple text button.
const FlatButton({
Key key,
@required this.onPressed,
this.onHighlightChanged,
this.textTheme,
this.textColor,
this.disabledTextColor,
......@@ -73,6 +75,7 @@ class FlatButton extends StatelessWidget {
FlatButton.icon({
Key key,
@required this.onPressed,
this.onHighlightChanged,
this.textTheme,
this.textColor,
this.disabledTextColor,
......@@ -102,6 +105,10 @@ class FlatButton extends StatelessWidget {
/// If this is set to null, the button will be disabled, see [enabled].
final VoidCallback onPressed;
/// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
/// callback.
final ValueChanged<bool> onHighlightChanged;
/// Defines the button's base colors, and the defaults for the button's minimum
/// size, internal padding, and shape.
///
......@@ -276,6 +283,7 @@ class FlatButton extends StatelessWidget {
return new RawMaterialButton(
onPressed: onPressed,
onHighlightChanged: onHighlightChanged,
fillColor: fillColor,
textStyle: theme.textTheme.button.copyWith(color: textColor),
highlightColor: _getHighlightColor(theme, buttonTheme),
......
......@@ -88,7 +88,7 @@ abstract class MaterialInkController {
/// The Material widget is responsible for:
///
/// 1. Clipping: Material clips its widget sub-tree to the shape specified by
/// [type] and [borderRadius].
/// [shape], [type], and [borderRadius].
/// 2. Elevation: Material elevates its widget sub-tree on the Z axis by
/// [elevation] pixels, and draws the appropriate shadow.
/// 3. Ink effects: Material shows ink effects implemented by [InkFeature]s
......@@ -108,14 +108,15 @@ abstract class MaterialInkController {
///
/// In general, the features of a [Material] should not change over time (e.g. a
/// [Material] should not change its [color], [shadowColor] or [type]).
/// Changes to [elevation] and [shadowColor] are animated. Changes to [shape] are
/// animated if [type] is not [MaterialType.transparency] and [ShapeBorder.lerp]
/// between the previous and next [shape] values is supported.
/// Changes to [elevation] and [shadowColor] are animated for [animationDuration].
/// Changes to [shape] are animated if [type] is not [MaterialType.transparency]
/// and [ShapeBorder.lerp] between the previous and next [shape] values is
/// supported. Shape changes are also animated for [animationDuration].
///
///
/// ## Shape
///
/// The shape for material is determined by [type] and [borderRadius].
/// The shape for material is determined by [shape], [type], and [borderRadius].
///
/// - If [shape] is non null, it determines the shape.
/// - If [shape] is null and [borderRadius] is non null, the shape is a
......@@ -153,9 +154,10 @@ abstract class MaterialInkController {
class Material extends StatefulWidget {
/// Creates a piece of material.
///
/// The [type], [elevation] and [shadowColor] arguments must not be null.
/// The [type], [elevation], [shadowColor], and [animationDuration] arguments
/// must not be null.
///
/// If a [shape] is specified, then the [borderRadius] property must not be
/// If a [shape] is specified, then the [borderRadius] property must be
/// null and the [type] property must not be [MaterialType.circle]. If the
/// [borderRadius] is specified, then the [type] property must not be
/// [MaterialType.circle]. In both cases, these restrictions are intended to
......@@ -169,11 +171,13 @@ class Material extends StatefulWidget {
this.textStyle,
this.borderRadius,
this.shape,
this.animationDuration: kThemeChangeDuration,
this.child,
}) : assert(type != null),
assert(elevation != null),
assert(shadowColor != null),
assert(!(shape != null && borderRadius != null)),
assert(animationDuration != null),
assert(!(identical(type, MaterialType.circle) && (borderRadius != null || shape != null))),
super(key: key);
......@@ -194,7 +198,7 @@ class Material extends StatefulWidget {
/// widget conceptually defines an independent printed piece of material.
///
/// Defaults to 0. Changing this value will cause the shadow to animate over
/// [kThemeChangeDuration].
/// [animationDuration].
final double elevation;
/// The color to paint the material.
......@@ -222,6 +226,12 @@ class Material extends StatefulWidget {
/// zero.
final ShapeBorder shape;
/// Defines the duration of animated changes for [shape], [elevation],
/// and [shadowColor].
///
/// The default value is [kThemeChangeDuration].
final Duration animationDuration;
/// If non-null, the corners of this box are rounded by this [BorderRadius].
/// Otherwise, the corners specified for the current [type] of material are
/// used.
......@@ -287,7 +297,7 @@ class _MaterialState extends State<Material> with TickerProviderStateMixin {
if (contents != null) {
contents = new AnimatedDefaultTextStyle(
style: widget.textStyle ?? Theme.of(context).textTheme.body1,
duration: kThemeChangeDuration,
duration: widget.animationDuration,
child: contents
);
}
......@@ -317,7 +327,7 @@ class _MaterialState extends State<Material> with TickerProviderStateMixin {
if (widget.type == MaterialType.canvas && widget.shape == null && widget.borderRadius == null) {
return new AnimatedPhysicalModel(
curve: Curves.fastOutSlowIn,
duration: kThemeChangeDuration,
duration: widget.animationDuration,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.zero,
elevation: widget.elevation,
......@@ -332,10 +342,10 @@ class _MaterialState extends State<Material> with TickerProviderStateMixin {
if (widget.type == MaterialType.transparency)
return _transparentInterior(shape: shape, contents: contents);
return new _MaterialInterior(
curve: Curves.fastOutSlowIn,
duration: kThemeChangeDuration,
duration: widget.animationDuration,
shape: shape,
elevation: widget.elevation,
color: backgroundColor,
......
This diff is collapsed.
......@@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart';
import 'button.dart';
import 'button_theme.dart';
import 'colors.dart';
import 'constants.dart';
import 'theme.dart';
/// A material design "raised button".
......@@ -27,7 +28,8 @@ import 'theme.dart';
/// If you want an ink-splash effect for taps, but don't want to use a button,
/// consider using [InkWell] directly.
///
/// Raised buttons will expand to fit the child widget, if necessary.
/// Raised buttons have a minimum size of 88.0 by 36.0 which can be overidden
/// with [ButtonTheme].
///
/// See also:
///
......@@ -36,7 +38,7 @@ import 'theme.dart';
/// * [FloatingActionButton], the round button in material applications.
/// * [IconButton], to create buttons that just contain icons.
/// * [InkWell], which implements the ink splash part of a flat button.
//// * [RawMaterialButton], the widget this widget is based on.
/// * [RawMaterialButton], the widget this widget is based on.
/// * <https://material.google.com/components/buttons.html>
class RaisedButton extends StatelessWidget {
/// Create a filled button.
......@@ -46,6 +48,7 @@ class RaisedButton extends StatelessWidget {
const RaisedButton({
Key key,
@required this.onPressed,
this.onHighlightChanged,
this.textTheme,
this.textColor,
this.disabledTextColor,
......@@ -59,10 +62,12 @@ class RaisedButton extends StatelessWidget {
this.disabledElevation: 0.0,
this.padding,
this.shape,
this.animationDuration: kThemeChangeDuration,
this.child,
}) : assert(elevation != null),
assert(highlightElevation != null),
assert(disabledElevation != null),
assert(animationDuration != null),
super(key: key);
/// Create a filled button from a pair of widgets that serve as the button's
......@@ -76,6 +81,7 @@ class RaisedButton extends StatelessWidget {
RaisedButton.icon({
Key key,
@required this.onPressed,
this.onHighlightChanged,
this.textTheme,
this.textColor,
this.disabledTextColor,
......@@ -88,6 +94,7 @@ class RaisedButton extends StatelessWidget {
this.highlightElevation: 8.0,
this.disabledElevation: 0.0,
this.shape,
this.animationDuration: kThemeChangeDuration,
@required Widget icon,
@required Widget label,
}) : assert(elevation != null),
......@@ -95,6 +102,7 @@ class RaisedButton extends StatelessWidget {
assert(disabledElevation != null),
assert(icon != null),
assert(label != null),
assert(animationDuration != null),
padding = const EdgeInsetsDirectional.only(start: 12.0, end: 16.0),
child = new Row(
mainAxisSize: MainAxisSize.min,
......@@ -111,6 +119,10 @@ class RaisedButton extends StatelessWidget {
/// If this is set to null, the button will be disabled, see [enabled].
final VoidCallback onPressed;
/// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
/// callback.
final ValueChanged<bool> onHighlightChanged;
/// Defines the button's base colors, and the defaults for the button's minimum
/// size, internal padding, and shape.
///
......@@ -272,6 +284,11 @@ class RaisedButton extends StatelessWidget {
/// shape as well.
final ShapeBorder shape;
/// Defines the duration of animated changes for [shape] and [elevation].
///
/// The default value is [kThemeChangeDuration].
final Duration animationDuration;
Brightness _getBrightness(ThemeData theme) {
return colorBrightness ?? theme.brightness;
}
......@@ -350,6 +367,7 @@ class RaisedButton extends StatelessWidget {
return new RawMaterialButton(
onPressed: onPressed,
onHighlightChanged: onHighlightChanged,
fillColor: fillColor,
textStyle: theme.textTheme.button.copyWith(color: textColor),
highlightColor: _getHighlightColor(theme, buttonTheme),
......@@ -360,6 +378,7 @@ class RaisedButton extends StatelessWidget {
padding: padding ?? buttonTheme.padding,
constraints: buttonTheme.constraints,
shape: shape ?? buttonTheme.shape,
animationDuration: animationDuration,
child: child,
);
}
......
......@@ -78,7 +78,7 @@ class ScrollController extends ChangeNotifier {
/// See also:
///
/// * [PageStorageKey], which should be used when more than one
//// scrollable appears in the same route, to distinguish the [PageStorage]
/// scrollable appears in the same route, to distinguish the [PageStorage]
/// locations used to save scroll offsets.
final bool keepScrollOffset;
......
// Copyright 2018 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:ui' show SemanticsFlag;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
void main() {
testWidgets('Outline button responds to tap when enabled', (WidgetTester tester) async {
int pressedCount = 0;
Widget buildFrame(VoidCallback onPressed) {
return new Directionality(
textDirection: TextDirection.ltr,
child: new Theme(
data: new ThemeData(),
child: new Center(
child: new OutlineButton(onPressed: onPressed),
),
),
);
}
await tester.pumpWidget(
buildFrame(() { pressedCount += 1; }),
);
expect(tester.widget<OutlineButton>(find.byType(OutlineButton)).enabled, true);
await tester.tap(find.byType(OutlineButton));
await tester.pumpAndSettle();
expect(pressedCount, 1);
await tester.pumpWidget(
buildFrame(null),
);
final Finder outlineButton = find.byType(OutlineButton);
expect(tester.widget<OutlineButton>(outlineButton).enabled, false);
await tester.tap(outlineButton);
await tester.pumpAndSettle();
expect(pressedCount, 1);
});
testWidgets('Outline shape and border overrides', (WidgetTester tester) async {
const Color fillColor = const Color(0xFF00FF00);
const Color borderColor = const Color(0xFFFF0000);
const Color highlightedBorderColor = const Color(0xFF0000FF);
const double borderWidth = 4.0;
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new Theme(
data: new ThemeData(),
child: new Container(
alignment: Alignment.topLeft,
child: new OutlineButton(
shape: const RoundedRectangleBorder(), // default border radius is 0
color: fillColor,
highlightedBorderColor: highlightedBorderColor,
borderSide: const BorderSide(
width: borderWidth,
color: borderColor,
),
onPressed: () { },
child: const Text('button')
),
),
),
),
);
final Finder outlineButton = find.byType(OutlineButton);
expect(tester.widget<OutlineButton>(outlineButton).enabled, true);
final Rect clipRect = new Rect.fromLTRB(0.0, 0.0, 116.0, 36.0);
final Path clipPath = new Path()..addRect(clipRect);
expect(
outlineButton,
paints
// initially the interior of the button is transparent
..path(color: fillColor.withAlpha(0x00))
..clipPath(pathMatcher: coversSameAreaAs(clipPath, areaToCompare: clipRect.inflate(10.0)))
..path(color: borderColor, strokeWidth: borderWidth)
);
final Offset center = tester.getCenter(outlineButton);
final TestGesture gesture = await tester.startGesture(center);
await tester.pump(); // start gesture
// Wait for the border's color to change to highlightedBorderColor and
// the fillColor to become opaque.
await tester.pump(const Duration(milliseconds: 200));
expect(
outlineButton,
paints
..path(color: fillColor.withAlpha(0xFF))
..clipPath(pathMatcher: coversSameAreaAs(clipPath, areaToCompare: clipRect.inflate(10.0)))
..path(color: highlightedBorderColor, strokeWidth: borderWidth)
);
// Tap gesture completes, button returns to its initial configuration.
await gesture.up();
await tester.pumpAndSettle();
expect(
outlineButton,
paints
..path(color: fillColor.withAlpha(0x00))
..clipPath(pathMatcher: coversSameAreaAs(clipPath, areaToCompare: clipRect.inflate(10.0)))
..path(color: borderColor, strokeWidth: borderWidth)
);
});
testWidgets('OutlineButton contributes semantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new Center(
child: new OutlineButton(
onPressed: () { },
child: const Text('ABC')
),
),
),
),
);
expect(semantics, hasSemantics(
new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics.rootChild(
actions: <SemanticsAction>[
SemanticsAction.tap,
],
label: 'ABC',
rect: new Rect.fromLTRB(0.0, 0.0, 88.0, 36.0),
transform: new Matrix4.translationValues(356.0, 282.0, 0.0),
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
)
],
),
ignoreId: true,
));
semantics.dispose();
});
testWidgets('OutlineButton scales textScaleFactor', (WidgetTester tester) async {
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new MediaQuery(
data: const MediaQueryData(textScaleFactor: 1.0),
child: new Center(
child: new OutlineButton(
onPressed: () { },
child: const Text('ABC'),
),
),
),
),
),
);
expect(tester.getSize(find.byType(OutlineButton)), equals(const Size(88.0, 36.0)));
expect(tester.getSize(find.byType(Text)), equals(const Size(42.0, 14.0)));
// textScaleFactor expands text, but not button.
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new MediaQuery(
data: const MediaQueryData(textScaleFactor: 1.3),
child: new Center(
child: new FlatButton(
onPressed: () { },
child: const Text('ABC'),
),
),
),
),
),
);
expect(tester.getSize(find.byType(FlatButton)), equals(const Size(88.0, 36.0)));
// Scaled text rendering is different on Linux and Mac by one pixel.
// TODO(#12357): Update this test when text rendering is fixed.
expect(tester.getSize(find.byType(Text)).width, isIn(<double>[54.0, 55.0]));
expect(tester.getSize(find.byType(Text)).height, isIn(<double>[18.0, 19.0]));
// Set text scale large enough to expand text and button.
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new Material(
child: new MediaQuery(
data: const MediaQueryData(textScaleFactor: 3.0),
child: new Center(
child: new FlatButton(
onPressed: () { },
child: const Text('ABC'),
),
),
),
),
),
);
// Scaled text rendering is different on Linux and Mac by one pixel.
// TODO(#12357): Update this test when text rendering is fixed.
expect(tester.getSize(find.byType(FlatButton)).width, isIn(<double>[158.0, 159.0]));
expect(tester.getSize(find.byType(FlatButton)).height, equals(42.0));
expect(tester.getSize(find.byType(Text)).width, isIn(<double>[126.0, 127.0]));
expect(tester.getSize(find.byType(Text)).height, equals(42.0));
});
testWidgets('OutlineButton implements debugFillDescription', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = new DiagnosticPropertiesBuilder();
new OutlineButton(
onPressed: () {},
textColor: const Color(0xFF00FF00),
disabledTextColor: const Color(0xFFFF0000),
color: const Color(0xFF000000),
highlightColor: const Color(0xFF1565C0),
splashColor: const Color(0xFF9E9E9E),
child: const Text('Hello'),
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode n) => n.toString()).toList();
expect(description, <String>[
'textColor: Color(0xff00ff00)',
'disabledTextColor: Color(0xffff0000)',
'color: Color(0xff000000)',
'highlightColor: Color(0xff1565c0)',
'splashColor: Color(0xff9e9e9e)',
]);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment