[ios] Reland Dynamic Content Resizing (#179153)

This is a reland of https://github.com/flutter/flutter/pull/177410

The previous pr was hitting deadlocks with tests that use screenshots.
This reland fixes it by submitting frames asynchronously instead of
synchronously.

This PR fixes https://github.com/flutter/flutter/issues/169147

This is the iOS implementation of
https://github.com/flutter/flutter/issues/149033

In Add-to-App scenarios, a common desired use case is to have the
embedded FlutterView be able to size itself based on it's content. Up
till now, the size had to be manually specified, which can be tedious
and inaccurate.

This PR adds a new flag, `isAutoResizable`, which enables the ability
for the embedded flutter view to dynamically set it's own size based off
it's content. Note that this feature will *NOT* work if the the embedded
app is wrapped in flexible or unsized widgets, such as Scaffold. The
FlutterView will just not display at all.

To use the new flag, just set it like
```
flutterViewController.autoResizable = true;
```
or in Swift

```
flutterViewController.isAutoResizable = true
```
## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
LouiseHsu 2025-12-02 10:59:44 -08:00 committed by GitHub
parent daddf7ab8e
commit 1bf445bb45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 500 additions and 6 deletions

View File

@ -54,6 +54,9 @@ Future<void> main() async {
final marquee = File(path.join(flutterModuleLibSource.path, 'marquee'));
marquee.copySync(path.join(flutterModuleLibDestination.path, 'marquee.dart'));
final resize = File(path.join(flutterModuleLibSource.path, 'resize'));
resize.copySync(path.join(flutterModuleLibDestination.path, 'resize.dart'));
section('Create package with native assets');
const ffiPackageName = 'ffi_package';

View File

@ -126,6 +126,30 @@ static const CGFloat kStandardTimeOut = 60.0;
XCTAssertTrue([app.navigationBars[@"Flutter iOS Demos Home"] waitForExistenceWithTimeout:kStandardTimeOut]);
}
- (void)testResize {
XCUIApplication *app = self.app;
[self waitForAndTapElement:app.buttons[@"Dynamic Content Resizing"]];
XCUIElement *flutterView = app.otherElements[@"flutter_view"];
CGRect flutterViewFrame = flutterView.frame;
CGSize flutterViewSize = flutterViewFrame.size;
XCTAssertTrue(flutterViewSize.height == 100);
[self waitForAndTapElement: app.otherElements[@"flutter_view"]];
XCUIElement *flutterViewPostClick = app.otherElements[@"flutter_view"];
CGRect flutterViewFramePostClick = flutterViewPostClick.frame;
CGSize flutterViewSizePostClick = flutterViewFramePostClick.size;
XCTAssertTrue(flutterViewSizePostClick.height == 200);
// Back navigation.
[app.navigationBars[@"Dynamic Content Resizing"].buttons[@"Flutter iOS Demos Home"] tap];
XCTAssertTrue([app.navigationBars[@"Flutter iOS Demos Home"] waitForExistenceWithTimeout:kStandardTimeOut]);
}
- (void)waitForAndTapElement:(XCUIElement *)element {
NSPredicate *hittable = [NSPredicate predicateWithFormat:@"exists == YES AND hittable == YES"];
[self expectationForPredicate:hittable evaluatedWithObject:element handler:nil];

View File

@ -13,6 +13,7 @@
74F97871215AB9E9005A0F04 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 74F9786F215AB9E9005A0F04 /* LaunchScreen.storyboard */; };
74F97874215AB9E9005A0F04 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 74F97873215AB9E9005A0F04 /* main.m */; };
7E06ECD9FAEFCA4A0ED04D6E /* libPods-FlutterUITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 327E8CC22A494E7D7155FB58 /* libPods-FlutterUITests.a */; };
F2D997DE2EA8BCB0002FC7EC /* DynamicResizingViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = F2D997DD2EA8BCB0002FC7EC /* DynamicResizingViewController.m */; };
F7C2661424AC18D00085742D /* DualFlutterViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = F7C2660E24AC18D00085742D /* DualFlutterViewController.m */; };
F7C2661524AC18D00085742D /* FullScreenViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = F7C2660F24AC18D00085742D /* FullScreenViewController.m */; };
F7C2661624AC18D00085742D /* HybridViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = F7C2661124AC18D00085742D /* HybridViewController.m */; };
@ -45,6 +46,8 @@
8E6751F293967EE3DFF4178A /* Pods-FlutterUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlutterUITests.release.xcconfig"; path = "Pods/Target Support Files/Pods-FlutterUITests/Pods-FlutterUITests.release.xcconfig"; sourceTree = "<group>"; };
91B7D4263BFD246A27391225 /* Pods-Host.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Host.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Host/Pods-Host.debug.xcconfig"; sourceTree = "<group>"; };
E20960BD8B505D605FE82853 /* libPods-Host.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Host.a"; sourceTree = BUILT_PRODUCTS_DIR; };
F2D997DC2EA8BCB0002FC7EC /* DynamicResizingViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DynamicResizingViewController.h; sourceTree = "<group>"; };
F2D997DD2EA8BCB0002FC7EC /* DynamicResizingViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DynamicResizingViewController.m; sourceTree = "<group>"; };
F7C2660E24AC18D00085742D /* DualFlutterViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DualFlutterViewController.m; sourceTree = "<group>"; };
F7C2660F24AC18D00085742D /* FullScreenViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FullScreenViewController.m; sourceTree = "<group>"; };
F7C2661024AC18D00085742D /* HybridViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = HybridViewController.h; sourceTree = "<group>"; };
@ -103,6 +106,8 @@
74F97863215AB9E8005A0F04 /* Host */ = {
isa = PBXGroup;
children = (
F2D997DC2EA8BCB0002FC7EC /* DynamicResizingViewController.h */,
F2D997DD2EA8BCB0002FC7EC /* DynamicResizingViewController.m */,
F7C2661724AC18DD0085742D /* MainViewController.h */,
F7C2661A24AC18DD0085742D /* MainViewController.m */,
F7C2661924AC18DD0085742D /* NativeViewController.h */,
@ -284,10 +289,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-FlutterUITests/Pods-FlutterUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-FlutterUITests/Pods-FlutterUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FlutterUITests/Pods-FlutterUITests-frameworks.sh\"\n";
@ -305,6 +314,8 @@
"${SRCROOT}/../hello/.ios/Flutter/flutter_export_environment.sh",
);
name = "[CP-User] Run Flutter Build hello Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\nset -u\nsource \"${SRCROOT}/../hello/.ios/Flutter/flutter_export_environment.sh\"\n\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/xcode_backend.sh build";
@ -317,10 +328,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Host/Pods-Host-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Host/Pods-Host-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Host/Pods-Host-frameworks.sh\"\n";
@ -338,6 +353,8 @@
"${SRCROOT}/../hello/.ios/Flutter/flutter_export_environment.sh",
);
name = "[CP-User] Run Flutter Build hello Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\nset -u\nsource \"${SRCROOT}/../hello/.ios/Flutter/flutter_export_environment.sh\"\n\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/xcode_backend.sh build";
@ -372,6 +389,7 @@
buildActionMask = 2147483647;
files = (
F7C2661524AC18D00085742D /* FullScreenViewController.m in Sources */,
F2D997DE2EA8BCB0002FC7EC /* DynamicResizingViewController.m in Sources */,
F7C2661424AC18D00085742D /* DualFlutterViewController.m in Sources */,
F7C2661C24AC18DD0085742D /* MainViewController.m in Sources */,
74F97874215AB9E9005A0F04 /* main.m in Sources */,

View File

@ -0,0 +1,16 @@
// 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 <UIKit/UIKit.h>
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface DynamicResizingViewController : UIViewController
@property (readonly, strong, nonatomic) FlutterViewController* flutterViewController;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,70 @@
// 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 <Flutter/Flutter.h>
#import "DynamicResizingViewController.h"
@interface DynamicResizingViewController ()
@end
@implementation DynamicResizingViewController {}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.title = @"Dynamic Content Resizing";
UIScrollView *scrollView = [[UIScrollView alloc] init];
scrollView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:scrollView];
UIStackView *stackView = [[UIStackView alloc] init];
stackView.translatesAutoresizingMaskIntoConstraints = NO;
stackView.axis = UILayoutConstraintAxisVertical;
stackView.spacing = 10;
[scrollView addSubview:stackView];
[NSLayoutConstraint activateConstraints:@[
[scrollView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[scrollView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor]
]];
[NSLayoutConstraint activateConstraints:@[
[stackView.topAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.topAnchor],
[stackView.leadingAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.leadingAnchor],
[stackView.trailingAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.trailingAnchor],
[stackView.bottomAnchor constraintEqualToAnchor:scrollView.contentLayoutGuide.bottomAnchor],
[stackView.widthAnchor constraintEqualToAnchor:scrollView.frameLayoutGuide.widthAnchor]
]];
for (int index = 1; index <= 50; index++) {
if (index == 10) {
_flutterViewController = [[FlutterViewController alloc] init];
[_flutterViewController setInitialRoute:@"resize"];
_flutterViewController.autoResizable = YES;
[self addChildViewController:_flutterViewController];
[stackView addArrangedSubview:_flutterViewController.view];
_flutterViewController.view.translatesAutoresizingMaskIntoConstraints = NO;
_flutterViewController.view.accessibilityIdentifier = @"flutter_view";
[_flutterViewController didMoveToParentViewController:self];
} else {
UILabel *label = [[UILabel alloc] init];
label.text = [NSString stringWithFormat:@" Hello from iOS %d ", index];
label.backgroundColor = (index % 2 == 0) ? [UIColor systemGray5Color] : [UIColor systemGray3Color];
label.translatesAutoresizingMaskIntoConstraints = NO;
[label.heightAnchor constraintEqualToConstant:44].active = YES;
[stackView addArrangedSubview:label];
}
}
}
@end

View File

@ -9,6 +9,7 @@
#import "FullScreenViewController.h"
#import "HybridViewController.h"
#import "NativeViewController.h"
#import "DynamicResizingViewController.h"
@interface MainViewController ()
@ -44,6 +45,7 @@
self.flutterViewWarmButton = [self addButton:@"Flutter View (Warm)" action:@selector(showFlutterViewWarm)];
[self addButton:@"Hybrid View (Warm)" action:@selector(showHybridView)];
[self addButton:@"Dual Flutter View (Cold)" action:@selector(showDualView)];
[self addButton:@"Dynamic Content Resizing" action:@selector(showContentResizingView)];
}
- (FlutterEngine *)engine {
@ -74,6 +76,13 @@
animated:YES];
}
- (void)showContentResizingView {
DynamicResizingViewController *resizingViewController =
[[DynamicResizingViewController alloc] init];
[self.navigationController pushViewController:resizingViewController
animated:NO];
}
- (void)showFullScreenCold {
FullScreenViewController *flutterViewController =
[[FullScreenViewController alloc] init];

View File

@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
import 'package:ffi_package/ffi_package.dart';
import 'marquee.dart';
import 'resize.dart';
/// Route names. (See [main] for more details.)
///
@ -18,6 +19,7 @@ const String greenMarqueeRouteName = 'marquee_green';
const String purpleMarqueeRouteName = 'marquee_purple';
const String fullscreenRouteName = 'full';
const String hybridRouteName = 'hybrid';
const String resizeRouteName = 'resize';
/// Channel used to let the Flutter app know to reset the app to a specific
/// route. See the [run] method.
@ -55,6 +57,9 @@ Future<String> run(String? name) async {
case purpleMarqueeRouteName:
runApp(Marquee(color: Colors.purple[400]));
break;
case resizeRouteName:
runApp(ResizeApp());
break;
case fullscreenRouteName:
case hybridRouteName:
default:

View File

@ -0,0 +1,42 @@
// 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 'package:flutter/material.dart';
class ResizeApp extends StatefulWidget {
/// Creates the [ResizeApp].
const ResizeApp({super.key});
@override
State<ResizeApp> createState() => _ResizeAppState();
}
class _ResizeAppState extends State<ResizeApp> {
int _listSize = 1;
void _addToList() {
setState(() {
_listSize++;
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _addToList, // The tap anywhere logic
child: Center(
heightFactor: 1,
child: Directionality(
textDirection: TextDirection.ltr,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (int i = 0; i < _listSize; i++)
Container(color: HSVColor.fromAHSV(1, (10.0 * i), 1, 1).toColor(), height: 100),
],
),
),
),
);
}
}

View File

@ -13,6 +13,7 @@
#include "flutter/fml/message_loop.h"
#include "flutter/fml/message_loop_impl.h"
#include "flutter/fml/message_loop_task_queues.h"
#include "flutter/fml/synchronization/waitable_event.h"
namespace fml {

View File

@ -254,6 +254,19 @@ FLUTTER_DARWIN_EXPORT
*/
@property(nonatomic, readonly) BOOL engineAllowHeadlessExecution;
/**
* Controls whether the created view can be sized based on its content.
* When set to `YES`, the FlutterView will be the same size as the outermost widget.
* Cannot be used with unbounded height widgets, such as Scaffold.
* This property is intended to be used with Add-to-App scenarios.
*
* Once auto resizing is enabled, the FlutterView will rely on custom constraints from then on.
* Avoid disabling it after enabling, as behaviour will then be undefined.
*
* Default is `NO`.
*/
@property(nonatomic, getter=isAutoResizable) BOOL autoResizable;
@end
NS_ASSUME_NONNULL_END

View File

@ -404,6 +404,14 @@ extern CFTimeInterval display_link_target;
}
- (void)presentOnMainThread:(FlutterTexture*)texture {
if (texture.texture.width != _drawableSize.width ||
texture.texture.height != _drawableSize.height) {
// This texture was created with an old size, but the view has since been
// resized. Do not present this stale frame to avoid distortion. The texture
// will be correctly recycled on the next frame.
return;
}
// This is needed otherwise frame gets skipped on touch begin / end. Go figure.
// Might also be placebo
[self setNeedsDisplay];

View File

@ -665,8 +665,22 @@ static CGRect GetCGRectFromDlRect(const DlRect& clipDlRect) {
withIosContext:(const std::shared_ptr<flutter::IOSContext>&)iosContext {
TRACE_EVENT0("flutter", "PlatformViewsController::SubmitFrame");
// No platform views to render; we're done.
// No platform views to render.
if (self.flutterView == nil || (self.compositionOrder.empty() && !self.hadPlatformViews)) {
// No platform views to render but the FlutterView may need to be resized.
__weak FlutterPlatformViewsController* weakSelf = self;
if (self.flutterView != nil) {
fml::TaskRunner::RunNowOrPostTask(
weakSelf.platformTaskRunner,
fml::MakeCopyable([weakSelf, frameSize = weakSelf.frameSize]() {
FlutterPlatformViewsController* strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf performResize:frameSize];
}));
}
self.hadPlatformViews = NO;
return background_frame->Submit();
}
@ -752,15 +766,13 @@ static CGRect GetCGRectFromDlRect(const DlRect& clipDlRect) {
std::vector<std::shared_ptr<flutter::OverlayLayer>> unusedLayers =
self.layerPool->RemoveUnusedLayers();
self.layerPool->RecycleLayers();
auto task = [self, //
platformViewLayers = std::move(platformViewLayers), //
currentCompositionParams = self.currentCompositionParams, //
viewsToRecomposite = self.viewsToRecomposite, //
compositionOrder = self.compositionOrder, //
unusedLayers = std::move(unusedLayers), //
surfaceFrames = std::move(surfaceFrames) //
]() mutable {
surfaceFrames = std::move(surfaceFrames)]() mutable {
[self performSubmit:platformViewLayers
currentCompositionParams:currentCompositionParams
viewsToRecomposite:viewsToRecomposite
@ -770,7 +782,6 @@ static CGRect GetCGRectFromDlRect(const DlRect& clipDlRect) {
};
fml::TaskRunner::RunNowOrPostTask(self.platformTaskRunner, fml::MakeCopyable(std::move(task)));
return didEncode;
}
@ -799,6 +810,16 @@ static CGRect GetCGRectFromDlRect(const DlRect& clipDlRect) {
}
}
- (void)performResize:(const flutter::DlISize&)frameSize {
TRACE_EVENT0("flutter", "PlatformViewsController::PerformResize");
FML_DCHECK([[NSThread currentThread] isMainThread]);
if (self.flutterView != nil) {
[(FlutterView*)self.flutterView
setIntrinsicContentSize:CGSizeMake(frameSize.width, frameSize.height)];
}
}
- (void)performSubmit:(const LayersMap&)platformViewLayers
currentCompositionParams:
(std::unordered_map<int64_t, flutter::EmbeddedViewParams>&)currentCompositionParams

View File

@ -29,6 +29,23 @@
- (void)flutterViewAccessibilityDidCall;
@end
/**
* A custom NSLayoutConstraint subclass for autoresizing the FlutterView.
* This class is a special NSLayoutConstraint used internally to
* manage the dynamic resizing of a FlutterView based on its content.
*
* In native, `intrinsicContentSize` is a public property that determines the preferred
* sized of an UIView, based on it's internal content. Given a position and layout constraints,
* this allows the UIView to size itself.
* However, the mechanism in which this sizing occurs based on`intrinsicContentSize`
* and the layout constraints is private.
*
* This custom NSLayoutConstraint allows us to replicate this mechanizm without needing to rely
* on private APIs.
*/
@interface FlutterAutoResizeLayoutConstraint : NSLayoutConstraint
@end
@interface FlutterView : UIView
- (instancetype)init NS_UNAVAILABLE;
@ -43,6 +60,19 @@
- (UIScreen*)screen;
- (MTLPixelFormat)pixelFormat;
/**
* A method that sets the instrinsic content size
* This is used when autoResizable is enabled.
*/
- (void)setIntrinsicContentSize:(CGSize)size;
/**
* A method that resets and recalculates the instrinsic content size
* Currently called when the device orientation changes.
*/
- (void)resetIntrinsicContentSize;
@property(nonatomic, assign, readwrite) BOOL autoResizable;
@end
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERVIEW_H_

View File

@ -16,8 +16,12 @@ FLUTTER_ASSERT_ARC
@property(nonatomic, weak) UIWindowScene* previousScene;
@end
@implementation FlutterAutoResizeLayoutConstraint
@end
@implementation FlutterView {
BOOL _isWideGamutEnabled;
CGSize _intrinsicSize;
}
- (instancetype)init {
@ -39,6 +43,71 @@ FLUTTER_ASSERT_ARC
return self.window.windowScene.screen;
}
// iOS has a concept of "intrinsicContentSize", which indicates the size a view would like to be
// based on its content. When an intrinsicContentSize is set, iOS will automatically add Auto Layout
// constraints for the width and/or height. However, the constraints use a private API. There are
// situations where we may want to filter these constraints. To avoid using a private API, Flutter
// creates a custom constraint called FlutterAutoResizeLayoutConstraint to add a width/height
// constraint that reflects the intrinsicContentSize.
- (void)setIntrinsicContentSize:(CGSize)size {
if (!self.autoResizable) {
return;
}
UIWindow* window = self.window;
CGFloat scale = window ? self.window.windowScene.screen.scale : self.traitCollection.displayScale;
CGSize scaledSize = CGSizeMake(size.width / scale, size.height / scale);
CGSize roundedScaleSize = CGSizeMake(roundf(scaledSize.width), roundf(scaledSize.height));
CGSize roundedIntrinsicSize =
CGSizeMake(roundf(_intrinsicSize.width), roundf(_intrinsicSize.height));
// If the size has not changed, don't update constraints.
if (CGSizeEqualToSize(roundedIntrinsicSize, roundedScaleSize)) {
return;
}
_intrinsicSize = scaledSize;
self.translatesAutoresizingMaskIntoConstraints = false;
// Remove any existing FlutterAutoResizeLayoutConstraint
[self removeAutoResizeLayoutConstraints];
FlutterAutoResizeLayoutConstraint* widthConstraint =
[FlutterAutoResizeLayoutConstraint constraintWithItem:self
attribute:NSLayoutAttributeWidth
relatedBy:NSLayoutRelationEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:1.0
constant:scaledSize.width];
FlutterAutoResizeLayoutConstraint* heightConstraint =
[FlutterAutoResizeLayoutConstraint constraintWithItem:self
attribute:NSLayoutAttributeHeight
relatedBy:NSLayoutRelationEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:1.0
constant:scaledSize.height];
[NSLayoutConstraint activateConstraints:@[ widthConstraint, heightConstraint ]];
[self setNeedsLayout];
}
- (void)resetIntrinsicContentSize {
_intrinsicSize = CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric);
[self removeAutoResizeLayoutConstraints];
}
- (void)removeAutoResizeLayoutConstraints {
for (NSLayoutConstraint* constraint in self.constraints) {
if ([constraint isKindOfClass:[FlutterAutoResizeLayoutConstraint class]]) {
constraint.active = NO;
}
}
}
- (MTLPixelFormat)pixelFormat {
if ([self.layer isKindOfClass:[CAMetalLayer class]]) {
// It is a known Apple bug that CAMetalLayer incorrectly reports its supported
@ -79,6 +148,8 @@ FLUTTER_ASSERT_ARC
_delegate = delegate;
_isWideGamutEnabled = isWideGamutEnabled;
self.layer.opaque = opaque;
_autoResizable = NO;
_intrinsicSize = CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric);
}
return self;

View File

@ -101,7 +101,6 @@ typedef struct MouseState {
* Whether we should ignore viewport metrics updates during rotation transition.
*/
@property(nonatomic, assign) BOOL shouldIgnoreViewportMetricsUpdatesDuringRotation;
/**
* Keyboard animation properties
*/
@ -131,6 +130,10 @@ typedef struct MouseState {
/// the same with frame rate of rendering.
@property(nonatomic, strong) VSyncClient* touchRateCorrectionVSyncClient;
/// The size of the FlutterView's frame, as determined by auto-layout,
/// before Flutter's custom auto-resizing constraints are applied.
@property(nonatomic, assign) CGSize sizeBeforeAutoResized;
/*
* Mouse and trackpad gesture recognizers
*/
@ -1458,6 +1461,7 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch)
bool firstViewBoundsUpdate = !_viewportMetrics.physical_width;
_viewportMetrics.device_pixel_ratio = scale;
[self setViewportMetricsSize];
[self checkAndUpdateAutoResizeConstraints];
[self setViewportMetricsPaddings];
[self updateViewportMetricsIfNeeded];
@ -1486,6 +1490,89 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch)
}
}
- (BOOL)isAutoResizable {
return self.flutterView.autoResizable;
}
- (void)setAutoResizable:(BOOL)value {
self.flutterView.autoResizable = value;
self.flutterView.contentMode = UIViewContentModeCenter;
}
- (void)checkAndUpdateAutoResizeConstraints {
if (!self.isAutoResizable) {
return;
}
[self updateAutoResizeConstraints];
}
/**
* Updates the FlutterAutoResizeLayoutConstraints based on the view's
* current frame.
*
* This method is invoked during viewDidLayoutSubviews, at which point the
* view has completed its subview layout and applied any existing Auto Layout
* constraints.
*
* Initially, the view's frame is used to determine the maximum size allowed
* by the native layout system. This size is then used to establish the viewport
* constraints for the Flutter engine.
*
* A critical consideration is that this initial frame-based sizing is only
* applicable if FlutterAutoResizeLayoutConstraints have not yet been applied
* by Flutter. Once Flutter applies its own FlutterAutoResizeLayoutConstraints,
* these constraints will subsequently dictate the view's frame.
*
* This interaction imposes a limitation: native layout constraints that are
* updated after Flutter has applied its auto-resize constraints may not
* function as expected or properly influence the FlutterView's size.
*/
- (void)updateAutoResizeConstraints {
BOOL hasBeenAutoResized = NO;
for (NSLayoutConstraint* constraint in self.view.constraints) {
if ([constraint isKindOfClass:[FlutterAutoResizeLayoutConstraint class]]) {
hasBeenAutoResized = YES;
break;
}
}
if (!hasBeenAutoResized) {
self.sizeBeforeAutoResized = self.view.frame.size;
}
CGFloat maxWidth = self.sizeBeforeAutoResized.width;
CGFloat maxHeight = self.sizeBeforeAutoResized.height;
CGFloat minWidth = self.sizeBeforeAutoResized.width;
CGFloat minHeight = self.sizeBeforeAutoResized.height;
// maxWidth or maxHeight may be 0 when the width/height are ambiguous, eg. for
// unsized widgets
if (maxWidth == 0) {
maxWidth = CGFLOAT_MAX;
[FlutterLogger
logWarning:
@"Warning: The outermost widget in the autoresizable Flutter view is unsized or has "
@"ambiguous dimensions, causing the host native view's width to be 0. The autoresizing "
@"logic is setting the viewport constraint to unbounded DBL_MAX to prevent "
@"rendering failure. Please ensure your top-level Flutter widget has explicit "
@"constraints (e.g., using SizedBox or Container)."];
}
if (maxHeight == 0) {
maxHeight = CGFLOAT_MAX;
[FlutterLogger
logWarning:
@"Warning: The outermost widget in the autoresizable Flutter view is unsized or has "
@"ambiguous dimensions, causing the host native view's width to be 0. The autoresizing "
@"logic is setting the viewport constraint to unbounded DBL_MAX to prevent "
@"rendering failure. Please ensure your top-level Flutter widget has explicit "
@"constraints (e.g., using SizedBox or Container)."];
}
_viewportMetrics.physical_min_width_constraint = minWidth * _viewportMetrics.device_pixel_ratio;
_viewportMetrics.physical_max_width_constraint = maxWidth * _viewportMetrics.device_pixel_ratio;
_viewportMetrics.physical_min_height_constraint = minHeight * _viewportMetrics.device_pixel_ratio;
_viewportMetrics.physical_max_height_constraint = maxHeight * _viewportMetrics.device_pixel_ratio;
}
- (void)viewSafeAreaInsetsDidChange {
[self setViewportMetricsPaddings];
[self updateViewportMetricsIfNeeded];
@ -2195,6 +2282,12 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch)
- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
[self onUserSettingsChanged:nil];
// Since this method can get triggered by changes in device orientation, reset and recalculate the
// instrinsic size.
if (self.isAutoResizable) {
[self.flutterView resetIntrinsicContentSize];
}
}
- (void)onUserSettingsChanged:(NSNotification*)notification {

View File

@ -142,6 +142,8 @@ extern NSNotificationName const FlutterViewControllerWillDealloc;
nextAction:(void (^)())next API_AVAILABLE(ios(13.4));
- (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer;
- (void)updateViewportMetricsIfNeeded;
- (void)updateAutoResizeConstraints;
- (void)checkAndUpdateAutoResizeConstraints;
- (void)onUserSettingsChanged:(NSNotification*)notification;
- (void)applicationWillTerminate:(NSNotification*)notification;
- (void)goToApplicationLifecycle:(nonnull NSString*)state;
@ -997,6 +999,44 @@ extern NSNotificationName const FlutterViewControllerWillDealloc;
OCMVerifyAll(mockEngine);
}
- (void)testUpdatedViewportMetricsDoesResizeFlutterViewWhenAutoResizable {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* realVC = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
id mockVC = OCMPartialMock(realVC);
mockEngine.viewController = mockVC;
OCMExpect([mockVC updateAutoResizeConstraints]);
[mockVC setAutoResizable:YES];
[mockVC viewDidLayoutSubviews];
OCMVerifyAll(mockVC);
}
- (void)testUpdatedViewportMetricsDoesNotResizeFlutterViewWhenNotAutoResizable {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* realVC = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
id mockVC = OCMPartialMock(realVC);
mockEngine.viewController = mockVC;
OCMReject([mockVC updateAutoResizeConstraints]);
[mockVC setAutoResizable:NO];
[mockVC viewDidLayoutSubviews];
OCMVerifyAll(mockVC);
}
- (void)testUpdateViewportMetricsIfNeeded_DoesNotInvokeEngineWhenShouldBeIgnoredDuringRotation {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
@ -1279,6 +1319,36 @@ extern NSNotificationName const FlutterViewControllerWillDealloc;
return mockTraitCollection;
}
- (void)testTraitCollectionDidChangeCallsResetIntrinsicContentSizeWhenAutoResizable {
// Setup test.
id mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* realVC = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
id partialMockVC = OCMPartialMock(realVC);
id mockFlutterView = OCMClassMock([FlutterView class]);
OCMStub([partialMockVC flutterView]).andReturn(mockFlutterView);
// Ensure isAutoResizable is YES
OCMStub([partialMockVC isAutoResizable]).andReturn(YES);
// Expect resetIntrinsicContentSize to be called on mockFlutterView
OCMExpect([mockFlutterView resetIntrinsicContentSize]);
// Exercise behavior under test.
[partialMockVC traitCollectionDidChange:nil];
// Verify behavior.
OCMVerifyAll(mockFlutterView);
// Clean up mocks
[partialMockVC stopMocking];
[mockFlutterView stopMocking];
}
#pragma mark - Platform Contrast
- (void)testItReportsNormalPlatformContrastByDefault {