Unverified Commit 0230a874 authored by amirh's avatar amirh Committed by GitHub

Add a PhysicalShape widget and a render object for it. (#13682)

This CL also refactors common logic for RenderPhysicalModel and
RenderPhysicalShape into a base class _RenderPhysicalModelBase.
parent 6a42ed3f
......@@ -954,6 +954,42 @@ abstract class CustomClipper<T> {
String toString() => '$runtimeType';
}
/// A [CustomClipper] that clips to the outer path of a [ShapeBorder].
class ShapeBorderClipper extends CustomClipper<Path> {
/// Creates a [ShapeBorder] clipper.
///
/// The [shapeBorder] argument must not be null.
///
/// The [textDirection] argument must be provided non-null if [shapeBorder]
/// 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,
this.textDirection,
}) : assert(shapeBorder != null);
// The shape border whose outer path this clipper clips to.
final ShapeBorder shapeBorder;
/// The text direction to use for getting the outer path for [shapeBorder].
///
/// [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.
@override
Path getClip(Size size) {
return shapeBorder.getOuterPath(Offset.zero & size, textDirection: textDirection);
}
@override
bool shouldReclip(covariant ShapeBorderClipper oldClipper) {
return oldClipper.shapeBorder != shapeBorder;
}
}
abstract class _RenderCustomClip<T> extends RenderProxyBox {
_RenderCustomClip({
RenderBox child,
......@@ -1292,11 +1328,83 @@ class RenderClipPath extends _RenderCustomClip<Path> {
}
}
/// Creates a physical model layer that clips its children to a rounded
/// A physical model layer casts a shadow based on its [elevation].
///
/// The concrete implementations [RenderPhysicalModel] and [RenderPhysicalShape]
/// determine the actual shape of the physical model.
abstract class _RenderPhysicalModelBase<T> extends _RenderCustomClip<T> {
/// The [shape], [elevation], [color], and [shadowColor] must not be null.
_RenderPhysicalModelBase({
@required RenderBox child,
@required double elevation,
@required Color color,
@required Color shadowColor,
CustomClipper<T> clipper,
}) : assert(elevation != null),
assert(color != null),
assert(shadowColor != null),
_elevation = elevation,
_color = color,
_shadowColor = shadowColor,
super(child: child, clipper: clipper);
/// The z-coordinate at which to place this material.
double get elevation => _elevation;
double _elevation;
set elevation(double value) {
assert(value != null);
if (elevation == value)
return;
final bool didNeedCompositing = alwaysNeedsCompositing;
_elevation = value;
if (didNeedCompositing != alwaysNeedsCompositing)
markNeedsCompositingBitsUpdate();
markNeedsPaint();
}
/// The shadow color.
Color get shadowColor => _shadowColor;
Color _shadowColor;
set shadowColor(Color value) {
assert(value != null);
if (shadowColor == value)
return;
_shadowColor = value;
markNeedsPaint();
}
/// The background color.
Color get color => _color;
Color _color;
set color(Color value) {
assert(value != null);
if (color == value)
return;
_color = value;
markNeedsPaint();
}
static final Paint _defaultPaint = new Paint();
static final Paint _transparentPaint = new Paint()..color = const Color(0x00000000);
// On Fuchsia, the system compositor is responsible for drawing shadows
// for physical model layers with non-zero elevation.
@override
bool get alwaysNeedsCompositing => _elevation != 0.0 && defaultTargetPlatform == TargetPlatform.fuchsia;
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DoubleProperty('elevation', elevation));
description.add(new DiagnosticsProperty<Color>('color', color));
}
}
/// Creates a physical model layer that clips its child to a rounded
/// rectangle.
///
/// A physical model layer casts a shadow based on its [elevation].
class RenderPhysicalModel extends _RenderCustomClip<RRect> {
class RenderPhysicalModel extends _RenderPhysicalModelBase<RRect> {
/// Creates a rounded-rectangular clip.
///
/// The [color] is required.
......@@ -1315,10 +1423,12 @@ class RenderPhysicalModel extends _RenderCustomClip<RRect> {
assert(shadowColor != null),
_shape = shape,
_borderRadius = borderRadius,
_elevation = elevation,
_color = color,
_shadowColor = shadowColor,
super(child: child);
super(
child: child,
elevation: elevation,
color: color,
shadowColor: shadowColor
);
/// The shape of the layer.
///
......@@ -1351,42 +1461,6 @@ class RenderPhysicalModel extends _RenderCustomClip<RRect> {
_markNeedsClip();
}
/// The z-coordinate at which to place this material.
double get elevation => _elevation;
double _elevation;
set elevation(double value) {
assert(value != null);
if (elevation == value)
return;
final bool didNeedCompositing = alwaysNeedsCompositing;
_elevation = value;
if (didNeedCompositing != alwaysNeedsCompositing)
markNeedsCompositingBitsUpdate();
markNeedsPaint();
}
/// The shadow color.
Color get shadowColor => _shadowColor;
Color _shadowColor;
set shadowColor(Color value) {
assert(value != null);
if (shadowColor == value)
return;
_shadowColor = value;
markNeedsPaint();
}
/// The background color.
Color get color => _color;
Color _color;
set color(Color value) {
assert(value != null);
if (color == value)
return;
_color = value;
markNeedsPaint();
}
@override
RRect get _defaultClip {
assert(hasSize);
......@@ -1412,14 +1486,6 @@ class RenderPhysicalModel extends _RenderCustomClip<RRect> {
return super.hitTest(result, position: position);
}
static final Paint _defaultPaint = new Paint();
static final Paint _transparentPaint = new Paint()..color = const Color(0x00000000);
// On Fuchsia, the system compositor is responsible for drawing shadows
// for physical model layers with non-zero elevation.
@override
bool get alwaysNeedsCompositing => _elevation != 0.0 && defaultTargetPlatform == TargetPlatform.fuchsia;
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
......@@ -1443,7 +1509,7 @@ class RenderPhysicalModel extends _RenderCustomClip<RRect> {
// TODO(jsimmons): remove this when Skia does it for us.
canvas.drawRect(
offsetBounds.inflate(20.0),
_transparentPaint,
_RenderPhysicalModelBase._transparentPaint,
);
canvas.drawShadow(
offsetClipPath,
......@@ -1462,7 +1528,7 @@ class RenderPhysicalModel extends _RenderCustomClip<RRect> {
// the side of correctness here.
// TODO(ianh): Find a better solution.
if (!offsetClipRRect.isRect)
canvas.saveLayer(offsetBounds, _defaultPaint);
canvas.saveLayer(offsetBounds, _RenderPhysicalModelBase._defaultPaint);
super.paint(context, offset);
if (!offsetClipRRect.isRect)
canvas.restore();
......@@ -1477,8 +1543,101 @@ class RenderPhysicalModel extends _RenderCustomClip<RRect> {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<BoxShape>('shape', shape));
description.add(new DiagnosticsProperty<BorderRadius>('borderRadius', borderRadius));
description.add(new DoubleProperty('elevation', elevation));
description.add(new DiagnosticsProperty<Color>('color', color));
}
}
/// Creates a physical shape layer that clips its child to a [Path].
///
/// A physical shape layer casts a shadow based on its [elevation].
///
/// See also:
///
/// * [RenderPhysicalModel], which is optimized for rounded rectangles and
/// circles.
class RenderPhysicalShape extends _RenderPhysicalModelBase<Path> {
/// Creates an arbitrary shape clip.
///
/// The [color] and [shape] parameters are required.
///
/// The [clipper], [elevation], [color] and [shadowColor] must
/// not be null.
RenderPhysicalShape({
RenderBox child,
@required CustomClipper<Path> clipper,
double elevation: 0.0,
@required Color color,
Color shadowColor: const Color(0xFF000000),
}) : assert(clipper != null),
assert(elevation != null),
assert(color != null),
assert(shadowColor != null),
super(
child: child,
elevation: elevation,
color: color,
shadowColor: shadowColor,
clipper: clipper,
);
@override
Path get _defaultClip => new Path()..addRect(Offset.zero & size);
@override
bool hitTest(HitTestResult result, { Offset position }) {
if (_clipper != null) {
_updateClip();
assert(_clip != null);
if (!_clip.contains(position))
return false;
}
return super.hitTest(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
_updateClip();
final Rect offsetBounds = offset & size;
final Path offsetPath = _clip.shift(offset);
if (needsCompositing) {
final PhysicalModelLayer physicalModel = new PhysicalModelLayer(
clipPath: offsetPath,
elevation: elevation,
color: color,
);
context.pushLayer(physicalModel, super.paint, offset, childPaintBounds: offsetBounds);
} else {
final Canvas canvas = context.canvas;
if (elevation != 0.0) {
// The drawShadow call doesn't add the region of the shadow to the
// picture's bounds, so we draw a hardcoded amount of extra space to
// account for the maximum potential area of the shadow.
// TODO(jsimmons): remove this when Skia does it for us.
canvas.drawRect(
offsetBounds.inflate(20.0),
_RenderPhysicalModelBase._transparentPaint,
);
canvas.drawShadow(
offsetPath,
shadowColor,
elevation,
color.alpha != 0xFF,
);
}
canvas.drawPath(offsetPath, new Paint()..color = color..style = PaintingStyle.fill);
canvas.saveLayer(offsetBounds, _RenderPhysicalModelBase._defaultPaint);
canvas.clipPath(offsetPath);
super.paint(context, offset);
canvas.restore();
assert(context.canvas == canvas, 'canvas changed even though needsCompositing was false');
}
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<CustomClipper<Path>>('clipper', clipper));
}
}
......
......@@ -642,6 +642,9 @@ class ClipPath extends SingleChildRenderObjectWidget {
/// Physical layers cast shadows based on an [elevation] which is nominally in
/// logical pixels, coming vertically out of the rendering surface.
///
/// For shapes that cannot be expressed as a rectangle with rounded corners use
/// [PhysicalShape].
///
/// See also:
///
/// * [DecoratedBox], which can apply more arbitrary shadow effects.
......@@ -717,6 +720,73 @@ class PhysicalModel extends SingleChildRenderObjectWidget {
}
}
/// A widget representing a physical layer that clips its children to a path.
///
/// Physical layers cast shadows based on an [elevation] which is nominally in
/// logical pixels, coming vertically out of the rendering surface.
///
/// [PhysicalModel] does the same but only supports shapes that can be expressed
/// as rectangles with rounded corners.
class PhysicalShape extends SingleChildRenderObjectWidget {
/// Creates a physical model with an arbitrary shape clip.
///
/// The [color] is required; physical things have a color.
///
/// The [clipper], [elevation], [color], and [shadowColor] must not be null.
const PhysicalShape({
Key key,
@required this.clipper,
this.elevation: 0.0,
@required this.color,
this.shadowColor: const Color(0xFF000000),
Widget child,
}) : assert(clipper != null),
assert(elevation != null),
assert(color != null),
assert(shadowColor != null),
super(key: key, child: child);
/// Determines which clip to use.
final CustomClipper<Path> clipper;
/// The z-coordinate at which to place this physical object.
final double elevation;
/// The background color.
final Color color;
/// When elevation is non zero the color to use for the shadow color.
final Color shadowColor;
@override
RenderPhysicalShape createRenderObject(BuildContext context) {
return new RenderPhysicalShape(
clipper: clipper,
elevation: elevation,
color: color,
shadowColor: shadowColor
);
}
@override
void updateRenderObject(BuildContext context, RenderPhysicalShape renderObject) {
renderObject
..clipper = clipper
..elevation = elevation
..color = color
..shadowColor = shadowColor;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new EnumProperty<CustomClipper<Path>>('clipper', clipper));
description.add(new DoubleProperty('elevation', elevation));
description.add(new DiagnosticsProperty<Color>('color', color));
description.add(new DiagnosticsProperty<Color>('shadowColor', shadowColor));
}
}
// POSITIONING AND SIZING NODES
/// A widget that applies a transformation before painting its child.
......
......@@ -91,4 +91,47 @@ void main() {
expect(config.getActionHandler(SemanticsAction.scrollLeft), isNotNull);
expect(config.getActionHandler(SemanticsAction.scrollRight), isNull);
});
group('RenderPhysicalShape', () {
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
test('shape change triggers repaint', () {
final RenderPhysicalShape root = new RenderPhysicalShape(
color: const Color(0xffff00ff),
clipper: const ShapeBorderClipper(shapeBorder: const CircleBorder()),
);
layout(root, phase: EnginePhase.composite);
expect(root.debugNeedsPaint, isFalse);
// Same shape, no repaint.
root.clipper = const ShapeBorderClipper(shapeBorder: const CircleBorder());
expect(root.debugNeedsPaint, isFalse);
// Different shape triggers repaint.
root.clipper = const ShapeBorderClipper(shapeBorder: 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()),
);
layout(root, phase: EnginePhase.composite);
expect(root.needsCompositing, isFalse);
// On non-Fuchsia platforms, Flutter draws its own shadows.
root.elevation = 1.0;
pumpFrame(phase: EnginePhase.composite);
expect(root.needsCompositing, isFalse);
root.elevation = 0.0;
pumpFrame(phase: EnginePhase.composite);
expect(root.needsCompositing, isFalse);
debugDefaultTargetPlatformOverride = null;
});
});
}
// Copyright 2017 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_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
void main() {
group('PhysicalShape', () {
testWidgets('properties', (WidgetTester tester) async {
await tester.pumpWidget(
const PhysicalShape(
clipper: const ShapeBorderClipper(shapeBorder: 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.color, const Color(0xFF0000FF));
expect(renderObject.shadowColor, const Color(0xFF00FF00));
expect(renderObject.elevation, 2.0);
});
testWidgets('hit test', (WidgetTester tester) async {
await tester.pumpWidget(
new PhysicalShape(
clipper: const ShapeBorderClipper(shapeBorder: const CircleBorder()),
elevation: 2.0,
color: const Color(0xFF0000FF),
shadowColor: const Color(0xFF00FF00),
child: new Container(color: const Color(0xFF0000FF)),
)
);
final RenderPhysicalShape renderPhysicalShape =
tester.renderObject(find.byType(PhysicalShape));
// The viewport is 800x600, the CircleBorder is centered and fits
// the shortest edge, so we get a circle of radius 300, centered at
// (400, 300).
//
// We test by sampling a few points around the left-most point of the
// circle (100, 300).
expect(tester.hitTestOnBinding(const Offset(99.0, 300.0)), doesNotHit(renderPhysicalShape));
expect(tester.hitTestOnBinding(const Offset(100.0, 300.0)), hits(renderPhysicalShape));
expect(tester.hitTestOnBinding(const Offset(100.0, 299.0)), doesNotHit(renderPhysicalShape));
expect(tester.hitTestOnBinding(const Offset(100.0, 301.0)), doesNotHit(renderPhysicalShape));
});
});
}
HitsRenderBox hits(RenderBox renderBox) => new HitsRenderBox(renderBox);
class HitsRenderBox extends Matcher {
const HitsRenderBox(this.renderBox);
final RenderBox renderBox;
@override
Description describe(Description description) =>
description.add('hit test result contains ').addDescriptionOf(renderBox);
@override
bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
final HitTestResult hitTestResult = item;
return hitTestResult.path.where(
(HitTestEntry entry) => entry.target == renderBox
).isNotEmpty;
}
}
DoesNotHitRenderBox doesNotHit(RenderBox renderBox) => new DoesNotHitRenderBox(renderBox);
class DoesNotHitRenderBox extends Matcher {
const DoesNotHitRenderBox(this.renderBox);
final RenderBox renderBox;
@override
Description describe(Description description) =>
description.add('hit test result doesn\'t contain ').addDescriptionOf(renderBox);
@override
bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
final HitTestResult hitTestResult = item;
return hitTestResult.path.where(
(HitTestEntry entry) => entry.target == renderBox
).isEmpty;
}
}
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