Unverified Commit 0672055a authored by amirh's avatar amirh Committed by GitHub

Support arbitrary shaped Material. (#14367)

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.
parent 340d9e00
......@@ -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].
......@@ -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,
......@@ -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.
......@@ -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.
......@@ -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,
......@@ -277,41 +291,59 @@ class _MaterialState extends State<Material> with TickerProviderStateMixin {
)
);
if (widget.type == MaterialType.circle) {
contents = new AnimatedPhysicalModel(
final ShapeBorder shape = _getShape();
if (widget.type == MaterialType.transparency)
return _clipToShape(shape: shape, contents: contents);
return new _MaterialInterior(
curve: Curves.fastOutSlowIn,
duration: kThemeChangeDuration,
shape: BoxShape.circle,
shape: shape,
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
);
}
} 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,
static Widget _clipToShape({ShapeBorder shape, Widget contents}) {
return new ClipPath(
child: contents,
clipper: new ShapeBorderClipper(
shape: shape,
),
);
}
return contents;
// 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],
);
case MaterialType.circle:
return const CircleBorder();
}
return new RoundedRectangleBorder();
}
}
......@@ -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),
);
}
}
......@@ -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
......
......@@ -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;
}
}
......
......@@ -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));
......
......@@ -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,
);
}
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -28,8 +29,8 @@ Widget buildMaterial(
);
}
RenderPhysicalModel getShadow(WidgetTester tester) {
return tester.renderObject(find.byType(PhysicalModel));
RenderPhysicalShape getShadow(WidgetTester tester) {
return tester.renderObject(find.byType(PhysicalShape));
}
class PaintRecorder extends CustomPainter {
......@@ -121,23 +122,23 @@ void main() {
// a kThemeChangeDuration time interval.
await tester.pumpWidget(buildMaterial(elevation: 0.0));
final RenderPhysicalModel modelA = getShadow(tester);
final RenderPhysicalShape modelA = getShadow(tester);
expect(modelA.elevation, equals(0.0));
await tester.pumpWidget(buildMaterial(elevation: 9.0));
final RenderPhysicalModel modelB = getShadow(tester);
final RenderPhysicalShape modelB = getShadow(tester);
expect(modelB.elevation, equals(0.0));
await tester.pump(const Duration(milliseconds: 1));
final RenderPhysicalModel modelC = getShadow(tester);
final RenderPhysicalShape modelC = getShadow(tester);
expect(modelC.elevation, closeTo(0.0, 0.001));
await tester.pump(kThemeChangeDuration ~/ 2);
final RenderPhysicalModel modelD = getShadow(tester);
final RenderPhysicalShape modelD = getShadow(tester);
expect(modelD.elevation, isNot(closeTo(0.0, 0.001)));
await tester.pump(kThemeChangeDuration);
final RenderPhysicalModel modelE = getShadow(tester);
final RenderPhysicalShape modelE = getShadow(tester);
expect(modelE.elevation, equals(9.0));
});
......@@ -146,23 +147,23 @@ void main() {
// a kThemeChangeDuration time interval.
await tester.pumpWidget(buildMaterial(shadowColor: const Color(0xFF00FF00)));
final RenderPhysicalModel modelA = getShadow(tester);
final RenderPhysicalShape modelA = getShadow(tester);
expect(modelA.shadowColor, equals(const Color(0xFF00FF00)));
await tester.pumpWidget(buildMaterial(shadowColor: const Color(0xFFFF0000)));
final RenderPhysicalModel modelB = getShadow(tester);
final RenderPhysicalShape modelB = getShadow(tester);
expect(modelB.shadowColor, equals(const Color(0xFF00FF00)));
await tester.pump(const Duration(milliseconds: 1));
final RenderPhysicalModel modelC = getShadow(tester);
final RenderPhysicalShape modelC = getShadow(tester);
expect(modelC.shadowColor, within<Color>(distance: 1, from: const Color(0xFF00FF00)));
await tester.pump(kThemeChangeDuration ~/ 2);
final RenderPhysicalModel modelD = getShadow(tester);
final RenderPhysicalShape modelD = getShadow(tester);
expect(modelD.shadowColor, isNot(within<Color>(distance: 1, from: const Color(0xFF00FF00))));
await tester.pump(kThemeChangeDuration);
final RenderPhysicalModel modelE = getShadow(tester);
final RenderPhysicalShape modelE = getShadow(tester);
expect(modelE.shadowColor, equals(const Color(0xFFFF0000)));
});
......@@ -198,6 +199,25 @@ void main() {
),
);
});
testWidgets('clips to shape when provided', (WidgetTester tester) async {
final GlobalKey materialKey = new GlobalKey();
await tester.pumpWidget(
new Material(
key: materialKey,
type: MaterialType.transparency,
shape: const StadiumBorder(),
child: const SizedBox(width: 100.0, height: 100.0)
)
);
expect(
find.byKey(materialKey),
clipsWithShapeBorder(
shape: const StadiumBorder(),
),
);
});
});
group('PhysicalModels', () {
......@@ -237,6 +257,24 @@ void main() {
));
});
testWidgets('canvas with shape and elevation', (WidgetTester tester) async {
final GlobalKey materialKey = new GlobalKey();
await tester.pumpWidget(
new Material(
key: materialKey,
type: MaterialType.canvas,
shape: const StadiumBorder(),
child: const SizedBox(width: 100.0, height: 100.0),
elevation: 1.0,
)
);
expect(find.byKey(materialKey), rendersOnPhysicalShape(
shape: const StadiumBorder(),
elevation: 1.0,
));
});
testWidgets('card', (WidgetTester tester) async {
final GlobalKey materialKey = new GlobalKey();
await tester.pumpWidget(
......@@ -273,6 +311,24 @@ void main() {
));
});
testWidgets('card with shape and elevation', (WidgetTester tester) async {
final GlobalKey materialKey = new GlobalKey();
await tester.pumpWidget(
new Material(
key: materialKey,
type: MaterialType.card,
shape: const StadiumBorder(),
elevation: 5.0,
child: const SizedBox(width: 100.0, height: 100.0),
)
);
expect(find.byKey(materialKey), rendersOnPhysicalShape(
shape: const StadiumBorder(),
elevation: 5.0,
));
});
testWidgets('circle', (WidgetTester tester) async {
final GlobalKey materialKey = new GlobalKey();
await tester.pumpWidget(
......@@ -327,5 +383,24 @@ void main() {
elevation: 4.0,
));
});
testWidgets('button with elevation and shape', (WidgetTester tester) async {
final GlobalKey materialKey = new GlobalKey();
await tester.pumpWidget(
new Material(
key: materialKey,
type: MaterialType.button,
child: const SizedBox(width: 100.0, height: 100.0),
color: const Color(0xFF0000FF),
shape: const StadiumBorder(),
elevation: 4.0,
)
);
expect(find.byKey(materialKey), rendersOnPhysicalShape(
shape: const StadiumBorder(),
elevation: 4.0,
));
});
});
}
......@@ -100,24 +100,24 @@ void main() {
test('shape change triggers repaint', () {
final RenderPhysicalShape root = new RenderPhysicalShape(
color: const Color(0xffff00ff),
clipper: const ShapeBorderClipper(shapeBorder: const CircleBorder()),
clipper: const ShapeBorderClipper(shape: const CircleBorder()),
);
layout(root, phase: EnginePhase.composite);
expect(root.debugNeedsPaint, isFalse);
// Same shape, no repaint.
root.clipper = const ShapeBorderClipper(shapeBorder: const CircleBorder());
root.clipper = const ShapeBorderClipper(shape: const CircleBorder());
expect(root.debugNeedsPaint, isFalse);
// Different shape triggers repaint.
root.clipper = const ShapeBorderClipper(shapeBorder: const StadiumBorder());
root.clipper = const ShapeBorderClipper(shape: const StadiumBorder());
expect(root.debugNeedsPaint, isTrue);
});
test('compositing on non-Fuchsia', () {
final RenderPhysicalShape root = new RenderPhysicalShape(
color: const Color(0xffff00ff),
clipper: const ShapeBorderClipper(shapeBorder: const CircleBorder()),
clipper: const ShapeBorderClipper(shape: const CircleBorder()),
);
layout(root, phase: EnginePhase.composite);
expect(root.needsCompositing, isFalse);
......
......@@ -110,6 +110,15 @@ class TestRecordingPaintingContext implements PaintingContext {
canvas.restore();
}
@override
void pushClipPath(bool needsCompositing, Offset offset, Rect bounds, Path clipPath, PaintingContextCallback painter) {
canvas
..save()
..clipPath(clipPath.shift(offset));
painter(this, offset);
canvas.restore();
}
@override
void pushTransform(bool needsCompositing, Offset offset, Matrix4 transform, PaintingContextCallback painter) {
canvas.save();
......
......@@ -12,14 +12,14 @@ void main() {
testWidgets('properties', (WidgetTester tester) async {
await tester.pumpWidget(
const PhysicalShape(
clipper: const ShapeBorderClipper(shapeBorder: const CircleBorder()),
clipper: const ShapeBorderClipper(shape: const CircleBorder()),
elevation: 2.0,
color: const Color(0xFF0000FF),
shadowColor: const Color(0xFF00FF00),
)
);
final RenderPhysicalShape renderObject = tester.renderObject(find.byType(PhysicalShape));
expect(renderObject.clipper, const ShapeBorderClipper(shapeBorder: const CircleBorder()));
expect(renderObject.clipper, const ShapeBorderClipper(shape: const CircleBorder()));
expect(renderObject.color, const Color(0xFF0000FF));
expect(renderObject.shadowColor, const Color(0xFF00FF00));
expect(renderObject.elevation, 2.0);
......@@ -28,7 +28,7 @@ void main() {
testWidgets('hit test', (WidgetTester tester) async {
await tester.pumpWidget(
new PhysicalShape(
clipper: const ShapeBorderClipper(shapeBorder: const CircleBorder()),
clipper: const ShapeBorderClipper(shape: const CircleBorder()),
elevation: 2.0,
color: const Color(0xFF0000FF),
shadowColor: const Color(0xFF00FF00),
......
This diff is collapsed.
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