diff --git a/packages/flutter/lib/src/painting/box_decoration.dart b/packages/flutter/lib/src/painting/box_decoration.dart index 4b067c52f2..f643acf40f 100644 --- a/packages/flutter/lib/src/painting/box_decoration.dart +++ b/packages/flutter/lib/src/painting/box_decoration.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart'; import 'basic_types.dart'; import 'border_radius.dart'; +import 'borders.dart'; import 'box_border.dart'; import 'box_shadow.dart'; import 'colors.dart'; @@ -462,10 +463,63 @@ class _BoxDecorationPainter extends BoxPainter { void _paintBackgroundColor(Canvas canvas, Rect rect, TextDirection? textDirection) { if (_decoration.color != null || _decoration.gradient != null) { - _paintBox(canvas, rect, _getBackgroundPaint(rect, textDirection), textDirection); + // When border is filled, the rect is reduced to avoid anti-aliasing + // rounding error leaking the background color around the clipped shape. + final Rect adjustedRect = _adjustedRectOnOutlinedBorder(rect, textDirection); + _paintBox(canvas, adjustedRect, _getBackgroundPaint(rect, textDirection), textDirection); } } + double _calculateAdjustedSide(BorderSide side) { + if (side.color.alpha == 255 && side.style == BorderStyle.solid) { + return side.strokeInset; + } + return 0; + } + + Rect _adjustedRectOnOutlinedBorder(Rect rect, TextDirection? textDirection) { + if (_decoration.border == null) { + return rect; + } + + if (_decoration.border is Border) { + final Border border = _decoration.border! as Border; + + final EdgeInsets insets = EdgeInsets.fromLTRB( + _calculateAdjustedSide(border.left), + _calculateAdjustedSide(border.top), + _calculateAdjustedSide(border.right), + _calculateAdjustedSide(border.bottom), + ) / 2; + + return Rect.fromLTRB( + rect.left + insets.left, + rect.top + insets.top, + rect.right - insets.right, + rect.bottom - insets.bottom, + ); + } else if (_decoration.border is BorderDirectional && textDirection != null) { + final BorderDirectional border = _decoration.border! as BorderDirectional; + final BorderSide leftSide = textDirection == TextDirection.rtl ? border.end : border.start; + final BorderSide rightSide = textDirection == TextDirection.rtl ? border.start : border.end; + + final EdgeInsets insets = EdgeInsets.fromLTRB( + _calculateAdjustedSide(leftSide), + _calculateAdjustedSide(border.top), + _calculateAdjustedSide(rightSide), + _calculateAdjustedSide(border.bottom), + ) / 2; + + return Rect.fromLTRB( + rect.left + insets.left, + rect.top + insets.top, + rect.right - insets.right, + rect.bottom - insets.bottom, + ); + } + return rect; + } + DecorationImagePainter? _imagePainter; void _paintBackgroundImage(Canvas canvas, Rect rect, ImageConfiguration configuration) { if (_decoration.image == null) { diff --git a/packages/flutter/lib/src/painting/shape_decoration.dart b/packages/flutter/lib/src/painting/shape_decoration.dart index 67f86dc672..7ab4803f01 100644 --- a/packages/flutter/lib/src/painting/shape_decoration.dart +++ b/packages/flutter/lib/src/painting/shape_decoration.dart @@ -406,13 +406,26 @@ class _ShapeDecorationPainter extends BoxPainter { void _paintInterior(Canvas canvas, Rect rect, TextDirection? textDirection) { if (_interiorPaint != null) { if (_decoration.shape.preferPaintInterior) { - _decoration.shape.paintInterior(canvas, rect, _interiorPaint!, textDirection: textDirection); + // When border is filled, the rect is reduced to avoid anti-aliasing + // rounding error leaking the background color around the clipped shape. + final Rect adjustedRect = _adjustedRectOnOutlinedBorder(rect); + _decoration.shape.paintInterior(canvas, adjustedRect, _interiorPaint!, textDirection: textDirection); } else { canvas.drawPath(_outerPath, _interiorPaint!); } } } + Rect _adjustedRectOnOutlinedBorder(Rect rect) { + if (_decoration.shape is OutlinedBorder && _decoration.color != null) { + final BorderSide side = (_decoration.shape as OutlinedBorder).side; + if (side.color.alpha == 255 && side.style == BorderStyle.solid) { + return rect.deflate(side.strokeInset / 2); + } + } + return rect; + } + DecorationImagePainter? _imagePainter; void _paintImage(Canvas canvas, ImageConfiguration configuration) { if (_decoration.image == null) { diff --git a/packages/flutter/test/material/data_table_test.dart b/packages/flutter/test/material/data_table_test.dart index ab1683c795..cabf72c656 100644 --- a/packages/flutter/test/material/data_table_test.dart +++ b/packages/flutter/test/material/data_table_test.dart @@ -1790,7 +1790,7 @@ void main() { expect( find.ancestor(of: find.byType(Table), matching: find.byType(Container)), paints..rect( - rect: const Rect.fromLTRB(0.0, 0.0, width, height), + rect: const Rect.fromLTRB(borderVertical / 2, borderHorizontal / 2, width - borderVertical / 2, height - borderHorizontal / 2), color: backgroundColor, ), ); diff --git a/packages/flutter/test/widgets/box_decoration_test.dart b/packages/flutter/test/widgets/box_decoration_test.dart index 5475e93fb5..c4ace8de10 100644 --- a/packages/flutter/test/widgets/box_decoration_test.dart +++ b/packages/flutter/test/widgets/box_decoration_test.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@Tags(['reduced-test-set']) +library; + import 'dart:async'; import 'dart:math' as math; import 'dart:ui' as ui show Image; @@ -577,4 +580,64 @@ Future main() async { ), ); }); + + // Regression test for https://github.com/flutter/flutter/issues/13675 + testWidgets('Border avoids clipping edges when possible', (WidgetTester tester) async { + final Key key = UniqueKey(); + Widget buildWidget(Color color) { + final List circles = []; + for (int i = 100; i > 25; i--) { + final double radius = i * 2.5; + final double angle = i * 0.5; + final double x = radius * math.cos(angle); + final double y = radius * math.sin(angle); + final Widget circle = Positioned( + left: 275 - x, + top: 275 - y, + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(75), + color: Colors.black, + border: Border.all(color: color, width: 50), + ), + ), + ); + circles.add(circle); + } + + return Center( + key: key, + child: Container( + width: 800, + height: 800, + decoration: const ShapeDecoration( + color: Colors.orangeAccent, + shape: CircleBorder( + side: BorderSide(strokeAlign: BorderSide.strokeAlignOutside), + ), + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: circles, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget(const Color(0xffffffff))); + await expectLater( + find.byKey(key), + matchesGoldenFile('painting.box_decoration.border.should_be_white.png'), + ); + + await tester.pumpWidget(buildWidget(const Color(0xfeffffff))); + await expectLater( + find.byKey(key), + matchesGoldenFile('painting.box_decoration.border.show_lines_due_to_opacity.png'), + ); + }); } diff --git a/packages/flutter/test/widgets/shape_decoration_test.dart b/packages/flutter/test/widgets/shape_decoration_test.dart index 1b49773390..9eb5fdd25b 100644 --- a/packages/flutter/test/widgets/shape_decoration_test.dart +++ b/packages/flutter/test/widgets/shape_decoration_test.dart @@ -2,6 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@Tags(['reduced-test-set']) +library; + +import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui show Image; @@ -158,4 +162,65 @@ Future main() async { expect(a.hashCode, equals(b.hashCode)); expect(a, equals(b)); }); + + // Regression test for https://github.com/flutter/flutter/issues/13675 + testWidgets('OutlinedBorder avoids clipping edges when possible', (WidgetTester tester) async { + final Key key = UniqueKey(); + Widget buildWidget(Color color) { + final List circles = []; + for (int i = 100; i > 25; i--) { + final double radius = i * 2.5; + final double angle = i * 0.5; + final double x = radius * math.cos(angle); + final double y = radius * math.sin(angle); + final Widget circle = Positioned( + left: 275 - x, + top: 275 - y, + child: Container( + width: 250, + height: 250, + decoration: ShapeDecoration( + color: Colors.black, + shape: CircleBorder( + side: BorderSide(color: color, width: 50), + ), + ), + ), + ); + circles.add(circle); + } + + return Center( + key: key, + child: Container( + width: 800, + height: 800, + decoration: const ShapeDecoration( + color: Colors.redAccent, + shape: CircleBorder( + side: BorderSide(strokeAlign: BorderSide.strokeAlignOutside), + ), + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: circles, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget(const Color(0xffffffff))); + await expectLater( + find.byKey(key), + matchesGoldenFile('painting.shape_decoration.outlined_border.should_be_white.png'), + ); + + await tester.pumpWidget(buildWidget(const Color(0xfeffffff))); + await expectLater( + find.byKey(key), + matchesGoldenFile('painting.shape_decoration.outlined_border.show_lines_due_to_opacity.png'), + ); + }); }