Michael Goderbauer 4534a24c09
Reapply "Dynamic view sizing" (#140165) (#140918)
This reverts commit
d24c01bd0c.

The original change was reverted because it caused some apps to get
stuck on the splash screen on some phones.

An investigation determined that this was due to a rounding error.
Example: The device reports a physical size of 1008.0 x 2198.0 with a
dpr of 1.912500023841858. Flutter would translate that to a logical size
of 527.0588169589221 x 1149.2810314243163 and use that as the input for
its layout algorithm. Since the constraints here are tight, the layout
algorithm would determine that the resulting logical size of the root
render object must be 527.0588169589221 x 1149.2810314243163.
Translating this back to physical pixels by applying the dpr resulted in
a physical size of 1007.9999999999999 x 2198.0 for the frame. Android
now rejected that frame because it didn't match the expected size of
1008.0 x 2198.0 and since no frame had been rendered would never take
down the splash screen.

Prior to dynamically sized views, this wasn't an issue because we would
hard-code the frame size to whatever the requested size was.

Changes in this PR over the original PR:

* The issue has been fixed now by constraining the calculated physical
size to the input physical constraints which makes sure that we always
end up with a size that is acceptable to the operating system.
* The `ViewConfiguration` was refactored to use the slightly more
convenient `BoxConstraints` over the `ViewConstraints` to represent
constraints. Both essentially represent the same thing, but
`BoxConstraints` are more powerful and we avoid a couple of translations
between the two by translating the` ViewConstraints` from the
`FlutterView` to `BoxConstraints` directly when the `ViewConfiguration`
is created.

All changes over the original PR are contained in the second commit of
this PR.

Fixes b/316813075
Part of https://github.com/flutter/flutter/issues/134501.
2024-01-09 14:10:43 -08:00

557 lines
18 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';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'multi_view_testing.dart';
void main() {
testWidgets('Widgets running with runApp can find View', (WidgetTester tester) async {
FlutterView? viewOf;
FlutterView? viewMaybeOf;
runApp(
Builder(
builder: (BuildContext context) {
viewOf = View.of(context);
viewMaybeOf = View.maybeOf(context);
return Container();
},
),
);
expect(viewOf, isNotNull);
expect(viewOf, isA<FlutterView>());
expect(viewMaybeOf, isNotNull);
expect(viewMaybeOf, isA<FlutterView>());
});
testWidgets('Widgets running with pumpWidget can find View', (WidgetTester tester) async {
FlutterView? view;
FlutterView? viewMaybeOf;
await tester.pumpWidget(
Builder(
builder: (BuildContext context) {
view = View.of(context);
viewMaybeOf = View.maybeOf(context);
return Container();
},
),
);
expect(view, isNotNull);
expect(view, isA<FlutterView>());
expect(viewMaybeOf, isNotNull);
expect(viewMaybeOf, isA<FlutterView>());
});
testWidgets('cannot find View behind a LookupBoundary', (WidgetTester tester) async {
await tester.pumpWidget(
LookupBoundary(
child: Container(),
),
);
final BuildContext context = tester.element(find.byType(Container));
expect(View.maybeOf(context), isNull);
expect(
() => View.of(context),
throwsA(isA<FlutterError>().having(
(FlutterError error) => error.message,
'message',
contains('The context provided to View.of() does have a View widget ancestor, but it is hidden by a LookupBoundary.'),
)),
);
});
testWidgets('child of view finds view, parentPipelineOwner, mediaQuery', (WidgetTester tester) async {
FlutterView? outsideView;
FlutterView? insideView;
PipelineOwner? outsideParent;
PipelineOwner? insideParent;
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: Builder(
builder: (BuildContext context) {
outsideView = View.maybeOf(context);
outsideParent = View.pipelineOwnerOf(context);
return View(
view: tester.view,
child: Builder(
builder: (BuildContext context) {
insideView = View.maybeOf(context);
insideParent = View.pipelineOwnerOf(context);
return const SizedBox();
},
),
);
},
),
);
expect(outsideView, isNull);
expect(insideView, equals(tester.view));
expect(outsideParent, isNotNull);
expect(insideParent, isNotNull);
expect(outsideParent, isNot(equals(insideParent)));
expect(outsideParent, tester.binding.rootPipelineOwner);
expect(insideParent, equals(tester.renderObject(find.byType(SizedBox)).owner));
final List<PipelineOwner> pipelineOwners = <PipelineOwner> [];
tester.binding.rootPipelineOwner.visitChildren((PipelineOwner child) {
pipelineOwners.add(child);
});
expect(pipelineOwners.single, equals(insideParent));
});
testWidgets('cannot have multiple views with same FlutterView', (WidgetTester tester) async {
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: ViewCollection(
views: <Widget>[
View(
view: tester.view,
child: const SizedBox(),
),
View(
view: tester.view,
child: const SizedBox(),
),
],
),
);
expect(
tester.takeException(),
isFlutterError.having(
(FlutterError e) => e.message,
'message',
contains('Multiple widgets used the same GlobalKey'),
),
);
});
testWidgets('ViewCollection may start with zero views', (WidgetTester tester) async {
expect(() => const ViewCollection(views: <Widget>[]), returnsNormally);
});
testWidgets('ViewAnchor.child does not see surrounding view', (WidgetTester tester) async {
FlutterView? inside;
FlutterView? outside;
await tester.pumpWidget(
Builder(
builder: (BuildContext context) {
outside = View.maybeOf(context);
return ViewAnchor(
view: Builder(
builder: (BuildContext context) {
inside = View.maybeOf(context);
return View(view: FakeView(tester.view), child: const SizedBox());
},
),
child: const SizedBox(),
);
},
),
);
expect(inside, isNull);
expect(outside, isNotNull);
});
testWidgets('ViewAnchor layout order', (WidgetTester tester) async {
Finder findSpyWidget(int label) {
return find.byWidgetPredicate((Widget w) => w is SpyRenderWidget && w.label == label);
}
final List<String> log = <String>[];
await tester.pumpWidget(
SpyRenderWidget(
label: 1,
log: log,
child: ViewAnchor(
view: View(
view: FakeView(tester.view),
child: SpyRenderWidget(label: 2, log: log),
),
child: SpyRenderWidget(label: 3, log: log),
),
),
);
log.clear();
tester.renderObject(findSpyWidget(3)).markNeedsLayout();
tester.renderObject(findSpyWidget(2)).markNeedsLayout();
tester.renderObject(findSpyWidget(1)).markNeedsLayout();
await tester.pump();
expect(log, <String>['layout 1', 'layout 3', 'layout 2']);
});
testWidgets('visitChildren of ViewAnchor visits both children', (WidgetTester tester) async {
await tester.pumpWidget(
ViewAnchor(
view: View(
view: FakeView(tester.view),
child: const ColoredBox(color: Colors.green),
),
child: const SizedBox(),
),
);
final Element viewAnchorElement = tester.element(find.byElementPredicate((Element e) => e.runtimeType.toString() == '_MultiChildComponentElement'));
final List<Element> children = <Element>[];
viewAnchorElement.visitChildren((Element element) {
children.add(element);
});
expect(children, hasLength(2));
await tester.pumpWidget(
const ViewAnchor(
child: SizedBox(),
),
);
children.clear();
viewAnchorElement.visitChildren((Element element) {
children.add(element);
});
expect(children, hasLength(1));
});
testWidgets('visitChildren of ViewCollection visits all children', (WidgetTester tester) async {
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: ViewCollection(
views: <Widget>[
View(
view: tester.view,
child: const SizedBox(),
),
View(
view: FakeView(tester.view),
child: const SizedBox(),
),
View(
view: FakeView(tester.view, viewId: 423),
child: const SizedBox(),
),
],
),
);
final Element viewAnchorElement = tester.element(find.byElementPredicate((Element e) => e.runtimeType.toString() == '_MultiChildComponentElement'));
final List<Element> children = <Element>[];
viewAnchorElement.visitChildren((Element element) {
children.add(element);
});
expect(children, hasLength(3));
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: ViewCollection(
views: <Widget>[
View(
view: tester.view,
child: const SizedBox(),
),
],
),
);
children.clear();
viewAnchorElement.visitChildren((Element element) {
children.add(element);
});
expect(children, hasLength(1));
});
group('renderObject getter', () {
testWidgets('ancestors of view see RenderView as renderObject', (WidgetTester tester) async {
late BuildContext builderContext;
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: Builder(
builder: (BuildContext context) {
builderContext = context;
return View(
view: tester.view,
child: const SizedBox(),
);
},
),
);
final RenderObject? renderObject = builderContext.findRenderObject();
expect(renderObject, isNotNull);
expect(renderObject, isA<RenderView>());
expect(renderObject, tester.renderObject(find.byType(View)));
expect(tester.element(find.byType(Builder)).renderObject, renderObject);
});
testWidgets('ancestors of ViewCollection get null for renderObject', (WidgetTester tester) async {
late BuildContext builderContext;
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: Builder(
builder: (BuildContext context) {
builderContext = context;
return ViewCollection(
views: <Widget>[
View(
view: tester.view,
child: const SizedBox(),
),
View(
view: FakeView(tester.view),
child: const SizedBox(),
),
],
);
},
),
);
final RenderObject? renderObject = builderContext.findRenderObject();
expect(renderObject, isNull);
expect(tester.element(find.byType(Builder)).renderObject, isNull);
});
testWidgets('ancestors of a ViewAnchor see the right RenderObject', (WidgetTester tester) async {
late BuildContext builderContext;
await tester.pumpWidget(
Builder(
builder: (BuildContext context) {
builderContext = context;
return ViewAnchor(
view: View(
view: FakeView(tester.view),
child: const ColoredBox(color: Colors.green),
),
child: const SizedBox(),
);
},
),
);
final RenderObject? renderObject = builderContext.findRenderObject();
expect(renderObject, isNotNull);
expect(renderObject, isA<RenderConstrainedBox>());
expect(renderObject, tester.renderObject(find.byType(SizedBox)));
expect(tester.element(find.byType(Builder)).renderObject, renderObject);
});
});
testWidgets('correctly switches between view configurations', (WidgetTester tester) async {
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: View(
view: tester.view,
deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner,
deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView,
child: const SizedBox(),
),
);
RenderObject renderView = tester.renderObject(find.byType(View));
expect(renderView, same(tester.binding.renderView));
expect(renderView.owner, same(tester.binding.pipelineOwner));
expect(tester.renderObject(find.byType(SizedBox)).owner, same(tester.binding.pipelineOwner));
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: View(
view: tester.view,
child: const SizedBox(),
),
);
renderView = tester.renderObject(find.byType(View));
expect(renderView, isNot(same(tester.binding.renderView)));
expect(renderView.owner, isNot(same(tester.binding.pipelineOwner)));
expect(tester.renderObject(find.byType(SizedBox)).owner, isNot(same(tester.binding.pipelineOwner)));
await pumpWidgetWithoutViewWrapper(
tester: tester,
widget: View(
view: tester.view,
deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner,
deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView,
child: const SizedBox(),
),
);
renderView = tester.renderObject(find.byType(View));
expect(renderView, same(tester.binding.renderView));
expect(renderView.owner, same(tester.binding.pipelineOwner));
expect(tester.renderObject(find.byType(SizedBox)).owner, same(tester.binding.pipelineOwner));
expect(() => View(
view: tester.view,
deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner,
child: const SizedBox(),
), throwsAssertionError);
expect(() => View(
view: tester.view,
deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView,
child: const SizedBox(),
), throwsAssertionError);
expect(() => View(
view: FakeView(tester.view),
deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView,
deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner,
child: const SizedBox(),
), throwsAssertionError);
});
testWidgets('attaches itself correctly', (WidgetTester tester) async {
final Key viewKey = UniqueKey();
late final PipelineOwner parentPipelineOwner;
await tester.pumpWidget(
ViewAnchor(
view: Builder(
builder: (BuildContext context) {
parentPipelineOwner = View.pipelineOwnerOf(context);
return View(
key: viewKey,
view: FakeView(tester.view),
child: const SizedBox(),
);
},
),
child: const ColoredBox(color: Colors.green),
),
);
expect(parentPipelineOwner, isNot(RendererBinding.instance.rootPipelineOwner));
final RenderView rawView = tester.renderObject<RenderView>(find.byKey(viewKey));
expect(RendererBinding.instance.renderViews, contains(rawView));
final List<PipelineOwner> children = <PipelineOwner>[];
parentPipelineOwner.visitChildren((PipelineOwner child) {
children.add(child);
});
final PipelineOwner rawViewOwner = rawView.owner!;
expect(children, contains(rawViewOwner));
// Remove that View from the tree.
await tester.pumpWidget(
const ViewAnchor(
child: ColoredBox(color: Colors.green),
),
);
expect(rawView.owner, isNull);
expect(RendererBinding.instance.renderViews, isNot(contains(rawView)));
children.clear();
parentPipelineOwner.visitChildren((PipelineOwner child) {
children.add(child);
});
expect(children, isNot(contains(rawViewOwner)));
});
testWidgets('RenderView does not use size of child if constraints are tight', (WidgetTester tester) async {
const Size physicalSize = Size(300, 600);
final Size logicalSize = physicalSize / tester.view.devicePixelRatio;
tester.view.physicalConstraints = ViewConstraints.tight(physicalSize);
await tester.pumpWidget(const Placeholder());
final RenderView renderView = tester.renderObject<RenderView>(find.byType(View));
expect(renderView.constraints, BoxConstraints.tight(logicalSize));
expect(renderView.size, logicalSize);
final RenderBox child = renderView.child!;
expect(child.constraints, BoxConstraints.tight(logicalSize));
expect(child.debugCanParentUseSize, isFalse);
expect(child.size, logicalSize);
});
testWidgets('RenderView sizes itself to child if constraints allow it (unconstrained)', (WidgetTester tester) async {
const Size size = Size(300, 600);
tester.view.physicalConstraints = const ViewConstraints(); // unconstrained
await tester.pumpWidget(SizedBox.fromSize(size: size));
final RenderView renderView = tester.renderObject<RenderView>(find.byType(View));
expect(renderView.constraints, const BoxConstraints());
expect(renderView.size, size);
final RenderBox child = renderView.child!;
expect(child.constraints, const BoxConstraints());
expect(child.debugCanParentUseSize, isTrue);
expect(child.size, size);
});
testWidgets('RenderView sizes itself to child if constraints allow it (constrained)', (WidgetTester tester) async {
const Size size = Size(30, 60);
const ViewConstraints viewConstraints = ViewConstraints(maxWidth: 333, maxHeight: 666);
final BoxConstraints boxConstraints = BoxConstraints.fromViewConstraints(viewConstraints / tester.view.devicePixelRatio);
tester.view.physicalConstraints = viewConstraints;
await tester.pumpWidget(SizedBox.fromSize(size: size));
final RenderView renderView = tester.renderObject<RenderView>(find.byType(View));
expect(renderView.constraints, boxConstraints);
expect(renderView.size, size);
final RenderBox child = renderView.child!;
expect(child.constraints, boxConstraints);
expect(child.debugCanParentUseSize, isTrue);
expect(child.size, size);
});
testWidgets('RenderView respects constraints when child wants to be bigger than allowed', (WidgetTester tester) async {
const Size size = Size(3000, 6000);
const ViewConstraints viewConstraints = ViewConstraints(maxWidth: 300, maxHeight: 600);
tester.view.physicalConstraints = viewConstraints;
await tester.pumpWidget(SizedBox.fromSize(size: size));
final RenderView renderView = tester.renderObject<RenderView>(find.byType(View));
expect(renderView.size, const Size(100, 200)); // viewConstraints.biggest / devicePixelRatio
final RenderBox child = renderView.child!;
expect(child.debugCanParentUseSize, isTrue);
expect(child.size, const Size(100, 200));
});
}
Future<void> pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) {
tester.binding.attachRootWidget(widget);
tester.binding.scheduleFrame();
return tester.binding.pump();
}
class SpyRenderWidget extends SizedBox {
const SpyRenderWidget({super.key, required this.label, required this.log, super.child});
final int label;
final List<String> log;
@override
RenderSpy createRenderObject(BuildContext context) {
return RenderSpy(
additionalConstraints: const BoxConstraints(),
label: label,
log: log,
);
}
@override
void updateRenderObject(BuildContext context, RenderSpy renderObject) {
renderObject
..label = label
..log = log;
}
}
class RenderSpy extends RenderConstrainedBox {
RenderSpy({required super.additionalConstraints, required this.label, required this.log});
int label;
List<String> log;
@override
void performLayout() {
log.add('layout $label');
super.performLayout();
}
}