mirror of
https://github.com/flutter/flutter.git
synced 2026-01-20 20:55:29 +08:00
This PR adds the framework support for a new iOS-style blur. The new
style, which I call "bounded blur", works by adding parameters to the
blur filter that specify the bounds for the region that the filter
sources pixels from.
As discussed in design doc
[flutter.dev/go/ios-style-blur-support](http://flutter.dev/go/ios-style-blur-support),
it's impossible to pass layout information to filters with the current
`ImageFilter` design. Therefore this PR creates a new class
`ImageFilterConfig`.
This PR also applies bounded blur to `CupertinoPopupSurface`. The
following images show the different looks of a dialog in front of
background with abrupt color changes just outside of the border. Notice
how the abrupt color changes no longer bleed in.
<img width="639" height="411" alt="image"
src="https://github.com/user-attachments/assets/4ceb9620-1056-45c3-b5fa-2ed16d90aace"
/>
<img width="639" height="411" alt="image"
src="https://github.com/user-attachments/assets/abe564f7-ea60-4d07-ad58-063c0e3794a5"
/>
This feature continues to matter for iOS 26, since the liquid glass
design also heavily features blurring.
### API changes
* `BackdropFilter`: Add `filterConfig`
* `RenderBackdropFilter`: Add `filterConfig`. Deprecate `filter`.
* `ImageFilter`: Add `debugShortDescription` (previously private
property `_shortDescription`)
### Demo
The following video compares the effect of a bounded blur and an
unbounded blur.
https://github.com/user-attachments/assets/f715db44-c0a0-4ac8-a163-6b859665b032
<details>
<summary>
Demo source
</summary>
```
// Add to pubspec.yaml:
//
// assets:
// - assets/kalimba.jpg
//
// and download the image from
// ec6f550237/engine/src/flutter/impeller/fixtures/kalimba.jpg
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: BlurEditorApp()));
class ControlPoint extends StatefulWidget {
const ControlPoint({
super.key,
required this.position,
required this.onPanUpdate,
this.radius = 20.0,
});
final Offset position;
final GestureDragUpdateCallback onPanUpdate;
final double radius;
@override
ControlPointState createState() => ControlPointState();
}
class ControlPointState extends State<ControlPoint> {
bool isHovering = false;
@override
Widget build(BuildContext context) {
return Positioned(
left: widget.position.dx - widget.radius,
top: widget.position.dy - widget.radius,
child: MouseRegion(
onEnter: (_) { setState((){ isHovering = true; }); },
onExit: (_) { setState((){ isHovering = false; }); },
cursor: SystemMouseCursors.move,
child: GestureDetector(
onPanUpdate: widget.onPanUpdate,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.radius * 2,
height: widget.radius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isHovering
? Colors.white.withValues(alpha: 0.8)
: Colors.white.withValues(alpha: 0.4),
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
if (isHovering)
BoxShadow(
color: Colors.white.withValues(alpha: 0.5),
blurRadius: 10,
spreadRadius: 2,
)
],
),
child: const Icon(Icons.drag_indicator, size: 16, color: Colors.black54),
),
),
),
);
}
}
class BlurPanel extends StatelessWidget {
const BlurPanel({super.key, required this.blurRect, required this.bounded});
final Rect blurRect;
final bool bounded;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/kalimba.jpg',
fit: BoxFit.cover,
),
),
Positioned.fromRect(
rect: blurRect,
child: ClipRect(
child: BackdropFilter(
filterConfig: ImageFilterConfig.blur(
sigmaX: 10, sigmaY: 10, bounded: bounded),
child: Container(),
)),
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
bounded ? 'Bounded Blur' : 'Unbounded Blur',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
),
);
}
}
class BlurEditorApp extends StatefulWidget {
const BlurEditorApp({super.key});
@override
State<BlurEditorApp> createState() => _BlurEditorAppState();
}
class _BlurEditorAppState extends State<BlurEditorApp> {
Offset p1 = const Offset(100, 100);
Offset p2 = const Offset(300, 300);
@override
Widget build(BuildContext context) {
final blurRect = Rect.fromPoints(p1, p2);
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Row(
children: [
Expanded(
child: BlurPanel(blurRect: blurRect, bounded: true),
),
Expanded(
child: BlurPanel(blurRect: blurRect, bounded: false),
),
],
),
),
ControlPoint(position: p1, onPanUpdate: (details) { setState(() => p1 = details.globalPosition); }),
ControlPoint(position: p2, onPanUpdate: (details) { setState(() => p2 = details.globalPosition); }),
],
),
);
}
}
```
</details>
## Pre-launch Checklist
- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.
If you need help, consider asking for advice on the #hackers-new channel
on [Discord].
**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.
<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1209 lines
42 KiB
Dart
1209 lines
42 KiB
Dart
// Copyright 2014 The Flutter 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 'dart:ui' as ui show Gradient, Image, ImageFilter;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'rendering_tester.dart';
|
|
|
|
void main() {
|
|
TestRenderingFlutterBinding.ensureInitialized();
|
|
test('RenderFittedBox handles applying paint transform and hit-testing with empty size', () {
|
|
final fittedBox = RenderFittedBox(
|
|
child: RenderCustomPaint(painter: TestCallbackPainter(onPaint: () {})),
|
|
);
|
|
|
|
layout(fittedBox, phase: EnginePhase.flushSemantics);
|
|
final transform = Matrix4.identity();
|
|
fittedBox.applyPaintTransform(fittedBox.child!, transform);
|
|
expect(transform, Matrix4.zero());
|
|
|
|
final hitTestResult = BoxHitTestResult();
|
|
expect(fittedBox.hitTestChildren(hitTestResult, position: Offset.zero), isFalse);
|
|
});
|
|
|
|
test('RenderFittedBox does not paint with empty sizes', () {
|
|
bool painted;
|
|
RenderFittedBox makeFittedBox(Size size) {
|
|
return RenderFittedBox(
|
|
child: RenderCustomPaint(
|
|
preferredSize: size,
|
|
painter: TestCallbackPainter(
|
|
onPaint: () {
|
|
painted = true;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// The RenderFittedBox paints if both its size and its child's size are nonempty.
|
|
painted = false;
|
|
layout(makeFittedBox(const Size(1, 1)), phase: EnginePhase.paint);
|
|
expect(painted, equals(true));
|
|
|
|
// The RenderFittedBox should not paint if its child is empty-sized.
|
|
painted = false;
|
|
layout(makeFittedBox(Size.zero), phase: EnginePhase.paint);
|
|
expect(painted, equals(false));
|
|
|
|
// The RenderFittedBox should not paint if it is empty.
|
|
painted = false;
|
|
layout(
|
|
makeFittedBox(const Size(1, 1)),
|
|
constraints: BoxConstraints.tight(Size.zero),
|
|
phase: EnginePhase.paint,
|
|
);
|
|
expect(painted, equals(false));
|
|
});
|
|
|
|
test('RenderPhysicalModel compositing', () {
|
|
final root = RenderPhysicalModel(color: const Color(0xffff00ff));
|
|
layout(root, phase: EnginePhase.composite);
|
|
expect(root.needsCompositing, isFalse);
|
|
|
|
// On Fuchsia, the system compositor is responsible for drawing shadows
|
|
// for physical model layers with non-zero elevation.
|
|
root.elevation = 1.0;
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
expect(root.needsCompositing, isFalse);
|
|
|
|
root.elevation = 0.0;
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
expect(root.needsCompositing, isFalse);
|
|
});
|
|
|
|
test('RenderSemanticsGestureHandler adds/removes correct semantic actions', () {
|
|
final renderObj = RenderSemanticsGestureHandler(
|
|
onTap: () {},
|
|
onHorizontalDragUpdate: (DragUpdateDetails details) {},
|
|
);
|
|
|
|
var config = SemanticsConfiguration();
|
|
renderObj.describeSemanticsConfiguration(config);
|
|
expect(config.getActionHandler(SemanticsAction.tap), isNotNull);
|
|
expect(config.getActionHandler(SemanticsAction.scrollLeft), isNotNull);
|
|
expect(config.getActionHandler(SemanticsAction.scrollRight), isNotNull);
|
|
|
|
config = SemanticsConfiguration();
|
|
renderObj.validActions = <SemanticsAction>{SemanticsAction.tap, SemanticsAction.scrollLeft};
|
|
|
|
renderObj.describeSemanticsConfiguration(config);
|
|
expect(config.getActionHandler(SemanticsAction.tap), isNotNull);
|
|
expect(config.getActionHandler(SemanticsAction.scrollLeft), isNotNull);
|
|
expect(config.getActionHandler(SemanticsAction.scrollRight), isNull);
|
|
});
|
|
|
|
group('RenderPhysicalShape', () {
|
|
test('shape change triggers repaint', () {
|
|
for (final TargetPlatform platform in TargetPlatform.values) {
|
|
debugDefaultTargetPlatformOverride = platform;
|
|
|
|
final root = RenderPhysicalShape(
|
|
color: const Color(0xffff00ff),
|
|
clipper: const ShapeBorderClipper(shape: CircleBorder()),
|
|
);
|
|
layout(root, phase: EnginePhase.composite);
|
|
expect(root.debugNeedsPaint, isFalse);
|
|
|
|
// Same shape, no repaint.
|
|
root.clipper = const ShapeBorderClipper(shape: CircleBorder());
|
|
expect(root.debugNeedsPaint, isFalse);
|
|
|
|
// Different shape triggers repaint.
|
|
root.clipper = const ShapeBorderClipper(shape: StadiumBorder());
|
|
expect(root.debugNeedsPaint, isTrue);
|
|
}
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
test('compositing', () {
|
|
for (final TargetPlatform platform in TargetPlatform.values) {
|
|
debugDefaultTargetPlatformOverride = platform;
|
|
final root = RenderPhysicalShape(
|
|
color: const Color(0xffff00ff),
|
|
clipper: const ShapeBorderClipper(shape: CircleBorder()),
|
|
);
|
|
layout(root, phase: EnginePhase.composite);
|
|
expect(root.needsCompositing, isFalse);
|
|
|
|
// On non-Fuchsia platforms, we composite physical shape layers
|
|
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;
|
|
});
|
|
});
|
|
|
|
test('RenderRepaintBoundary can capture images of itself', () async {
|
|
var boundary = RenderRepaintBoundary();
|
|
layout(boundary, constraints: BoxConstraints.tight(const Size(100.0, 200.0)));
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
ui.Image image = await boundary.toImage();
|
|
expect(image.width, equals(100));
|
|
expect(image.height, equals(200));
|
|
|
|
// Now with pixel ratio set to something other than 1.0.
|
|
boundary = RenderRepaintBoundary();
|
|
layout(boundary, constraints: BoxConstraints.tight(const Size(100.0, 200.0)));
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
image = await boundary.toImage(pixelRatio: 2.0);
|
|
expect(image.width, equals(200));
|
|
expect(image.height, equals(400));
|
|
|
|
// Try building one with two child layers and make sure it renders them both.
|
|
boundary = RenderRepaintBoundary();
|
|
final stack = RenderStack()..alignment = Alignment.topLeft;
|
|
final blackBox = RenderDecoratedBox(
|
|
decoration: const BoxDecoration(color: Color(0xff000000)),
|
|
child: RenderConstrainedBox(
|
|
additionalConstraints: BoxConstraints.tight(const Size.square(20.0)),
|
|
),
|
|
);
|
|
stack.add(
|
|
RenderOpacity()
|
|
..opacity = 0.5
|
|
..child = blackBox,
|
|
);
|
|
final whiteBox = RenderDecoratedBox(
|
|
decoration: const BoxDecoration(color: Color(0xffffffff)),
|
|
child: RenderConstrainedBox(
|
|
additionalConstraints: BoxConstraints.tight(const Size.square(10.0)),
|
|
),
|
|
);
|
|
final positioned = RenderPositionedBox(
|
|
widthFactor: 2.0,
|
|
heightFactor: 2.0,
|
|
alignment: Alignment.topRight,
|
|
child: whiteBox,
|
|
);
|
|
stack.add(positioned);
|
|
boundary.child = stack;
|
|
layout(boundary, constraints: BoxConstraints.tight(const Size(20.0, 20.0)));
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
image = await boundary.toImage();
|
|
expect(image.width, equals(20));
|
|
expect(image.height, equals(20));
|
|
ByteData data = (await image.toByteData())!;
|
|
|
|
int getPixel(int x, int y) => data.getUint32((x + y * image.width) * 4);
|
|
|
|
expect(data.lengthInBytes, equals(20 * 20 * 4));
|
|
expect(data.elementSizeInBytes, equals(1));
|
|
expect(getPixel(0, 0), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0xffffffff));
|
|
|
|
final layer = boundary.debugLayer! as OffsetLayer;
|
|
|
|
image = await layer.toImage(Offset.zero & const Size(20.0, 20.0));
|
|
expect(image.width, equals(20));
|
|
expect(image.height, equals(20));
|
|
data = (await image.toByteData())!;
|
|
expect(getPixel(0, 0), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0xffffffff));
|
|
|
|
// non-zero offsets.
|
|
image = await layer.toImage(const Offset(-10.0, -10.0) & const Size(30.0, 30.0));
|
|
expect(image.width, equals(30));
|
|
expect(image.height, equals(30));
|
|
data = (await image.toByteData())!;
|
|
expect(getPixel(0, 0), equals(0x00000000));
|
|
expect(getPixel(10, 10), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0x00000000));
|
|
expect(getPixel(image.width - 1, 10), equals(0xffffffff));
|
|
|
|
// offset combined with a custom pixel ratio.
|
|
image = await layer.toImage(
|
|
const Offset(-10.0, -10.0) & const Size(30.0, 30.0),
|
|
pixelRatio: 2.0,
|
|
);
|
|
expect(image.width, equals(60));
|
|
expect(image.height, equals(60));
|
|
data = (await image.toByteData())!;
|
|
expect(getPixel(0, 0), equals(0x00000000));
|
|
expect(getPixel(20, 20), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0x00000000));
|
|
expect(getPixel(image.width - 1, 20), equals(0xffffffff));
|
|
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/49857
|
|
|
|
test('RenderRepaintBoundary can capture images of itself synchronously', () async {
|
|
var boundary = RenderRepaintBoundary();
|
|
layout(boundary, constraints: BoxConstraints.tight(const Size(100.0, 200.0)));
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
ui.Image image = boundary.toImageSync();
|
|
expect(image.width, equals(100));
|
|
expect(image.height, equals(200));
|
|
|
|
// Now with pixel ratio set to something other than 1.0.
|
|
boundary = RenderRepaintBoundary();
|
|
layout(boundary, constraints: BoxConstraints.tight(const Size(100.0, 200.0)));
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
image = boundary.toImageSync(pixelRatio: 2.0);
|
|
expect(image.width, equals(200));
|
|
expect(image.height, equals(400));
|
|
|
|
// Try building one with two child layers and make sure it renders them both.
|
|
boundary = RenderRepaintBoundary();
|
|
final stack = RenderStack()..alignment = Alignment.topLeft;
|
|
final blackBox = RenderDecoratedBox(
|
|
decoration: const BoxDecoration(color: Color(0xff000000)),
|
|
child: RenderConstrainedBox(
|
|
additionalConstraints: BoxConstraints.tight(const Size.square(20.0)),
|
|
),
|
|
);
|
|
stack.add(
|
|
RenderOpacity()
|
|
..opacity = 0.5
|
|
..child = blackBox,
|
|
);
|
|
final whiteBox = RenderDecoratedBox(
|
|
decoration: const BoxDecoration(color: Color(0xffffffff)),
|
|
child: RenderConstrainedBox(
|
|
additionalConstraints: BoxConstraints.tight(const Size.square(10.0)),
|
|
),
|
|
);
|
|
final positioned = RenderPositionedBox(
|
|
widthFactor: 2.0,
|
|
heightFactor: 2.0,
|
|
alignment: Alignment.topRight,
|
|
child: whiteBox,
|
|
);
|
|
stack.add(positioned);
|
|
boundary.child = stack;
|
|
layout(boundary, constraints: BoxConstraints.tight(const Size(20.0, 20.0)));
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
image = boundary.toImageSync();
|
|
expect(image.width, equals(20));
|
|
expect(image.height, equals(20));
|
|
ByteData data = (await image.toByteData())!;
|
|
|
|
int getPixel(int x, int y) => data.getUint32((x + y * image.width) * 4);
|
|
|
|
expect(data.lengthInBytes, equals(20 * 20 * 4));
|
|
expect(data.elementSizeInBytes, equals(1));
|
|
expect(getPixel(0, 0), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0xffffffff));
|
|
|
|
final layer = boundary.debugLayer! as OffsetLayer;
|
|
|
|
image = layer.toImageSync(Offset.zero & const Size(20.0, 20.0));
|
|
expect(image.width, equals(20));
|
|
expect(image.height, equals(20));
|
|
data = (await image.toByteData())!;
|
|
expect(getPixel(0, 0), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0xffffffff));
|
|
|
|
// non-zero offsets.
|
|
image = layer.toImageSync(const Offset(-10.0, -10.0) & const Size(30.0, 30.0));
|
|
expect(image.width, equals(30));
|
|
expect(image.height, equals(30));
|
|
data = (await image.toByteData())!;
|
|
expect(getPixel(0, 0), equals(0x00000000));
|
|
expect(getPixel(10, 10), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0x00000000));
|
|
expect(getPixel(image.width - 1, 10), equals(0xffffffff));
|
|
|
|
// offset combined with a custom pixel ratio.
|
|
image = layer.toImageSync(const Offset(-10.0, -10.0) & const Size(30.0, 30.0), pixelRatio: 2.0);
|
|
expect(image.width, equals(60));
|
|
expect(image.height, equals(60));
|
|
data = (await image.toByteData())!;
|
|
expect(getPixel(0, 0), equals(0x00000000));
|
|
expect(getPixel(20, 20), equals(0x00000080));
|
|
expect(getPixel(image.width - 1, 0), equals(0x00000000));
|
|
expect(getPixel(image.width - 1, 20), equals(0xffffffff));
|
|
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/49857
|
|
|
|
test('RenderOpacity does not composite if it is transparent', () {
|
|
final renderOpacity = RenderOpacity(
|
|
opacity: 0.0,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
);
|
|
|
|
layout(renderOpacity, phase: EnginePhase.composite);
|
|
expect(renderOpacity.needsCompositing, false);
|
|
});
|
|
|
|
test('RenderOpacity does composite if it is opaque', () {
|
|
final renderOpacity = RenderOpacity(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
);
|
|
|
|
layout(renderOpacity, phase: EnginePhase.composite);
|
|
expect(renderOpacity.needsCompositing, true);
|
|
});
|
|
|
|
test('RenderOpacity does composite if it is partially opaque', () {
|
|
final renderOpacity = RenderOpacity(
|
|
opacity: 0.1,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
);
|
|
|
|
layout(renderOpacity, phase: EnginePhase.composite);
|
|
expect(renderOpacity.needsCompositing, true);
|
|
});
|
|
|
|
test('RenderOpacity reuses its layer', () {
|
|
_testLayerReuse<OpacityLayer>(
|
|
RenderOpacity(
|
|
opacity: 0.5, // must not be 0 or 1.0. Otherwise, it won't create a layer
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderAnimatedOpacity does not composite if it is transparent', () async {
|
|
final Animation<double> opacityAnimation = AnimationController(vsync: FakeTickerProvider())
|
|
..value = 0.0;
|
|
|
|
final renderAnimatedOpacity = RenderAnimatedOpacity(
|
|
opacity: opacityAnimation,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
);
|
|
|
|
layout(renderAnimatedOpacity, phase: EnginePhase.composite);
|
|
expect(renderAnimatedOpacity.needsCompositing, false);
|
|
});
|
|
|
|
test('RenderAnimatedOpacity does composite if it is opaque', () {
|
|
final Animation<double> opacityAnimation = AnimationController(vsync: FakeTickerProvider())
|
|
..value = 1.0;
|
|
|
|
final renderAnimatedOpacity = RenderAnimatedOpacity(
|
|
opacity: opacityAnimation,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
);
|
|
|
|
layout(renderAnimatedOpacity, phase: EnginePhase.composite);
|
|
expect(renderAnimatedOpacity.needsCompositing, true);
|
|
});
|
|
|
|
test('RenderAnimatedOpacity does composite if it is partially opaque', () {
|
|
final Animation<double> opacityAnimation = AnimationController(vsync: FakeTickerProvider())
|
|
..value = 0.5;
|
|
|
|
final renderAnimatedOpacity = RenderAnimatedOpacity(
|
|
opacity: opacityAnimation,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
);
|
|
|
|
layout(renderAnimatedOpacity, phase: EnginePhase.composite);
|
|
expect(renderAnimatedOpacity.needsCompositing, true);
|
|
});
|
|
|
|
test('RenderAnimatedOpacity reuses its layer', () {
|
|
final Animation<double> opacityAnimation = AnimationController(vsync: FakeTickerProvider())
|
|
..value = 0.5; // must not be 0 or 1.0. Otherwise, it won't create a layer
|
|
|
|
_testLayerReuse<OpacityLayer>(
|
|
RenderAnimatedOpacity(
|
|
opacity: opacityAnimation,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderShaderMask reuses its layer', () {
|
|
_testLayerReuse<ShaderMaskLayer>(
|
|
RenderShaderMask(
|
|
shaderCallback: (Rect rect) {
|
|
return ui.Gradient.radial(rect.center, rect.shortestSide / 2.0, const <Color>[
|
|
Color.fromRGBO(0, 0, 0, 1.0),
|
|
Color.fromRGBO(255, 255, 255, 1.0),
|
|
]);
|
|
},
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderBackdropFilter reuses its layer', () {
|
|
_testLayerReuse<BackdropFilterLayer>(
|
|
RenderBackdropFilter(
|
|
filter: ui.ImageFilter.blur(),
|
|
child: RenderSizedBox(const Size(1.0, 1.0)), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderClipRect reuses its layer', () {
|
|
_testLayerReuse<ClipRectLayer>(
|
|
RenderClipRect(
|
|
clipper: _TestRectClipper(),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderClipRRect reuses its layer', () {
|
|
_testLayerReuse<ClipRRectLayer>(
|
|
RenderClipRRect(
|
|
clipper: _TestRRectClipper(),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderClipOval reuses its layer', () {
|
|
_testLayerReuse<ClipPathLayer>(
|
|
RenderClipOval(
|
|
clipper: _TestRectClipper(),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderClipPath reuses its layer', () {
|
|
_testLayerReuse<ClipPathLayer>(
|
|
RenderClipPath(
|
|
clipper: _TestPathClipper(),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderPhysicalModel reuses its layer', () {
|
|
_testLayerReuse<ClipRRectLayer>(
|
|
RenderPhysicalModel(
|
|
clipBehavior: Clip.hardEdge,
|
|
color: const Color.fromRGBO(0, 0, 0, 1.0),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderPhysicalShape reuses its layer', () {
|
|
_testLayerReuse<ClipPathLayer>(
|
|
RenderPhysicalShape(
|
|
clipper: _TestPathClipper(),
|
|
clipBehavior: Clip.hardEdge,
|
|
color: const Color.fromRGBO(0, 0, 0, 1.0),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
test('RenderTransform reuses its layer', () {
|
|
_testLayerReuse<TransformLayer>(
|
|
RenderTransform(
|
|
// Use a 3D transform to force compositing.
|
|
transform: Matrix4.rotationX(0.1),
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
});
|
|
|
|
void testFittedBoxWithClipRectLayer() {
|
|
_testLayerReuse<ClipRectLayer>(
|
|
RenderFittedBox(
|
|
fit: BoxFit.cover,
|
|
clipBehavior: Clip.hardEdge,
|
|
// Inject opacity under the clip to force compositing.
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(100.0, 200.0)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
}
|
|
|
|
void testFittedBoxWithTransformLayer() {
|
|
_testLayerReuse<TransformLayer>(
|
|
RenderFittedBox(
|
|
fit: BoxFit.fill,
|
|
// Inject opacity under the clip to force compositing.
|
|
child: RenderRepaintBoundary(
|
|
child: RenderSizedBox(const Size(1, 1)),
|
|
), // size doesn't matter
|
|
),
|
|
);
|
|
}
|
|
|
|
test('RenderFittedBox reuses ClipRectLayer', () {
|
|
testFittedBoxWithClipRectLayer();
|
|
});
|
|
|
|
test('RenderFittedBox reuses TransformLayer', () {
|
|
testFittedBoxWithTransformLayer();
|
|
});
|
|
|
|
test('RenderFittedBox switches between ClipRectLayer and TransformLayer, and reuses them', () {
|
|
testFittedBoxWithClipRectLayer();
|
|
|
|
// clip -> transform
|
|
testFittedBoxWithTransformLayer();
|
|
// transform -> clip
|
|
testFittedBoxWithClipRectLayer();
|
|
});
|
|
|
|
test('RenderFittedBox respects clipBehavior', () {
|
|
const viewport = BoxConstraints(maxHeight: 100.0, maxWidth: 100.0);
|
|
for (final clip in <Clip?>[null, ...Clip.values]) {
|
|
final context = TestClipPaintingContext();
|
|
final RenderFittedBox box;
|
|
switch (clip) {
|
|
case Clip.none:
|
|
case Clip.hardEdge:
|
|
case Clip.antiAlias:
|
|
case Clip.antiAliasWithSaveLayer:
|
|
box = RenderFittedBox(child: box200x200, fit: BoxFit.none, clipBehavior: clip!);
|
|
case null:
|
|
box = RenderFittedBox(child: box200x200, fit: BoxFit.none);
|
|
}
|
|
layout(
|
|
box,
|
|
constraints: viewport,
|
|
phase: EnginePhase.composite,
|
|
onErrors: expectNoFlutterErrors,
|
|
);
|
|
box.paint(context, Offset.zero);
|
|
// By default, clipBehavior should be Clip.none
|
|
expect(context.clipBehavior, equals(clip ?? Clip.none));
|
|
}
|
|
});
|
|
|
|
test('RenderMouseRegion can change properties when detached', () {
|
|
final object = RenderMouseRegion();
|
|
object
|
|
..opaque = false
|
|
..onEnter = (_) {}
|
|
..onExit = (_) {}
|
|
..onHover = (_) {};
|
|
// Passes if no error is thrown
|
|
});
|
|
|
|
test('RenderFractionalTranslation updates its semantics after its translation value is set', () {
|
|
final box = _TestSemanticsUpdateRenderFractionalTranslation(
|
|
translation: const Offset(0.5, 0.5),
|
|
);
|
|
layout(box, constraints: BoxConstraints.tight(const Size(200.0, 200.0)));
|
|
expect(box.markNeedsSemanticsUpdateCallCount, 1);
|
|
box.translation = const Offset(0.4, 0.4);
|
|
expect(box.markNeedsSemanticsUpdateCallCount, 2);
|
|
box.translation = const Offset(0.3, 0.3);
|
|
expect(box.markNeedsSemanticsUpdateCallCount, 3);
|
|
});
|
|
|
|
test('RenderFollowerLayer hit test without a leader layer and the showWhenUnlinked is true', () {
|
|
final follower = RenderFollowerLayer(
|
|
link: LayerLink(),
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
);
|
|
layout(follower, constraints: BoxConstraints.tight(const Size(200.0, 200.0)));
|
|
final hitTestResult = BoxHitTestResult();
|
|
expect(follower.hitTest(hitTestResult, position: Offset.zero), isTrue);
|
|
});
|
|
|
|
test('RenderFollowerLayer hit test without a leader layer and the showWhenUnlinked is false', () {
|
|
final follower = RenderFollowerLayer(
|
|
link: LayerLink(),
|
|
showWhenUnlinked: false,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
);
|
|
layout(follower, constraints: BoxConstraints.tight(const Size(200.0, 200.0)));
|
|
final hitTestResult = BoxHitTestResult();
|
|
expect(follower.hitTest(hitTestResult, position: Offset.zero), isFalse);
|
|
});
|
|
|
|
test('RenderFollowerLayer hit test with a leader layer and the showWhenUnlinked is true', () {
|
|
// Creates a layer link with a leader.
|
|
final link = LayerLink();
|
|
final leader = LeaderLayer(link: link);
|
|
leader.attach(Object());
|
|
|
|
final follower = RenderFollowerLayer(link: link, child: RenderSizedBox(const Size(1.0, 1.0)));
|
|
layout(follower, constraints: BoxConstraints.tight(const Size(200.0, 200.0)));
|
|
final hitTestResult = BoxHitTestResult();
|
|
expect(follower.hitTest(hitTestResult, position: Offset.zero), isTrue);
|
|
});
|
|
|
|
test('RenderFollowerLayer hit test with a leader layer and the showWhenUnlinked is false', () {
|
|
// Creates a layer link with a leader.
|
|
final link = LayerLink();
|
|
final leader = LeaderLayer(link: link);
|
|
leader.attach(Object());
|
|
|
|
final follower = RenderFollowerLayer(
|
|
link: link,
|
|
showWhenUnlinked: false,
|
|
child: RenderSizedBox(const Size(1.0, 1.0)),
|
|
);
|
|
layout(follower, constraints: BoxConstraints.tight(const Size(200.0, 200.0)));
|
|
final hitTestResult = BoxHitTestResult();
|
|
// The follower is still hit testable because there is a leader layer.
|
|
expect(follower.hitTest(hitTestResult, position: Offset.zero), isTrue);
|
|
});
|
|
|
|
test('RenderObject can become a repaint boundary', () {
|
|
final childBox = ConditionalRepaintBoundary();
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
|
|
renderBox.isRepaintBoundary = true;
|
|
renderBox.markNeedsCompositingBitsUpdate();
|
|
renderBox.markNeedsCompositedLayerUpdate();
|
|
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
|
|
// The first time the render object becomes a repaint boundary
|
|
// we must repaint from the parent to allow the layer to be
|
|
// created.
|
|
expect(childBox.paintCount, 2);
|
|
expect(renderBox.paintCount, 2);
|
|
expect(renderBox.debugLayer, isA<OffsetLayer>());
|
|
|
|
renderBox.markNeedsCompositedLayerUpdate();
|
|
expect(renderBox.debugNeedsPaint, false);
|
|
expect(renderBox.debugNeedsCompositedLayerUpdate, true);
|
|
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
|
|
// The second time the layer exists and we can skip paint.
|
|
expect(childBox.paintCount, 2);
|
|
expect(renderBox.paintCount, 2);
|
|
expect(renderBox.debugLayer, isA<OffsetLayer>());
|
|
|
|
renderBox.isRepaintBoundary = false;
|
|
renderBox.markNeedsCompositingBitsUpdate();
|
|
|
|
pumpFrame(phase: EnginePhase.composite);
|
|
|
|
// Once it stops being a repaint boundary we must repaint to
|
|
// remove the layer. its required that the render object
|
|
// perform this action in paint.
|
|
expect(childBox.paintCount, 3);
|
|
expect(renderBox.paintCount, 3);
|
|
expect(renderBox.debugLayer, null);
|
|
|
|
// When the render object is not a repaint boundary, calling
|
|
// markNeedsLayerPropertyUpdate is the same as calling
|
|
// markNeedsPaint.
|
|
|
|
renderBox.markNeedsCompositedLayerUpdate();
|
|
expect(renderBox.debugNeedsPaint, true);
|
|
expect(renderBox.debugNeedsCompositedLayerUpdate, true);
|
|
});
|
|
|
|
test(
|
|
'RenderObject with repaint boundary asserts when a composited layer is replaced during layer property update',
|
|
() {
|
|
final childBox = ConditionalRepaintBoundary(isRepaintBoundary: true);
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
|
|
// Ignore old layer.
|
|
childBox.offsetLayerFactory = (OffsetLayer? oldLayer) {
|
|
return TestOffsetLayerA();
|
|
};
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
|
|
renderBox.markNeedsCompositedLayerUpdate();
|
|
|
|
pumpFrame(phase: EnginePhase.composite, onErrors: expectAssertionError);
|
|
},
|
|
skip: kIsWeb, // https://github.com/flutter/flutter/issues/102086
|
|
);
|
|
|
|
test(
|
|
'RenderObject with repaint boundary asserts when a composited layer is replaced during painting',
|
|
() {
|
|
final childBox = ConditionalRepaintBoundary(isRepaintBoundary: true);
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
|
|
// Ignore old layer.
|
|
childBox.offsetLayerFactory = (OffsetLayer? oldLayer) {
|
|
return TestOffsetLayerA();
|
|
};
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
renderBox.markNeedsPaint();
|
|
|
|
pumpFrame(phase: EnginePhase.composite, onErrors: expectAssertionError);
|
|
},
|
|
skip: kIsWeb, // https://github.com/flutter/flutter/issues/102086
|
|
);
|
|
|
|
test(
|
|
'RenderObject with repaint boundary asserts when a composited layer tries to update its own offset',
|
|
() {
|
|
final childBox = ConditionalRepaintBoundary(isRepaintBoundary: true);
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
|
|
// Ignore old layer.
|
|
childBox.offsetLayerFactory = (OffsetLayer? oldLayer) {
|
|
return (oldLayer ?? TestOffsetLayerA())..offset = const Offset(2133, 4422);
|
|
};
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
renderBox.markNeedsPaint();
|
|
|
|
pumpFrame(phase: EnginePhase.composite, onErrors: expectAssertionError);
|
|
},
|
|
skip: kIsWeb, // https://github.com/flutter/flutter/issues/102086
|
|
);
|
|
|
|
test(
|
|
'RenderObject markNeedsPaint while repaint boundary, and then updated to no longer be a repaint boundary with '
|
|
'calling markNeedsCompositingBitsUpdate 1',
|
|
() {
|
|
final childBox = ConditionalRepaintBoundary(isRepaintBoundary: true);
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
// Ignore old layer.
|
|
childBox.offsetLayerFactory = (OffsetLayer? oldLayer) {
|
|
return oldLayer ?? TestOffsetLayerA();
|
|
};
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
|
|
childBox.markNeedsPaint();
|
|
childBox.isRepaintBoundary = false;
|
|
childBox.markNeedsCompositingBitsUpdate();
|
|
|
|
expect(() => pumpFrame(phase: EnginePhase.composite), returnsNormally);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'RenderObject markNeedsPaint while repaint boundary, and then updated to no longer be a repaint boundary with '
|
|
'calling markNeedsCompositingBitsUpdate 2',
|
|
() {
|
|
final childBox = ConditionalRepaintBoundary(isRepaintBoundary: true);
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
// Ignore old layer.
|
|
childBox.offsetLayerFactory = (OffsetLayer? oldLayer) {
|
|
return oldLayer ?? TestOffsetLayerA();
|
|
};
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
|
|
childBox.isRepaintBoundary = false;
|
|
childBox.markNeedsCompositingBitsUpdate();
|
|
childBox.markNeedsPaint();
|
|
|
|
expect(() => pumpFrame(phase: EnginePhase.composite), returnsNormally);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'RenderObject markNeedsPaint while repaint boundary, and then updated to no longer be a repaint boundary with '
|
|
'calling markNeedsCompositingBitsUpdate 3',
|
|
() {
|
|
final childBox = ConditionalRepaintBoundary(isRepaintBoundary: true);
|
|
final renderBox = ConditionalRepaintBoundary(child: childBox);
|
|
// Ignore old layer.
|
|
childBox.offsetLayerFactory = (OffsetLayer? oldLayer) {
|
|
return oldLayer ?? TestOffsetLayerA();
|
|
};
|
|
|
|
layout(renderBox, phase: EnginePhase.composite);
|
|
|
|
expect(childBox.paintCount, 1);
|
|
expect(renderBox.paintCount, 1);
|
|
|
|
childBox.isRepaintBoundary = false;
|
|
childBox.markNeedsCompositedLayerUpdate();
|
|
childBox.markNeedsCompositingBitsUpdate();
|
|
|
|
expect(() => pumpFrame(phase: EnginePhase.composite), returnsNormally);
|
|
},
|
|
);
|
|
|
|
test('Offstage implements paintsChild correctly', () {
|
|
final box = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 20),
|
|
);
|
|
final parent = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 20),
|
|
);
|
|
final offstage = RenderOffstage(offstage: false, child: box);
|
|
parent.child = offstage;
|
|
|
|
expect(offstage.paintsChild(box), true);
|
|
|
|
offstage.offstage = true;
|
|
|
|
expect(offstage.paintsChild(box), false);
|
|
});
|
|
|
|
test('Opacity implements paintsChild correctly', () {
|
|
final RenderBox box = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 20),
|
|
);
|
|
final opacity = RenderOpacity(child: box);
|
|
|
|
expect(opacity.paintsChild(box), true);
|
|
|
|
opacity.opacity = 0;
|
|
|
|
expect(opacity.paintsChild(box), false);
|
|
});
|
|
|
|
test('AnimatedOpacity sets paint matrix to zero when alpha == 0', () {
|
|
final RenderBox box = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 20),
|
|
);
|
|
final opacityAnimation = AnimationController(value: 1, vsync: FakeTickerProvider());
|
|
final opacity = RenderAnimatedOpacity(opacity: opacityAnimation, child: box);
|
|
|
|
// Make it listen to the animation.
|
|
opacity.attach(PipelineOwner());
|
|
|
|
expect(opacity.paintsChild(box), true);
|
|
|
|
opacityAnimation.value = 0;
|
|
|
|
expect(opacity.paintsChild(box), false);
|
|
});
|
|
|
|
test('AnimatedOpacity sets paint matrix to zero when alpha == 0 (sliver)', () {
|
|
final RenderSliver sliver = RenderSliverToBoxAdapter(
|
|
child: RenderConstrainedBox(additionalConstraints: const BoxConstraints.tightFor(width: 20)),
|
|
);
|
|
final opacityAnimation = AnimationController(value: 1, vsync: FakeTickerProvider());
|
|
final opacity = RenderSliverAnimatedOpacity(opacity: opacityAnimation, sliver: sliver);
|
|
|
|
// Make it listen to the animation.
|
|
opacity.attach(PipelineOwner());
|
|
|
|
expect(opacity.paintsChild(sliver), true);
|
|
|
|
opacityAnimation.value = 0;
|
|
|
|
expect(opacity.paintsChild(sliver), false);
|
|
});
|
|
|
|
test('RenderCustomClip extenders respect clipBehavior when asked to describeApproximateClip', () {
|
|
final RenderBox child = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 200, height: 200),
|
|
);
|
|
final renderClipRect = RenderClipRect(clipBehavior: Clip.none, child: child);
|
|
layout(renderClipRect);
|
|
expect(renderClipRect.describeApproximatePaintClip(child), null);
|
|
renderClipRect.clipBehavior = Clip.hardEdge;
|
|
expect(renderClipRect.describeApproximatePaintClip(child), Offset.zero & renderClipRect.size);
|
|
renderClipRect.clipBehavior = Clip.antiAlias;
|
|
expect(renderClipRect.describeApproximatePaintClip(child), Offset.zero & renderClipRect.size);
|
|
renderClipRect.clipBehavior = Clip.antiAliasWithSaveLayer;
|
|
expect(renderClipRect.describeApproximatePaintClip(child), Offset.zero & renderClipRect.size);
|
|
});
|
|
|
|
// Simulate painting a RenderBox as if 'debugPaintSizeEnabled == true'
|
|
DebugPaintCallback debugPaint(RenderBox renderBox) {
|
|
layout(renderBox);
|
|
pumpFrame(phase: EnginePhase.compositingBits);
|
|
return (PaintingContext context, Offset offset) {
|
|
renderBox.paint(context, offset);
|
|
renderBox.debugPaintSize(context, offset);
|
|
};
|
|
}
|
|
|
|
test(
|
|
'RenderClipPath.debugPaintSize draws a path and a debug text when clipBehavior is not Clip.none',
|
|
() {
|
|
DebugPaintCallback debugPaintClipRect(Clip clip) {
|
|
final RenderBox child = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 200, height: 200),
|
|
);
|
|
final renderClipPath = RenderClipPath(clipBehavior: clip, child: child);
|
|
return debugPaint(renderClipPath);
|
|
}
|
|
|
|
// RenderClipPath.debugPaintSize draws when clipBehavior is not Clip.none
|
|
expect(debugPaintClipRect(Clip.hardEdge), paintsExactlyCountTimes(#drawPath, 1));
|
|
expect(debugPaintClipRect(Clip.hardEdge), paintsExactlyCountTimes(#drawParagraph, 1));
|
|
|
|
// RenderClipPath.debugPaintSize does not draw when clipBehavior is Clip.none
|
|
// Regression test for https://github.com/flutter/flutter/issues/105969
|
|
expect(debugPaintClipRect(Clip.none), paintsExactlyCountTimes(#drawPath, 0));
|
|
expect(debugPaintClipRect(Clip.none), paintsExactlyCountTimes(#drawParagraph, 0));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'RenderClipRect.debugPaintSize draws a rect and a debug text when clipBehavior is not Clip.none',
|
|
() {
|
|
DebugPaintCallback debugPaintClipRect(Clip clip) {
|
|
final RenderBox child = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 200, height: 200),
|
|
);
|
|
final renderClipRect = RenderClipRect(clipBehavior: clip, child: child);
|
|
return debugPaint(renderClipRect);
|
|
}
|
|
|
|
// RenderClipRect.debugPaintSize draws when clipBehavior is not Clip.none
|
|
expect(debugPaintClipRect(Clip.hardEdge), paintsExactlyCountTimes(#drawRect, 1));
|
|
expect(debugPaintClipRect(Clip.hardEdge), paintsExactlyCountTimes(#drawParagraph, 1));
|
|
|
|
// RenderClipRect.debugPaintSize does not draw when clipBehavior is Clip.none
|
|
expect(debugPaintClipRect(Clip.none), paintsExactlyCountTimes(#drawRect, 0));
|
|
expect(debugPaintClipRect(Clip.none), paintsExactlyCountTimes(#drawParagraph, 0));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'RenderClipRRect.debugPaintSize draws a rounded rect and a debug text when clipBehavior is not Clip.none',
|
|
() {
|
|
DebugPaintCallback debugPaintClipRRect(Clip clip) {
|
|
final RenderBox child = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 200, height: 200),
|
|
);
|
|
final renderClipRRect = RenderClipRRect(clipBehavior: clip, child: child);
|
|
return debugPaint(renderClipRRect);
|
|
}
|
|
|
|
// RenderClipRRect.debugPaintSize draws when clipBehavior is not Clip.none
|
|
expect(debugPaintClipRRect(Clip.hardEdge), paintsExactlyCountTimes(#drawRRect, 1));
|
|
expect(debugPaintClipRRect(Clip.hardEdge), paintsExactlyCountTimes(#drawParagraph, 1));
|
|
|
|
// RenderClipRRect.debugPaintSize does not draw when clipBehavior is Clip.none
|
|
expect(debugPaintClipRRect(Clip.none), paintsExactlyCountTimes(#drawRRect, 0));
|
|
expect(debugPaintClipRRect(Clip.none), paintsExactlyCountTimes(#drawParagraph, 0));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'RenderClipOval.debugPaintSize draws a path and a debug text when clipBehavior is not Clip.none',
|
|
() {
|
|
DebugPaintCallback debugPaintClipOval(Clip clip) {
|
|
final RenderBox child = RenderConstrainedBox(
|
|
additionalConstraints: const BoxConstraints.tightFor(width: 200, height: 200),
|
|
);
|
|
final renderClipOval = RenderClipOval(clipBehavior: clip, child: child);
|
|
return debugPaint(renderClipOval);
|
|
}
|
|
|
|
// RenderClipOval.debugPaintSize draws when clipBehavior is not Clip.none
|
|
expect(debugPaintClipOval(Clip.hardEdge), paintsExactlyCountTimes(#drawPath, 1));
|
|
expect(debugPaintClipOval(Clip.hardEdge), paintsExactlyCountTimes(#drawParagraph, 1));
|
|
|
|
// RenderClipOval.debugPaintSize does not draw when clipBehavior is Clip.none
|
|
expect(debugPaintClipOval(Clip.none), paintsExactlyCountTimes(#drawPath, 0));
|
|
expect(debugPaintClipOval(Clip.none), paintsExactlyCountTimes(#drawParagraph, 0));
|
|
},
|
|
);
|
|
|
|
test('RenderProxyBox behavior can be mixed in along with another base class', () {
|
|
final fancyProxyBox = RenderFancyProxyBox(fancy: 6);
|
|
// Box has behavior from its base class:
|
|
expect(fancyProxyBox.fancyMethod(), 36);
|
|
// Box has behavior from RenderProxyBox:
|
|
expect(
|
|
// ignore: invalid_use_of_protected_member
|
|
fancyProxyBox.computeDryLayout(const BoxConstraints(minHeight: 8)),
|
|
const Size(0, 8),
|
|
);
|
|
});
|
|
|
|
test('computeDryLayout constraints are covariant', () {
|
|
final box = RenderBoxWithTestConstraints();
|
|
const constraints = TestConstraints(testValue: 6);
|
|
expect(box.computeDryLayout(constraints), const Size.square(6));
|
|
});
|
|
|
|
test('RenderBackdropFilter handles mix uses of .filter and .filterConfig', () {
|
|
final filter1 = ui.ImageFilter.blur();
|
|
final filter2 = ui.ImageFilter.matrix(Float64List.fromList(Matrix4.identity().storage));
|
|
final filter3 = ui.ImageFilter.compose(outer: filter1, inner: filter2);
|
|
|
|
final backdropFilter = RenderBackdropFilter(filter: filter1);
|
|
|
|
expect(backdropFilter.filter, filter1);
|
|
expect(backdropFilter.filterConfig, equals(ImageFilterConfig(filter1)));
|
|
|
|
backdropFilter.filterConfig = ImageFilterConfig(filter2);
|
|
|
|
expect(backdropFilter.filter, filter2);
|
|
expect(backdropFilter.filterConfig, equals(ImageFilterConfig(filter2)));
|
|
|
|
backdropFilter.filter = filter3;
|
|
|
|
expect(backdropFilter.filter, filter3);
|
|
expect(backdropFilter.filterConfig, equals(ImageFilterConfig(filter3)));
|
|
|
|
const filterConfig1 = ImageFilterConfig.blur(sigmaX: 10.0, sigmaY: 10.0);
|
|
backdropFilter.filterConfig = filterConfig1;
|
|
|
|
expect(backdropFilter.filterConfig, equals(filterConfig1));
|
|
expect(() => backdropFilter.filter, throwsAssertionError);
|
|
});
|
|
}
|
|
|
|
class _TestRectClipper extends CustomClipper<Rect> {
|
|
@override
|
|
Rect getClip(Size size) {
|
|
return Rect.zero;
|
|
}
|
|
|
|
@override
|
|
Rect getApproximateClipRect(Size size) => getClip(size);
|
|
|
|
@override
|
|
bool shouldReclip(_TestRectClipper oldClipper) => true;
|
|
}
|
|
|
|
class _TestRRectClipper extends CustomClipper<RRect> {
|
|
@override
|
|
RRect getClip(Size size) {
|
|
return RRect.zero;
|
|
}
|
|
|
|
@override
|
|
Rect getApproximateClipRect(Size size) => getClip(size).outerRect;
|
|
|
|
@override
|
|
bool shouldReclip(_TestRRectClipper oldClipper) => true;
|
|
}
|
|
|
|
// Forces two frames and checks that:
|
|
// - a layer is created on the first frame
|
|
// - the layer is reused on the second frame
|
|
void _testLayerReuse<L extends Layer>(RenderBox renderObject) {
|
|
expect(L, isNot(Layer));
|
|
expect(renderObject.debugLayer, null);
|
|
layout(
|
|
renderObject,
|
|
phase: EnginePhase.paint,
|
|
constraints: BoxConstraints.tight(const Size(10, 10)),
|
|
);
|
|
final Layer? layer = renderObject.debugLayer;
|
|
expect(layer, isA<L>());
|
|
expect(layer, isNotNull);
|
|
|
|
// Mark for repaint otherwise pumpFrame is a noop.
|
|
renderObject.markNeedsPaint();
|
|
expect(renderObject.debugNeedsPaint, true);
|
|
pumpFrame(phase: EnginePhase.paint);
|
|
expect(renderObject.debugNeedsPaint, false);
|
|
expect(renderObject.debugLayer, same(layer));
|
|
}
|
|
|
|
class _TestPathClipper extends CustomClipper<Path> {
|
|
@override
|
|
Path getClip(Size size) {
|
|
return Path()..addRect(const Rect.fromLTWH(50.0, 50.0, 100.0, 100.0));
|
|
}
|
|
|
|
@override
|
|
bool shouldReclip(_TestPathClipper oldClipper) => false;
|
|
}
|
|
|
|
class _TestSemanticsUpdateRenderFractionalTranslation extends RenderFractionalTranslation {
|
|
_TestSemanticsUpdateRenderFractionalTranslation({required super.translation});
|
|
|
|
int markNeedsSemanticsUpdateCallCount = 0;
|
|
|
|
@override
|
|
void markNeedsSemanticsUpdate() {
|
|
markNeedsSemanticsUpdateCallCount++;
|
|
super.markNeedsSemanticsUpdate();
|
|
}
|
|
}
|
|
|
|
class ConditionalRepaintBoundary extends RenderProxyBox {
|
|
ConditionalRepaintBoundary({this.isRepaintBoundary = false, RenderBox? child}) : super(child);
|
|
|
|
@override
|
|
bool isRepaintBoundary = false;
|
|
|
|
OffsetLayer Function(OffsetLayer?)? offsetLayerFactory;
|
|
|
|
int paintCount = 0;
|
|
|
|
@override
|
|
OffsetLayer updateCompositedLayer({required covariant OffsetLayer? oldLayer}) {
|
|
return offsetLayerFactory?.call(oldLayer) ?? super.updateCompositedLayer(oldLayer: oldLayer);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
paintCount += 1;
|
|
super.paint(context, offset);
|
|
}
|
|
}
|
|
|
|
class TestOffsetLayerA extends OffsetLayer {}
|
|
|
|
class RenderFancyBox extends RenderBox {
|
|
RenderFancyBox({required this.fancy}) : super();
|
|
|
|
late int fancy;
|
|
|
|
int fancyMethod() {
|
|
return fancy * fancy;
|
|
}
|
|
}
|
|
|
|
class RenderFancyProxyBox extends RenderFancyBox
|
|
with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
|
|
RenderFancyProxyBox({required super.fancy});
|
|
}
|
|
|
|
void expectAssertionError() {
|
|
final FlutterErrorDetails errorDetails = TestRenderingFlutterBinding.instance
|
|
.takeFlutterErrorDetails()!;
|
|
final bool asserted = errorDetails.toString().contains('Failed assertion');
|
|
if (!asserted) {
|
|
FlutterError.reportError(errorDetails);
|
|
}
|
|
}
|
|
|
|
typedef DebugPaintCallback = void Function(PaintingContext context, Offset offset);
|
|
|
|
class TestConstraints extends BoxConstraints {
|
|
const TestConstraints({double extent = 100, required this.testValue})
|
|
: super(maxWidth: extent, maxHeight: extent);
|
|
|
|
final double testValue;
|
|
}
|
|
|
|
class RenderBoxWithTestConstraints extends RenderProxyBox {
|
|
@override
|
|
Size computeDryLayout(TestConstraints constraints) {
|
|
return constraints.constrain(Size.square(constraints.testValue));
|
|
}
|
|
}
|