Skip to content

Commit

Permalink
Support arbitrary shaped Material. (flutter#14367)
Browse files Browse the repository at this point in the history
For backward compatibility we keep supporting specifying the shape as a
combination of MaterialType and borderRadius, and we just use that as a
default when shapeBorder is null.

To cleanup the implementation if shapeBorder was not specified we just
translate the specified shape to a shapeBorder internally.
I benchmarked paint, layout and hit testing, with the specialized shape
clippers vs. the equivalent path clippers and did not see any
significant performance difference.

For testing, I extended the clippers/physicalShape matchers to match either the
specialized shape or the equivalent shape.
  • Loading branch information
amirh authored Jan 31, 2018
1 parent 340d9e0 commit 0672055
Show file tree
Hide file tree
Showing 10 changed files with 475 additions and 98 deletions.
209 changes: 169 additions & 40 deletions packages/flutter/lib/src/material/material.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,21 @@ abstract class MaterialInkController {
/// material, use a [MaterialInkController] obtained via [Material.of].
///
/// In general, the features of a [Material] should not change over time (e.g. a
/// [Material] should not change its [color], [shadowColor] or [type]). The one
/// exception is the [elevation], changes to which will be animated.
/// [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.
///
///
/// ## Shape
///
/// The shape for material is determined by [type] and [borderRadius].
///
/// - If [borderRadius] is non null, the shape is a rounded rectangle, with
/// corners specified by [borderRadius].
/// - If [borderRadius] is null, [type] determines the shape as follows:
/// - If [shape] is non null, it determines the shape.
/// - If [shape] is null and [borderRadius] is non null, the shape is a
/// rounded rectangle, with corners specified by [borderRadius].
/// - If [shape] and [borderRadius] are null, [type] determines the
/// shape as follows:
/// - [MaterialType.canvas]: the default material shape is a rectangle.
/// - [MaterialType.card]: the default material shape is a rectangle with
/// rounded edges. The edge radii is specified by [kMaterialEdges].
Expand Down Expand Up @@ -145,6 +150,12 @@ class Material extends StatefulWidget {
/// Creates a piece of material.
///
/// The [type], [elevation] and [shadowColor] arguments must not be null.
///
/// If a [shape] is specified, then the [borderRadius] property must not 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
/// catch likely errors.
const Material({
Key key,
this.type: MaterialType.canvas,
Expand All @@ -153,11 +164,13 @@ class Material extends StatefulWidget {
this.shadowColor: const Color(0xFF000000),
this.textStyle,
this.borderRadius,
this.shape,
this.child,
}) : assert(type != null),
assert(elevation != null),
assert(shadowColor != null),
assert(!(identical(type, MaterialType.circle) && borderRadius != null)),
assert(!(shape != null && borderRadius != null)),
assert(!(identical(type, MaterialType.circle) && (borderRadius != null || shape != null))),
super(key: key);

/// The widget below this widget in the tree.
Expand Down Expand Up @@ -196,6 +209,8 @@ class Material extends StatefulWidget {
/// The typographical style to use for text within this material.
final TextStyle textStyle;

final ShapeBorder shape;

/// 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.
Expand Down Expand Up @@ -255,7 +270,6 @@ class _MaterialState extends State<Material> with TickerProviderStateMixin {
final Color backgroundColor = _getBackgroundColor(context);
assert(backgroundColor != null || widget.type == MaterialType.transparency);
Widget contents = widget.child;
final BorderRadius radius = widget.borderRadius ?? kMaterialEdges[widget.type];
if (contents != null) {
contents = new AnimatedDefaultTextStyle(
style: widget.textStyle ?? Theme.of(context).textTheme.body1,
Expand All @@ -277,41 +291,59 @@ class _MaterialState extends State<Material> with TickerProviderStateMixin {
)
);

if (widget.type == MaterialType.circle) {
contents = new AnimatedPhysicalModel(
curve: Curves.fastOutSlowIn,
duration: kThemeChangeDuration,
shape: BoxShape.circle,
elevation: widget.elevation,
color: backgroundColor,
shadowColor: widget.shadowColor,
animateColor: false,
child: contents,
);
} else if (widget.type == MaterialType.transparency) {
if (radius == null) {
contents = new ClipRect(child: contents);
} else {
contents = new ClipRRect(
borderRadius: radius,
child: contents
final ShapeBorder shape = _getShape();

if (widget.type == MaterialType.transparency)
return _clipToShape(shape: shape, contents: contents);

return new _MaterialInterior(
curve: Curves.fastOutSlowIn,
duration: kThemeChangeDuration,
shape: shape,
elevation: widget.elevation,
color: backgroundColor,
shadowColor: widget.shadowColor,
child: contents,
);

}

static Widget _clipToShape({ShapeBorder shape, Widget contents}) {
return new ClipPath(
child: contents,
clipper: new ShapeBorderClipper(
shape: shape,
),
);
}

// Determines the shape for this Material.
//
// If a shape was specified, it will determine the shape.
// If a borderRadius was specified, the shape is a rounded
// rectangle.
// Otherwise, the shape is determined by the widget type as described in the
// Material class documentation.
ShapeBorder _getShape() {
if (widget.shape != null)
return widget.shape;
if (widget.borderRadius != null)
return new RoundedRectangleBorder(borderRadius: widget.borderRadius);
switch (widget.type) {
case MaterialType.canvas:
case MaterialType.transparency:
return new RoundedRectangleBorder();

case MaterialType.card:
case MaterialType.button:
return new RoundedRectangleBorder(
borderRadius: kMaterialEdges[widget.type],
);
}
} else {
contents = new AnimatedPhysicalModel(
curve: Curves.fastOutSlowIn,
duration: kThemeChangeDuration,
shape: BoxShape.rectangle,
borderRadius: radius ?? BorderRadius.zero,
elevation: widget.elevation,
color: backgroundColor,
shadowColor: widget.shadowColor,
animateColor: false,
child: contents,
);
}

return contents;
case MaterialType.circle:
return const CircleBorder();
}
return new RoundedRectangleBorder();
}
}

Expand Down Expand Up @@ -475,3 +507,100 @@ abstract class InkFeature {
@override
String toString() => describeIdentity(this);
}

/// An interpolation between two [ShapeBorder]s.
///
/// This class specializes the interpolation of [Tween] to use [ShapeBorder.lerp].
class ShapeBorderTween extends Tween<ShapeBorder> {
/// Creates a [ShapeBorder] tween.
///
/// the [begin] and [end] properties may be null; see [ShapeBorder.lerp] for
/// the null handling semantics.
ShapeBorderTween({ShapeBorder begin, ShapeBorder end}): super(begin: begin, end: end);

/// Returns the value this tween has at the given animation clock value.
@override
ShapeBorder lerp(double t) {
return ShapeBorder.lerp(begin, end, t);
}
}

/// The interior of non-transparent material.
///
/// Animates [elevation], [shadowColor], and [shape].
class _MaterialInterior extends ImplicitlyAnimatedWidget {
const _MaterialInterior({
Key key,
@required this.child,
@required this.shape,
@required this.elevation,
@required this.color,
@required this.shadowColor,
Curve curve: Curves.linear,
@required Duration duration,
}) : assert(child != null),
assert(shape != null),
assert(elevation != null),
assert(color != null),
assert(shadowColor != null),
super(key: key, curve: curve, duration: duration);

/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.child}
final Widget child;

/// The border of the widget.
///
/// This border will be painted, and in addition the outer path of the border
/// determines the physical shape.
final ShapeBorder shape;

/// The target z-coordinate at which to place this physical object.
final double elevation;

/// The target background color.
final Color color;

/// The target shadow color.
final Color shadowColor;

@override
_MaterialInteriorState createState() => new _MaterialInteriorState();

@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<ShapeBorder>('shape', shape));
description.add(new DoubleProperty('elevation', elevation));
description.add(new DiagnosticsProperty<Color>('color', color));
description.add(new DiagnosticsProperty<Color>('shadowColor', shadowColor));
}
}

class _MaterialInteriorState extends AnimatedWidgetBaseState<_MaterialInterior> {
Tween<double> _elevation;
ColorTween _shadowColor;
ShapeBorderTween _border;

@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_elevation = visitor(_elevation, widget.elevation, (dynamic value) => new Tween<double>(begin: value));
_shadowColor = visitor(_shadowColor, widget.shadowColor, (dynamic value) => new ColorTween(begin: value));
_border = visitor(_border, widget.shape, (dynamic value) => new ShapeBorderTween(begin: value));
}

@override
Widget build(BuildContext context) {
return new PhysicalShape(
child: widget.child,
clipper: new ShapeBorderClipper(
shape: _border.evaluate(animation),
textDirection: Directionality.of(context)
),
elevation: _elevation.evaluate(animation),
color: widget.color,
shadowColor: _shadowColor.evaluate(animation),
);
}
}
6 changes: 0 additions & 6 deletions packages/flutter/lib/src/rendering/layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -591,12 +591,6 @@ class ClipPathLayer extends ContainerLayer {
addChildrenToScene(builder, layerOffset);
builder.pop();
}

@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<Path>('clipPath', clipPath));
}
}

/// A composited layer that applies a given transformation matrix to its
Expand Down
18 changes: 9 additions & 9 deletions packages/flutter/lib/src/rendering/proxy_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1052,35 +1052,35 @@ abstract class CustomClipper<T> {
class ShapeBorderClipper extends CustomClipper<Path> {
/// Creates a [ShapeBorder] clipper.
///
/// The [shapeBorder] argument must not be null.
/// The [shape] argument must not be null.
///
/// The [textDirection] argument must be provided non-null if [shapeBorder]
/// The [textDirection] argument must be provided non-null if [shape]
/// has a text direction dependency (for example if it is expressed in terms
/// of "start" and "end" instead of "left" and "right"). It may be null if
/// the border will not need the text direction to paint itself.
const ShapeBorderClipper({
@required this.shapeBorder,
@required this.shape,
this.textDirection,
}) : assert(shapeBorder != null);
}) : assert(shape != null);

/// The shape border whose outer path this clipper clips to.
final ShapeBorder shapeBorder;
final ShapeBorder shape;

/// The text direction to use for getting the outer path for [shapeBorder].
/// The text direction to use for getting the outer path for [shape].
///
/// [ShapeBorder]s can depend on the text direction (e.g having a "dent"
/// towards the start of the shape).
final TextDirection textDirection;

/// Returns the outer path of [shapeBorder] as the clip.
/// Returns the outer path of [shape] as the clip.
@override
Path getClip(Size size) {
return shapeBorder.getOuterPath(Offset.zero & size, textDirection: textDirection);
return shape.getOuterPath(Offset.zero & size, textDirection: textDirection);
}

@override
bool shouldReclip(covariant ShapeBorderClipper oldClipper) {
return oldClipper.shapeBorder != shapeBorder;
return oldClipper.shape != shape;
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/widgets/basic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ class PhysicalShape extends SingleChildRenderObjectWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new EnumProperty<CustomClipper<Path>>('clipper', clipper));
description.add(new DiagnosticsProperty<CustomClipper<Path>>('clipper', clipper));
description.add(new DoubleProperty('elevation', elevation));
description.add(new DiagnosticsProperty<Color>('color', color));
description.add(new DiagnosticsProperty<Color>('shadowColor', shadowColor));
Expand Down
6 changes: 3 additions & 3 deletions packages/flutter/test/material/ink_well_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,17 @@ void main() {
),
),
);
expect(tester.renderObject<RenderProxyBox>(find.byType(PhysicalModel)).child, paintsNothing);
expect(tester.renderObject<RenderProxyBox>(find.byType(PhysicalShape)).child, paintsNothing);
await tester.tap(find.byType(InkWell));
await tester.pump();
await tester.pump(const Duration(milliseconds: 10));
expect(tester.renderObject<RenderProxyBox>(find.byType(PhysicalModel)).child, paints..circle());
expect(tester.renderObject<RenderProxyBox>(find.byType(PhysicalShape)).child, paints..circle());
await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
await tester.pump(const Duration(milliseconds: 10));
await tester.drag(find.byType(ListView), const Offset(0.0, 1000.0));
await tester.pump(const Duration(milliseconds: 10));
expect(
tester.renderObject<RenderProxyBox>(find.byType(PhysicalModel)).child,
tester.renderObject<RenderProxyBox>(find.byType(PhysicalShape)).child,
keepAlive ? (paints..circle()) : paintsNothing,
);
}
Expand Down
Loading

0 comments on commit 0672055

Please sign in to comment.