Fix anti-aliasing when painting borders with solid colors. (#153365)

Trying to reland https://github.com/flutter/flutter/pull/122317 in 2024. Let's see if we can.

<img width="666" alt="image" src="https://user-images.githubusercontent.com/351125/182002867-03d55bbb-163d-48b9-ba3c-ed32dbef2680.png">

Side effect: shapes with border will be rounder:
![Frame 6](https://github.com/user-attachments/assets/95324ebc-8db5-4365-817f-bc62304b9044)

Close https://github.com/flutter/flutter/issues/13675.
This commit is contained in:
Bernardo Ferrari 2024-08-15 16:02:23 -03:00 committed by GitHub
parent 70460854d1
commit ce63c029c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 198 additions and 3 deletions

View File

@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart';
import 'basic_types.dart'; import 'basic_types.dart';
import 'border_radius.dart'; import 'border_radius.dart';
import 'borders.dart';
import 'box_border.dart'; import 'box_border.dart';
import 'box_shadow.dart'; import 'box_shadow.dart';
import 'colors.dart'; import 'colors.dart';
@ -462,10 +463,63 @@ class _BoxDecorationPainter extends BoxPainter {
void _paintBackgroundColor(Canvas canvas, Rect rect, TextDirection? textDirection) { void _paintBackgroundColor(Canvas canvas, Rect rect, TextDirection? textDirection) {
if (_decoration.color != null || _decoration.gradient != null) { 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; DecorationImagePainter? _imagePainter;
void _paintBackgroundImage(Canvas canvas, Rect rect, ImageConfiguration configuration) { void _paintBackgroundImage(Canvas canvas, Rect rect, ImageConfiguration configuration) {
if (_decoration.image == null) { if (_decoration.image == null) {

View File

@ -406,13 +406,26 @@ class _ShapeDecorationPainter extends BoxPainter {
void _paintInterior(Canvas canvas, Rect rect, TextDirection? textDirection) { void _paintInterior(Canvas canvas, Rect rect, TextDirection? textDirection) {
if (_interiorPaint != null) { if (_interiorPaint != null) {
if (_decoration.shape.preferPaintInterior) { 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 { } else {
canvas.drawPath(_outerPath, _interiorPaint!); 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; DecorationImagePainter? _imagePainter;
void _paintImage(Canvas canvas, ImageConfiguration configuration) { void _paintImage(Canvas canvas, ImageConfiguration configuration) {
if (_decoration.image == null) { if (_decoration.image == null) {

View File

@ -1790,7 +1790,7 @@ void main() {
expect( expect(
find.ancestor(of: find.byType(Table), matching: find.byType(Container)), find.ancestor(of: find.byType(Table), matching: find.byType(Container)),
paints..rect( 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, color: backgroundColor,
), ),
); );

View File

@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
@Tags(<String>['reduced-test-set'])
library;
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui show Image; import 'dart:ui' as ui show Image;
@ -577,4 +580,64 @@ Future<void> 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<Widget> circles = <Widget>[];
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'),
);
});
} }

View File

@ -2,6 +2,10 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
@Tags(<String>['reduced-test-set'])
library;
import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui show Image; import 'dart:ui' as ui show Image;
@ -158,4 +162,65 @@ Future<void> main() async {
expect(a.hashCode, equals(b.hashCode)); expect(a.hashCode, equals(b.hashCode));
expect(a, equals(b)); 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<Widget> circles = <Widget>[];
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'),
);
});
} }