diff --git a/packages/flutter/lib/src/painting/borders.dart b/packages/flutter/lib/src/painting/borders.dart index 03d9ebc90e..06a1cc57b5 100644 --- a/packages/flutter/lib/src/painting/borders.dart +++ b/packages/flutter/lib/src/painting/borders.dart @@ -259,6 +259,318 @@ class BorderSide { String toString() => '$runtimeType($color, ${width.toStringAsFixed(1)}, $style)'; } +/// Base class for shape outlines. +/// +/// This class handles how to add multiple borders together. +@immutable +abstract class ShapeBorder { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const ShapeBorder(); + + /// The widths of the sides of this border represented as an [EdgeInsets]. + /// + /// Specifically, this is the amount by which a rectangle should be inset so + /// as to avoid painting over any important part of the border. It is the + /// amount by which additional borders will be inset before they are drawn. + /// + /// This can be used, for example, with a [Padding] widget to inset a box by + /// the size of these borders. + /// + /// Shapes that have a fixed ratio regardless of the area on which they are + /// painted, or that change their rendering based on the size they are given + /// when painting (for instance [CircleBorder]), will not return valid + /// [dimensions] information because they cannot know their eventual size when + /// computing their [dimensions]. + EdgeInsetsGeometry get dimensions; + + /// Attempts to create a new object that represents the amalgamation of [this] + /// border and the `other` border. + /// + /// If the type of the other border isn't known, or the given instance cannot + /// be reasonably added to this instance, then this should return null. + /// + /// This method is used by the [operator +] implementation. + /// + /// The `reversed` argument is true if this object was the right operand of + /// the `+` operator, and false if it was the left operand. + @protected + ShapeBorder add(ShapeBorder other, { bool reversed: false }) => null; + + /// Creates a new border consisting of the two borders on either side of the + /// operator. + /// + /// If the borders belong to classes that know how to add themselves, then + /// this results in a new border that represents the intelligent addition of + /// those two borders (see [add]). Otherwise, an object is returned that + /// merely paints the two borders sequentially, with the left hand operand on + /// the inside and the right hand operand on the outside. + ShapeBorder operator +(ShapeBorder other) { + return add(other) ?? other.add(this, reversed: true) ?? new _CompoundBorder([other, this]); + } + + /// Creates a new border with the widths of this border multiplied by `t`. + ShapeBorder scale(double t); + + /// Linearly interpolates from `a` to [this]. + /// + /// When implementing this method in subclasses, return null if this class + /// cannot interpolate from `a`. In that case, [lerp] will try `a`'s [lerpTo] + /// method instead. If `a` is null, this must not return null. + /// + /// The base class implementation handles the case of `a` being null by + /// deferring to [scale]. + /// + /// Instead of calling this directly, use [ShapeBorder.lerp]. + @protected + ShapeBorder lerpFrom(ShapeBorder a, double t) { + if (a == null) + return scale(t); + return null; + } + + /// Linearly interpolates from [this] to `b`. + /// + /// This is called if `b`'s [lerpTo] did not know how to handle this class. + /// + /// When implementing this method in subclasses, return null if this class + /// cannot interpolate from `b`. In that case, [lerp] will apply a default + /// behavior instead. If `b` is null, this must not return null. + /// + /// The base class implementation handles the case of `b` being null by + /// deferring to [scale]. + /// + /// Instead of calling this directly, use [ShapeBorder.lerp]. + @protected + ShapeBorder lerpTo(ShapeBorder b, double t) { + if (b == null) + return scale(1.0 - t); + return null; + } + + /// Linearly interpolates from `begin` to `end`. + /// + /// This defers to `end`'s [lerpTo] function if `end` is not null. If `end` is + /// null or if its [lerpTo] returns null, it uses `begin`'s [lerpFrom] + /// function instead. If both return null, it returns `begin` before `t=0.5` + /// and `end` after `t=0.5`. + static ShapeBorder lerp(ShapeBorder begin, ShapeBorder end, double t) { + ShapeBorder result; + if (end != null) + result = end.lerpFrom(begin, t); + if (result == null && begin != null) + result = begin.lerpTo(end, t); + return result ?? (t < 0.5 ? begin : end); + } + + /// Create a [Path] that describes the outer edge of the border. + /// + /// This path must not cross the path given by [getInnerPath] for the same + /// [Rect]. + /// + /// To obtain a [Path] that describes the area of the border itself, set the + /// [Path.fillType] of the returned object to [PathFillType.evenOdd], and add + /// to this object the path returned from [getInnerPath] (using + /// [Path.addPath]). + /// + /// The `textDirection` argument must be provided non-null if the border + /// 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. + /// + /// See also: + /// + /// * [getInnerPath], which creates the path for the inner edge. + /// * [Path.contains], which can tell if an [Offset] is within a [Path]. + Path getOuterPath(Rect rect, { TextDirection textDirection }); + + /// Create a [Path] that describes the inner edge of the border. + /// + /// This path must not cross the path given by [getOuterPath] for the same + /// [Rect]. + /// + /// To obtain a [Path] that describes the area of the border itself, set the + /// [Path.fillType] of the returned object to [PathFillType.evenOdd], and add + /// to this object the path returned from [getOuterPath] (using + /// [Path.addPath]). + /// + /// The `textDirection` argument must be provided and non-null if the border + /// 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. + /// + /// See also: + /// + /// * [getOuterPath], which creates the path for the outer edge. + /// * [Path.contains], which can tell if an [Offset] is within a [Path]. + Path getInnerPath(Rect rect, { TextDirection textDirection }); + + /// Paints the border within the given [Rect] on the given [Canvas]. + /// + /// The `textDirection` argument must be provided and non-null if the border + /// 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. + void paint(Canvas canvas, Rect rect, { TextDirection textDirection }); + + @override + String toString() { + return '$runtimeType()'; + } +} + +/// Represents the addition of two otherwise-incompatible borders. +/// +/// The borders are listed from the outside to the inside. +class _CompoundBorder extends ShapeBorder { + _CompoundBorder(this.borders) { + assert(borders != null); + assert(borders.length >= 2); + assert(!borders.any((ShapeBorder border) => border is _CompoundBorder)); + } + + final List borders; + + @override + EdgeInsetsGeometry get dimensions { + return borders.fold( + EdgeInsets.zero, + (EdgeInsetsGeometry previousValue, ShapeBorder border) { + return previousValue.add(border.dimensions); + }, + ); + } + + @override + ShapeBorder add(ShapeBorder other, { bool reversed: false }) { + // This wraps the list of borders with "other", or, if "reversed" is true, + // wraps "other" with the list of borders. + // If "reversed" is false, "other" should end up being at the start of the + // list, otherwise, if "reversed" is true, it should end up at the end. + // First, see if we can merge the new adjacent borders. + if (other is! _CompoundBorder) { + // Here, "ours" is the border at the side where we're adding the new + // border, and "merged" is the result of attempting to merge it with the + // new border. If it's null, it couldn't be merged. + final ShapeBorder ours = reversed ? borders.last : borders.first; + final ShapeBorder merged = ours.add(other, reversed: reversed) + ?? other.add(ours, reversed: !reversed); + if (merged != null) { + final List result = []; + result.addAll(borders); + result[reversed ? result.length - 1 : 0] = merged; + return new _CompoundBorder(result); + } + } + // We can't, so fall back to just adding the new border to the list. + final List mergedBorders = []; + if (reversed) + mergedBorders.addAll(borders); + if (other is _CompoundBorder) + mergedBorders.addAll(other.borders); + else + mergedBorders.add(other); + if (!reversed) + mergedBorders.addAll(borders); + return new _CompoundBorder(mergedBorders); + } + + @override + ShapeBorder scale(double t) { + return new _CompoundBorder( + borders.map((ShapeBorder border) => border.scale(t)).toList() + ); + } + + @override + ShapeBorder lerpFrom(ShapeBorder a, double t) { + return _CompoundBorder.lerp(a, this, t); + } + + @override + ShapeBorder lerpTo(ShapeBorder b, double t) { + return _CompoundBorder.lerp(this, b, t); + } + + static _CompoundBorder lerp(ShapeBorder a, ShapeBorder b, double t) { + assert(a is _CompoundBorder || b is _CompoundBorder); // Not really necessary, but all call sites currently intend this. + final List aList = a is _CompoundBorder ? a.borders : [a]; + final List bList = b is _CompoundBorder ? b.borders : [b]; + final List results = []; + final int length = math.max(aList.length, bList.length); + for (int index = 0; index < length; index += 1) { + final ShapeBorder localA = index < aList.length ? aList[index] : null; + final ShapeBorder localB = index < bList.length ? bList[index] : null; + if (localA != null && localB != null) { + final ShapeBorder localResult = localA.lerpTo(localB, t) ?? localB.lerpFrom(localA, t); + if (localResult != null) { + results.add(localResult); + continue; + } + } + // If we're changing from one shape to another, make sure the shape that is coming in + // is inserted before the shape that is going away, so that the outer path changes to + // the new border earlier rather than later. (This affects, among other things, where + // the ShapeDecoration class puts its background.) + if (localB != null) + results.add(localB.scale(t)); + if (localA != null) + results.add(localA.scale(1.0 - t)); + } + return new _CompoundBorder(results); + } + + @override + Path getInnerPath(Rect rect, { TextDirection textDirection }) { + for (int index = 0; index < borders.length - 1; index += 1) + rect = borders[index].dimensions.resolve(textDirection).deflateRect(rect); + return borders.last.getInnerPath(rect); + } + + @override + Path getOuterPath(Rect rect, { TextDirection textDirection }) { + return borders.first.getOuterPath(rect); + } + + @override + void paint(Canvas canvas, Rect rect, { TextDirection textDirection }) { + for (ShapeBorder border in borders) { + border.paint(canvas, rect, textDirection: textDirection); + rect = border.dimensions.resolve(textDirection).deflateRect(rect); + } + } + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (runtimeType != other.runtimeType) + return false; + final _CompoundBorder typedOther = other; + if (borders == typedOther.borders) + return true; + if (borders.length != typedOther.borders.length) + return false; + for (int index = 0; index < borders.length; index += 1) { + if (borders[index] != typedOther.borders[index]) + return false; + } + return true; + } + + @override + int get hashCode => hashList(borders); + + @override + String toString() { + // We list them in reverse order because when adding two borders they end up + // in the list in the opposite order of what the source looks like: a + b => + // [b, a]. We do this to make the painting code more optimal, and most of + // the rest of the code doesn't care, except toString() (for debugging). + return borders.reversed.map((ShapeBorder border) => border.toString()).join(' + '); + } +} + /// A border of a box, comprised of four sides. /// /// The sides are represented by [BorderSide] objects. @@ -315,8 +627,7 @@ class BorderSide { /// * [BorderSide], which is used to describe each side of the box. /// * [Theme], from the material layer, which can be queried to obtain appropriate colors /// to use for borders in a material app, as shown in the "divider" sample above. -@immutable -class Border { +class Border extends ShapeBorder { /// Creates a border. /// /// All the sides of the border default to [BorderSide.none]. @@ -373,10 +684,7 @@ class Border { /// The left side of this border. final BorderSide left; - /// The widths of the sides of this border represented as an [EdgeInsets]. - /// - /// This can be used, for example, with a [Padding] widget to inset a box by - /// the size of these borders. + @override EdgeInsetsGeometry get dimensions { return new EdgeInsets.fromLTRB(left.width, top.width, right.width, bottom.width); } @@ -410,7 +718,11 @@ class Border { return true; } - Border add(Border typedOther) { + @override + Border add(ShapeBorder other, { bool reversed: false }) { + if (other is! Border) + return null; + final Border typedOther = other; if (BorderSide.canMerge(top, typedOther.top) && BorderSide.canMerge(right, typedOther.right) && BorderSide.canMerge(bottom, typedOther.bottom) && @@ -421,6 +733,7 @@ class Border { } /// Creates a new border with the widths of this border multiplied by `t`. + @override Border scale(double t) { return new Border( top: top.scale(t), @@ -430,6 +743,34 @@ class Border { ); } + /// Linearly interpolates from `a` to [this]. + /// + /// If `a` is null, this defers to [scale]. + /// + /// If `a` is also a [Border], this uses [Border.lerp]. + /// + /// Otherwise, it defers to [ShapeBorder.lerpFrom]. + @override + ShapeBorder lerpFrom(ShapeBorder a, double t) { + if (a is Border) + return Border.lerp(a, this, t); + return super.lerpFrom(a, t); + } + + /// Linearly interpolates from [this] to `b`. + /// + /// If `b` is null, this defers to [scale]. + /// + /// If `b` is also a [Border], this uses [Border.lerp]. + /// + /// Otherwise, it defers to [ShapeBorder.lerpTo]. + @override + ShapeBorder lerpTo(ShapeBorder b, double t) { + if (b is Border) + return Border.lerp(this, b, t); + return super.lerpTo(b, t); + } + /// Linearly interpolate between two borders. /// /// If a border is null, it is treated as having four [BorderSide.none] @@ -449,6 +790,18 @@ class Border { ); } + @override + Path getInnerPath(Rect rect, { TextDirection textDirection }) { + return new Path() + ..addRect(dimensions.resolve(textDirection).deflateRect(rect)); + } + + @override + Path getOuterPath(Rect rect, { TextDirection textDirection }) { + return new Path() + ..addRect(rect); + } + /// Paints the border within the given [Rect] on the given [Canvas]. /// /// Uniform borders are more efficient to paint than more complex borders. @@ -460,10 +813,17 @@ class Border { /// may specify a [BorderRadius]. If a `borderRadius` is specified, there is /// the requirement that the border [isUniform]. /// + /// The [getInnerPath] and [getOuterPath] methods do not know about the + /// `shape` and `borderRadius` arguments. + /// + /// The `textDirection` argument is not used by this paint method. + /// /// See also: /// /// * [paintBorder], which is used if the border is not uniform. + @override void paint(Canvas canvas, Rect rect, { + TextDirection textDirection, BoxShape shape: BoxShape.rectangle, BorderRadius borderRadius, }) { diff --git a/packages/flutter/test/painting/border_test.dart b/packages/flutter/test/painting/border_test.dart index e6652a6561..5f77ee7f5e 100644 --- a/packages/flutter/test/painting/border_test.dart +++ b/packages/flutter/test/painting/border_test.dart @@ -51,34 +51,34 @@ void main() { final BorderSide yellow2 = const BorderSide(color: const Color(0xFFFFFF00), width: 2.0); final BorderSide yellowNone0 = const BorderSide(color: const Color(0xFFFFFF00), width: 0.0, style: BorderStyle.none); expect( - new Border(top: yellow2).add(new Border(right: magenta3)), + new Border(top: yellow2) + new Border(right: magenta3), new Border(top: yellow2, right: magenta3), ); expect( - new Border(bottom: magenta3).add(new Border(bottom: magenta3)), + new Border(bottom: magenta3) + new Border(bottom: magenta3), new Border(bottom: magenta6), ); expect( - new Border(left: magenta3, right: yellowNone0).add(new Border(right: yellow2)), + new Border(left: magenta3, right: yellowNone0) + new Border(right: yellow2), new Border(left: magenta3, right: yellow2), ); expect( - const Border().add(const Border()), + const Border() + const Border(), const Border(), ); expect( - new Border(left: magenta3).add(new Border(left: yellow2)), - isNull, + new Border(left: magenta3) + new Border(left: yellow2), + isNot(const isInstanceOf()), // see shape_border_test.dart for better tests of this case ); final Border b3 = new Border(top: magenta3); final Border b6 = new Border(top: magenta6); - expect(b3.add(b3), b6); + expect(b3 + b3, b6); final Border b0 = new Border(top: yellowNone0); final Border bZ = const Border(); - expect(b0.add(b0), bZ); - expect(bZ.add(bZ), bZ); - expect(b0.add(bZ), bZ); - expect(bZ.add(b0), bZ); + expect(b0 + b0, bZ); + expect(bZ + bZ, bZ); + expect(b0 + bZ, bZ); + expect(bZ + b0, bZ); }); test('Border.scale', () { diff --git a/packages/flutter/test/painting/shape_border_test.dart b/packages/flutter/test/painting/shape_border_test.dart new file mode 100644 index 0000000000..172a9d1df3 --- /dev/null +++ b/packages/flutter/test/painting/shape_border_test.dart @@ -0,0 +1,73 @@ +// 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/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +void main() { + test('Compound borders', () { + final Border b1 = new Border.all(color: const Color(0xFF00FF00)); + final Border b2 = new Border.all(color: const Color(0xFF0000FF)); + expect( + (b1 + b2).toString(), + 'Border.all(BorderSide(Color(0xff00ff00), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff0000ff), 1.0, BorderStyle.solid))', + ); + expect( + (b1 + (b2 + b2)).toString(), + 'Border.all(BorderSide(Color(0xff00ff00), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff0000ff), 2.0, BorderStyle.solid))', + ); + expect( + ((b1 + b2) + b2).toString(), + 'Border.all(BorderSide(Color(0xff00ff00), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff0000ff), 2.0, BorderStyle.solid))', + ); + expect((b1 + b2) + b2, b1 + (b2 + b2)); + expect( + (b1 + b2).scale(3.0).toString(), + 'Border.all(BorderSide(Color(0xff00ff00), 3.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff0000ff), 3.0, BorderStyle.solid))', + ); + expect( + (b1 + b2).scale(0.0).toString(), + 'Border.all(BorderSide(Color(0xff00ff00), 0.0, BorderStyle.none)) + ' + 'Border.all(BorderSide(Color(0xff0000ff), 0.0, BorderStyle.none))', + ); + expect( + ShapeBorder.lerp(b2 + b1, b1 + b2, 0.0).toString(), + 'Border.all(BorderSide(Color(0xff0000ff), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff00ff00), 1.0, BorderStyle.solid))', + ); + expect( + ShapeBorder.lerp(b2 + b1, b1 + b2, 0.25).toString(), + 'Border.all(BorderSide(Color(0xff003fbf), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff00bf3f), 1.0, BorderStyle.solid))', + ); + expect( + ShapeBorder.lerp(b2 + b1, b1 + b2, 0.5).toString(), + 'Border.all(BorderSide(Color(0xff007f7f), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff007f7f), 1.0, BorderStyle.solid))', + ); + expect( + ShapeBorder.lerp(b2 + b1, b1 + b2, 1.0).toString(), + 'Border.all(BorderSide(Color(0xff00ff00), 1.0, BorderStyle.solid)) + ' + 'Border.all(BorderSide(Color(0xff0000ff), 1.0, BorderStyle.solid))' + ); + expect((b1 + b2).dimensions, const EdgeInsets.all(2.0)); + final Rect rect = new Rect.fromLTRB(11.0, 15.0, 299.0, 175.0); + expect((Canvas canvas) => (b1 + b2).paint(canvas, rect), paints + ..rect(rect: rect.deflate(0.5), color: b2.top.color) + ..rect(rect: rect.deflate(1.5), color: b1.top.color) + ); + expect((b1 + b2 + b1).dimensions, const EdgeInsets.all(3.0)); + expect((Canvas canvas) => (b1 + b2 + b1).paint(canvas, rect), paints + ..rect(rect: rect.deflate(0.5), color: b1.top.color) + ..rect(rect: rect.deflate(1.5), color: b2.top.color) + ..rect(rect: rect.deflate(2.5), color: b1.top.color) + ); + }); +}