diff --git a/dev/devicelab/bin/tasks/module_test_ios.dart b/dev/devicelab/bin/tasks/module_test_ios.dart index bd89dcfe162..de412c5247d 100644 --- a/dev/devicelab/bin/tasks/module_test_ios.dart +++ b/dev/devicelab/bin/tasks/module_test_ios.dart @@ -54,6 +54,9 @@ Future 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'; diff --git a/dev/integration_tests/ios_host_app/FlutterUITests/FlutterUITests.m b/dev/integration_tests/ios_host_app/FlutterUITests/FlutterUITests.m index 91f9cc890cd..cf45751408c 100644 --- a/dev/integration_tests/ios_host_app/FlutterUITests/FlutterUITests.m +++ b/dev/integration_tests/ios_host_app/FlutterUITests/FlutterUITests.m @@ -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]; diff --git a/dev/integration_tests/ios_host_app/Host.xcodeproj/project.pbxproj b/dev/integration_tests/ios_host_app/Host.xcodeproj/project.pbxproj index f782c3aa32a..bf390e4896f 100644 --- a/dev/integration_tests/ios_host_app/Host.xcodeproj/project.pbxproj +++ b/dev/integration_tests/ios_host_app/Host.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 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 = ""; }; + F2D997DD2EA8BCB0002FC7EC /* DynamicResizingViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DynamicResizingViewController.m; sourceTree = ""; }; F7C2660E24AC18D00085742D /* DualFlutterViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DualFlutterViewController.m; sourceTree = ""; }; F7C2660F24AC18D00085742D /* FullScreenViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FullScreenViewController.m; sourceTree = ""; }; F7C2661024AC18D00085742D /* HybridViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = HybridViewController.h; sourceTree = ""; }; @@ -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 */, diff --git a/dev/integration_tests/ios_host_app/Host/DynamicResizingViewController.h b/dev/integration_tests/ios_host_app/Host/DynamicResizingViewController.h new file mode 100644 index 00000000000..3e02dd71208 --- /dev/null +++ b/dev/integration_tests/ios_host_app/Host/DynamicResizingViewController.h @@ -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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface DynamicResizingViewController : UIViewController + +@property (readonly, strong, nonatomic) FlutterViewController* flutterViewController; + +@end + +NS_ASSUME_NONNULL_END diff --git a/dev/integration_tests/ios_host_app/Host/DynamicResizingViewController.m b/dev/integration_tests/ios_host_app/Host/DynamicResizingViewController.m new file mode 100644 index 00000000000..ee832d646a5 --- /dev/null +++ b/dev/integration_tests/ios_host_app/Host/DynamicResizingViewController.m @@ -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 + +#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 diff --git a/dev/integration_tests/ios_host_app/Host/MainViewController.m b/dev/integration_tests/ios_host_app/Host/MainViewController.m index f2c1c66096b..2f5796de3c6 100644 --- a/dev/integration_tests/ios_host_app/Host/MainViewController.m +++ b/dev/integration_tests/ios_host_app/Host/MainViewController.m @@ -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]; diff --git a/dev/integration_tests/ios_host_app/flutterapp/lib/main b/dev/integration_tests/ios_host_app/flutterapp/lib/main index 87557d41728..066ce1c0e3e 100644 --- a/dev/integration_tests/ios_host_app/flutterapp/lib/main +++ b/dev/integration_tests/ios_host_app/flutterapp/lib/main @@ -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 run(String? name) async { case purpleMarqueeRouteName: runApp(Marquee(color: Colors.purple[400])); break; + case resizeRouteName: + runApp(ResizeApp()); + break; case fullscreenRouteName: case hybridRouteName: default: diff --git a/dev/integration_tests/ios_host_app/flutterapp/lib/resize b/dev/integration_tests/ios_host_app/flutterapp/lib/resize new file mode 100644 index 00000000000..6e53646991b --- /dev/null +++ b/dev/integration_tests/ios_host_app/flutterapp/lib/resize @@ -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 createState() => _ResizeAppState(); +} + +class _ResizeAppState extends State { + 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: [ + for (int i = 0; i < _listSize; i++) + Container(color: HSVColor.fromAHSV(1, (10.0 * i), 1, 1).toColor(), height: 100), + ], + ), + ), + ), + ); + } +} diff --git a/engine/src/flutter/fml/task_runner.cc b/engine/src/flutter/fml/task_runner.cc index 8920a8f73ab..1e869243e14 100644 --- a/engine/src/flutter/fml/task_runner.cc +++ b/engine/src/flutter/fml/task_runner.cc @@ -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 { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h index 19fe17d0ce9..a92b8402272 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h @@ -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 diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterMetalLayer.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterMetalLayer.mm index a291fda8c71..00c0e807b28 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterMetalLayer.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterMetalLayer.mm @@ -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]; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsController.mm index 8fdaf2fb76c..91ffc6c4aac 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsController.mm @@ -665,8 +665,22 @@ static CGRect GetCGRectFromDlRect(const DlRect& clipDlRect) { withIosContext:(const std::shared_ptr&)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> 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&)currentCompositionParams diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h index 7f3a32ed63f..fdaf8b9e266 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h @@ -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_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm index 76ee723f924..a3756745694 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm @@ -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; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 371611319d9..48480ec61cd 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -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 { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 97d105f4422..66614c12163 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -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 {