diff --git a/.gitignore b/.gitignore index 157646e..8a0669b 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ fastlane/test_output iOSInjectionProject/ .build +.tmp diff --git a/CHANGELOG b/CHANGELOG index 085c3c6..cff5987 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,13 @@ +## 0.5.0 + +1. 新增 `ZDFlexLayoutAsyncMode` 枚举,支持三种布局计算模式:同步、RunLoop Idle、后台线程 +2. 后台线程模式采用"缓存侧表"方案:主线程预测量 → 后台纯数值计算 → 主线程刷新,不污染 YGNode style +3. 新增 `applyLayoutWithAsyncMode:preservingOrigin:dimensionFlexibility:` 统一异步布局 API +4. 新增 `useLegacyPreMeasure` 属性,可切换回旧版预测量实现作为备用 +5. 修复 RunLoop Idle 模式下 weakSelf 未做 nil 判断的问题 +6. 文本测量统一使用 TextKit(`NSTextStorage + NSLayoutManager + NSTextContainer`),支持多行文本在后台线程正确计算高度 +7. 修复 `isUIView`(`isMemberOfClass:`)导致 UILabel 等子类走错测量分支的问题 + ## 0.4.0 1. `Yoga`更新到`0.3.2.1` diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 9de6001..7e2a81e 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -26,8 +26,10 @@ FA843035234F4234000F7E35 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FA843033234F4234000F7E35 /* LaunchScreen.storyboard */; }; FA843038234F4234000F7E35 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843037234F4234000F7E35 /* main.m */; }; FA843042234F4234000F7E35 /* DemoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843041234F4234000F7E35 /* DemoTests.m */; }; + FA843042234F4234000F7E36 /* AsyncLayoutTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA843041234F4234000F7E36 /* AsyncLayoutTests.m */; }; FA84304D234F4234000F7E35 /* DemoUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = FA84304C234F4234000F7E35 /* DemoUITests.m */; }; FA90B083237D4D2600DBDB77 /* NormalLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */; }; + FA90B0A0237D4D2600DBDB77 /* AsyncLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */; }; FA90B086237D4D3E00DBDB77 /* ScrollViewLayoutController.m in Sources */ = {isa = PBXBuildFile; fileRef = FA90B085237D4D3E00DBDB77 /* ScrollViewLayoutController.m */; }; FACF10A425B70AEC00C91DA3 /* Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACF10A325B70AEC00C91DA3 /* Empty.swift */; }; FAEA4A0F235F0F6000422C35 /* same_city_users.json in Resources */ = {isa = PBXBuildFile; fileRef = FAEA4A0E235F0F6000422C35 /* same_city_users.json */; }; @@ -94,12 +96,15 @@ FA843037234F4234000F7E35 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; FA84303D234F4234000F7E35 /* DemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FA843041234F4234000F7E35 /* DemoTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DemoTests.m; sourceTree = ""; }; + FA843041234F4234000F7E36 /* AsyncLayoutTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AsyncLayoutTests.m; sourceTree = ""; }; FA843043234F4234000F7E35 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA843048234F4234000F7E35 /* DemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FA84304C234F4234000F7E35 /* DemoUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DemoUITests.m; sourceTree = ""; }; FA84304E234F4234000F7E35 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FA90B081237D4D2600DBDB77 /* NormalLayoutController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NormalLayoutController.h; sourceTree = ""; }; FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NormalLayoutController.m; sourceTree = ""; }; + FA90B09E237D4D2600DBDB77 /* AsyncLayoutController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AsyncLayoutController.h; sourceTree = ""; }; + FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AsyncLayoutController.m; sourceTree = ""; }; FA90B084237D4D3E00DBDB77 /* ScrollViewLayoutController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScrollViewLayoutController.h; sourceTree = ""; }; FA90B085237D4D3E00DBDB77 /* ScrollViewLayoutController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ScrollViewLayoutController.m; sourceTree = ""; }; FACF10A325B70AEC00C91DA3 /* Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Empty.swift; sourceTree = ""; }; @@ -245,6 +250,7 @@ isa = PBXGroup; children = ( FA843041234F4234000F7E35 /* DemoTests.m */, + FA843041234F4234000F7E36 /* AsyncLayoutTests.m */, FA843043234F4234000F7E35 /* Info.plist */, ); path = DemoTests; @@ -266,6 +272,8 @@ FA90B082237D4D2600DBDB77 /* NormalLayoutController.m */, FA90B084237D4D3E00DBDB77 /* ScrollViewLayoutController.h */, FA90B085237D4D3E00DBDB77 /* ScrollViewLayoutController.m */, + FA90B09E237D4D2600DBDB77 /* AsyncLayoutController.h */, + FA90B09F237D4D2600DBDB77 /* AsyncLayoutController.m */, ); path = LayoutDemo; sourceTree = ""; @@ -557,6 +565,7 @@ FAEA4A13235F0FD400422C35 /* SameCityUserListController.m in Sources */, FAEA4A16235F0FF600422C35 /* SameCityUserListViewModel.m in Sources */, FA90B083237D4D2600DBDB77 /* NormalLayoutController.m in Sources */, + FA90B0A0237D4D2600DBDB77 /* AsyncLayoutController.m in Sources */, FAEA4A19235F104A00422C35 /* UserModel.m in Sources */, FA617C0C23572AA100477C31 /* ZDFlexCell.m in Sources */, FA1A54DF2355ECE400C80712 /* YogaKitListViewModel.m in Sources */, @@ -575,6 +584,7 @@ buildActionMask = 2147483647; files = ( FA843042234F4234000F7E35 /* DemoTests.m in Sources */, + FA843042234F4234000F7E36 /* AsyncLayoutTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h new file mode 100644 index 0000000..7df9f9c --- /dev/null +++ b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.h @@ -0,0 +1,17 @@ +// +// AsyncLayoutController.h +// Demo +// +// Created by Zero.D.Saber on 2024/12/1. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Demonstrates ZDFlexLayoutAsyncMode: Sync, RunLoop Idle, and BackgroundThread modes. +@interface AsyncLayoutController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m new file mode 100644 index 0000000..2502330 --- /dev/null +++ b/Demo/Demo/Sample/LayoutDemo/AsyncLayoutController.m @@ -0,0 +1,261 @@ +// +// AsyncLayoutController.m +// Demo +// +// Created by Zero.D.Saber on 2024/12/1. +// + +#import "AsyncLayoutController.h" +@import ZDFlexLayoutKit; + +@interface AsyncLayoutController () + +@property (nonatomic, strong) UIView *syncContainer; +@property (nonatomic, strong) UIView *idleContainer; +@property (nonatomic, strong) UIView *asyncContainer; +@property (nonatomic, strong) UISegmentedControl *modeSegment; + +@end + +@implementation AsyncLayoutController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Async Layout Demo"; + self.view.backgroundColor = UIColor.whiteColor; + + [self setupModeSelector]; + [self buildLayoutWithMode:ZDFlexLayoutAsyncModeSync]; +} + +#pragma mark - Mode Selector + +- (void)setupModeSelector { + self.modeSegment = [[UISegmentedControl alloc] initWithItems:@[@"Sync", @"RunLoop Idle", @"Background Thread"]]; + self.modeSegment.selectedSegmentIndex = 0; + [self.modeSegment addTarget:self action:@selector(modeChanged:) forControlEvents:UIControlEventValueChanged]; + self.modeSegment.frame = CGRectMake(20, 120, self.view.bounds.size.width - 40, 36); + [self.view addSubview:self.modeSegment]; +} + +- (void)modeChanged:(UISegmentedControl *)sender { + [self clearLayout]; + ZDFlexLayoutAsyncMode mode; + switch (sender.selectedSegmentIndex) { + case 0: mode = ZDFlexLayoutAsyncModeSync; break; + case 1: mode = ZDFlexLayoutAsyncModeRunloopIdle; break; + case 2: mode = ZDFlexLayoutAsyncModeBackgroundThread; break; + default: mode = ZDFlexLayoutAsyncModeSync; break; + } + [self buildLayoutWithMode:mode]; +} + +#pragma mark - Layout Construction + +- (void)clearLayout { + [self.syncContainer removeFromSuperview]; + [self.idleContainer removeFromSuperview]; + [self.asyncContainer removeFromSuperview]; + self.syncContainer = nil; + self.idleContainer = nil; + self.asyncContainer = nil; +} + +- (void)buildLayoutWithMode:(ZDFlexLayoutAsyncMode)mode { + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 160, self.view.bounds.size.width, self.view.bounds.size.height - 160)]; + container.backgroundColor = UIColor.systemGroupedBackgroundColor; + [self.view addSubview:container]; + + // Root flex container + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(16)); + }]; + + // Title label + UILabel *titleLabel = [self makeLabelWithText:[self titleForMode:mode] fontSize:18 color:UIColor.blackColor]; + [titleLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(12)); + }]; + [container addChild:titleLabel]; + + // Card 1: UILabel measurement + UIView *card1 = [self makeCardWithTitle:@"UILabel (text measurement)" + content:@"This label demonstrates that text content is correctly measured in async mode without accessing UIKit on the background thread."]; + [container addChild:card1]; + + // Card 2: UIImageView measurement + UIView *card2 = [self makeImageCard]; + [container addChild:card2]; + + // Card 3: Custom UIView with sizeThatFits + UIView *card3 = [self makeCustomViewCard]; + [container addChild:card3]; + + // Card 4: Mixed layout with flexGrow + UIView *card4 = [self makeFlexGrowCard]; + [container addChild:card4]; + + // Apply layout with selected mode + [container.flexLayout applyLayoutWithAsyncMode:mode + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + switch (mode) { + case ZDFlexLayoutAsyncModeSync: + self.syncContainer = container; + break; + case ZDFlexLayoutAsyncModeRunloopIdle: + self.idleContainer = container; + break; + case ZDFlexLayoutAsyncModeBackgroundThread: + self.asyncContainer = container; + break; + } +} + +#pragma mark - Card Builders + +- (UIView *)makeCardWithTitle:(NSString *)title content:(NSString *)content { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(12)); + make.marginBottom(YGPointValue(12)); + }]; + + UILabel *titleLabel = [self makeLabelWithText:title fontSize:14 color:UIColor.darkGrayColor]; + [titleLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(6)); + }]; + [card addChild:titleLabel]; + + UILabel *contentLabel = [self makeLabelWithText:content fontSize:13 color:UIColor.grayColor]; + contentLabel.numberOfLines = 0; + [contentLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [card addChild:contentLabel]; + + return card; +} + +- (UIView *)makeImageCard { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.alignItems(YGAlignCenter); + make.padding(YGPointValue(12)); + make.marginBottom(YGPointValue(12)); + }]; + + UIImageView *imageView = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"photo.fill"]]; + imageView.tintColor = UIColor.systemBlueColor; + imageView.contentMode = UIViewContentModeScaleAspectFit; + [imageView zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(40)).height(YGPointValue(40)); + make.marginRight(YGPointValue(12)); + }]; + [card addChild:imageView]; + + UILabel *label = [self makeLabelWithText:@"UIImageView with explicit size (40x40)" fontSize:13 color:UIColor.darkGrayColor]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexShrink(1); + }]; + [card addChild:label]; + + return card; +} + +- (UIView *)makeCustomViewCard { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(12)); + make.marginBottom(YGPointValue(12)); + }]; + + UILabel *titleLabel = [self makeLabelWithText:@"Custom views with sizeThatFits:" fontSize:14 color:UIColor.darkGrayColor]; + [titleLabel zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(8)); + }]; + [card addChild:titleLabel]; + + UISwitch *toggle = UISwitch.new; + [toggle zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [card addChild:toggle]; + + return card; +} + +- (UIView *)makeFlexGrowCard { + UIView *card = UIView.new; + card.backgroundColor = UIColor.whiteColor; + card.layer.cornerRadius = 8; + card.clipsToBounds = YES; + [card zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.padding(YGPointValue(8)); + make.marginBottom(YGPointValue(12)); + make.height(YGPointValue(60)); + }]; + + NSArray *colors = @[UIColor.systemRedColor, UIColor.systemGreenColor, UIColor.systemBlueColor]; + NSArray *grows = @[@1, @2, @1]; + + for (NSInteger i = 0; i < 3; i++) { + UIView *bar = UIView.new; + bar.backgroundColor = colors[i]; + bar.layer.cornerRadius = 4; + CGFloat grow = [grows[i] floatValue]; + [bar zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(grow); + make.marginHorizontal(YGPointValue(4)); + }]; + [card addChild:bar]; + } + + return card; +} + +#pragma mark - Helpers + +- (UILabel *)makeLabelWithText:(NSString *)text fontSize:(CGFloat)size color:(UIColor *)color { + UILabel *label = UILabel.new; + label.text = text; + label.font = [UIFont systemFontOfSize:size]; + label.textColor = color; + label.numberOfLines = 0; + return label; +} + +- (NSString *)titleForMode:(ZDFlexLayoutAsyncMode)mode { + switch (mode) { + case ZDFlexLayoutAsyncModeSync: return @"Mode: Sync (immediate)"; + case ZDFlexLayoutAsyncModeRunloopIdle: return @"Mode: RunLoop Idle"; + case ZDFlexLayoutAsyncModeBackgroundThread: return @"Mode: Background Thread"; + } +} + +@end diff --git a/Demo/Demo/ViewController.m b/Demo/Demo/ViewController.m index e05a784..b515ddc 100644 --- a/Demo/Demo/ViewController.m +++ b/Demo/Demo/ViewController.m @@ -7,6 +7,7 @@ // #import "ViewController.h" +#import "AsyncLayoutController.h" //#import @import ZDFlexLayoutKit; @@ -18,7 +19,21 @@ @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; - // Do any additional setup after loading the view. + + UIButton *asyncBtn = [UIButton buttonWithType:UIButtonTypeSystem]; + asyncBtn.frame = CGRectMake(43, 430, 343, 50); + asyncBtn.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin; + asyncBtn.backgroundColor = UIColor.systemGreenColor; + asyncBtn.titleLabel.font = [UIFont systemFontOfSize:18]; + [asyncBtn setTitle:@"AsyncLayout" forState:UIControlStateNormal]; + [asyncBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal]; + [asyncBtn addTarget:self action:@selector(pushAsyncLayout) forControlEvents:UIControlEventTouchUpInside]; + [self.view addSubview:asyncBtn]; +} + +- (void)pushAsyncLayout { + AsyncLayoutController *vc = [[AsyncLayoutController alloc] init]; + [self.navigationController pushViewController:vc animated:YES]; } @end diff --git a/Demo/DemoTests/AsyncLayoutTests.m b/Demo/DemoTests/AsyncLayoutTests.m new file mode 100644 index 0000000..a39c8b5 --- /dev/null +++ b/Demo/DemoTests/AsyncLayoutTests.m @@ -0,0 +1,623 @@ +// +// AsyncLayoutTests.m +// DemoTests +// +// Created by Zero.D.Saber on 2024/12/1. +// + +#import +#import "ZDFlexLayoutKit.h" + +@interface AsyncLayoutTests : XCTestCase + +@property (nonatomic, strong) UIView *rootView; + +@end + +@implementation AsyncLayoutTests + +- (void)setUp { + [super setUp]; + self.rootView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; +} + +- (void)tearDown { + self.rootView = nil; + [super tearDown]; +} + +#pragma mark - Sync Mode Tests + +- (void)testSyncMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)).height(YGPointValue(50)); + }]; + [container addChild:child]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTAssertEqual(child.frame.size.width, 100); + XCTAssertEqual(child.frame.size.height, 50); + XCTAssertEqual(child.frame.origin.x, 0); + XCTAssertEqual(child.frame.origin.y, 0); +} + +- (void)testSyncMode_FlexGrow { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + }]; + + UIView *child1 = UIView.new; + [child1 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(1); + }]; + [container addChild:child1]; + + UIView *child2 = UIView.new; + [child2 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(2); + }]; + [container addChild:child2]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTAssertEqual(child1.frame.size.width, 125); + XCTAssertEqual(child2.frame.size.width, 250); +} + +- (void)testSyncMode_LabelMeasurement { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UILabel *label = UILabel.new; + label.text = @"Hello World"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTAssertGreaterThan(label.frame.size.width, 0); + XCTAssertGreaterThan(label.frame.size.height, 0); +} + +#pragma mark - RunLoop Idle Mode Tests + +- (void)testRunloopIdleMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(200)).height(YGPointValue(100)); + }]; + [container addChild:child]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeRunloopIdle + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + // RunLoop idle: layout is deferred. Run the runloop to trigger execution. + XCTestExpectation *expectation = [self expectationWithDescription:@"RunLoop idle layout applied"]; + dispatch_async(dispatch_get_main_queue(), ^{ + // After one runloop cycle, the observer should have fired + dispatch_async(dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + }); + + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child.frame.size.width, 200); + XCTAssertEqual(child.frame.size.height, 100); +} + +- (void)testRunloopIdleMode_MultipleChildren { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child1 = UIView.new; + [child1 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(375)).height(YGPointValue(44)); + }]; + [container addChild:child1]; + + UIView *child2 = UIView.new; + [child2 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(375)).height(YGPointValue(88)); + }]; + [container addChild:child2]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeRunloopIdle + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"RunLoop idle multi-child"]; + dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child1.frame.origin.y, 0); + XCTAssertEqual(child1.frame.size.height, 44); + XCTAssertEqual(child2.frame.origin.y, 44); + XCTAssertEqual(child2.frame.size.height, 88); +} + +#pragma mark - Background Thread Mode Tests + +- (void)testBackgroundThreadMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)).height(YGPointValue(50)); + }]; + [container addChild:child]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background thread layout"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child.frame.size.width, 100); + XCTAssertEqual(child.frame.size.height, 50); +} + +- (void)testBackgroundThreadMode_LabelMeasurement { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.width(YGPointValue(375)); + }]; + + UILabel *label = UILabel.new; + label.text = @"Async layout test label"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background label measurement"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertGreaterThan(label.frame.size.width, 0); + XCTAssertGreaterThan(label.frame.size.height, 0); +} + +- (void)testBackgroundThreadMode_ImageViewMeasurement { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIGraphicsBeginImageContext(CGSizeMake(60, 40)); + UIImage *testImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + UIImageView *imageView = [[UIImageView alloc] initWithImage:testImage]; + [imageView zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:imageView]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background imageview measurement"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(imageView.frame.size.width, 60); + XCTAssertEqual(imageView.frame.size.height, 40); +} + +- (void)testBackgroundThreadMode_CustomViewSizeThatFits { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UISwitch *toggle = UISwitch.new; + [toggle zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:toggle]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background custom view"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertGreaterThan(toggle.frame.size.width, 0); + XCTAssertGreaterThan(toggle.frame.size.height, 0); +} + +- (void)testBackgroundThreadMode_FlexGrow { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + }]; + + UIView *child1 = UIView.new; + [child1 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(1).height(YGPointValue(50)); + }]; + [container addChild:child1]; + + UIView *child2 = UIView.new; + [child2 zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexGrow(2).height(YGPointValue(50)); + }]; + [container addChild:child2]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background flexGrow"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child1.frame.size.width, 125); + XCTAssertEqual(child2.frame.size.width, 250); +} + +#pragma mark - Consistency Tests (Sync vs Async produce same results) + +- (void)testConsistency_SyncAndBackgroundProduceSameFrames { + // Build identical layout trees and compare results + UIView *(^buildTree)(void) = ^{ + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + make.padding(YGPointValue(16)); + }]; + + UILabel *label = UILabel.new; + label.text = @"Test consistency"; + label.font = [UIFont systemFontOfSize:14]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.marginBottom(YGPointValue(8)); + }]; + [container addChild:label]; + + UIView *row = UIView.new; + [row zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.height(YGPointValue(80)); + }]; + [container addChild:row]; + + UIView *red = UIView.new; + [red zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexGrow(1); + }]; + [row addChild:red]; + + UIView *blue = UIView.new; + [blue zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexGrow(2); + }]; + [row addChild:blue]; + + return container; + }; + + // Sync calculation + UIView *syncContainer = buildTree(); + [syncContainer.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + // Async calculation + UIView *asyncContainer = buildTree(); + [asyncContainer.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Async done"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + // Compare: label frames + UILabel *syncLabel = (UILabel *)syncContainer.children.firstObject; + UILabel *asyncLabel = (UILabel *)asyncContainer.children.firstObject; + XCTAssertTrue(CGRectEqualToRect(syncLabel.frame, asyncLabel.frame), + @"Label frames differ: sync=%@ async=%@", + NSStringFromCGRect(syncLabel.frame), NSStringFromCGRect(asyncLabel.frame)); + + // Compare: row container frames + UIView *syncRow = (UIView *)syncContainer.children[1]; + UIView *asyncRow = (UIView *)asyncContainer.children[1]; + XCTAssertTrue(CGRectEqualToRect(syncRow.frame, asyncRow.frame), + @"Row frames differ: sync=%@ async=%@", + NSStringFromCGRect(syncRow.frame), NSStringFromCGRect(asyncRow.frame)); +} + +#pragma mark - Style Pollution Tests + +- (void)testBackgroundThreadMode_DoesNotPollutNodeStyle { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UILabel *label = UILabel.new; + label.text = @"Style pollution test"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + // Record original style + YGValue widthBefore = label.flexLayout.width; + YGValue heightBefore = label.flexLayout.height; + + // Run background layout + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Style pollution check"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + // Verify style is NOT polluted + YGValue widthAfter = label.flexLayout.width; + YGValue heightAfter = label.flexLayout.height; + + XCTAssertEqual(widthBefore.unit, widthAfter.unit, @"Width unit changed after async layout"); + XCTAssertEqual(widthBefore.value, widthAfter.value, @"Width value changed after async layout"); + XCTAssertEqual(heightBefore.unit, heightAfter.unit, @"Height unit changed after async layout"); + XCTAssertEqual(heightBefore.value, heightAfter.value, @"Height value changed after async layout"); +} + +- (void)testBackgroundThreadMode_SubsequentSyncLayoutWorks { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UILabel *label = UILabel.new; + label.text = @"First pass"; + label.font = [UIFont systemFontOfSize:16]; + [label zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + }]; + [container addChild:label]; + + // First: background async + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"First async done"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation1 fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + CGRect frameAfterAsync = label.frame; + XCTAssertGreaterThan(frameAfterAsync.size.width, 0); + + // Second: change text and recalculate synchronously + label.text = @"Second pass with longer text content here"; + [label.flexLayout markDirty]; + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + CGRect frameAfterSync = label.frame; + XCTAssertGreaterThan(frameAfterSync.size.width, frameAfterAsync.size.width, + @"Sync layout after async should reflect new longer text"); +} + +#pragma mark - Legacy Mode Tests + +- (void)testLegacyPreMeasureMode_BasicLayout { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)).height(YGPointValue(50)); + }]; + [container addChild:child]; + + container.flexLayout.useLegacyPreMeasure = YES; + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Legacy mode layout"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(child.frame.size.width, 100); + XCTAssertEqual(child.frame.size.height, 50); +} + +#pragma mark - Virtual View (ZDFlexLayoutDiv) Tests + +- (void)testBackgroundThreadMode_VirtualView { + UIView *container = self.rootView; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionColumn); + }]; + + ZDFlexLayoutDiv *div = ZDFlexLayoutDiv.new; + [div zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.flexDirection(YGFlexDirectionRow); + make.height(YGPointValue(60)); + }]; + [container addChild:div]; + + UIView *left = UIView.new; + [left zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.width(YGPointValue(100)); + }]; + [div addChild:left]; + + UIView *right = UIView.new; + [right zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexGrow(1); + }]; + [div addChild:right]; + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleNone]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background virtual view"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + + XCTAssertEqual(left.frame.size.width, 100); + XCTAssertEqual(left.frame.size.height, 60); + XCTAssertEqual(right.frame.size.width, 275); + XCTAssertEqual(right.frame.size.height, 60); +} + +#pragma mark - Performance Tests + +- (void)testPerformance_SyncMode { + [self measureBlock:^{ + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexDirection(YGFlexDirectionColumn); + }]; + + for (int i = 0; i < 100; i++) { + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.height(YGPointValue(44)); + }]; + [container addChild:child]; + } + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + }]; +} + +- (void)testPerformance_BackgroundThreadMode { + [self measureBlock:^{ + UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 375, 667)]; + [container zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES).flexDirection(YGFlexDirectionColumn); + }]; + + for (int i = 0; i < 100; i++) { + UIView *child = UIView.new; + [child zd_makeFlexLayout:^(ZDFlexLayoutMaker * _Nonnull make) { + make.isEnabled(YES); + make.height(YGPointValue(44)); + }]; + [container addChild:child]; + } + + [container.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"perf"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2.0 handler:nil]; + }]; +} + +@end diff --git a/README.md b/README.md index 1ded778..84ea9ef 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ + 链式调用 -+ 异步计算 ++ 异步计算(支持多种模式) + 自动更新布局 @@ -21,6 +21,45 @@ > PS:开启自动更新布局后,在布局发生改变需要更新时需要手动调用 `markDirty` ,`gone` 不需要调用 `markDirty` ,它内部会自己处理 +## Async Layout + +支持三种异步布局模式,通过 `ZDFlexLayoutAsyncMode` 枚举控制: + +| 模式 | 说明 | +|------|------| +| `ZDFlexLayoutAsyncModeSync` | 同步计算并刷新(默认) | +| `ZDFlexLayoutAsyncModeRunloopIdle` | 延迟到主线程 RunLoop 空闲时计算并刷新 | +| `ZDFlexLayoutAsyncModeBackgroundThread` | 后台线程计算,主线程刷新 | + +### 后台线程模式原理 + +采用"主线程预测量 + 缓存侧表 + 线程安全 measure func"三阶段方案,借鉴 ReactNative 的设计思路: + +1. **Phase 1(主线程)**:遍历叶子节点,捕获测量所需信息存入缓存侧表(文本节点捕获 `NSTextStorage`,其他节点调用 `sizeThatFits:`),不修改 YGNode style +2. **Phase 2(后台线程)**:Yoga 计算,文本节点使用 TextKit(`NSLayoutManager + NSTextContainer`)在 Yoga 提供的真实宽度约束下测量,其他节点返回预存尺寸 +3. **Phase 3(主线程)**:应用 frame、恢复 measure 函数、清除缓存 + +### 使用示例 + +```objc +// 后台线程异步计算 +[view.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeBackgroundThread + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + +// RunLoop 空闲时计算 +[view.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeRunloopIdle + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; + +// 同步计算(等同于 applyLayoutPreservingOrigin:) +[view.flexLayout applyLayoutWithAsyncMode:ZDFlexLayoutAsyncModeSync + preservingOrigin:YES + dimensionFlexibility:ZDDimensionFlexibilityFlexibleHeight]; +``` + +> 可通过设置 `flexLayout.useLegacyPreMeasure = YES` 切换到旧版预测量实现(直接修改 YGNode style),作为备用方案。 + ## Install ```ruby diff --git a/Sources/Core/Private/ZDCalculateHelper.m b/Sources/Core/Private/ZDCalculateHelper.m index 96497e4..7faaf05 100644 --- a/Sources/Core/Private/ZDCalculateHelper.m +++ b/Sources/Core/Private/ZDCalculateHelper.m @@ -19,11 +19,11 @@ static void zd_init(void) { if (!_asyncTaskQueue) { _asyncTaskQueue = [[NSMutableOrderedSet alloc] init]; } - + if (!_asyncMainThreadQueue) { _asyncMainThreadQueue = [NSMutableOrderedSet orderedSet]; } - + if (!_taskGroup) { _taskGroup = dispatch_group_create(); } @@ -33,7 +33,7 @@ static void zd_lock(dispatch_block_t callback) { if (!callback) { return; } - + if (@available(iOS 10.0, *)) { static os_unfair_lock lock = OS_UNFAIR_LOCK_INIT; os_unfair_lock_lock(&lock); @@ -46,7 +46,7 @@ static void zd_lock(dispatch_block_t callback) { dispatch_once(&onceToken, ^{ lock = dispatch_semaphore_create(1); }); - + dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER); callback(); dispatch_semaphore_signal(lock); @@ -64,13 +64,13 @@ static dispatch_queue_t zd_calculate_queue(void) { __attribute__((__overloadable__)) static void zd_addAsyncTaskBlockWithCompleteCallback(dispatch_block_t task, dispatch_block_t complete) { if (task == nil) return; - + zd_init(); - + dispatch_group_enter(_taskGroup); dispatch_async(zd_calculate_queue(), ^{ task(); - + zd_lock(^{ [_asyncTaskQueue addObject:^{ dispatch_group_leave(_taskGroup); @@ -89,9 +89,9 @@ static dispatch_queue_t zd_calculate_queue(void) { __attribute__((__overloadable__)) static void zd_addAsyncTaskBlockWithCompleteCallback(NSArray *tasks, dispatch_block_t allComplete) { if (tasks == nil || tasks.count == 0) return; - + zd_init(); - + dispatch_group_enter(_taskGroup); dispatch_async(zd_calculate_queue(), ^{ for (dispatch_block_t task in tasks) { @@ -107,7 +107,7 @@ static dispatch_queue_t zd_calculate_queue(void) { }); } -__unused static void zd_executeAsyncTasks(void) { +static void zd_executeAsyncTasks(void) { zd_lock(^{ // onComplete block for (dispatch_block_t task in _asyncTaskQueue) { @@ -135,12 +135,12 @@ static void zd_sourceContextCallBackLog(void *info) { static void zd_initRunloop(void) { CFRunLoopRef runloop = CFRunLoopGetMain(); CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopBeforeWaiting | kCFRunLoopExit, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { - //zd_executeAsyncTasks(); + zd_executeAsyncTasks(); zd_executeMainThreadAsyncTasks(); }); CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes); CFRelease(observer); - + CFRunLoopSourceContext *sourceContext = calloc(1, sizeof(CFRunLoopSourceContext)); sourceContext->perform = zd_sourceContextCallBackLog; _runloopSource = CFRunLoopSourceCreate(CFAllocatorGetDefault(), 0, sourceContext); @@ -153,7 +153,7 @@ static void zd_autoLayoutWhenIdle(dispatch_block_t layoutTask) { if (!layoutTask) { return; } - + zd_init(); [_asyncMainThreadQueue addObject:layoutTask]; } diff --git a/Sources/Core/Private/ZDFlexLayoutCore+Private.h b/Sources/Core/Private/ZDFlexLayoutCore+Private.h index feade19..abc5d44 100644 --- a/Sources/Core/Private/ZDFlexLayoutCore+Private.h +++ b/Sources/Core/Private/ZDFlexLayoutCore+Private.h @@ -14,4 +14,10 @@ - (instancetype)initWithView:(ZDFlexLayoutView)view; +/// Must be called on main thread — reads UIView.traitCollection +- (void)updateLayoutDirectionIfNeeded; + +/// Calculate layout with optional async mode (pre-measures leaf nodes, skips measure func) +- (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode; + @end diff --git a/Sources/Core/Public/ZDFlexLayoutCore.h b/Sources/Core/Public/ZDFlexLayoutCore.h index b342212..e5f6c8c 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.h +++ b/Sources/Core/Public/ZDFlexLayoutCore.h @@ -116,6 +116,13 @@ YG_EXTERN_C_END @property (nonatomic, readwrite, assign) YGValue columnGap; @property (nonatomic, readwrite, assign) YGValue allGap; +/** + When YES, background thread async mode uses the legacy pre-measure approach + that sets explicit width/height on YGNode style directly. + When NO (default), uses a cache side table approach that does not mutate YGNode style. + */ +@property (nonatomic, assign) BOOL useLegacyPreMeasure; + /** Get the resolved direction of this node. This won't be YGDirectionInherit */ @@ -167,6 +174,23 @@ YG_EXTERN_C_END preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size; +/** + Perform a layout calculation with the given async mode. + @param asyncMode Choose between sync, runloop idle, or background thread calculation. + @param preserveOrigin Whether to preserve the current origin. + @param dimensionFlexibility Which dimensions are flexible. + */ +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility; + +/** + Perform a layout calculation with the given async mode and a specific constraint size. + */ +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + constraintSize:(CGSize)size; + /** Returns the size of the view based on provided constraints. Pass NaN for an unconstrained dimension. */ diff --git a/Sources/Core/Public/ZDFlexLayoutCore.m b/Sources/Core/Public/ZDFlexLayoutCore.m index 795d721..895c028 100644 --- a/Sources/Core/Public/ZDFlexLayoutCore.m +++ b/Sources/Core/Public/ZDFlexLayoutCore.m @@ -115,23 +115,45 @@ - (void)set ## objc_capitalized_name: (YGValue)objc_lowercased_name YG_VALUE_EDGE_PROPERTY(lowercased_name ## Vertical, capitalized_name ## Vertical, capitalized_name, YGEdgeVertical) \ YG_VALUE_EDGE_PROPERTY(lowercased_name, capitalized_name, capitalized_name, YGEdgeAll) -__attribute__((weak)) YGValue YGPointValue(CGFloat value) -{ +__attribute__((weak)) YGValue YGPointValue(CGFloat value) { return (YGValue) { .value = value, .unit = YGUnitPoint }; } -__attribute__((weak)) YGValue YGPercentValue(CGFloat value) -{ - return (YGValue) { .value = value, .unit = YGUnitPercent }; +__attribute__((weak)) YGValue YGPercentValue(CGFloat value) { + return (YGValue) { .value = value, .unit = YGUnitPercent }; } static YGConfigRef globalConfig; +// --- Static function forward declarations --- +@class _ZDFLTextKitPool; + +static NSValue *ZDNodeKey(YGNodeConstRef node); +static void ZDMeasureCacheSetSize(NSMutableDictionary *cache, YGNodeRef node, CGSize size); +static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines); +static void ZDSetThreadAsyncCache(NSMutableDictionary *cache); +static NSMutableDictionary *ZDGetCurrentAsyncCache(void); +static _ZDFLTextKitPool *_ZDTextKitPool(void); +static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize); +static NSTextStorage *ZDTextStorageFromLabel(UILabel *label); +static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view); +static CGFloat YGSanitizeMeasurement(CGFloat constrainedSize, CGFloat measuredSize, YGMeasureMode measureMode); +static YGSize YGMeasureView(YGNodeConstRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode); +static YGSize YGCachedMeasureView(YGNodeConstRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode); +static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews); +static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyncMode); +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache); +static void YGRestoreMeasureFuncs(ZDFlexLayoutView const view); +static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin); + @interface ZDFlexLayoutCore () @property (nonatomic, weak, readwrite) ZDFlexLayoutView view; @property (nonatomic, assign, readonly) BOOL isUIView; +/// Per-pass measure cache, safe for concurrent async layouts of different roots. +@property (nonatomic, strong) NSMutableDictionary *asyncMeasureCache; + @end @implementation ZDFlexLayoutCore @@ -140,84 +162,75 @@ @implementation ZDFlexLayoutCore @synthesize isIncludedInLayout = _isIncludedInLayout; @synthesize node = _node; -+ (void)initialize -{ - globalConfig = YGConfigNew(); - YGConfigSetExperimentalFeatureEnabled(globalConfig, YGExperimentalFeatureWebFlexBasis, true); - YGConfigSetPointScaleFactor(globalConfig, [UIScreen mainScreen].scale); ++ (void)initialize { + globalConfig = YGConfigNew(); + YGConfigSetExperimentalFeatureEnabled(globalConfig, YGExperimentalFeatureWebFlexBasis, true); + YGConfigSetPointScaleFactor(globalConfig, [UIScreen mainScreen].scale); } -- (instancetype)initWithView:(ZDFlexLayoutView)view -{ - if (self = [super init]) { - _view = view; - _node = YGNodeNewWithConfig(globalConfig); - YGNodeSetContext(_node, (__bridge void *)view); - _isEnabled = NO; - _isIncludedInLayout = YES; - _isUIView = [view isMemberOfClass:[UIView class]]; - } - - return self; +- (instancetype)initWithView:(ZDFlexLayoutView)view { + if (self = [super init]) { + _view = view; + _node = YGNodeNewWithConfig(globalConfig); + YGNodeSetContext(_node, (__bridge void *)view); + _isEnabled = NO; + _isIncludedInLayout = YES; + _isUIView = [view isMemberOfClass:[UIView class]]; + } + + return self; } -- (void)dealloc -{ - YGNodeFree(self.node); +- (void)dealloc { + YGNodeFree(self.node); } -- (BOOL)isDirty -{ - return YGNodeIsDirty(self.node); +- (BOOL)isDirty { + return YGNodeIsDirty(self.node); } -- (void)markDirty -{ - if (self.isDirty || !self.isLeaf) { - return; - } - - // Yoga is not happy if we try to mark a node as "dirty" before we have set - // the measure function. Since we already know that this is a leaf, - // this *should* be fine. Forgive me Hack Gods. - const YGNodeRef node = self.node; - if (!YGNodeHasMeasureFunc(node)) { - YGNodeSetMeasureFunc(node, YGMeasureView); - } - - YGNodeMarkDirty(node); +- (void)markDirty { + + if (self.isDirty || !self.isLeaf) { + return; + } + + // Yoga is not happy if we try to mark a node as "dirty" before we have set + // the measure function. Since we already know that this is a leaf, + // this *should* be fine. Forgive me Hack Gods. + const YGNodeRef node = self.node; + if (!YGNodeHasMeasureFunc(node)) { + YGNodeSetMeasureFunc(node, YGMeasureView); + } + + YGNodeMarkDirty(node); } -- (NSUInteger)numberOfChildren -{ - return YGNodeGetChildCount(self.node); +- (NSUInteger)numberOfChildren { + return YGNodeGetChildCount(self.node); } -- (BOOL)isLeaf -{ - NSAssert([NSThread isMainThread], @"This method must be called on the main thread."); - if (self.isEnabled) { - for (ZDFlexLayoutView subview in self.view.children) { - ZDFlexLayoutCore *const yoga = subview.flexLayout; - if (yoga.isEnabled && yoga.isIncludedInLayout) { - return NO; - } - } - } - - return YES; +- (BOOL)isLeaf { + if (self.isEnabled) { + for (ZDFlexLayoutView subview in self.view.children) { + ZDFlexLayoutCore *const yoga = subview.flexLayout; + if (yoga.isEnabled && yoga.isIncludedInLayout) { + return NO; + } + } + } + + return YES; } #pragma mark - Style -- (YGPositionType)position -{ - return YGNodeStyleGetPositionType(self.node); +- (YGPositionType)position { + return YGNodeStyleGetPositionType(self.node); } -- (void)setPosition:(YGPositionType)position -{ - YGNodeStyleSetPositionType(self.node, position); +- (void)setPosition:(YGPositionType)position { + YGNodeStyleSetPositionType(self.node, position); } YG_PROPERTY(YGDirection, direction, Direction) @@ -268,142 +281,358 @@ - (void)setPosition:(YGPositionType)position #pragma mark - Layout and Sizing -- (YGDirection)resolvedDirection -{ - return YGNodeLayoutGetDirection(self.node); +- (YGDirection)resolvedDirection { + + return YGNodeLayoutGetDirection(self.node); } #pragma mark - Sync -- (void)applyLayout -{ - [self applyLayoutPreservingOrigin:NO]; +- (void)applyLayout { + + [self applyLayoutPreservingOrigin:NO]; } -- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin -{ - [self asyncApplyLayout:NO preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; +- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin { + + [self asyncApplyLayout:NO preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; } -- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility -{ - [self asyncApplyLayout:NO preservingOrigin:preserveOrigin dimensionFlexibility:dimensionFlexibility]; +- (void)applyLayoutPreservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility { + + [self asyncApplyLayout:NO preservingOrigin:preserveOrigin dimensionFlexibility:dimensionFlexibility]; } #pragma mark - Async -- (void)asyncApplyLayoutPreservingOrigin:(BOOL)preserveOrigin -{ - [self asyncApplyLayout:YES preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; +- (void)asyncApplyLayoutPreservingOrigin:(BOOL)preserveOrigin { + + [self asyncApplyLayout:YES preservingOrigin:preserveOrigin constraintSize:self.view.layoutFrame.size]; } -- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility -{ - CGSize size = self.view.layoutFrame.size; - if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { - size.width = YGUndefined; - } - if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { - size.height = YGUndefined; - } - [self asyncApplyLayout:async preservingOrigin:preserveOrigin constraintSize:size]; +- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility { + + CGSize size = self.view.layoutFrame.size; + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { + size.width = YGUndefined; + } + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { + size.height = YGUndefined; + } + [self asyncApplyLayout:async preservingOrigin:preserveOrigin constraintSize:size]; } -- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size -{ - self.isEnabled = YES; - - __weak typeof(self) weakTarget = self; - __auto_type calculateBlock = ^{ - __strong typeof(weakTarget) self = weakTarget; - [self calculateLayoutWithSize:size]; - YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); - }; - - if (async) { - [ZDCalculateHelper asyncLayoutTask:calculateBlock]; - } - else { - calculateBlock(); - } +- (void)asyncApplyLayout:(BOOL)async preservingOrigin:(BOOL)preserveOrigin constraintSize:(CGSize)size { + + ZDFlexLayoutAsyncMode asyncMode = async ? ZDFlexLayoutAsyncModeRunloopIdle : ZDFlexLayoutAsyncModeSync; + [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; +} + +#pragma mark - Async Mode API + +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + dimensionFlexibility:(ZDDimensionFlexibility)dimensionFlexibility { + + CGSize size = self.view.layoutFrame.size; + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleWidth) { + size.width = YGUndefined; + } + if (dimensionFlexibility & ZDDimensionFlexibilityFlexibleHeight) { + size.height = YGUndefined; + } + [self applyLayoutWithAsyncMode:asyncMode preservingOrigin:preserveOrigin constraintSize:size]; +} + +- (void)applyLayoutWithAsyncMode:(ZDFlexLayoutAsyncMode)asyncMode + preservingOrigin:(BOOL)preserveOrigin + constraintSize:(CGSize)size { + + self.isEnabled = YES; + + switch (asyncMode) { + case ZDFlexLayoutAsyncModeBackgroundThread: { + [self updateLayoutDirectionIfNeeded]; + + __weak typeof(self) weakTarget = self; + + if (self.useLegacyPreMeasure) { + // Legacy path: pre-measure by mutating YGNode style directly + YGAttachNodesFromViewHierachy(self.view, YES); + + [ZDCalculateHelper asyncCalculateTask:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + const YGNodeRef node = self.node; + YGNodeCalculateLayout(node, size.width, size.height, YGNodeStyleGetDirection(node)); + } onComplete:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }]; + } else { + // Phase 1 (main thread): pre-measure all leaves, store in per-pass cache + self.asyncMeasureCache = [NSMutableDictionary dictionary]; + YGPreMeasureAndCacheLeafNodes(self.view, self.asyncMeasureCache); + + NSMutableDictionary *cache = self.asyncMeasureCache; + // Phase 2 (background thread): pure numeric Yoga calculation + [ZDCalculateHelper asyncCalculateTask:^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + ZDSetThreadAsyncCache(cache); + const YGNodeRef node = self.node; + YGNodeCalculateLayout(node, size.width, size.height, YGNodeStyleGetDirection(node)); + ZDSetThreadAsyncCache(nil); + } onComplete:^{ + // Phase 3 (main thread): apply frames, restore measure funcs, clear cache + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + YGRestoreMeasureFuncs(self.view); + self.asyncMeasureCache = nil; + }]; + } + break; + } + case ZDFlexLayoutAsyncModeRunloopIdle: { + __weak typeof(self) weakTarget = self; + __auto_type calculateBlock = ^{ + __strong typeof(weakTarget) self = weakTarget; + if (!self) return; + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + }; + [ZDCalculateHelper asyncLayoutTask:calculateBlock]; + break; + } + case ZDFlexLayoutAsyncModeSync: + default: { + [self calculateLayoutWithSize:size]; + YGApplyLayoutToViewHierarchy(self.view, preserveOrigin); + break; + } + } } #pragma mark - -- (CGSize)intrinsicSize -{ - const CGSize constrainedSize = { - .width = YGUndefined, - .height = YGUndefined, - }; - return [self calculateLayoutWithSize:constrainedSize]; +- (CGSize)intrinsicSize { + const CGSize constrainedSize = { + .width = YGUndefined, + .height = YGUndefined, + }; + return [self calculateLayoutWithSize:constrainedSize]; } -- (CGSize)calculateLayoutWithSize:(CGSize)size -{ - NSAssert([NSThread isMainThread], @"Yoga calculation must be done on main."); - NSAssert(self.isEnabled, @"Yoga is not enabled for this view."); +- (void)updateLayoutDirectionIfNeeded { + UIView *view = self.view.owningView; + if (view && view.traitCollection.layoutDirection != UITraitEnvironmentLayoutDirectionUnspecified) { + self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionLTR : YGDirectionRTL; + } +} + +- (CGSize)calculateLayoutWithSize:(CGSize)size { + return [self calculateLayoutWithSize:size asyncMode:NO]; +} + +- (CGSize)calculateLayoutWithSize:(CGSize)size asyncMode:(BOOL)asyncMode { + NSAssert(self.isEnabled, @"Yoga is not enabled for this view."); + + YGAttachNodesFromViewHierachy(self.view, asyncMode); + + const YGNodeRef node = self.node; + YGNodeCalculateLayout( + node, + size.width, + size.height, + YGNodeStyleGetDirection(node) + ); + + return (CGSize) { + .width = YGNodeLayoutGetWidth(node), + .height = YGNodeLayoutGetHeight(node), + }; +} + +@end + +#pragma mark - Measure Cache + +static NSValue *ZDNodeKey(YGNodeConstRef node) { + return [NSValue valueWithPointer:node]; +} + +static void ZDMeasureCacheSetSize(NSMutableDictionary *cache, YGNodeRef node, CGSize size) { + if (!cache) return; + cache[ZDNodeKey(node)] = [NSValue valueWithCGSize:size]; +} - UIView *view = self.view.owningView; - if (view && view.traitCollection.layoutDirection != UITraitEnvironmentLayoutDirectionUnspecified) { - self.direction = view.traitCollection.layoutDirection == UITraitEnvironmentLayoutDirectionLeftToRight ? YGDirectionRTL : YGDirectionLTR; +static void ZDMeasureCacheSetTextStorage(NSMutableDictionary *cache, YGNodeRef node, NSTextStorage *textStorage, NSInteger numberOfLines) { + if (!cache || !textStorage) return; + cache[ZDNodeKey(node)] = @{@"storage": textStorage, @"lines": @(numberOfLines)}; +} + +static NSString *const kZDAsyncCacheThreadKey = @"ZDFL.AsyncMeasureCache"; + +static void ZDSetThreadAsyncCache(NSMutableDictionary *cache) { + [NSThread currentThread].threadDictionary[kZDAsyncCacheThreadKey] = cache; +} + +static NSMutableDictionary *ZDGetCurrentAsyncCache(void) { + return [NSThread currentThread].threadDictionary[kZDAsyncCacheThreadKey]; +} + +#pragma mark - Text Measurement (TextKit) + +@interface _ZDFLTextKitPool : NSObject +@property (nonatomic, strong, readonly) NSLayoutManager *layoutManager; +@property (nonatomic, strong, readonly) NSTextContainer *textContainer; +@end +@implementation _ZDFLTextKitPool +- (instancetype)init { + if (self = [super init]) { + _layoutManager = [[NSLayoutManager alloc] init]; + _textContainer = [[NSTextContainer alloc] initWithSize:CGSizeZero]; + _textContainer.lineFragmentPadding = 0; + _textContainer.lineBreakMode = NSLineBreakByWordWrapping; + [_layoutManager addTextContainer:_textContainer]; } - - YGAttachNodesFromViewHierachy(self.view); - - const YGNodeRef node = self.node; - YGNodeCalculateLayout( - node, - size.width, - size.height, - YGNodeStyleGetDirection(node) - ); - - return (CGSize) { - .width = YGNodeLayoutGetWidth(node), - .height = YGNodeLayoutGetHeight(node), - }; + return self; } +@end -#pragma mark - Private +static _ZDFLTextKitPool *_ZDTextKitPool(void) { + NSString *key = @"ZDFL.TextKitPool"; + NSThread *thread = [NSThread currentThread]; + _ZDFLTextKitPool *pool = thread.threadDictionary[key]; + if (!pool) { + pool = [[_ZDFLTextKitPool alloc] init]; + thread.threadDictionary[key] = pool; + } + return pool; +} -static YGSize YGMeasureView( - YGNodeConstRef node, - float width, - YGMeasureMode widthMode, - float height, - YGMeasureMode heightMode) -{ - const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; - const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; +/// 使用 TextKit 测量文本(线程安全),借鉴 React Native 的实现方式。 +/// 支持 NSTextStorage 直接传入(后台线程),或从 UILabel 提取文本信息(主线程)。 +/// Uses per-thread NSLayoutManager + NSTextContainer pool to avoid repeated allocations. +static CGSize ZDMeasureText(NSTextStorage *textStorage, NSInteger numberOfLines, CGSize constraintSize) { + if (!textStorage || textStorage.length == 0) return CGSizeZero; + + _ZDFLTextKitPool *pool = _ZDTextKitPool(); + NSTextContainer *textContainer = pool.textContainer; + textContainer.size = constraintSize; + textContainer.maximumNumberOfLines = numberOfLines; + + // addLayoutManager resets the layout manager's glyph tree implicitly, + // so no explicit invalidateLayout is needed before ensure. + [textStorage addLayoutManager:pool.layoutManager]; + [pool.layoutManager ensureLayoutForTextContainer:textContainer]; + + CGRect usedRect = [pool.layoutManager usedRectForTextContainer:textContainer]; + [textStorage removeLayoutManager:pool.layoutManager]; + + return CGSizeMake(ceil(usedRect.size.width), ceil(usedRect.size.height)); +} - CGSize sizeThatFits = CGSizeZero; - - // The default implementation of sizeThatFits: returns the existing size of - // the view. That means that if we want to layout an empty UIView, which - // already has got a frame set, its measured size should be CGSizeZero, but - // UIKit returns the existing size. - // - // See https://github.com/facebook/yoga/issues/606 for more information. - ZDFlexLayoutView view = (__bridge ZDFlexLayoutView)YGNodeGetContext(node); - if (!view.flexLayout.isUIView || [view.children count] > 0) { - sizeThatFits = [view sizeThatFits:(CGSize) { - .width = constrainedWidth, - .height = constrainedHeight, +/// 从 UILabel 构造 NSTextStorage(主线程调用,捕获文本快照) +/// 需要将段落样式的 lineBreakMode 强制设为 WordWrapping,否则 TextKit +/// 会遵循 UILabel 默认的 TruncatingTail 导致不换行、只计算单行高度。 +static NSTextStorage *ZDTextStorageFromLabel(UILabel *label) { + NSAttributedString *attrText = label.attributedText; + if (attrText.length > 0) { + NSMutableAttributedString *mutable = [attrText mutableCopy]; + [mutable enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, mutable.length) + options:0 + usingBlock:^(NSParagraphStyle *style, NSRange range, BOOL *stop) { + NSMutableParagraphStyle *newStyle = style ? [style mutableCopy] : [[NSMutableParagraphStyle alloc] init]; + newStyle.lineBreakMode = NSLineBreakByWordWrapping; + [mutable addAttribute:NSParagraphStyleAttributeName value:newStyle range:range]; }]; + return [[NSTextStorage alloc] initWithAttributedString:mutable]; } + if (label.text.length > 0) { + NSMutableParagraphStyle *paraStyle = [[NSMutableParagraphStyle alloc] init]; + paraStyle.lineBreakMode = NSLineBreakByWordWrapping; + paraStyle.alignment = label.textAlignment; + NSDictionary *attrs = @{ + NSFontAttributeName: label.font ?: [UIFont systemFontOfSize:17], + NSParagraphStyleAttributeName: paraStyle, + }; + return [[NSTextStorage alloc] initWithString:label.text attributes:attrs]; + } + return nil; +} - return (YGSize) { - .width = YGSanitizeMeasurement(constrainedWidth, sizeThatFits.width, widthMode), - .height = YGSanitizeMeasurement(constrainedHeight, sizeThatFits.height, heightMode), - }; +#pragma mark - Legacy Pre-Measure (useLegacyPreMeasure) +static BOOL YGPreMeasureLeafNode(YGNodeRef node, ZDFlexLayoutView view) { + + YGValue nodeWidth = YGNodeStyleGetWidth(node); + YGValue nodeHeight = YGNodeStyleGetHeight(node); + + BOOL hasExplicitWidth = (nodeWidth.unit == YGUnitPoint && !YGFloatIsUndefined(nodeWidth.value)); + BOOL hasExplicitHeight = (nodeHeight.unit == YGUnitPoint && !YGFloatIsUndefined(nodeHeight.value)); + + if (hasExplicitWidth && hasExplicitHeight) { + return YES; // Already fully constrained, no measure func needed + } + + if (![view isKindOfClass:[UIView class]]) { + // ZDFlexLayoutDiv — sizeThatFits returns CGSizeZero, thread-safe + // Keep the measure function for these; they don't call UIKit + return NO; + } + + UIView *uiView = (UIView *)view; + CGSize measuredSize = CGSizeZero; + + if ([uiView isKindOfClass:[UILabel class]]) { + UILabel *label = (UILabel *)uiView; + CGSize constrainedSize = (CGSize){ + hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, + hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX + }; + NSTextStorage *ts = ZDTextStorageFromLabel(label); + measuredSize = ZDMeasureText(ts, label.numberOfLines, constrainedSize); + } else if ([uiView isKindOfClass:[UIImageView class]]) { + UIImage *image = ((UIImageView *)uiView).image; + if (image) { + measuredSize = image.size; + } + } else { + // For other UIView leaf nodes (plain UIView, custom views, etc.): + // sizeThatFits: requires main thread. + // If neither width nor height is set, we must call sizeThatFits: on main thread. + // If at least one dimension is set, we can skip measurement. + if (!hasExplicitWidth && !hasExplicitHeight) { + // Must measure on main thread — keep measure func + return NO; + } + // At least one dimension is explicit — store what we have + measuredSize = CGSizeMake( + hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX, + hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX + ); + } + + if (!hasExplicitWidth && measuredSize.width > 0 && measuredSize.width < CGFLOAT_MAX) { + YGNodeStyleSetWidth(node, measuredSize.width); + } + if (!hasExplicitHeight && measuredSize.height > 0 && measuredSize.height < CGFLOAT_MAX) { + YGNodeStyleSetHeight(node, measuredSize.height); + } + + return YES; } +#pragma mark - Yoga Measure Functions + static CGFloat YGSanitizeMeasurement( - CGFloat constrainedSize, - CGFloat measuredSize, - YGMeasureMode measureMode) -{ + CGFloat constrainedSize, + CGFloat measuredSize, + YGMeasureMode measureMode +) { CGFloat result; if (measureMode == YGMeasureModeExactly) { result = constrainedSize; @@ -412,141 +641,305 @@ static CGFloat YGSanitizeMeasurement( } else { result = measuredSize; } - + return result; } -static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) -{ - if (YGNodeGetChildCount(node) != subviews.count) { - return NO; - } - - for (int i = 0; i < subviews.count; i++) { - if (YGNodeGetChild(node, i) != subviews[i].flexLayout.node) { - return NO; - } - } - - return YES; -} - -static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view) -{ - ZDFlexLayoutCore *const yoga = view.flexLayout; - const YGNodeRef node = yoga.node; - - // Only leaf nodes should have a measure function - if (yoga.isLeaf) { - YGRemoveAllChildren(node); - YGNodeSetMeasureFunc(node, YGMeasureView); - } else { - YGNodeSetMeasureFunc(node, NULL); - - NSMutableArray *subviewsToInclude = [[NSMutableArray alloc] initWithCapacity:view.children.count]; - for (ZDFlexLayoutView subview in view.children) { - if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { - [subviewsToInclude addObject:subview]; - } - } - - if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { - YGRemoveAllChildren(node); - for (int i = 0; i < subviewsToInclude.count; i++) { - YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); - } - } - - for (ZDFlexLayoutView const subview in subviewsToInclude) { - YGAttachNodesFromViewHierachy(subview); - } - } +static YGSize YGMeasureView( + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode +) { + const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; + const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; + + CGSize sizeThatFits = CGSizeZero; + + // The default implementation of sizeThatFits: returns the existing size of + // the view. That means that if we want to layout an empty UIView, which + // already has got a frame set, its measured size should be CGSizeZero, but + // UIKit returns the existing size. + // + // See https://github.com/facebook/yoga/issues/606 for more information. + ZDFlexLayoutView view = (__bridge ZDFlexLayoutView)YGNodeGetContext(node); + if (!view.flexLayout.isUIView || [view.children count] > 0) { + sizeThatFits = [view sizeThatFits:(CGSize) { + .width = constrainedWidth, + .height = constrainedHeight, + }]; + } + + return (YGSize) { + .width = YGSanitizeMeasurement(constrainedWidth, sizeThatFits.width, widthMode), + .height = YGSanitizeMeasurement(constrainedHeight, sizeThatFits.height, heightMode), + }; } -static void YGRemoveAllChildren(const YGNodeRef node) -{ - if (node == NULL) { - return; - } - - YGNodeRemoveAllChildren(node); -} +/// 后台线程安全的 measure 函数。 +/// 文本节点使用 TextKit 在 Yoga 提供的真实约束下测量;非文本节点返回预存的固定尺寸。 +/// 通过节点上下文找到当前布局的根视图,从根视图的 per-pass cache 读取数据, +/// 避免多次并发异步布局之间的竞态条件。 +static YGSize YGCachedMeasureView( + YGNodeConstRef node, + float width, + YGMeasureMode widthMode, + float height, + YGMeasureMode heightMode +) { + const CGFloat constrainedWidth = (widthMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : width; + const CGFloat constrainedHeight = (heightMode == YGMeasureModeUndefined) ? CGFLOAT_MAX : height; -static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin) -{ - NSCAssert([NSThread isMainThread], @"Framesetting should only be done on the main thread."); + NSMutableDictionary *cache = ZDGetCurrentAsyncCache(); + if (!cache) return (YGSize){ .width = 0, .height = 0 }; - const ZDFlexLayoutCore *yoga = view.flexLayout; + id cached = cache[ZDNodeKey(node)]; + if (!cached) return (YGSize){ .width = 0, .height = 0 }; - if (!yoga.isEnabled || !yoga.isIncludedInLayout) { - return; + CGSize measuredSize; + if ([cached isKindOfClass:[NSDictionary class]]) { + NSTextStorage *textStorage = cached[@"storage"]; + NSInteger numberOfLines = [cached[@"lines"] integerValue]; + CGSize constraint = CGSizeMake(constrainedWidth, constrainedHeight); + measuredSize = ZDMeasureText(textStorage, numberOfLines, constraint); + } else { + measuredSize = [(NSValue *)cached CGSizeValue]; } - YGNodeRef node = yoga.node; - const CGPoint topLeft = { - YGNodeLayoutGetLeft(node), - YGNodeLayoutGetTop(node), - }; - - const CGPoint bottomRight = { - topLeft.x + YGNodeLayoutGetWidth(node), - topLeft.y + YGNodeLayoutGetHeight(node), + return (YGSize) { + .width = YGSanitizeMeasurement(constrainedWidth, measuredSize.width, widthMode), + .height = YGSanitizeMeasurement(constrainedHeight, measuredSize.height, heightMode), }; +} - const CGPoint origin = preserveOrigin ? view.layoutFrame.origin : CGPointZero; - view.layoutFrame = (CGRect) { - .origin = { - .x = ZDFLRoundPixelValue(topLeft.x + origin.x), - .y = ZDFLRoundPixelValue(topLeft.y + origin.y), - }, - .size = { - .width = ZDFLRoundPixelValue(bottomRight.x) - ZDFLRoundPixelValue(topLeft.x), - .height = ZDFLRoundPixelValue(bottomRight.y) - ZDFLRoundPixelValue(topLeft.y), - }, - }; +#pragma mark - Tree Walkers + +static BOOL YGNodeHasExactSameChildren(const YGNodeRef node, NSArray *subviews) { + + if (YGNodeGetChildCount(node) != subviews.count) { + return NO; + } + + for (int i = 0; i < subviews.count; i++) { + if (YGNodeGetChild(node, i) != subviews[i].flexLayout.node) { + return NO; + } + } + + return YES; +} - if (!yoga.isLeaf) { - for (NSUInteger i = 0; i < view.children.count; i++) { - YGApplyLayoutToViewHierarchy(view.children[i], NO); - } +static void YGAttachNodesFromViewHierachy(ZDFlexLayoutView const view, BOOL asyncMode) { + + ZDFlexLayoutCore *const yoga = view.flexLayout; + const YGNodeRef node = yoga.node; + + // Only leaf nodes should have a measure function + if (yoga.isLeaf) { + YGNodeRemoveAllChildren(node); + + if (asyncMode) { + // In async mode, try to pre-measure the leaf node and set fixed sizes. + // If fully pre-measured, skip setting YGMeasureFunc so Yoga won't + // call back into UIKit during background calculation. + if (YGPreMeasureLeafNode(node, view)) { + YGNodeSetMeasureFunc(node, NULL); + } else if (!yoga.isUIView) { + // Non-UIView (ZDFlexLayoutDiv): sizeThatFits returns CGSizeZero, thread-safe + YGNodeSetMeasureFunc(node, YGMeasureView); + } else { + // UIView leaf that couldn't be pre-measured (no explicit size, not UILabel/UIImageView). + // Setting YGMeasureFunc would call sizeThatFits: on background thread → unsafe. + // Instead, leave measure func unset and let Yoga use default sizing. + YGNodeSetMeasureFunc(node, NULL); + } + } else { + YGNodeSetMeasureFunc(node, YGMeasureView); + } + } else { + YGNodeSetMeasureFunc(node, NULL); + + NSMutableArray *subviewsToInclude = [[NSMutableArray alloc] initWithCapacity:view.children.count]; + for (ZDFlexLayoutView subview in view.children) { + if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { + [subviewsToInclude addObject:subview]; + } + } + + if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { + YGNodeRemoveAllChildren(node); + for (int i = 0; i < subviewsToInclude.count; i++) { + YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); + } + } + + for (ZDFlexLayoutView const subview in subviewsToInclude) { + YGAttachNodesFromViewHierachy(subview, asyncMode); + } + } +} - if ([view respondsToSelector:@selector(needReApplyLayoutAtNextRunloop)]) { - [view needReApplyLayoutAtNextRunloop]; - } - } +/// 主线程递归遍历视图树,预测量所有叶子节点的固有尺寸并存入缓存侧表。 +/// 每个叶子节点设置 YGCachedMeasureView 作为 measure 函数,确保后台计算时不回调 UIKit。 +/// 不修改 YGNode 的 style 属性(width/height),避免污染后续布局。 +static void YGPreMeasureAndCacheLeafNodes(ZDFlexLayoutView const view, NSMutableDictionary *cache) { + + ZDFlexLayoutCore *const yoga = view.flexLayout; + const YGNodeRef node = yoga.node; + + if (yoga.isLeaf) { + YGNodeRemoveAllChildren(node); + + YGValue nodeWidth = YGNodeStyleGetWidth(node); + YGValue nodeHeight = YGNodeStyleGetHeight(node); + BOOL hasExplicitWidth = (nodeWidth.unit == YGUnitPoint && !YGFloatIsUndefined(nodeWidth.value)); + BOOL hasExplicitHeight = (nodeHeight.unit == YGUnitPoint && !YGFloatIsUndefined(nodeHeight.value)); + + if (hasExplicitWidth && hasExplicitHeight) { + YGNodeSetMeasureFunc(node, NULL); + return; + } + + CGFloat constraintW = hasExplicitWidth ? nodeWidth.value : CGFLOAT_MAX; + CGFloat constraintH = hasExplicitHeight ? nodeHeight.value : CGFLOAT_MAX; + CGSize constrainedSize = CGSizeMake(constraintW, constraintH); + CGSize measuredSize = CGSizeZero; + + if (![view isKindOfClass:[UIView class]]) { + measuredSize = [view sizeThatFits:constrainedSize]; + ZDMeasureCacheSetSize(cache, node, measuredSize); + } else { + UIView *uiView = (UIView *)view; + if ([uiView isKindOfClass:[UILabel class]]) { + // 文本节点:捕获 NSTextStorage 供后台 TextKit 测量 + UILabel *label = (UILabel *)uiView; + NSTextStorage *textStorage = ZDTextStorageFromLabel(label); + if (textStorage) { + ZDMeasureCacheSetTextStorage(cache, node, textStorage, label.numberOfLines); + } else { + ZDMeasureCacheSetSize(cache, node, CGSizeZero); + } + } else if ([uiView isKindOfClass:[UIImageView class]]) { + UIImage *image = ((UIImageView *)uiView).image; + measuredSize = image ? image.size : CGSizeZero; + ZDMeasureCacheSetSize(cache, node, measuredSize); + } else { + measuredSize = [uiView sizeThatFits:constrainedSize]; + ZDMeasureCacheSetSize(cache, node, measuredSize); + } + } + YGNodeSetMeasureFunc(node, YGCachedMeasureView); + } else { + YGNodeSetMeasureFunc(node, NULL); + + NSMutableArray *subviewsToInclude = [[NSMutableArray alloc] initWithCapacity:view.children.count]; + for (ZDFlexLayoutView subview in view.children) { + if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { + [subviewsToInclude addObject:subview]; + } + } + + if (!YGNodeHasExactSameChildren(node, subviewsToInclude)) { + YGNodeRemoveAllChildren(node); + for (int i = 0; i < subviewsToInclude.count; i++) { + YGNodeInsertChild(node, subviewsToInclude[i].flexLayout.node, i); + } + } + + for (ZDFlexLayoutView const subview in subviewsToInclude) { + YGPreMeasureAndCacheLeafNodes(subview, cache); + } + } } -@end +/// 后台布局完成后恢复所有叶子节点的 measure 函数为标准的 YGMeasureView, +/// 确保后续同步布局能正常调用 sizeThatFits: 进行测量。 +static void YGRestoreMeasureFuncs(ZDFlexLayoutView const view) { + + ZDFlexLayoutCore *const yoga = view.flexLayout; + const YGNodeRef node = yoga.node; + + if (yoga.isLeaf) { + YGNodeSetMeasureFunc(node, YGMeasureView); + } else { + for (ZDFlexLayoutView subview in view.children) { + if (subview.flexLayout.isEnabled && subview.flexLayout.isIncludedInLayout) { + YGRestoreMeasureFuncs(subview); + } + } + } +} -//-------------------------- Function ------------------------ -#pragma mark - +#pragma mark - Layout Application + +static void YGApplyLayoutToViewHierarchy(ZDFlexLayoutView view, BOOL preserveOrigin) { + NSCAssert([NSThread isMainThread], @"Framesetting should only be done on the main thread."); + + const ZDFlexLayoutCore *yoga = view.flexLayout; + + if (!yoga.isEnabled || !yoga.isIncludedInLayout) { + return; + } + + YGNodeRef node = yoga.node; + const CGPoint topLeft = { + YGNodeLayoutGetLeft(node), + YGNodeLayoutGetTop(node), + }; + + const CGPoint bottomRight = { + topLeft.x + YGNodeLayoutGetWidth(node), + topLeft.y + YGNodeLayoutGetHeight(node), + }; + + const CGPoint origin = preserveOrigin ? view.layoutFrame.origin : CGPointZero; + view.layoutFrame = (CGRect) { + .origin = { + .x = ZDFLRoundPixelValue(topLeft.x + origin.x), + .y = ZDFLRoundPixelValue(topLeft.y + origin.y), + }, + .size = { + .width = ZDFLRoundPixelValue(bottomRight.x) - ZDFLRoundPixelValue(topLeft.x), + .height = ZDFLRoundPixelValue(bottomRight.y) - ZDFLRoundPixelValue(topLeft.y), + }, + }; + + if (!yoga.isLeaf) { + for (NSUInteger i = 0; i < view.children.count; i++) { + YGApplyLayoutToViewHierarchy(view.children[i], NO); + } + + if ([view respondsToSelector:@selector(needReApplyLayoutAtNextRunloop)]) { + [view needReApplyLayoutAtNextRunloop]; + } + } +} -CGFloat ZDFLScreenScale(void) -{ - static CGFloat scale = 0.0; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), YES, 0); - scale = CGContextGetCTM(UIGraphicsGetCurrentContext()).a; - UIGraphicsEndImageContext(); - }); - return scale; +#pragma mark - Pixel Utilities + +CGFloat ZDFLScreenScale(void) { + static CGFloat scale = 0.0; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), YES, 0); + scale = CGContextGetCTM(UIGraphicsGetCurrentContext()).a; + UIGraphicsEndImageContext(); + }); + return scale; } -CGFloat ZDFLRoundPixelValue(CGFloat value) -{ - CGFloat scale = ZDFLScreenScale(); - return roundf(value * scale) / scale; +CGFloat ZDFLRoundPixelValue(CGFloat value) { + CGFloat scale = ZDFLScreenScale(); + return roundf(value * scale) / scale; } -CGFloat ZDFLCeilPixelValue(CGFloat value) -{ - CGFloat scale = ZDFLScreenScale(); - return ceil((value - FLT_EPSILON) * scale) / scale; +CGFloat ZDFLCeilPixelValue(CGFloat value) { + CGFloat scale = ZDFLScreenScale(); + return ceil((value - FLT_EPSILON) * scale) / scale; } -CGFloat ZDFLFloorPixelValue(CGFloat f) -{ - CGFloat scale = ZDFLScreenScale(); - return floor((f + FLT_EPSILON) * scale) / scale; +CGFloat ZDFLFloorPixelValue(CGFloat f) { + CGFloat scale = ZDFLScreenScale(); + return floor((f + FLT_EPSILON) * scale) / scale; } diff --git a/Sources/Core/Public/ZDFlexLayoutDefine.h b/Sources/Core/Public/ZDFlexLayoutDefine.h index b7a750a..cc82917 100644 --- a/Sources/Core/Public/ZDFlexLayoutDefine.h +++ b/Sources/Core/Public/ZDFlexLayoutDefine.h @@ -24,4 +24,14 @@ typedef NS_OPTIONS(NSInteger, ZDDimensionFlexibility) { #define YGDimensionFlexibilityFlexibleHeight (ZDDimensionFlexibilityFlexibleHeight) #define YGDimensionFlexibilityFlexibleAll (ZDDimensionFlexibilityFlexibleAll) +/// Async execution mode for layout calculation +typedef NS_ENUM(NSInteger, ZDFlexLayoutAsyncMode) { + /// Calculate and apply layout synchronously on the calling thread. + ZDFlexLayoutAsyncModeSync = 0, + /// Defer layout calculation to the main runloop idle time. + ZDFlexLayoutAsyncModeRunloopIdle, + /// Calculate layout on a background thread, then apply frames on the main thread. + ZDFlexLayoutAsyncModeBackgroundThread, +}; + #endif /* ZDFlexLayoutDefine_h */