Unverified Commit 75ca31b0 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Correct Badge interpretation of its alignment parameter (#119853)

parent d8154fde
......@@ -16,7 +16,7 @@ class _${blockName}DefaultsM3 extends BadgeThemeData {
smallSize: ${tokens["md.comp.badge.size"]},
largeSize: ${tokens["md.comp.badge.large.size"]},
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: const AlignmentDirectional(12, -4),
alignment: AlignmentDirectional.topEnd,
);
final BuildContext context;
......
......@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'badge_theme.dart';
......@@ -35,6 +36,7 @@ class Badge extends StatelessWidget {
this.textStyle,
this.padding,
this.alignment,
this.offset,
this.label,
this.isLabelVisible = true,
this.child,
......@@ -54,6 +56,7 @@ class Badge extends StatelessWidget {
this.textStyle,
this.padding,
this.alignment,
this.offset,
required int count,
this.isLabelVisible = true,
this.child,
......@@ -106,13 +109,29 @@ class Badge extends StatelessWidget {
/// left and right if the theme's value is null.
final EdgeInsetsGeometry? padding;
/// The location of the [label] relative to the [child].
/// Combined with [offset] to determine the location of the [label]
/// relative to the [child].
///
/// The alignment positions the label in the same way a child of an
/// [Align] widget is positioned, except that, the alignment is
/// resolved as if the label was a [largeSize] square and [offset]
/// is added to the result.
///
/// This value is only used if [label] is non-null.
///
/// Defaults to the [BadgeTheme]'s alignment, or
/// [AlignmentDirectional.topEnd] if the theme's value is null.
final AlignmentGeometry? alignment;
/// Combined with [alignment] to determine the location of the [label]
/// relative to the [child].
///
/// This value is only used if [label] is non-null.
///
/// Defaults to the [BadgeTheme]'s alignment, or `start = 12`
/// and `top = -4` if the theme's value is null.
final AlignmentDirectional? alignment;
/// Defaults to the [BadgeTheme]'s offset, or
/// if the theme's value is null then `Offset(4, -4)` for
/// [TextDirection.ltr] or `Offset(-4, -4)` for [TextDirection.rtl].
final Offset? offset;
/// The badge's content, typically a [Text] widget that contains 1 to 4
/// characters.
......@@ -168,24 +187,99 @@ class Badge extends StatelessWidget {
return badge;
}
final AlignmentDirectional effectiveAlignment = alignment ?? badgeTheme.alignment ?? defaults.alignment!;
final AlignmentGeometry effectiveAlignment = alignment ?? badgeTheme.alignment ?? defaults.alignment!;
final TextDirection textDirection = Directionality.of(context);
final Offset defaultOffset = textDirection == TextDirection.ltr ? const Offset(4, -4) : const Offset(-4, -4);
final Offset effectiveOffset = offset ?? badgeTheme.offset ?? defaultOffset;
return
Stack(
clipBehavior: Clip.none,
children: <Widget>[
child!,
Positioned.directional(
textDirection: Directionality.of(context),
start: label == null ? null : effectiveAlignment.start,
end: label == null ? 0 : null,
top: label == null ? 0 : effectiveAlignment.y,
child: badge,
Positioned.fill(
child: _Badge(
alignment: effectiveAlignment,
offset: label == null ? Offset.zero : effectiveOffset,
textDirection: textDirection,
child: badge,
),
),
],
);
}
}
class _Badge extends SingleChildRenderObjectWidget {
const _Badge({
required this.alignment,
required this.offset,
required this.textDirection,
super.child, // the badge
});
final AlignmentGeometry alignment;
final Offset offset;
final TextDirection textDirection;
@override
_RenderBadge createRenderObject(BuildContext context) {
return _RenderBadge(
alignment: alignment,
offset: offset,
textDirection: Directionality.maybeOf(context),
);
}
@override
void updateRenderObject(BuildContext context, _RenderBadge renderObject) {
renderObject
..alignment = alignment
..offset = offset
..textDirection = Directionality.maybeOf(context);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment));
properties.add(DiagnosticsProperty<Offset>('offset', offset));
}
}
class _RenderBadge extends RenderAligningShiftedBox {
_RenderBadge({
super.textDirection,
super.alignment,
required Offset offset,
}) : _offset = offset;
Offset get offset => _offset;
Offset _offset;
set offset(Offset value) {
if (_offset == value) {
return;
}
_offset = value;
markNeedsLayout();
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
assert(constraints.hasBoundedWidth);
assert(constraints.hasBoundedHeight);
size = constraints.biggest;
child!.layout(const BoxConstraints(), parentUsesSize: true);
final double badgeSize = child!.size.height;
final Alignment resolvedAlignment = alignment.resolve(textDirection);
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = offset + resolvedAlignment.alongOffset(Offset(size.width - badgeSize, size.height - badgeSize));
}
}
// BEGIN GENERATED TOKEN PROPERTIES - Badge
// Do not edit by hand. The code between the "BEGIN GENERATED" and
......@@ -200,7 +294,7 @@ class _BadgeDefaultsM3 extends BadgeThemeData {
smallSize: 6.0,
largeSize: 16.0,
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: const AlignmentDirectional(12, -4),
alignment: AlignmentDirectional.topEnd,
);
final BuildContext context;
......
......@@ -41,6 +41,7 @@ class BadgeThemeData with Diagnosticable {
this.textStyle,
this.padding,
this.alignment,
this.offset,
});
/// Overrides the default value for [Badge.backgroundColor].
......@@ -62,7 +63,10 @@ class BadgeThemeData with Diagnosticable {
final EdgeInsetsGeometry? padding;
/// Overrides the default value for [Badge.alignment].
final AlignmentDirectional? alignment;
final AlignmentGeometry? alignment;
/// Overrides the default value for [Badge.offset].
final Offset? offset;
/// Creates a copy of this object but with the given fields replaced with the
/// new values.
......@@ -73,7 +77,8 @@ class BadgeThemeData with Diagnosticable {
double? largeSize,
TextStyle? textStyle,
EdgeInsetsGeometry? padding,
AlignmentDirectional? alignment,
AlignmentGeometry? alignment,
Offset? offset,
}) {
return BadgeThemeData(
backgroundColor: backgroundColor ?? this.backgroundColor,
......@@ -83,6 +88,7 @@ class BadgeThemeData with Diagnosticable {
textStyle: textStyle ?? this.textStyle,
padding: padding ?? this.padding,
alignment: alignment ?? this.alignment,
offset: offset ?? this.offset,
);
}
......@@ -95,7 +101,8 @@ class BadgeThemeData with Diagnosticable {
largeSize: lerpDouble(a?.largeSize, b?.largeSize, t),
textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t),
padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t),
alignment: AlignmentDirectional.lerp(a?.alignment, b?.alignment, t),
alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t),
offset: Offset.lerp(a?.offset, b?.offset, t),
);
}
......@@ -108,6 +115,7 @@ class BadgeThemeData with Diagnosticable {
textStyle,
padding,
alignment,
offset,
);
@override
......@@ -125,7 +133,8 @@ class BadgeThemeData with Diagnosticable {
&& other.largeSize == largeSize
&& other.textStyle == textStyle
&& other.padding == padding
&& other.alignment == alignment;
&& other.alignment == alignment
&& other.offset == offset;
}
@override
......@@ -137,7 +146,8 @@ class BadgeThemeData with Diagnosticable {
properties.add(DoubleProperty('largeSize', largeSize, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('textStyle', textStyle, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
properties.add(DiagnosticsProperty<AlignmentDirectional>('alignment', alignment, defaultValue: null));
properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null));
properties.add(DiagnosticsProperty<Offset>('offset', offset, defaultValue: null));
}
}
......
......@@ -37,7 +37,8 @@ void main() {
theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError),
);
// default badge alignment = AlignmentDirectional(12, -4)
// default badge alignment = AlignmentDirection.topEnd
// default offset for LTR = Offset(4, -4)
// default padding = EdgeInsets.symmetric(horizontal: 4)
// default largeSize = 16
// '0'.width = 12
......@@ -46,16 +47,9 @@ void main() {
expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size
expect(tester.getTopLeft(find.byType(Badge)), Offset.zero);
// x = alignment.start + padding.left
// y = alignment.top
expect(tester.getTopLeft(find.text('0')), const Offset(16, -4));
final RenderBox box = tester.renderObject(find.byType(Badge));
// '0'.width = 12
// L = alignment.start
// T = alignment.top
// R = L + '0'.width + padding.width
// B = T + largeSize, R = largeSize/2
expect(box, paints..rrect(rrect: RRect.fromLTRBR(12, -4, 32, 12, const Radius.circular(8)), color: theme.colorScheme.error));
});
......@@ -89,26 +83,13 @@ void main() {
theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError),
);
// default badge alignment = AlignmentDirectional(12, -4)
// default padding = EdgeInsets.symmetric(horizontal: 4)
// default largeSize = 16
// '0'.width = 12
// icon.width = 24
expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size
expect(tester.getTopLeft(find.byType(Badge)), Offset.zero);
// x = icon.width - alignment.start - '0'.width - padding.right
// y = alignment.top
expect(tester.getTopLeft(find.text('0')), const Offset(-4, -4));
expect(tester.getTopLeft(find.text('0')), const Offset(0, -4));
final RenderBox box = tester.renderObject(find.byType(Badge));
// L = icon.width - alignment.start - '0.width' - padding.width
// T = alignment.top
// R = L + '0.width' + padding.width
// B = T + largeSize
// R = largeSize/2
expect(box, paints..rrect(rrect: RRect.fromLTRBR(-8, -4, 12, 12, const Radius.circular(8)), color: theme.colorScheme.error));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(-4, -4, 16, 12, const Radius.circular(8)), color: theme.colorScheme.error));
});
// Essentially the same as 'Large Badge defaults'
......@@ -282,4 +263,153 @@ void main() {
final RenderBox box = tester.renderObject(find.byType(Badge));
expect(box, isNot(paints..rrect()));
});
testWidgets('Large Badge alignment', (WidgetTester tester) async {
const Radius badgeRadius = Radius.circular(8);
Widget buildFrame(Alignment alignment, [Offset offset = Offset.zero]) {
return MaterialApp(
theme: ThemeData.light(useMaterial3: true),
home: Align(
alignment: Alignment.topLeft,
child: Badge(
// Default largeSize = 16, badge with label is "large".
label: Container(width: 8, height: 8, color: Colors.blue),
alignment: alignment,
offset: offset,
child: Container(
color: const Color(0xFF00FF00),
width: 200,
height: 200,
),
),
),
);
}
await tester.pumpWidget(buildFrame(Alignment.topLeft));
final RenderBox box = tester.renderObject(find.byType(Badge));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 16, 16, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.topCenter));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 0, 100 + 8, 16, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.topRight));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 0, 200, 16, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.centerLeft));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100 - 8, 16, 100 + 8, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.centerRight));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 100 - 8, 200, 100 + 8, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomLeft));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200 - 16, 16, 200, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomCenter));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 200 - 16, 100 + 8, 200, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomRight));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 200 - 16, 200, 200, badgeRadius)));
const Offset offset = Offset(5, 10);
await tester.pumpWidget(buildFrame(Alignment.topLeft, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 16, 16, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.topCenter, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 0, 100 + 8, 16, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.topRight, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 0, 200, 16, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.centerLeft, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100 - 8, 16, 100 + 8, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.centerRight, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 100 - 8, 200, 100 + 8, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.bottomLeft, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200 - 16, 16, 200, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.bottomCenter, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 200 - 16, 100 + 8, 200, badgeRadius).shift(offset)));
await tester.pumpWidget(buildFrame(Alignment.bottomRight, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 200 - 16, 200, 200, badgeRadius).shift(offset)));
});
testWidgets('Small Badge alignment', (WidgetTester tester) async {
const Radius badgeRadius = Radius.circular(3);
Widget buildFrame(Alignment alignment, [Offset offset = Offset.zero]) {
return MaterialApp(
theme: ThemeData.light(useMaterial3: true),
home: Align(
alignment: Alignment.topLeft,
child: Badge(
// Default smallSize = 6, badge without label is "small".
alignment: alignment,
offset: offset, // Not used for smallSize badges.
child: Container(
color: const Color(0xFF00FF00),
width: 200,
height: 200,
),
),
),
);
}
await tester.pumpWidget(buildFrame(Alignment.topLeft));
final RenderBox box = tester.renderObject(find.byType(Badge));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 6, 6, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.topCenter));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 0, 100 + 3, 6, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.topRight));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 0, 200, 6, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.centerLeft));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100 - 3, 6, 100 + 3, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.centerRight));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 100 - 3, 200, 100 + 3, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomLeft));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200 - 6, 6, 200, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomCenter));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 200 - 6, 100 + 3, 200, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomRight));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 200 - 6, 200, 200, badgeRadius)));
const Offset offset = Offset(5, 10); // Not used for smallSize Badges.
await tester.pumpWidget(buildFrame(Alignment.topLeft, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 6, 6, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.topCenter, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 0, 100 + 3, 6, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.topRight, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 0, 200, 6, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.centerLeft, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100 - 3, 6, 100 + 3, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.centerRight, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 100 - 3, 200, 100 + 3, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomLeft, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200 - 6, 6, 200, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomCenter, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 200 - 6, 100 + 3, 200, badgeRadius)));
await tester.pumpWidget(buildFrame(Alignment.bottomRight, offset));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 200 - 6, 200, 200, badgeRadius)));
});
}
......@@ -23,6 +23,7 @@ void main() {
expect(themeData.textStyle, null);
expect(themeData.padding, null);
expect(themeData.alignment, null);
expect(themeData.offset, null);
});
testWidgets('Default BadgeThemeData debugFillProperties', (WidgetTester tester) async {
......@@ -47,6 +48,7 @@ void main() {
textStyle: TextStyle(fontSize: 4),
padding: EdgeInsets.all(5),
alignment: AlignmentDirectional(6, 7),
offset: Offset.zero,
).debugFillProperties(builder);
final List<String> description = builder.properties
......@@ -61,7 +63,8 @@ void main() {
'largeSize: 2.0',
'textStyle: TextStyle(inherit: true, size: 4.0)',
'padding: EdgeInsets.all(5.0)',
'alignment: AlignmentDirectional(6.0, 7.0)'
'alignment: AlignmentDirectional(6.0, 7.0)',
'offset: Offset(0.0, 0.0)'
]);
});
......@@ -75,7 +78,8 @@ void main() {
largeSize: 20,
textStyle: TextStyle(fontSize: 12),
padding: EdgeInsets.symmetric(horizontal: 5),
alignment: AlignmentDirectional(24, 0),
alignment: Alignment.topRight,
offset: Offset(24, 0),
);
await tester.pumpWidget(
......@@ -95,8 +99,7 @@ void main() {
// text width = 48 = fontSize * 4, text height = fontSize
expect(tester.getSize(find.text('1234')), const Size(48, 12));
// x = 29 = alignment.start + padding.left, y = 4 = (largeSize - fontSize) / 2
expect(tester.getTopLeft(find.text('1234')), const Offset(29, 4));
expect(tester.getTopLeft(find.text('1234')), const Offset(33, 4));
expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size
......@@ -107,8 +110,7 @@ void main() {
expect(textStyle.color, black);
final RenderBox box = tester.renderObject(find.byType(Badge));
// L = alignment.start, T = alignment.top, R = L + fontSize * 4 + padding.width, B = largeSize R = largeSize/2
expect(box, paints..rrect(rrect: RRect.fromLTRBR(24, 0, 82, 20, const Radius.circular(10)), color: green));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(28, 0, 86, 20, const Radius.circular(10)), color: green));
});
......@@ -125,7 +127,8 @@ void main() {
largeSize: 20,
textStyle: TextStyle(fontSize: 12),
padding: EdgeInsets.symmetric(horizontal: 5),
alignment: AlignmentDirectional(24, 0),
alignment: Alignment.topRight,
offset: Offset(24, 0),
);
await tester.pumpWidget(
......@@ -143,13 +146,13 @@ void main() {
);
expect(tester.getSize(find.text('1234')), const Size(48, 12));
expect(tester.getTopLeft(find.text('1234')), const Offset(29, 4));
expect(tester.getTopLeft(find.text('1234')), const Offset(33, 4));
expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size
expect(tester.getTopLeft(find.byType(Badge)), Offset.zero);
final TextStyle textStyle = tester.renderObject<RenderParagraph>(find.text('1234')).text.style!;
expect(textStyle.fontSize, 12);
expect(textStyle.color, black);
final RenderBox box = tester.renderObject(find.byType(Badge));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(24, 0, 82, 20, const Radius.circular(10)), color: green));
expect(box, paints..rrect(rrect: RRect.fromLTRBR(28, 0, 86, 20, const Radius.circular(10)), color: green));
});
}
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