diff --git a/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.h b/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.h index a87d67d19230..97b58580e707 100644 --- a/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.h +++ b/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.h @@ -17,4 +17,14 @@ minimumFontSize:(CGFloat)minimumFontSize maximumFontSize:(CGFloat)maximumFontSize; +/** + * Like `-scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:`, for text storages that are not laid out by an + * attached `NSLayoutManager`. `measureBlock` lays out the text storage as it currently is and returns the size of the + * laid out text, setting `isTruncated` if the last line is truncated. + */ +- (void)scaleFontSizeToFitSize:(CGSize)size + minimumFontSize:(CGFloat)minimumFontSize + maximumFontSize:(CGFloat)maximumFontSize + measureUsingBlock:(NS_NOESCAPE CGSize (^)(BOOL *isTruncated))measureBlock; + @end diff --git a/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.m b/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.m index c92b2e94c6a1..11c616a02a86 100644 --- a/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.m +++ b/packages/react-native/Libraries/Text/Text/NSTextStorage+FontScaling.m @@ -18,9 +18,32 @@ @implementation NSTextStorage (FontScaling) - (void)scaleFontSizeToFitSize:(CGSize)size minimumFontSize:(CGFloat)minimumFontSize maximumFontSize:(CGFloat)maximumFontSize +{ + [self scaleFontSizeToFitSize:size + minimumFontSize:minimumFontSize + maximumFontSize:maximumFontSize + measureUsingBlock:^CGSize(BOOL *isTruncated) { + NSLayoutManager *layoutManager = self.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + + [layoutManager ensureLayoutForTextContainer:textContainer]; + + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + NSRange truncatedGlyphRange = + [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:glyphRange.length - 1]; + *isTruncated = truncatedGlyphRange.location != NSNotFound; + + return [layoutManager usedRectForTextContainer:textContainer].size; + }]; +} + +- (void)scaleFontSizeToFitSize:(CGSize)size + minimumFontSize:(CGFloat)minimumFontSize + maximumFontSize:(CGFloat)maximumFontSize + measureUsingBlock:(NS_NOESCAPE CGSize (^)(BOOL *isTruncated))measureBlock { // Don't scale the font if it already fits - if ([self compareToSize:size thresholdRatio:0.01] & RCTTextSizeComparisonSmaller) { + if ([self compareToSize:size thresholdRatio:0.01 measureUsingBlock:measureBlock] & RCTTextSizeComparisonSmaller) { return; } @@ -35,7 +58,9 @@ - (void)scaleFontSizeToFitSize:(CGSize)size while (true) { [self scaleFontSizeWithRatio:ratio minimumFontSize:minimumFontSize maximumFontSize:maximumFontSize]; - RCTTextSizeComparisonOptions comparison = [self compareToSize:size thresholdRatio:0.01]; + RCTTextSizeComparisonOptions comparison = [self compareToSize:size + thresholdRatio:0.01 + measureUsingBlock:measureBlock]; if ((comparison & RCTTextSizeComparisonWithinRange) && (comparison & RCTTextSizeComparisonSmaller)) { return; @@ -61,11 +86,10 @@ - (void)scaleFontSizeToFitSize:(CGSize)size } } -- (RCTTextSizeComparisonOptions)compareToSize:(CGSize)size thresholdRatio:(CGFloat)thresholdRatio +- (RCTTextSizeComparisonOptions)compareToSize:(CGSize)size + thresholdRatio:(CGFloat)thresholdRatio + measureUsingBlock:(NS_NOESCAPE CGSize (^)(BOOL *isTruncated))measureBlock { - NSLayoutManager *layoutManager = self.layoutManagers.firstObject; - NSTextContainer *textContainer = layoutManager.textContainers.firstObject; - // A workaround for truncatedGlyphRangeInLineFragmentForGlyphAtIndex returning NSNotFound when text has only // one character and it gets truncated if ([self length] == 1) { @@ -75,18 +99,14 @@ - (RCTTextSizeComparisonOptions)compareToSize:(CGSize)size thresholdRatio:(CGFlo } } - [layoutManager ensureLayoutForTextContainer:textContainer]; - // Does it fit the text container? - NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; - NSRange truncatedGlyphRange = [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:glyphRange.length - 1]; + BOOL isTruncated = NO; + CGSize measuredSize = measureBlock(&isTruncated); - if (truncatedGlyphRange.location != NSNotFound) { + if (isTruncated) { return RCTTextSizeComparisonLarger; } - CGSize measuredSize = [layoutManager usedRectForTextContainer:textContainer].size; - // Does it fit the size? BOOL fitsSize = size.width >= measuredSize.width && size.height >= measuredSize.height; diff --git a/packages/react-native/React/Tests/Text/RCTTextLayoutManagerTextKit2Tests.mm b/packages/react-native/React/Tests/Text/RCTTextLayoutManagerTextKit2Tests.mm new file mode 100644 index 000000000000..2942cf3339b5 --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTTextLayoutManagerTextKit2Tests.mm @@ -0,0 +1,383 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import + +#import +#import +#import +#import + +#include +#include +#include + +using namespace facebook::react; + +namespace { + +struct TextKit2TestCase { + const char *name; + AttributedString attributedString; + ParagraphAttributes paragraphAttributes; + Float width; + // TextKit 1 reports the baseline of the first glyph of a line, which can be offset by shaping (e.g. in Arabic); + // TextKit 2 reports the baseline of the line. + bool comparesBaselines = true; + // Color emoji are rasterized slightly differently. + bool comparesPixels = true; + // Before iOS 26, TextKit 2 puts the trailing whitespace of right-to-left lines on the other side of their bounds. + bool comparesLineFramesBeforeIOS26 = true; +}; + +TextAttributes textAttributes(Float fontSize, Float lineHeight = NAN, std::optional alignment = {}) +{ + TextAttributes attributes = TextAttributes::defaultTextAttributes(); + attributes.fontSize = fontSize; + attributes.lineHeight = lineHeight; + attributes.alignment = alignment; + return attributes; +} + +AttributedString::Fragment textFragment(std::string string, TextAttributes attributes) +{ + AttributedString::Fragment fragment; + fragment.string = std::move(string); + fragment.textAttributes = std::move(attributes); + return fragment; +} + +AttributedString::Fragment attachmentFragment(facebook::react::Size size, TextAttributes attributes) +{ + AttributedString::Fragment fragment; + fragment.string = AttributedString::Fragment::AttachmentCharacter(); + fragment.textAttributes = std::move(attributes); + fragment.parentShadowView.layoutMetrics.frame.size = size; + return fragment; +} + +AttributedString attributedString(std::vector fragments) +{ + AttributedString attributedString; + for (auto &fragment : fragments) { + attributedString.appendFragment(std::move(fragment)); + } + return attributedString; +} + +ParagraphAttributes paragraphAttributes( + int maximumNumberOfLines = 0, + EllipsizeMode ellipsizeMode = EllipsizeMode::Tail, + bool adjustsFontSizeToFit = false) +{ + ParagraphAttributes paragraphAttributes; + paragraphAttributes.maximumNumberOfLines = maximumNumberOfLines; + paragraphAttributes.ellipsizeMode = ellipsizeMode; + paragraphAttributes.adjustsFontSizeToFit = adjustsFontSizeToFit; + return paragraphAttributes; +} + +const std::string kLorem = "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs."; + +/** + * Text that TextKit 1 and TextKit 2 lay out, measure and draw the same way. + */ +std::vector equivalentTestCases() +{ + return { + {"single line", attributedString({textFragment("Hello world", textAttributes(14))}), paragraphAttributes(), 300}, + {"wrapping", attributedString({textFragment(kLorem, textAttributes(16))}), paragraphAttributes(), 200}, + {"centered", + attributedString({textFragment(kLorem, textAttributes(16, NAN, TextAlignment::Center))}), + paragraphAttributes(), + 200}, + {"right aligned", + attributedString({textFragment(kLorem, textAttributes(16, NAN, TextAlignment::Right))}), + paragraphAttributes(), + 200}, + {"line height", attributedString({textFragment(kLorem, textAttributes(16, 30))}), paragraphAttributes(), 200}, + {"compressed line height", + attributedString({textFragment(kLorem, textAttributes(16, 12))}), + paragraphAttributes(), + 200}, + {"trailing line break", + attributedString({textFragment("Line one\n", textAttributes(14))}), + paragraphAttributes(), + 300}, + {"trailing empty paragraph", + attributedString({textFragment("A\n\n", textAttributes(20))}), + paragraphAttributes(), + 300}, + {"trailing empty paragraph with line height", + attributedString({textFragment("A\n\n", textAttributes(20, 30))}), + paragraphAttributes(), + 300}, + {"only a line break", attributedString({textFragment("\n", textAttributes(20))}), paragraphAttributes(), 300}, + {"paragraphs", + attributedString({textFragment(kLorem + "\n" + kLorem, textAttributes(14))}), + paragraphAttributes(), + 180}, + {"tail truncation", attributedString({textFragment(kLorem, textAttributes(16))}), paragraphAttributes(2), 200}, + {"tail truncation of a paragraph", + attributedString({textFragment("Short\nSecond paragraph that is long enough\nThird", textAttributes(16))}), + paragraphAttributes(2), + 200}, + {"line limit with a trailing empty line", + attributedString({textFragment("one\ntwo\n", textAttributes(16))}), + paragraphAttributes(2), + 200}, + {"clipped single line", + attributedString({textFragment(kLorem, textAttributes(16))}), + paragraphAttributes(1, EllipsizeMode::Clip), + 200}, + {"mixed font sizes", + attributedString( + {textFragment("Small ", textAttributes(12)), + textFragment("BIG", textAttributes(36)), + textFragment(" small again and wrapping onto the next line", textAttributes(12))}), + paragraphAttributes(), + 160}, + {"attachments", + attributedString( + {textFragment("Before ", textAttributes(16)), + attachmentFragment({.width = 40, .height = 30}, textAttributes(16)), + textFragment(" after and more words that wrap ", textAttributes(16)), + attachmentFragment({.width = 20, .height = 20}, textAttributes(16))}), + paragraphAttributes(), + 200}, + {"attachment after the line limit", + attributedString( + {textFragment("First\nSecond ", textAttributes(16)), + attachmentFragment({.width = 20, .height = 20}, textAttributes(16))}), + paragraphAttributes(1), + 200}, + {"emoji", + attributedString({textFragment( + "Emoji 😀👨‍👩‍👧‍👦🇵🇱 mixed with text that wraps", textAttributes(16))}), + paragraphAttributes(), + 150, + true, + false}, + {"CJK", + attributedString({textFragment("日本語のテキストは単語の間にスペースがありません。", textAttributes(16))}), + paragraphAttributes(), + 150}, + {"right-to-left text with natural alignment", + attributedString({textFragment("مرحبا بالعالم هذا نص عربي طويل يلتف على عدة أسطر", textAttributes(16))}), + paragraphAttributes(), + 150, + false, + true, + false}, + {"Thai", + attributedString({textFragment("ภาษาไทยไม่มีช่องว่างระหว่างคำซึ่งต้องตัดคำ", textAttributes(16))}), + paragraphAttributes(), + 150}, + {"adjusts font size to fit", + attributedString({textFragment(kLorem, textAttributes(16))}), + paragraphAttributes(2, EllipsizeMode::Tail, true), + 200}, + }; +} + +UIImage *drawAttributedString(RCTTextLayoutManager *textLayoutManager, const TextKit2TestCase &testCase, CGSize size) +{ + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + format.scale = 3; + format.opaque = YES; + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; + return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { + [UIColor.whiteColor setFill]; + UIRectFill(CGRectMake(0, 0, size.width, size.height)); + [textLayoutManager drawAttributedString:testCase.attributedString + paragraphAttributes:testCase.paragraphAttributes + frame:CGRectMake(0, 0, size.width, size.height) + drawHighlightPath:nil]; + }]; +} + +} // namespace + +@interface RCTTextLayoutManagerTextKit2Tests : XCTestCase +@end + +@implementation RCTTextLayoutManagerTextKit2Tests { + RCTTextLayoutManager *_textKit1LayoutManager; + RCTTextLayoutManager *_textKit2LayoutManager; +} + +- (void)setUp +{ + [super setUp]; + _textKit1LayoutManager = [[RCTTextLayoutManager alloc] initWithTextKit2Enabled:NO]; + _textKit2LayoutManager = [[RCTTextLayoutManager alloc] initWithTextKit2Enabled:YES]; +} + +- (BOOL)skipIfTextKit2IsUnavailable +{ + if (!_textKit2LayoutManager.usesTextKit2) { + NSLog(@"TextKit 2 requires iOS 16 or later; skipping."); + return YES; + } + return NO; +} + +- (TextMeasurement)measure:(const TextKit2TestCase &)testCase with:(RCTTextLayoutManager *)textLayoutManager +{ + return [textLayoutManager + measureAttributedString:testCase.attributedString + paragraphAttributes:testCase.paragraphAttributes + layoutContext:{.pointScaleFactor = 3} + layoutConstraints:{.maximumSize = { + .width = testCase.width, .height = std::numeric_limits::infinity()}}]; +} + +- (void)testUsesTextKit2OnlyWhenEnabled +{ + XCTAssertFalse(_textKit1LayoutManager.usesTextKit2); + if (@available(iOS 16.0, *)) { + XCTAssertTrue(_textKit2LayoutManager.usesTextKit2); + } +} + +- (void)testMeasurementMatchesTextKit1 +{ + if ([self skipIfTextKit2IsUnavailable]) { + return; + } + for (const auto &testCase : equivalentTestCases()) { + TextMeasurement textKit1 = [self measure:testCase with:_textKit1LayoutManager]; + TextMeasurement textKit2 = [self measure:testCase with:_textKit2LayoutManager]; + + XCTAssertEqualWithAccuracy(textKit1.size.width, textKit2.size.width, 0.01, @"%s", testCase.name); + XCTAssertEqualWithAccuracy(textKit1.size.height, textKit2.size.height, 0.01, @"%s", testCase.name); + + XCTAssertEqual(textKit1.attachments.size(), textKit2.attachments.size(), @"%s", testCase.name); + for (size_t index = 0; index < MIN(textKit1.attachments.size(), textKit2.attachments.size()); index++) { + const auto &attachment1 = textKit1.attachments[index]; + const auto &attachment2 = textKit2.attachments[index]; + XCTAssertEqual(attachment1.isClipped, attachment2.isClipped, @"%s, attachment %zu", testCase.name, index); + if (!attachment1.isClipped && !attachment2.isClipped) { + XCTAssertEqualWithAccuracy(attachment1.frame.origin.x, attachment2.frame.origin.x, 0.01, @"%s", testCase.name); + XCTAssertEqualWithAccuracy(attachment1.frame.origin.y, attachment2.frame.origin.y, 0.01, @"%s", testCase.name); + XCTAssertEqualWithAccuracy( + attachment1.frame.size.width, attachment2.frame.size.width, 0.01, @"%s", testCase.name); + XCTAssertEqualWithAccuracy( + attachment1.frame.size.height, attachment2.frame.size.height, 0.01, @"%s", testCase.name); + } + } + } +} + +- (void)testLinesMatchTextKit1 +{ + if ([self skipIfTextKit2IsUnavailable]) { + return; + } + BOOL isIOS26OrLater = NO; + if (@available(iOS 26.0, *)) { + isIOS26OrLater = YES; + } + for (const auto &testCase : equivalentTestCases()) { + BOOL comparesLineFrames = isIOS26OrLater || testCase.comparesLineFramesBeforeIOS26; + CGSize size = CGSizeMake(testCase.width, CGFLOAT_MAX); + LinesMeasurements textKit1 = [_textKit1LayoutManager getLinesForAttributedString:testCase.attributedString + paragraphAttributes:testCase.paragraphAttributes + size:size]; + LinesMeasurements textKit2 = [_textKit2LayoutManager getLinesForAttributedString:testCase.attributedString + paragraphAttributes:testCase.paragraphAttributes + size:size]; + + XCTAssertEqual(textKit1.size(), textKit2.size(), @"%s", testCase.name); + for (size_t index = 0; index < MIN(textKit1.size(), textKit2.size()); index++) { + const auto &line1 = textKit1[index]; + const auto &line2 = textKit2[index]; + XCTAssertEqual(line1.text, line2.text, @"%s, line %zu", testCase.name, index); + if (!comparesLineFrames) { + continue; + } + XCTAssertEqualWithAccuracy( + line1.frame.origin.x, line2.frame.origin.x, 0.01, @"%s, line %zu", testCase.name, index); + XCTAssertEqualWithAccuracy( + line1.frame.origin.y, line2.frame.origin.y, 0.01, @"%s, line %zu", testCase.name, index); + XCTAssertEqualWithAccuracy( + line1.frame.size.width, line2.frame.size.width, 0.01, @"%s, line %zu", testCase.name, index); + XCTAssertEqualWithAccuracy( + line1.frame.size.height, line2.frame.size.height, 0.01, @"%s, line %zu", testCase.name, index); + // TextKit 1 reports a baseline at the bottom of lines that only contain a line break. + if (testCase.comparesBaselines && line1.text != "\n") { + XCTAssertEqualWithAccuracy(line1.ascender, line2.ascender, 0.01, @"%s, line %zu", testCase.name, index); + XCTAssertEqualWithAccuracy(line1.descender, line2.descender, 0.01, @"%s, line %zu", testCase.name, index); + } + } + } +} + +- (void)testDrawingMatchesTextKit1 +{ + if ([self skipIfTextKit2IsUnavailable]) { + return; + } + for (const auto &testCase : equivalentTestCases()) { + if (!testCase.comparesPixels) { + continue; + } + TextMeasurement measurement = [self measure:testCase with:_textKit1LayoutManager]; + CGSize size = CGSizeMake(testCase.width, MAX(measurement.size.height, 1)); + NSData *textKit1 = UIImagePNGRepresentation(drawAttributedString(_textKit1LayoutManager, testCase, size)); + NSData *textKit2 = UIImagePNGRepresentation(drawAttributedString(_textKit2LayoutManager, testCase, size)); + XCTAssertEqualObjects(textKit1, textKit2, @"%s draws differently", testCase.name); + } +} + +#pragma mark - Intentional differences + +- (void)testTruncatedAttachmentDoesNotGrowItsLine +{ + if ([self skipIfTextKit2IsUnavailable]) { + return; + } + // TextKit 1 makes the last line tall enough for an attachment it truncates; TextKit 2 does not lay it out. + TextKit2TestCase testCase = { + "truncated attachment", + attributedString( + {textFragment(kLorem, textAttributes(16)), + attachmentFragment({.width = 20, .height = 40}, textAttributes(16))}), + paragraphAttributes(2), + 200}; + TextKit2TestCase withoutAttachment = { + "without attachment", attributedString({textFragment(kLorem, textAttributes(16))}), paragraphAttributes(2), 200}; + + TextMeasurement measurement = [self measure:testCase with:_textKit2LayoutManager]; + XCTAssertEqual(measurement.attachments.size(), 1); + XCTAssertTrue(measurement.attachments[0].isClipped); + XCTAssertEqualWithAccuracy( + measurement.size.height, [self measure:withoutAttachment with:_textKit2LayoutManager].size.height, 0.01); +} + +- (void)testClippingWithMultipleLinesDropsWhatDoesNotFit +{ + if ([self skipIfTextKit2IsUnavailable]) { + return; + } + // TextKit 1 puts the rest of the text on the last line and clips it at the edge of the container; TextKit 2 wraps + // it like any other line and drops what does not fit. + TextKit2TestCase testCase = { + "clipped lines", + attributedString({textFragment(kLorem, textAttributes(16))}), + paragraphAttributes(2, EllipsizeMode::Clip), + 200}; + LinesMeasurements lines = + [_textKit2LayoutManager getLinesForAttributedString:testCase.attributedString + paragraphAttributes:testCase.paragraphAttributes + size:CGSizeMake(testCase.width, CGFLOAT_MAX)]; + XCTAssertEqual(lines.size(), 2); + XCTAssertLessThanOrEqual(lines[1].frame.size.width, testCase.width); +} + +@end diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 0c879d6415f9..e7cd891a7974 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ /** @@ -216,6 +216,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enableIOSTextBaselineOffsetPerLine(): Boolean = accessor.enableIOSTextBaselineOffsetPerLine() + /** + * Lays out, measures and draws iOS Text with TextKit 2 (NSTextLayoutManager) instead of TextKit 1 (NSLayoutManager). Requires iOS 16 or later; older versions keep using TextKit 1. + */ + @JvmStatic + public fun enableIOSTextKit2(): Boolean = accessor.enableIOSTextKit2() + /** * iOS Views will clip to their padding box vs border box */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 28329721fab8..cc61a886d4fc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<71d0554dc45df7d5365f81b7ec8c3d5b>> + * @generated SignedSource<<33d9dada6b04f897acaf50dd101f760f>> */ /** @@ -51,6 +51,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableFontScaleChangesUpdatingLayoutCache: Boolean? = null private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null + private var enableIOSTextKit2Cache: Boolean? = null private var enableIOSViewClipToPaddingBoxCache: Boolean? = null private var enableImagePrefetchingAndroidCache: Boolean? = null private var enableImageTransparentTintColorCache: Boolean? = null @@ -394,6 +395,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enableIOSTextKit2(): Boolean { + var cached = enableIOSTextKit2Cache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enableIOSTextKit2() + enableIOSTextKit2Cache = cached + } + return cached + } + override fun enableIOSViewClipToPaddingBox(): Boolean { var cached = enableIOSViewClipToPaddingBoxCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 6f83f1cc3dbd..eeaa1c93b939 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<312cec99bd429d22e25f4e82d7fc4ee7>> + * @generated SignedSource<<7978a4fcf26e09ab537b8cd12f4743f5>> */ /** @@ -90,6 +90,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enableIOSTextBaselineOffsetPerLine(): Boolean + @DoNotStrip @JvmStatic public external fun enableIOSTextKit2(): Boolean + @DoNotStrip @JvmStatic public external fun enableIOSViewClipToPaddingBox(): Boolean @DoNotStrip @JvmStatic public external fun enableImagePrefetchingAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index f4998cb8b416..ebcee0a8bf10 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<669f16e131d4a5ddc026ffecc6893575>> + * @generated SignedSource<<39f216d172695fdbe82fa717d06c876f>> */ /** @@ -85,6 +85,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enableIOSTextBaselineOffsetPerLine(): Boolean = false + override fun enableIOSTextKit2(): Boolean = false + override fun enableIOSViewClipToPaddingBox(): Boolean = false override fun enableImagePrefetchingAndroid(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 536ee78905d7..b2d90ed8ddf7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<0bd25bf6a785133ec204bafca5e34c2c>> */ /** @@ -55,6 +55,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableFontScaleChangesUpdatingLayoutCache: Boolean? = null private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null + private var enableIOSTextKit2Cache: Boolean? = null private var enableIOSViewClipToPaddingBoxCache: Boolean? = null private var enableImagePrefetchingAndroidCache: Boolean? = null private var enableImageTransparentTintColorCache: Boolean? = null @@ -429,6 +430,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enableIOSTextKit2(): Boolean { + var cached = enableIOSTextKit2Cache + if (cached == null) { + cached = currentProvider.enableIOSTextKit2() + accessedFeatureFlags.add("enableIOSTextKit2") + enableIOSTextKit2Cache = cached + } + return cached + } + override fun enableIOSViewClipToPaddingBox(): Boolean { var cached = enableIOSViewClipToPaddingBoxCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 0143013e3ccd..79a6c79d3e6f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<18267103ef4f3eb22f23a15b808528c2>> + * @generated SignedSource<<074d3690279f3661f0c43f0499f642ac>> */ /** @@ -85,6 +85,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enableIOSTextBaselineOffsetPerLine(): Boolean + @DoNotStrip public fun enableIOSTextKit2(): Boolean + @DoNotStrip public fun enableIOSViewClipToPaddingBox(): Boolean @DoNotStrip public fun enableImagePrefetchingAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 526398c18c11..99498e50f68d 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<568d6e4175fb51e09bc3d4441ffa9741>> + * @generated SignedSource<> */ /** @@ -225,6 +225,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enableIOSTextKit2() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableIOSTextKit2"); + return method(javaProvider_); + } + bool enableIOSViewClipToPaddingBox() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableIOSViewClipToPaddingBox"); @@ -762,6 +768,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableIOSTextBaselineOffsetPerLine( return ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine(); } +bool JReactNativeFeatureFlagsCxxInterop::enableIOSTextKit2( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enableIOSTextKit2(); +} + bool JReactNativeFeatureFlagsCxxInterop::enableIOSViewClipToPaddingBox( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox(); @@ -1201,6 +1212,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enableIOSTextBaselineOffsetPerLine", JReactNativeFeatureFlagsCxxInterop::enableIOSTextBaselineOffsetPerLine), + makeNativeMethod( + "enableIOSTextKit2", + JReactNativeFeatureFlagsCxxInterop::enableIOSTextKit2), makeNativeMethod( "enableIOSViewClipToPaddingBox", JReactNativeFeatureFlagsCxxInterop::enableIOSViewClipToPaddingBox), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 5f3fdda386cd..8da83e5fdacf 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2e0f936c003aafc456ab3f960ca4b2f3>> + * @generated SignedSource<> */ /** @@ -123,6 +123,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enableIOSTextBaselineOffsetPerLine( facebook::jni::alias_ref); + static bool enableIOSTextKit2( + facebook::jni::alias_ref); + static bool enableIOSViewClipToPaddingBox( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index adc3531518ef..ac986700998b 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ /** @@ -150,6 +150,10 @@ bool ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine() { return getAccessor().enableIOSTextBaselineOffsetPerLine(); } +bool ReactNativeFeatureFlags::enableIOSTextKit2() { + return getAccessor().enableIOSTextKit2(); +} + bool ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox() { return getAccessor().enableIOSViewClipToPaddingBox(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index cd4d40533d5f..01dd5e7ae392 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<62dbb584d6aaf595314636a81346e3e5>> + * @generated SignedSource<<31c3cf5d353b9888d972e33c4bfb0910>> */ /** @@ -196,6 +196,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enableIOSTextBaselineOffsetPerLine(); + /** + * Lays out, measures and draws iOS Text with TextKit 2 (NSTextLayoutManager) instead of TextKit 1 (NSLayoutManager). Requires iOS 16 or later; older versions keep using TextKit 1. + */ + RN_EXPORT static bool enableIOSTextKit2(); + /** * iOS Views will clip to their padding box vs border box */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 93ef1cc62ace..446ccce888af 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<2147589380944413c0de1345af24998c>> */ /** @@ -587,6 +587,24 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enableIOSTextKit2() { + auto flagValue = enableIOSTextKit2_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(31, "enableIOSTextKit2"); + + flagValue = currentProvider_->enableIOSTextKit2(); + enableIOSTextKit2_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enableIOSViewClipToPaddingBox() { auto flagValue = enableIOSViewClipToPaddingBox_.load(); @@ -596,7 +614,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSViewClipToPaddingBox() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(31, "enableIOSViewClipToPaddingBox"); + markFlagAsAccessed(32, "enableIOSViewClipToPaddingBox"); flagValue = currentProvider_->enableIOSViewClipToPaddingBox(); enableIOSViewClipToPaddingBox_ = flagValue; @@ -614,7 +632,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImagePrefetchingAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(32, "enableImagePrefetchingAndroid"); + markFlagAsAccessed(33, "enableImagePrefetchingAndroid"); flagValue = currentProvider_->enableImagePrefetchingAndroid(); enableImagePrefetchingAndroid_ = flagValue; @@ -632,7 +650,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImageTransparentTintColor() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(33, "enableImageTransparentTintColor"); + markFlagAsAccessed(34, "enableImageTransparentTintColor"); flagValue = currentProvider_->enableImageTransparentTintColor(); enableImageTransparentTintColor_ = flagValue; @@ -650,7 +668,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImmediateUpdateModeForContentOffsetC // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(34, "enableImmediateUpdateModeForContentOffsetChanges"); + markFlagAsAccessed(35, "enableImmediateUpdateModeForContentOffsetChanges"); flagValue = currentProvider_->enableImmediateUpdateModeForContentOffsetChanges(); enableImmediateUpdateModeForContentOffsetChanges_ = flagValue; @@ -668,7 +686,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(35, "enableImperativeEvents"); + markFlagAsAccessed(36, "enableImperativeEvents"); flagValue = currentProvider_->enableImperativeEvents(); enableImperativeEvents_ = flagValue; @@ -686,7 +704,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeFocus() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(36, "enableImperativeFocus"); + markFlagAsAccessed(37, "enableImperativeFocus"); flagValue = currentProvider_->enableImperativeFocus(); enableImperativeFocus_ = flagValue; @@ -704,7 +722,7 @@ bool ReactNativeFeatureFlagsAccessor::enableInteropViewManagerClassLookUpOptimiz // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(37, "enableInteropViewManagerClassLookUpOptimizationIOS"); + markFlagAsAccessed(38, "enableInteropViewManagerClassLookUpOptimizationIOS"); flagValue = currentProvider_->enableInteropViewManagerClassLookUpOptimizationIOS(); enableInteropViewManagerClassLookUpOptimizationIOS_ = flagValue; @@ -722,7 +740,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIntersectionObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(38, "enableIntersectionObserverByDefault"); + markFlagAsAccessed(39, "enableIntersectionObserverByDefault"); flagValue = currentProvider_->enableIntersectionObserverByDefault(); enableIntersectionObserverByDefault_ = flagValue; @@ -740,7 +758,7 @@ bool ReactNativeFeatureFlagsAccessor::enableKeyEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(39, "enableKeyEvents"); + markFlagAsAccessed(40, "enableKeyEvents"); flagValue = currentProvider_->enableKeyEvents(); enableKeyEvents_ = flagValue; @@ -758,7 +776,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(40, "enableLayoutAnimationsOnAndroid"); + markFlagAsAccessed(41, "enableLayoutAnimationsOnAndroid"); flagValue = currentProvider_->enableLayoutAnimationsOnAndroid(); enableLayoutAnimationsOnAndroid_ = flagValue; @@ -776,7 +794,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnIOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(41, "enableLayoutAnimationsOnIOS"); + markFlagAsAccessed(42, "enableLayoutAnimationsOnIOS"); flagValue = currentProvider_->enableLayoutAnimationsOnIOS(); enableLayoutAnimationsOnIOS_ = flagValue; @@ -794,7 +812,7 @@ bool ReactNativeFeatureFlagsAccessor::enableModuleArgumentNSNullConversionIOS() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(42, "enableModuleArgumentNSNullConversionIOS"); + markFlagAsAccessed(43, "enableModuleArgumentNSNullConversionIOS"); flagValue = currentProvider_->enableModuleArgumentNSNullConversionIOS(); enableModuleArgumentNSNullConversionIOS_ = flagValue; @@ -812,7 +830,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMountingCoordinatorPullModelAndroid( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(43, "enableMountingCoordinatorPullModelAndroid"); + markFlagAsAccessed(44, "enableMountingCoordinatorPullModelAndroid"); flagValue = currentProvider_->enableMountingCoordinatorPullModelAndroid(); enableMountingCoordinatorPullModelAndroid_ = flagValue; @@ -830,7 +848,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMutationObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(44, "enableMutationObserverByDefault"); + markFlagAsAccessed(45, "enableMutationObserverByDefault"); flagValue = currentProvider_->enableMutationObserverByDefault(); enableMutationObserverByDefault_ = flagValue; @@ -848,7 +866,7 @@ bool ReactNativeFeatureFlagsAccessor::enableNativeCSSParsing() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(45, "enableNativeCSSParsing"); + markFlagAsAccessed(46, "enableNativeCSSParsing"); flagValue = currentProvider_->enableNativeCSSParsing(); enableNativeCSSParsing_ = flagValue; @@ -866,7 +884,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreallocatedPropsDiffOnInsertAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(46, "enablePreallocatedPropsDiffOnInsertAndroid"); + markFlagAsAccessed(47, "enablePreallocatedPropsDiffOnInsertAndroid"); flagValue = currentProvider_->enablePreallocatedPropsDiffOnInsertAndroid(); enablePreallocatedPropsDiffOnInsertAndroid_ = flagValue; @@ -884,7 +902,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(47, "enablePreparedTextLayout"); + markFlagAsAccessed(48, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(49, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enableResizeObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enableResizeObserverByDefault"); + markFlagAsAccessed(50, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(51, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableViewCulling"); + markFlagAsAccessed(52, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableViewRecycling"); + markFlagAsAccessed(53, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "enableViewRecyclingForImage"); + markFlagAsAccessed(54, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(55, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "enableViewRecyclingForText"); + markFlagAsAccessed(56, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "enableViewRecyclingForView"); + markFlagAsAccessed(57, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(58, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::fixBorderlessRippleAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(59, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(60, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fixTextLayoutOnPixelDensityChange() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fixTextLayoutOnPixelDensityChange"); + markFlagAsAccessed(61, "fixTextLayoutOnPixelDensityChange"); flagValue = currentProvider_->fixTextLayoutOnPixelDensityChange(); fixTextLayoutOnPixelDensityChange_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(62, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(63, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "fuseboxEnabledRelease"); + markFlagAsAccessed(64, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(65, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1208,7 +1226,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(66, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(67, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(68, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(69, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "perfIssuesEnabled"); + markFlagAsAccessed(70, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "perfMonitorV2Enabled"); + markFlagAsAccessed(71, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1316,7 +1334,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "preparedTextCacheSize"); + markFlagAsAccessed(72, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(73, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "redBoxV2Android"); + markFlagAsAccessed(74, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "redBoxV2IOS"); + markFlagAsAccessed(75, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(76, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(77, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(78, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(79, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(80, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(81, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(82, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(83, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "useFabricInterop"); + markFlagAsAccessed(84, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(85, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "useNestedScrollViewAndroid"); + markFlagAsAccessed(86, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useSharedAnimatedBackend"); + markFlagAsAccessed(87, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1604,7 +1622,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(87, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(88, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1622,7 +1640,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(88, "useTurboModuleInterop"); + markFlagAsAccessed(89, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1640,7 +1658,7 @@ bool ReactNativeFeatureFlagsAccessor::useWindowDensityForDisplayMetricsAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(89, "useWindowDensityForDisplayMetricsAndroid"); + markFlagAsAccessed(90, "useWindowDensityForDisplayMetricsAndroid"); flagValue = currentProvider_->useWindowDensityForDisplayMetricsAndroid(); useWindowDensityForDisplayMetricsAndroid_ = flagValue; @@ -1658,7 +1676,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(90, "viewCullingOutsetRatio"); + markFlagAsAccessed(91, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1676,7 +1694,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(91, "viewTransitionEnabled"); + markFlagAsAccessed(92, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1694,7 +1712,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(92, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(93, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1712,7 +1730,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(93, "virtualViewPrerenderRatio"); + markFlagAsAccessed(94, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 5c05c3bc5427..a241f9e782fa 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<074e6ad1d773f289f93ce1d16656ec6e>> + * @generated SignedSource<> */ /** @@ -65,6 +65,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableFontScaleChangesUpdatingLayout(); bool enableIOSCompressedTextFrameAdjustment(); bool enableIOSTextBaselineOffsetPerLine(); + bool enableIOSTextKit2(); bool enableIOSViewClipToPaddingBox(); bool enableImagePrefetchingAndroid(); bool enableImageTransparentTintColor(); @@ -139,7 +140,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 94> accessedFeatureFlags_; + std::array, 95> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -172,6 +173,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableFontScaleChangesUpdatingLayout_; std::atomic> enableIOSCompressedTextFrameAdjustment_; std::atomic> enableIOSTextBaselineOffsetPerLine_; + std::atomic> enableIOSTextKit2_; std::atomic> enableIOSViewClipToPaddingBox_; std::atomic> enableImagePrefetchingAndroid_; std::atomic> enableImageTransparentTintColor_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index 82208e3861b9..ab6241529531 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<52db1adfed5fd99e4ca145b50dc413a0>> + * @generated SignedSource<<710e3219e8ad9f87c71f74c53587d938>> */ /** @@ -153,6 +153,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enableIOSTextKit2() override { + return false; + } + bool enableIOSViewClipToPaddingBox() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 4d40ac107fc4..df6abdf9378c 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<8d27c5f3feb4c8000922012882701b7e>> + * @generated SignedSource<> */ /** @@ -326,6 +326,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enableIOSTextBaselineOffsetPerLine(); } + bool enableIOSTextKit2() override { + auto value = values_["enableIOSTextKit2"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enableIOSTextKit2(); + } + bool enableIOSViewClipToPaddingBox() override { auto value = values_["enableIOSViewClipToPaddingBox"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 3ba8405c14ff..b7a1d82d35da 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<7e5839223cfce81a42708f876c4c77ee>> + * @generated SignedSource<<7f9fc56a71755cc50511b043fedb0766>> */ /** @@ -58,6 +58,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableFontScaleChangesUpdatingLayout() = 0; virtual bool enableIOSCompressedTextFrameAdjustment() = 0; virtual bool enableIOSTextBaselineOffsetPerLine() = 0; + virtual bool enableIOSTextKit2() = 0; virtual bool enableIOSViewClipToPaddingBox() = 0; virtual bool enableImagePrefetchingAndroid() = 0; virtual bool enableImageTransparentTintColor() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index f2d4210f8d23..06e0871bfb97 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<0fe9327863b07c41f72b3c13b1f3b937>> */ /** @@ -199,6 +199,11 @@ bool NativeReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine( return ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine(); } +bool NativeReactNativeFeatureFlags::enableIOSTextKit2( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enableIOSTextKit2(); +} + bool NativeReactNativeFeatureFlags::enableIOSViewClipToPaddingBox( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 4aafb51f00fc..afa893dd9867 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<84b944e162fc53799326c584a5edd17d>> + * @generated SignedSource<<69d34615a3e06c984f24fd71ebdd17ff>> */ /** @@ -100,6 +100,8 @@ class NativeReactNativeFeatureFlags bool enableIOSTextBaselineOffsetPerLine(jsi::Runtime& runtime); + bool enableIOSTextKit2(jsi::Runtime& runtime); + bool enableIOSViewClipToPaddingBox(jsi::Runtime& runtime); bool enableImagePrefetchingAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextKit2Layout.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextKit2Layout.h new file mode 100644 index 000000000000..71ef3fa3c2cf --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextKit2Layout.h @@ -0,0 +1,131 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Called for every laid out line, in order. Mirrors the arguments of + * `-[NSLayoutManager enumerateLineFragmentsForGlyphRange:usingBlock:]`, with character ranges instead of glyph ranges. + * `rect` spans the whole width of the text container. `baseline` is the distance from the top of the line to the + * baseline of its first character, including that character's `NSBaselineOffsetAttributeName` (as returned by + * `-[NSLayoutManager locationForGlyphAtIndex:]`). + */ +typedef void ( + ^RCTTextKit2LineFragmentBlock)(NSRange characterRange, CGRect rect, CGRect usedRect, CGFloat baseline, BOOL *stop); + +/** + * A TextKit 2 (`NSTextLayoutManager`) text stack that answers the layout queries `RCTTextLayoutManager` used to ask + * TextKit 1 (`NSLayoutManager`), with TextKit 1 compatible semantics where the two engines differ. + * + * Like the TextKit 1 stack it replaces, an instance must only be used from one thread at a time. + */ +API_AVAILABLE(ios(16.0), tvos(16.0)) +@interface RCTTextKit2Layout : NSObject + +/** + * Takes ownership of `textStorage` (`NSTextStorage` cannot be shared between text stacks) and prepares it for + * TextKit 2 layout in a container of `size`. `lineBreakMode` describes the last line, as + * `-[NSTextContainer lineBreakMode]` does. + */ +- (instancetype)initWithTextStorage:(NSTextStorage *)textStorage + size:(CGSize)size + maximumNumberOfLines:(NSUInteger)maximumNumberOfLines + lineBreakMode:(NSLineBreakMode)lineBreakMode NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; + +@property (nonatomic, readonly) NSTextStorage *textStorage; +@property (nonatomic, readonly) NSTextLayoutManager *textLayoutManager; +@property (nonatomic, readonly) NSTextContainer *textContainer; + +/** + * Lays out all the text that fits into the container. Every query below does it first, so calling it directly is only + * needed to control when the work happens. + */ +- (void)ensureLayout; + +/** + * Enumerates laid out lines. Like its TextKit 1 counterpart, it does not report the extra line fragment that follows + * a trailing line break; see `extraLineFragmentUsedRect`. + */ +- (void)enumerateLineFragmentsUsingBlock:(NS_NOESCAPE RCTTextKit2LineFragmentBlock)block; + +/** + * The equivalent of `-[NSLayoutManager usedRectForTextContainer:]`: the union of the used rects of all lines, + * including the extra line fragment. + */ +@property (nonatomic, readonly) CGRect usedRect; + +/** + * The equivalent of `-[NSLayoutManager extraLineFragmentUsedRect]`: the empty line that follows a trailing line + * break, or `CGRectZero` if the text does not end with one. + */ +@property (nonatomic, readonly) CGRect extraLineFragmentUsedRect; + +/** + * The characters that belong to laid out lines; the equivalent of mapping + * `-[NSLayoutManager glyphRangeForTextContainer:]` to characters. + */ +@property (nonatomic, readonly) NSRange characterRangeOfLaidOutText; + +/** + * Characters elided by tail truncation on the last line, including the one whose place the ellipsis takes, or + * `{NSNotFound, 0}`. The equivalent of `-[NSLayoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:]`. + */ +@property (nonatomic, readonly) NSRange truncatedCharacterRange; + +/** + * The rect `-[NSLayoutManager boundingRectForGlyphRange:inTextContainer:]` returns for an attachment character: the + * attachment's horizontal extent and the vertical extent of its line. `CGRectNull` if the attachment is not displayed + * (it is on a line that does not fit or was elided by truncation). + */ +- (CGRect)boundingRectForAttachmentAtCharacterIndex:(NSUInteger)characterIndex; + +/** + * The bounding rect of all laid out glyphs, including the parts that draw outside of their lines. The equivalent of + * `-[NSLayoutManager boundingRectForGlyphRange:inTextContainer:]` for all laid out glyphs. + */ +@property (nonatomic, readonly) CGRect glyphBoundingRect; + +/** + * The equivalent of `-[NSLayoutManager + * characterIndexForPoint:inTextContainer:fractionOfDistanceBetweenInsertionPoints:]`. Points above the text map to the + * first character and points after the end of a line map to its last character with a fraction of 1. + */ +- (NSUInteger)characterIndexForPoint:(CGPoint)point fractionOfDistanceBetweenInsertionPoints:(CGFloat *)fraction; + +/** + * The equivalent of `-[NSLayoutManager enumerateEnclosingRectsForGlyphRange:withinSelectedGlyphRange:inTextContainer: + * usingBlock:]` for a character range: one rect per line, extended to the end of the line when the range continues on + * the next one. + */ +- (void)enumerateEnclosingRectsForCharacterRange:(NSRange)characterRange + usingBlock:(NS_NOESCAPE void (^)(CGRect rect, BOOL *stop))block; + +/** + * Enumerates the rects the characters of `characterRange` occupy, at least one per line they are laid out on. Each + * rect spans the line vertically and the characters (including trailing whitespace) horizontally. + */ +- (void)enumerateTextSegmentsForCharacterRange:(NSRange)characterRange + usingBlock:(NS_NOESCAPE void (^)(CGRect rect, BOOL *stop))block; + +/** + * Draws the backgrounds and glyphs of all laid out lines, with the container's origin at `point`. + */ +- (void)drawAtPoint:(CGPoint)point inContext:(CGContextRef)context; + +/** + * Scales the fonts of the text storage so that the text fits the container without truncation; see + * `-[NSTextStorage scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:]`. + */ +- (void)scaleFontSizeToFitWithMinimumFontSize:(CGFloat)minimumFontSize maximumFontSize:(CGFloat)maximumFontSize; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextKit2Layout.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextKit2Layout.mm new file mode 100644 index 000000000000..f96c31d3b8cd --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextKit2Layout.mm @@ -0,0 +1,735 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTTextKit2Layout.h" + +#import +#import + +#import +#import + +/** + * TextKit 1 clamps the size of text containers to 10,000,000 points. Without the clamp, TextKit 2 positions + * centered and right aligned lines in a container of unbounded width at coordinates too large to measure them. + */ +static constexpr CGFloat RCTTextKit2MaximumContainerWidth = 10000000; + +namespace { + +/** + * A laid out line, with TextKit 1 compatible geometry, in the text container's coordinate space. + */ +struct RCTTextKit2Line { + NSTextLineFragment *lineFragment; + // The location of the line fragment's paragraph in the text storage. + NSUInteger paragraphLocation; + NSRange characterRange; + CGRect usedRect; +}; + +struct RCTTextKit2LayoutSnapshot { + std::vector lines; + CGRect extraLineFragmentUsedRect = CGRectZero; + std::optional truncatedCharacterRange; +}; + +} // namespace + +/** + * TextKit 1 aligns `NSTextAlignmentNatural` paragraphs according to their base writing direction, falling back to the + * default writing direction of the user's language. TextKit 2 resolves it from the paragraph's content instead (so + * Arabic text in a left-to-right app is right aligned). Resolve it the TextKit 1 way up front. React Native sets an + * explicit alignment for right-to-left layouts, so in practice this affects right-to-left text in left-to-right apps. + */ +static void RCTResolveNaturalTextAlignment(NSMutableAttributedString *attributedString) +{ + NSWritingDirection defaultWritingDirection = [NSParagraphStyle defaultWritingDirectionForLanguage:nil]; + [attributedString + enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSParagraphStyle *_Nullable paragraphStyle, NSRange range, BOOL *stop) { + if (paragraphStyle != nil && paragraphStyle.alignment != NSTextAlignmentNatural) { + return; + } + NSWritingDirection writingDirection = + paragraphStyle != nil ? paragraphStyle.baseWritingDirection : NSWritingDirectionNatural; + if (writingDirection == NSWritingDirectionNatural) { + writingDirection = defaultWritingDirection; + } + NSMutableParagraphStyle *resolvedParagraphStyle = + [paragraphStyle ?: [NSParagraphStyle defaultParagraphStyle] mutableCopy]; + resolvedParagraphStyle.alignment = + writingDirection == NSWritingDirectionRightToLeft ? NSTextAlignmentRight : NSTextAlignmentLeft; + [attributedString addAttribute:NSParagraphStyleAttributeName value:resolvedParagraphStyle range:range]; + }]; +} + +/** + * With one line and `NSLineBreakByClipping`, TextKit 1 puts the whole paragraph on that line and clips it at the edge + * of the container, while TextKit 2 wraps it and drops the following lines. A paragraph that clips instead of + * wrapping gives the TextKit 1 behavior. + */ +static void RCTApplyClippingLineBreakMode(NSMutableAttributedString *attributedString) +{ + [attributedString enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSParagraphStyle *_Nullable paragraphStyle, NSRange range, BOOL *stop) { + NSMutableParagraphStyle *clippingParagraphStyle = + [paragraphStyle ?: [NSParagraphStyle defaultParagraphStyle] mutableCopy]; + clippingParagraphStyle.lineBreakMode = NSLineBreakByClipping; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:clippingParagraphStyle + range:range]; + }]; +} + +static BOOL RCTIsLineSeparatorParagraph(NSTextLineFragment *lineFragment) +{ + NSString *paragraph = lineFragment.attributedString.string; + return paragraph.length > 0 && lineFragment.characterRange.location == 0 && + lineFragment.characterRange.length == paragraph.length && + [paragraph stringByTrimmingCharactersInSet:[NSCharacterSet newlineCharacterSet]].length == 0; +} + +@interface RCTTextKit2Layout () +@end + +@implementation RCTTextKit2Layout { + NSTextContentStorage *_textContentStorage; + // The layout of the current contents of the text storage; reset when they change. + std::optional _snapshot; +} + +- (instancetype)initWithTextStorage:(NSTextStorage *)textStorage + size:(CGSize)size + maximumNumberOfLines:(NSUInteger)maximumNumberOfLines + lineBreakMode:(NSLineBreakMode)lineBreakMode +{ + if (self = [super init]) { + _textStorage = textStorage; + + [textStorage beginEditing]; + RCTResolveNaturalTextAlignment(textStorage); + if (maximumNumberOfLines == 1 && lineBreakMode == NSLineBreakByClipping) { + RCTApplyClippingLineBreakMode(textStorage); + } + [textStorage endEditing]; + + _textContainer = [[NSTextContainer alloc] + initWithSize:CGSizeMake(MIN(size.width, RCTTextKit2MaximumContainerWidth), size.height)]; + _textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5. + _textContainer.lineBreakMode = lineBreakMode; + _textContainer.maximumNumberOfLines = maximumNumberOfLines; + + _textLayoutManager = [NSTextLayoutManager new]; + _textLayoutManager.usesFontLeading = NO; + _textLayoutManager.textContainer = _textContainer; + + _textContentStorage = [NSTextContentStorage new]; + [_textContentStorage addTextLayoutManager:_textLayoutManager]; + _textContentStorage.textStorage = textStorage; + + textStorage.delegate = self; + } + return self; +} + +#pragma mark - NSTextStorageDelegate + +- (void)textStorage:(NSTextStorage *)textStorage + didProcessEditing:(NSTextStorageEditActions)editedMask + range:(NSRange)editedRange + changeInLength:(NSInteger)delta +{ + _snapshot.reset(); +} + +- (void)ensureLayout +{ + [_textLayoutManager ensureLayoutForRange:_textContentStorage.documentRange]; +} + +#pragma mark - Private + +- (NSUInteger)_characterIndexForLocation:(id)location +{ + return [_textContentStorage offsetFromLocation:_textContentStorage.documentRange.location toLocation:location]; +} + +- (nullable NSTextRange *)_textRangeForCharacterRange:(NSRange)characterRange +{ + id documentLocation = _textContentStorage.documentRange.location; + id location = [_textContentStorage locationFromLocation:documentLocation + withOffset:characterRange.location]; + id endLocation = [_textContentStorage locationFromLocation:documentLocation + withOffset:NSMaxRange(characterRange)]; + if (location == nil || endLocation == nil) { + return nil; + } + return [[NSTextRange alloc] initWithLocation:location endLocation:endLocation]; +} + +/** + * Lays out the text and collects its lines with TextKit 1 compatible geometry. + */ +- (const RCTTextKit2LayoutSnapshot &)_layoutSnapshot +{ + if (!_snapshot) { + _snapshot = [self _makeLayoutSnapshot]; + } + return *_snapshot; +} + +- (RCTTextKit2LayoutSnapshot)_makeLayoutSnapshot +{ + [self ensureLayout]; + + __block RCTTextKit2LayoutSnapshot snapshot; + __block NSUInteger lastParagraphEnd = 0; + [_textLayoutManager + enumerateTextLayoutFragmentsFromLocation:nil + options:NSTextLayoutFragmentEnumerationOptionsEnsuresLayout | + NSTextLayoutFragmentEnumerationOptionsEnsuresExtraLineFragment + usingBlock:^BOOL(NSTextLayoutFragment *layoutFragment) { + NSTextRange *paragraphRange = layoutFragment.rangeInElement; + NSUInteger paragraphLocation = + [self _characterIndexForLocation:paragraphRange.location]; + CGPoint origin = layoutFragment.layoutFragmentFrame.origin; + NSArray *lineFragments = layoutFragment.textLineFragments; + for (NSUInteger index = 0; index < lineFragments.count; index++) { + NSTextLineFragment *lineFragment = lineFragments[index]; + CGRect lineRect = + CGRectOffset(lineFragment.typographicBounds, origin.x, origin.y); + + if (lineFragment.characterRange.length == 0) { + // The extra line fragment that follows a trailing line break. + NSTextLineFragment *previousLineFragment = + index > 0 ? lineFragments[index - 1] : nil; + if (previousLineFragment != nil && + RCTIsLineSeparatorParagraph(previousLineFragment)) { + // When the last paragraph is just a line break, TextKit 2 lays out the + // extra line fragment with the metrics of the default font (Helvetica + // 12), overlapping the line break's line. TextKit 1 lays it out below, + // with the attributes of the line break, so it matches that line. + CGRect previousLineRect = snapshot.lines.back().usedRect; + snapshot.extraLineFragmentUsedRect = CGRectMake( + previousLineRect.origin.x, + CGRectGetMaxY(previousLineRect), + 0, + previousLineRect.size.height); + } else { + snapshot.extraLineFragmentUsedRect = CGRectMake( + lineRect.origin.x, lineRect.origin.y, 0, lineRect.size.height); + } + continue; + } + + // TextKit 2 adds the extra space glyphs with tall marks (e.g. Thai) need + // between lines, while TextKit 1 makes the line itself taller. + if (index + 1 < lineFragments.count && + lineFragments[index + 1].characterRange.length > 0) { + CGFloat nextLineOriginY = + origin.y + lineFragments[index + 1].typographicBounds.origin.y; + lineRect.size.height = + MAX(lineRect.size.height, nextLineOriginY - lineRect.origin.y); + } + + // Before iOS 26, the bounds of centered and right aligned lines include the + // trailing whitespace that hangs past the end of the container; TextKit 1 + // leaves it out. + CGFloat containerWidth = self->_textContainer.size.width; + if (CGRectGetMaxX(lineRect) > containerWidth) { + lineRect.size.width = MAX(containerWidth - lineRect.origin.x, 0); + } + + NSRange characterRange = lineFragment.characterRange; + snapshot.lines.push_back( + RCTTextKit2Line{ + .lineFragment = lineFragment, + .paragraphLocation = paragraphLocation, + .characterRange = NSMakeRange( + paragraphLocation + characterRange.location, characterRange.length), + .usedRect = lineRect, + }); + } + // The range of a truncated layout fragment can end before its paragraph. + lastParagraphEnd = + [self _characterIndexForLocation:layoutFragment.textElement.elementRange + .endLocation]; + return YES; + }]; + + // When the layout stops before the end of a paragraph (because of the line limit or the height of the container), + // TextKit 1 assigns the rest of the paragraph to the last line; TextKit 2 sometimes leaves out its last characters. + if (!snapshot.lines.empty()) { + RCTTextKit2Line &lastLine = snapshot.lines.back(); + if (NSMaxRange(lastLine.characterRange) < lastParagraphEnd) { + lastLine.characterRange.length = lastParagraphEnd - lastLine.characterRange.location; + } + } + + return snapshot; +} + +#pragma mark - Lines + +- (void)enumerateLineFragmentsUsingBlock:(NS_NOESCAPE RCTTextKit2LineFragmentBlock)block +{ + // A copy, because the block may change the text storage, which discards the snapshot. + std::vector lines = [self _layoutSnapshot].lines; + CGFloat containerWidth = _textContainer.size.width; + BOOL stop = NO; + for (const auto &line : lines) { + NSTextLineFragment *lineFragment = line.lineFragment; + // TextKit 2 reports the baseline of the line, while TextKit 1 reports where the first glyph is drawn, which + // includes its baseline offset. + NSNumber *baselineOffset = [lineFragment.attributedString attribute:NSBaselineOffsetAttributeName + atIndex:lineFragment.characterRange.location + effectiveRange:nil]; + CGFloat baseline = lineFragment.glyphOrigin.y - baselineOffset.doubleValue; + block( + line.characterRange, + CGRectMake(0, line.usedRect.origin.y, containerWidth, line.usedRect.size.height), + line.usedRect, + baseline, + &stop); + if (stop) { + break; + } + } +} + +- (CGRect)extraLineFragmentUsedRect +{ + return [self _layoutSnapshot].extraLineFragmentUsedRect; +} + +- (CGRect)usedRect +{ + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + CGRect usedRect = CGRectNull; + for (const auto &line : snapshot.lines) { + usedRect = CGRectUnion(usedRect, line.usedRect); + } + // Like TextKit 1, only count the extra line fragment if it fits into the container. + CGRect extraLineFragmentUsedRect = snapshot.extraLineFragmentUsedRect; + if (!CGRectEqualToRect(extraLineFragmentUsedRect, CGRectZero) && + CGRectGetMaxY(extraLineFragmentUsedRect) <= _textContainer.size.height) { + usedRect = CGRectUnion(usedRect, extraLineFragmentUsedRect); + } + return CGRectIsNull(usedRect) ? CGRectZero : usedRect; +} + +- (NSRange)characterRangeOfLaidOutText +{ + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + return NSMakeRange(0, snapshot.lines.empty() ? 0 : NSMaxRange(snapshot.lines.back().characterRange)); +} + +- (NSRange)truncatedCharacterRange +{ + return [self _truncatedCharacterRange]; +} + +- (NSRange)_truncatedCharacterRange +{ + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + if (!snapshot.truncatedCharacterRange) { + _snapshot->truncatedCharacterRange = [self _truncatedCharacterRangeInSnapshot:snapshot]; + } + return *snapshot.truncatedCharacterRange; +} + +- (NSRange)_truncatedCharacterRangeInSnapshot:(const RCTTextKit2LayoutSnapshot &)snapshot +{ + NSRange notFound = NSMakeRange(NSNotFound, 0); + // TextKit 1 reports truncation only for tail truncation, on the last line that fits. + if (_textContainer.maximumNumberOfLines == 0 || _textContainer.lineBreakMode != NSLineBreakByTruncatingTail || + snapshot.lines.empty()) { + return notFound; + } + + // TextKit 2 does not expose the truncated range. The line fragment of a truncated line spans the characters that + // are elided, but they report location zero, except the first one, whose place the ellipsis takes. Only the starts + // of composed character sequences are checked: the first one of the line legitimately starts at zero, and the + // combining marks of the last one of a paragraph report zero too. + const RCTTextKit2Line &lastLine = snapshot.lines.back(); + NSTextLineFragment *lineFragment = lastLine.lineFragment; + NSString *paragraph = lineFragment.attributedString.string; + NSRange lineRange = lineFragment.characterRange; + NSUInteger firstElidedIndex = NSNotFound; + for (NSUInteger index = NSMaxRange([paragraph rangeOfComposedCharacterSequenceAtIndex:lineRange.location]); + index < NSMaxRange(lineRange); + index = NSMaxRange([paragraph rangeOfComposedCharacterSequenceAtIndex:index])) { + if ([lineFragment locationForCharacterAtIndex:index].x == 0) { + firstElidedIndex = index; + break; + } + } + if (firstElidedIndex == NSNotFound) { + return notFound; + } + + NSUInteger truncatedIndex = + lastLine.paragraphLocation + [paragraph rangeOfComposedCharacterSequenceAtIndex:firstElidedIndex - 1].location; + return NSMakeRange(truncatedIndex, NSMaxRange(lastLine.characterRange) - truncatedIndex); +} + +#pragma mark - Geometry + +- (CGRect)boundingRectForAttachmentAtCharacterIndex:(NSUInteger)characterIndex +{ + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + + id location = [_textContentStorage locationFromLocation:_textContentStorage.documentRange.location + withOffset:characterIndex]; + if (location == nil) { + return CGRectNull; + } + NSTextLayoutFragment *layoutFragment = [_textLayoutManager textLayoutFragmentForLocation:location]; + if (layoutFragment == nil || layoutFragment.state != NSTextLayoutFragmentStateLayoutAvailable) { + return CGRectNull; + } + + // TextKit 2 does not lay out attachments that are elided by truncation, and reports a zero frame for them. + CGRect attachmentFrame = [layoutFragment frameForTextAttachmentAtLocation:location]; + NSTextAttachment *attachment = [_textStorage attribute:NSAttachmentAttributeName + atIndex:characterIndex + effectiveRange:nil]; + if (CGRectEqualToRect(attachmentFrame, CGRectZero) && !CGSizeEqualToSize(attachment.bounds.size, CGSizeZero)) { + return CGRectNull; + } + + for (const auto &line : snapshot.lines) { + if (NSLocationInRange(characterIndex, line.characterRange)) { + return CGRectMake( + layoutFragment.layoutFragmentFrame.origin.x + attachmentFrame.origin.x, + line.usedRect.origin.y, + attachmentFrame.size.width, + line.usedRect.size.height); + } + } + return CGRectNull; +} + +- (CGRect)glyphBoundingRect +{ + // TextKit 1 extends the line fragment rects by the font bounding boxes of the glyphs drawn on them, positioned at + // the line's baseline. + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + CGFloat containerWidth = _textContainer.size.width; + __block CGRect boundingRect = CGRectNull; + for (const auto &line : snapshot.lines) { + CGRect lineFragmentRect = CGRectMake(0, line.usedRect.origin.y, containerWidth, line.usedRect.size.height); + boundingRect = CGRectUnion(boundingRect, lineFragmentRect); + NSTextLineFragment *lineFragment = line.lineFragment; + CGFloat baselineY = line.usedRect.origin.y + lineFragment.glyphOrigin.y; + [lineFragment.attributedString + enumerateAttributesInRange:lineFragment.characterRange + options:0 + usingBlock:^(NSDictionary *attributes, NSRange range, BOOL *stop) { + UIFont *font = attributes[NSFontAttributeName]; + if (font == nil) { + return; + } + CGRect fontBoundingBox = CTFontGetBoundingBox((__bridge CTFontRef)font); + CGFloat baselineOffset = [attributes[NSBaselineOffsetAttributeName] doubleValue]; + CGFloat top = baselineY - baselineOffset - CGRectGetMaxY(fontBoundingBox); + CGFloat bottom = baselineY - baselineOffset - CGRectGetMinY(fontBoundingBox); + boundingRect = CGRectUnion( + boundingRect, + CGRectMake(lineFragmentRect.origin.x, top, lineFragmentRect.size.width, bottom - top)); + }]; + } + return CGRectIsNull(boundingRect) ? CGRectZero : boundingRect; +} + +- (NSUInteger)characterIndexForPoint:(CGPoint)point fractionOfDistanceBetweenInsertionPoints:(CGFloat *)fraction +{ + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + *fraction = 0; + + // Like TextKit 1, points above the text hit the start of it, and points below it hit the end of it. + if (snapshot.lines.empty() || point.y < CGRectGetMinY(snapshot.lines.front().usedRect)) { + return 0; + } + const RCTTextKit2Line *hitLine = nullptr; + for (const auto &line : snapshot.lines) { + if (point.y < CGRectGetMaxY(line.usedRect)) { + hitLine = &line; + break; + } + } + if (hitLine == nullptr) { + *fraction = 1; + return NSMaxRange(snapshot.lines.back().characterRange) - 1; + } + + NSString *string = _textStorage.string; + + if (hitLine == &snapshot.lines.back()) { + // TextKit 1 spreads the characters elided by truncation evenly over the ellipsis that replaces them. + NSRange truncatedCharacterRange = [self _truncatedCharacterRange]; + if (truncatedCharacterRange.location != NSNotFound) { + CGRect ellipsisRect = [self _ellipsisRectForTruncatedCharacterRange:truncatedCharacterRange inSnapshot:snapshot]; + if (point.x >= CGRectGetMaxX(ellipsisRect)) { + // After the end of the line. If it ends with a line break, TextKit 1 maps the point to the ellipsis. + NSUInteger lastCharacterIndex = NSMaxRange(hitLine->characterRange) - 1; + *fraction = 1; + return [[NSCharacterSet newlineCharacterSet] characterIsMember:[string characterAtIndex:lastCharacterIndex]] + ? truncatedCharacterRange.location + : [string rangeOfComposedCharacterSequenceAtIndex:lastCharacterIndex].location; + } + if (point.x >= CGRectGetMinX(ellipsisRect) && point.x < CGRectGetMaxX(ellipsisRect)) { + CGFloat position = + (point.x - CGRectGetMinX(ellipsisRect)) / ellipsisRect.size.width * truncatedCharacterRange.length; + NSUInteger offset = MIN((NSUInteger)position, truncatedCharacterRange.length - 1); + *fraction = position - offset; + return truncatedCharacterRange.location + offset; + } + } + } + + NSTextLineFragment *lineFragment = hitLine->lineFragment; + CGPoint linePoint = CGPointMake(point.x - hitLine->usedRect.origin.x, point.y - hitLine->usedRect.origin.y); + NSRange lineRange = lineFragment.characterRange; + NSInteger index = [lineFragment characterIndexForPoint:linePoint]; + if (index >= (NSInteger)NSMaxRange(lineRange)) { + // TextKit 2 returns the insertion point after the line, TextKit 1 the last composed character sequence with a + // fraction of 1. + *fraction = 1; + return [string rangeOfComposedCharacterSequenceAtIndex:hitLine->paragraphLocation + NSMaxRange(lineRange) - 1] + .location; + } + index = MAX(index, (NSInteger)lineRange.location); + NSUInteger characterIndex = hitLine->paragraphLocation + index; + if ([[NSCharacterSet newlineCharacterSet] characterIsMember:[string characterAtIndex:characterIndex]]) { + // Line breaks have no width, so TextKit 1 considers points on them to be after them, unless they are before the + // start of the line. + *fraction = linePoint.x < [lineFragment locationForCharacterAtIndex:index].x ? 0 : 1; + } else { + *fraction = [lineFragment fractionOfDistanceThroughGlyphForPoint:linePoint]; + } + return characterIndex; +} + +/** + * The rect of the ellipsis that replaces the characters elided by tail truncation on the last line, or `CGRectNull` if + * the line is laid out right to left. + */ +- (CGRect)_ellipsisRectForTruncatedCharacterRange:(NSRange)truncatedCharacterRange + inSnapshot:(const RCTTextKit2LayoutSnapshot &)snapshot +{ + const RCTTextKit2Line &lastLine = snapshot.lines.back(); + NSTextLineFragment *lineFragment = lastLine.lineFragment; + CGFloat ellipsisLocation = + [lineFragment locationForCharacterAtIndex:truncatedCharacterRange.location - lastLine.paragraphLocation].x; + if ([lineFragment locationForCharacterAtIndex:lineFragment.characterRange.location].x > ellipsisLocation) { + return CGRectNull; + } + CGFloat ellipsisMinX = lastLine.usedRect.origin.x + ellipsisLocation; + return CGRectMake( + ellipsisMinX, + lastLine.usedRect.origin.y, + MAX(CGRectGetMaxX(lastLine.usedRect) - ellipsisMinX, 0), + lastLine.usedRect.size.height); +} + +- (void)enumerateEnclosingRectsForCharacterRange:(NSRange)characterRange + usingBlock:(NS_NOESCAPE void (^)(CGRect rect, BOOL *stop))block +{ + [self _enumerateTextSegmentsForCharacterRange:characterRange + type:NSTextLayoutManagerSegmentTypeHighlight + usingBlock:block]; +} + +- (void)enumerateTextSegmentsForCharacterRange:(NSRange)characterRange + usingBlock:(NS_NOESCAPE void (^)(CGRect rect, BOOL *stop))block +{ + [self _enumerateTextSegmentsForCharacterRange:characterRange + type:NSTextLayoutManagerSegmentTypeStandard + usingBlock:block]; +} + +- (void)_enumerateTextSegmentsForCharacterRange:(NSRange)characterRange + type:(NSTextLayoutManagerSegmentType)type + usingBlock:(NS_NOESCAPE void (^)(CGRect rect, BOOL *stop))block +{ + const RCTTextKit2LayoutSnapshot &snapshot = [self _layoutSnapshot]; + NSRange truncatedCharacterRange = [self _truncatedCharacterRange]; + if (truncatedCharacterRange.location == NSNotFound || + NSMaxRange(characterRange) <= truncatedCharacterRange.location) { + [self _enumerateLaidOutTextSegmentsForCharacterRange:characterRange type:type snapshot:snapshot usingBlock:block]; + return; + } + + // TextKit 2 reports empty segments for ranges that reach into the characters elided by truncation, while TextKit 1 + // maps the elided characters to the ellipsis that replaces them. Measure the visible part of the range and extend it + // with the ellipsis. + CGRect ellipsisRect = [self _ellipsisRectForTruncatedCharacterRange:truncatedCharacterRange inSnapshot:snapshot]; + if (CGRectIsNull(ellipsisRect)) { + [self _enumerateLaidOutTextSegmentsForCharacterRange:characterRange type:type snapshot:snapshot usingBlock:block]; + return; + } + const RCTTextKit2Line &lastLine = snapshot.lines.back(); + NSUInteger lastLineEnd = NSMaxRange(lastLine.characterRange); + if (type == NSTextLayoutManagerSegmentTypeHighlight && NSMaxRange(characterRange) >= lastLineEnd && + [[NSCharacterSet newlineCharacterSet] characterIsMember:[_textStorage.string characterAtIndex:lastLineEnd - 1]]) { + // Like any range that includes a line break, it extends to the end of the line. + ellipsisRect.size.width = _textContainer.size.width - ellipsisRect.origin.x; + } + + NSMutableArray *rects = [NSMutableArray new]; + if (characterRange.location < truncatedCharacterRange.location) { + [self _enumerateLaidOutTextSegmentsForCharacterRange:NSMakeRange( + characterRange.location, + truncatedCharacterRange.location - characterRange.location) + type:type + snapshot:snapshot + usingBlock:^(CGRect rect, BOOL *stop) { + [rects addObject:[NSValue valueWithCGRect:rect]]; + }]; + } + CGRect lastRect = rects.lastObject.CGRectValue; + if (rects.count > 0 && CGRectGetMidY(lastRect) >= CGRectGetMinY(ellipsisRect) && + CGRectGetMidY(lastRect) < CGRectGetMaxY(ellipsisRect)) { + rects[rects.count - 1] = [NSValue valueWithCGRect:CGRectUnion(lastRect, ellipsisRect)]; + } else { + [rects addObject:[NSValue valueWithCGRect:ellipsisRect]]; + } + + BOOL stop = NO; + for (NSValue *rect in rects) { + block(rect.CGRectValue, &stop); + if (stop) { + return; + } + } +} + +- (void)_enumerateLaidOutTextSegmentsForCharacterRange:(NSRange)characterRange + type:(NSTextLayoutManagerSegmentType)type + snapshot:(const RCTTextKit2LayoutSnapshot &)snapshot + usingBlock:(NS_NOESCAPE void (^)(CGRect rect, BOOL *stop))block +{ + NSTextRange *textRange = [self _textRangeForCharacterRange:characterRange]; + if (textRange == nil) { + return; + } + + NSMutableArray *rects = [NSMutableArray new]; + [_textLayoutManager + enumerateTextSegmentsInRange:textRange + type:type + options:NSTextLayoutManagerSegmentOptionsRangeNotRequired + usingBlock:^BOOL( + NSTextRange *_Nullable segmentRange, + CGRect segmentFrame, + CGFloat baselinePosition, + NSTextContainer *textContainer) { + // Segments span the typographic bounds of their lines; use the TextKit 1 compatible line + // geometry instead. + for (const auto &line : snapshot.lines) { + if (CGRectGetMidY(segmentFrame) < CGRectGetMaxY(line.usedRect)) { + segmentFrame.origin.y = line.usedRect.origin.y; + segmentFrame.size.height = line.usedRect.size.height; + break; + } + } + CGRect previousRect = rects.lastObject.CGRectValue; + if (type == NSTextLayoutManagerSegmentTypeHighlight && rects.count > 0 && + fabs(CGRectGetMinX(previousRect) - CGRectGetMinX(segmentFrame)) < 0.01 && + fabs(CGRectGetWidth(previousRect) - CGRectGetWidth(segmentFrame)) < 0.01 && + fabs(CGRectGetMaxY(previousRect) - CGRectGetMinY(segmentFrame)) < 0.01) { + // Like TextKit 1, merge the rects of consecutive lines the range spans entirely. + rects[rects.count - 1] = [NSValue valueWithCGRect:CGRectUnion(previousRect, segmentFrame)]; + } else { + [rects addObject:[NSValue valueWithCGRect:segmentFrame]]; + } + return YES; + }]; + + BOOL stop = NO; + for (NSValue *rect in rects) { + block(rect.CGRectValue, &stop); + if (stop) { + return; + } + } +} + +#pragma mark - Drawing + +- (void)drawAtPoint:(CGPoint)point inContext:(CGContextRef)context +{ + // TextKit 1 paints backgrounds over the enclosing rects of their ranges, which extend to the end of the line when a + // range continues on the next one, while TextKit 2 only paints behind the glyphs. Paint them the TextKit 1 way and + // keep TextKit 2 from painting them again. + __block BOOL hasBackgroundColor = NO; + [_textStorage + enumerateAttribute:NSBackgroundColorAttributeName + inRange:self.characterRangeOfLaidOutText + options:0 + usingBlock:^(UIColor *_Nullable backgroundColor, NSRange range, BOOL *stop) { + if (backgroundColor == nil) { + return; + } + hasBackgroundColor = YES; + CGContextSaveGState(context); + CGContextSetFillColorWithColor(context, backgroundColor.CGColor); + [self + enumerateEnclosingRectsForCharacterRange:range + usingBlock:^(CGRect rect, BOOL *innerStop) { + // Like TextKit 1, align the rects to device + // pixels so that adjacent ones do not leave seams. + CGRect deviceRect = CGContextConvertRectToDeviceSpace( + context, CGRectOffset(rect, point.x, point.y)); + deviceRect = CGRectMake( + round(CGRectGetMinX(deviceRect)), + round(CGRectGetMinY(deviceRect)), + round(CGRectGetMaxX(deviceRect)) - + round(CGRectGetMinX(deviceRect)), + round(CGRectGetMaxY(deviceRect)) - + round(CGRectGetMinY(deviceRect))); + CGContextFillRect( + context, CGContextConvertRectToUserSpace(context, deviceRect)); + }]; + CGContextRestoreGState(context); + }]; + if (hasBackgroundColor) { + [_textStorage removeAttribute:NSBackgroundColorAttributeName range:NSMakeRange(0, _textStorage.length)]; + } + + [self ensureLayout]; + [_textLayoutManager + enumerateTextLayoutFragmentsFromLocation:nil + options:NSTextLayoutFragmentEnumerationOptionsEnsuresLayout + usingBlock:^BOOL(NSTextLayoutFragment *layoutFragment) { + CGPoint origin = layoutFragment.layoutFragmentFrame.origin; + [layoutFragment drawAtPoint:CGPointMake(point.x + origin.x, point.y + origin.y) + inContext:context]; + return YES; + }]; +} + +#pragma mark - Font scaling + +- (void)scaleFontSizeToFitWithMinimumFontSize:(CGFloat)minimumFontSize maximumFontSize:(CGFloat)maximumFontSize +{ + [_textStorage scaleFontSizeToFitSize:_textContainer.size + minimumFontSize:minimumFontSize + maximumFontSize:maximumFontSize + measureUsingBlock:^CGSize(BOOL *isTruncated) { + *isTruncated = self.truncatedCharacterRange.location != NSNotFound; + return self.usedRect.size; + }]; +} + +@end diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index 2cd7f1bce247..026b1713d534 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -29,6 +29,22 @@ using RCTTextLayoutFragmentEnumerationBlock = */ @interface RCTTextLayoutManager : NSObject +/** + * Lays out text with TextKit 2 when the `enableIOSTextKit2` feature flag is enabled, TextKit 1 otherwise. + */ +- (instancetype)init; + +/** + * Lays out text with TextKit 2 (`NSTextLayoutManager`) if `textKit2Enabled` is set and the OS supports it (iOS 16 or + * later), and with TextKit 1 (`NSLayoutManager`) otherwise. + */ +- (instancetype)initWithTextKit2Enabled:(BOOL)textKit2Enabled NS_DESIGNATED_INITIALIZER; + +/** + * Whether this instance lays out text with TextKit 2. + */ +@property (nonatomic, readonly) BOOL usesTextKit2; + - (facebook::react::TextMeasurement)measureAttributedString:(const facebook::react::AttributedString &)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes layoutContext:(facebook::react::TextLayoutContext)layoutContext diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 3e93b60e64be..375b9cef351f 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -11,6 +11,7 @@ #import #import "RCTAttributedTextUtils.h" +#import "RCTTextKit2Layout.h" #import #import @@ -21,10 +22,195 @@ using namespace facebook::react; +/** + * Called once for every line a decorated range is laid out on, with the top of the line and the horizontal extent of + * the range on it, in the text container's coordinate space. + */ +using RCTCustomDecorationLineSegmentBlock = void (^)(CGFloat lineOriginY, CGFloat x1, CGFloat x2); +using RCTCustomDecorationLineSegmentEnumerator = + void (^)(NSRange characterRange, NS_NOESCAPE RCTCustomDecorationLineSegmentBlock block); + +/** + * Custom decoration pass: enumerate `RCTCustomDecorationAttributeName` ranges and paint each one ourselves. Covers wavy + * (no UIKit equivalent), dotted, and dashed (UIKit's pattern bits don't match browser geometry). + */ +static void RCTDrawCustomDecorations( + NSAttributedString *textStorage, + NSRange characterRange, + CGPoint origin, + NS_NOESCAPE RCTCustomDecorationLineSegmentEnumerator enumerateLineSegments) +{ + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (ctx == nullptr) { + return; + } + + [textStorage + enumerateAttribute:RCTCustomDecorationAttributeName + inRange:characterRange + options:0 + usingBlock:^(NSDictionary *_Nullable attrs, NSRange attrRange, __unused BOOL *stop) { + if (attrs == nil) { + return; + } + NSArray *lines = attrs[@"lines"]; + UIColor *strokeColor = attrs[@"color"]; + NSString *style = attrs[@"style"]; + UIFont *font = [textStorage attribute:NSFontAttributeName + atIndex:attrRange.location + effectiveRange:nullptr]; + if (font == nil || strokeColor == nil || style == nil) { + return; + } + + CGFloat fontSize = font.pointSize; + // Thickness scales with the type size so the decoration + // remains visible at small sizes and proportionate at + // large ones. ~`fontSize / 12` plus a 1.5pt floor. + CGFloat thickness = MAX(fontSize / 12.0f, 1.5f); + CGFloat wavyWavelength = 1.0f + 2.0f * std::round(2.0f * thickness + 0.5f); + CGFloat wavyCpDistance = 0.5f + std::round(3.0f * thickness + 0.5f); + + CGContextSaveGState(ctx); + CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor); + CGContextSetLineWidth(ctx, thickness); + CGContextSetShouldAntialias(ctx, YES); + + if ([style isEqualToString:@"dotted"]) { + const std::array dotIntervals = {0.0f, thickness * 2.0f}; + CGContextSetLineDash(ctx, 0, dotIntervals.data(), dotIntervals.size()); + CGContextSetLineCap(ctx, kCGLineCapRound); + } else if ([style isEqualToString:@"dashed"]) { + const std::array dashIntervals = {thickness * 2.0f, thickness}; + CGContextSetLineDash(ctx, 0, dashIntervals.data(), dashIntervals.size()); + CGContextSetLineCap(ctx, kCGLineCapButt); + } else { + CGContextSetLineCap(ctx, kCGLineCapRound); + } + + enumerateLineSegments(attrRange, ^(CGFloat lineOriginY, CGFloat segmentX1, CGFloat segmentX2) { + CGFloat x1 = segmentX1 + origin.x; + CGFloat x2 = segmentX2 + origin.x; + CGFloat baseline = lineOriginY + font.ascender + origin.y; + + for (NSString *line in lines) { + CGFloat y = 0.0f; + if ([line isEqualToString:@"underline"]) { + if ([style isEqualToString:@"wavy"]) { + y = baseline + 1.0f; + } else { + y = baseline + thickness + 1.0f; + } + } else { + y = baseline - (font.ascender + font.descender) / 2.0f + 1.0f; + } + if ([style isEqualToString:@"wavy"]) { + CGContextSaveGState(ctx); + CGContextClipToRect( + ctx, + CGRectMake(x1, y - wavyCpDistance - thickness, x2 - x1, 2 * wavyCpDistance + 2 * thickness)); + CGContextBeginPath(ctx); + CGContextMoveToPoint(ctx, x1, y); + CGFloat step = wavyWavelength / 2.0f; + CGFloat wx = x1; + while (wx < x2) { + CGFloat midX = wx + step; + CGContextAddCurveToPoint( + ctx, midX, y + wavyCpDistance, midX, y - wavyCpDistance, wx + wavyWavelength, y); + wx += wavyWavelength; + } + CGContextStrokePath(ctx); + CGContextRestoreGState(ctx); + } else { + CGContextBeginPath(ctx); + CGContextMoveToPoint(ctx, x1, y); + CGContextAddLineToPoint(ctx, x2, y); + CGContextStrokePath(ctx); + } + } + }); + + CGContextRestoreGState(ctx); + }]; +} + +/** + * Removes the colors of the truncated range so that the ellipsis replacing it takes the colors of the last visible + * character rather than the first truncated one. + */ +static void RCTApplyColorsOfLastVisibleCharacterToTruncatedRange(NSTextStorage *textStorage, NSRange characterRange) +{ + if (characterRange.location > 0 && characterRange.length > 0) { + for (NSAttributedStringKey key in @[ NSForegroundColorAttributeName, NSBackgroundColorAttributeName ]) { + [textStorage removeAttribute:key range:characterRange]; + id attribute = [textStorage attribute:key atIndex:characterRange.location - 1 effectiveRange:nil]; + if (attribute != nullptr) { + [textStorage addAttribute:key value:attribute range:characterRange]; + } + } + } +} + +static CGFloat RCTMaximumLineHeight(NSAttributedString *attributedString) +{ + __block CGFloat maximumLineHeight = 0; + [attributedString + enumerateAttribute:NSParagraphStyleAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) { + if (paragraphStyle != nil) { + maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight); + } + }]; + return maximumLineHeight; +} + +/** + * Grows `frame` vertically so that glyphs drawing outside of their (compressed) lines are not clipped, keeping the + * lines centered in the grown frame. + */ +static CGRect RCTDrawingFrameForGlyphBounds(CGRect frame, CGRect glyphBounds, CGRect *containerFrame) +{ + CGFloat glyphHeight = CGRectGetMaxY(glyphBounds) - MIN(glyphBounds.origin.y, 0); + CGFloat drawingHeight = MAX(frame.size.height, glyphHeight); + CGFloat extraHeight = drawingHeight - frame.size.height; + + CGRect localContainerFrame = frame; + localContainerFrame.origin.y -= extraHeight / 2.0; + localContainerFrame.size.height = drawingHeight; + if (containerFrame != nullptr) { + *containerFrame = localContainerFrame; + } + + CGRect drawingFrame = frame; + drawingFrame.size.height = drawingHeight; + drawingFrame.origin.y = localContainerFrame.origin.y; + if (glyphBounds.origin.y < 0) { + drawingFrame.origin.y += (drawingHeight - glyphBounds.size.height) / 2.0 - glyphBounds.origin.y; + } + return drawingFrame; +} + @implementation RCTTextLayoutManager { SimpleThreadSafeCache, 256> _cache; } +- (instancetype)init +{ + return [self initWithTextKit2Enabled:ReactNativeFeatureFlags::enableIOSTextKit2()]; +} + +- (instancetype)initWithTextKit2Enabled:(BOOL)textKit2Enabled +{ + if (self = [super init]) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + _usesTextKit2 = textKit2Enabled; + } + } + return self; +} + static NSLineBreakMode RCTNSLineBreakModeFromEllipsizeMode(EllipsizeMode ellipsizeMode) { switch (ellipsizeMode) { @@ -52,6 +238,15 @@ - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedStr } CGSize maximumSize = CGSize{layoutConstraints.maximumSize.width, CGFLOAT_MAX}; + if (_usesTextKit2) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + RCTTextKit2Layout *layout = [self _textKit2LayoutWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + size:maximumSize]; + return [self _textKit2MeasureLayout:layout paragraphAttributes:paragraphAttributes layoutContext:layoutContext]; + } + } + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:attributedString paragraphAttributes:paragraphAttributes size:maximumSize]; @@ -83,6 +278,15 @@ - (CGRect)drawingFrameForAttributedString:(const AttributedString &)attributedSt return frame; } + if (_usesTextKit2) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + return [self _textKit2DrawingFrameForAttributedString:attributedString + paragraphAttributes:paragraphAttributes + frame:frame + containerFrame:containerFrame]; + } + } + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes @@ -94,38 +298,12 @@ - (CGRect)drawingFrameForAttributedString:(const AttributedString &)attributedSt NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; [self processTruncatedAttributedText:textStorage textContainer:textContainer layoutManager:layoutManager]; - __block CGFloat maximumLineHeight = 0; - [textStorage enumerateAttribute:NSParagraphStyleAttributeName - inRange:NSMakeRange(0, textStorage.length) - options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired - usingBlock:^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) { - if (paragraphStyle != nil) { - maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight); - } - }]; - if (maximumLineHeight == 0 || glyphRange.length == 0) { + if (RCTMaximumLineHeight(textStorage) == 0 || glyphRange.length == 0) { return frame; } CGRect glyphBounds = [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]; - CGFloat glyphHeight = CGRectGetMaxY(glyphBounds) - MIN(glyphBounds.origin.y, 0); - CGFloat drawingHeight = MAX(frame.size.height, glyphHeight); - CGFloat extraHeight = drawingHeight - frame.size.height; - - CGRect localContainerFrame = frame; - localContainerFrame.origin.y -= extraHeight / 2.0; - localContainerFrame.size.height = drawingHeight; - if (containerFrame != nullptr) { - *containerFrame = localContainerFrame; - } - - CGRect drawingFrame = frame; - drawingFrame.size.height = drawingHeight; - drawingFrame.origin.y = localContainerFrame.origin.y; - if (glyphBounds.origin.y < 0) { - drawingFrame.origin.y += (drawingHeight - glyphBounds.size.height) / 2.0 - glyphBounds.origin.y; - } - return drawingFrame; + return RCTDrawingFrameForGlyphBounds(frame, glyphBounds, containerFrame); } - (void)drawAttributedString:(const AttributedString &)attributedString @@ -133,6 +311,16 @@ - (void)drawAttributedString:(const AttributedString &)attributedString frame:(CGRect)frame drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block { + if (_usesTextKit2) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + [self _textKit2DrawAttributedString:attributedString + paragraphAttributes:paragraphAttributes + frame:frame + drawHighlightPath:block]; + return; + } + } + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes @@ -157,135 +345,35 @@ - (void)drawAttributedString:(const AttributedString &)attributedString CGContextRestoreGState(context); #endif - // Custom decoration pass: enumerate `RCTCustomDecorationAttributeName` - // ranges and paint each one ourselves. Covers wavy (no UIKit equivalent), - // dotted, and dashed (UIKit's pattern bits don't match browser geometry). - { - CGContextRef ctx = UIGraphicsGetCurrentContext(); - if (ctx != nullptr) { - NSRange charRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr]; - [textStorage - enumerateAttribute:RCTCustomDecorationAttributeName - inRange:charRange - options:0 - usingBlock:^(NSDictionary *_Nullable attrs, NSRange attrRange, __unused BOOL *stop) { - if (attrs == nil) { - return; - } - NSArray *lines = attrs[@"lines"]; - UIColor *strokeColor = attrs[@"color"]; - NSString *style = attrs[@"style"]; - UIFont *font = [textStorage attribute:NSFontAttributeName - atIndex:attrRange.location - effectiveRange:nullptr]; - if (font == nil || strokeColor == nil || style == nil) { - return; - } - - CGFloat fontSize = font.pointSize; - // Thickness scales with the type size so the decoration - // remains visible at small sizes and proportionate at - // large ones. ~`fontSize / 12` plus a 1.5pt floor. - CGFloat thickness = MAX(fontSize / 12.0f, 1.5f); - CGFloat wavyWavelength = 1.0f + 2.0f * std::round(2.0f * thickness + 0.5f); - CGFloat wavyCpDistance = 0.5f + std::round(3.0f * thickness + 0.5f); - - NSRange targetGlyphRange = [layoutManager glyphRangeForCharacterRange:attrRange - actualCharacterRange:nullptr]; - - CGContextSaveGState(ctx); - CGContextSetStrokeColorWithColor(ctx, strokeColor.CGColor); - CGContextSetLineWidth(ctx, thickness); - CGContextSetShouldAntialias(ctx, YES); - - if ([style isEqualToString:@"dotted"]) { - const std::array dotIntervals = {0.0f, thickness * 2.0f}; - CGContextSetLineDash(ctx, 0, dotIntervals.data(), dotIntervals.size()); - CGContextSetLineCap(ctx, kCGLineCapRound); - } else if ([style isEqualToString:@"dashed"]) { - const std::array dashIntervals = {thickness * 2.0f, thickness}; - CGContextSetLineDash(ctx, 0, dashIntervals.data(), dashIntervals.size()); - CGContextSetLineCap(ctx, kCGLineCapButt); - } else { - CGContextSetLineCap(ctx, kCGLineCapRound); - } - - [layoutManager - enumerateLineFragmentsForGlyphRange:targetGlyphRange - usingBlock:^( - CGRect lineRect, - __unused CGRect usedRect, - NSTextContainer *_Nonnull container, - NSRange lineGlyphRange, - __unused BOOL *_Nonnull innerStop) { - NSRange intersection = - NSIntersectionRange(targetGlyphRange, lineGlyphRange); - if (intersection.length == 0) { - return; - } - CGRect firstGlyphRect = [layoutManager - boundingRectForGlyphRange:NSMakeRange(intersection.location, 1) - inTextContainer:container]; - CGRect lastGlyphRect = [layoutManager - boundingRectForGlyphRange:NSMakeRange( - NSMaxRange(intersection) - 1, 1) - inTextContainer:container]; - CGFloat x1 = firstGlyphRect.origin.x + frame.origin.x; - CGFloat x2 = CGRectGetMaxX(lastGlyphRect) + frame.origin.x; - CGFloat baseline = - lineRect.origin.y + font.ascender + frame.origin.y; - - for (NSString *line in lines) { - CGFloat y = 0.0f; - if ([line isEqualToString:@"underline"]) { - if ([style isEqualToString:@"wavy"]) { - y = baseline + 1.0f; - } else { - y = baseline + thickness + 1.0f; - } - } else { - y = baseline - (font.ascender + font.descender) / 2.0f + 1.0f; - } - if ([style isEqualToString:@"wavy"]) { - CGContextSaveGState(ctx); - CGContextClipToRect( - ctx, - CGRectMake( - x1, - y - wavyCpDistance - thickness, - x2 - x1, - 2 * wavyCpDistance + 2 * thickness)); - CGContextBeginPath(ctx); - CGContextMoveToPoint(ctx, x1, y); - CGFloat step = wavyWavelength / 2.0f; - CGFloat wx = x1; - while (wx < x2) { - CGFloat midX = wx + step; - CGContextAddCurveToPoint( - ctx, - midX, - y + wavyCpDistance, - midX, - y - wavyCpDistance, - wx + wavyWavelength, - y); - wx += wavyWavelength; - } - CGContextStrokePath(ctx); - CGContextRestoreGState(ctx); - } else { - CGContextBeginPath(ctx); - CGContextMoveToPoint(ctx, x1, y); - CGContextAddLineToPoint(ctx, x2, y); - CGContextStrokePath(ctx); - } - } - }]; - - CGContextRestoreGState(ctx); - }]; - } - } + RCTDrawCustomDecorations( + textStorage, + [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nullptr], + frame.origin, + ^(NSRange characterRange, RCTCustomDecorationLineSegmentBlock segmentBlock) { + NSRange targetGlyphRange = [layoutManager glyphRangeForCharacterRange:characterRange + actualCharacterRange:nullptr]; + [layoutManager + enumerateLineFragmentsForGlyphRange:targetGlyphRange + usingBlock:^( + CGRect lineRect, + __unused CGRect usedRect, + NSTextContainer *_Nonnull container, + NSRange lineGlyphRange, + __unused BOOL *_Nonnull innerStop) { + NSRange intersection = NSIntersectionRange(targetGlyphRange, lineGlyphRange); + if (intersection.length == 0) { + return; + } + CGRect firstGlyphRect = [layoutManager + boundingRectForGlyphRange:NSMakeRange(intersection.location, 1) + inTextContainer:container]; + CGRect lastGlyphRect = [layoutManager + boundingRectForGlyphRange:NSMakeRange(NSMaxRange(intersection) - 1, 1) + inTextContainer:container]; + segmentBlock( + lineRect.origin.y, firstGlyphRect.origin.x, CGRectGetMaxX(lastGlyphRect)); + }]; + }); if (block != nil) { __block UIBezierPath *highlightPath = nil; @@ -343,19 +431,8 @@ - (void)processTruncatedAttributedText:(NSTextStorage *)textStorage NSRange characterRange = [layoutManager characterRangeForGlyphRange:truncatedRange actualGlyphRange:nil]; - if (characterRange.location > 0 && characterRange.length > 0) { - // Remove color attributes for truncated range - for (NSAttributedStringKey key in - @[ NSForegroundColorAttributeName, NSBackgroundColorAttributeName ]) { - [textStorage removeAttribute:key range:characterRange]; - id attribute = [textStorage attribute:key - atIndex:characterRange.location - 1 - effectiveRange:nil]; - if (attribute != nullptr) { - [textStorage addAttribute:key value:attribute range:characterRange]; - } - } - } + RCTApplyColorsOfLastVisibleCharacterToTruncatedRange( + textStorage, characterRange); } } line++; @@ -367,6 +444,14 @@ - (LinesMeasurements)getLinesForAttributedString:(const facebook::react::Attribu paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes size:(CGSize)size { + if (_usesTextKit2) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + return [self _textKit2GetLinesForAttributedString:attributedString + paragraphAttributes:paragraphAttributes + size:size]; + } + } + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes @@ -454,24 +539,45 @@ - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttribute [textStorage addLayoutManager:layoutManager]; if (paragraphAttributes.adjustsFontSizeToFit) { - CGFloat maximumFontSize = [self _maximumFontSizeInAttributedString:attributedString]; - CGFloat minimumFontSize = 4.0; - if (!isnan(paragraphAttributes.minimumFontSize)) { - minimumFontSize = paragraphAttributes.minimumFontSize; - } else if (!isnan(paragraphAttributes.minimumFontScale)) { - minimumFontSize = MAX(paragraphAttributes.minimumFontScale * maximumFontSize, 4.0); - } + CGFloat minimumFontSize, maximumFontSize; + [self _getMinimumFontSize:&minimumFontSize + maximumFontSize:&maximumFontSize + forAttributedString:attributedString + paragraphAttributes:paragraphAttributes]; [textStorage scaleFontSizeToFitSize:size minimumFontSize:minimumFontSize maximumFontSize:maximumFontSize]; } return textStorage; } +- (void)_getMinimumFontSize:(CGFloat *)minimumFontSize + maximumFontSize:(CGFloat *)maximumFontSize + forAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes +{ + *maximumFontSize = [self _maximumFontSizeInAttributedString:attributedString]; + *minimumFontSize = 4.0; + if (!isnan(paragraphAttributes.minimumFontSize)) { + *minimumFontSize = paragraphAttributes.minimumFontSize; + } else if (!isnan(paragraphAttributes.minimumFontScale)) { + *minimumFontSize = MAX(paragraphAttributes.minimumFontScale * *maximumFontSize, 4.0); + } +} + - (std::shared_ptr)getEventEmitterWithAttributeString:(const AttributedString &)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes frame:(CGRect)frame atPoint:(CGPoint)point { + if (_usesTextKit2) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + return [self _textKit2GetEventEmitterWithAttributeString:attributedString + paragraphAttributes:paragraphAttributes + frame:frame + atPoint:point]; + } + } + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes @@ -503,6 +609,17 @@ - (void)getRectWithAttributedString:(const AttributedString &)attributedString frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block { + if (_usesTextKit2) { + if (@available(iOS 16.0, tvOS 16.0, *)) { + [self _textKit2GetRectWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + enumerateAttribute:enumerateAttribute + frame:frame + usingBlock:block]; + return; + } + } + NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes @@ -663,4 +780,319 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage return TextMeasurement{.size = {.width = size.width, .height = size.height}, .attachments = attachments}; } +#pragma mark - TextKit 2 + +- (RCTTextKit2Layout *)_textKit2LayoutWithAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; + + RCTApplyBaselineOffset(textStorage); + + RCTTextKit2Layout *layout = + [[RCTTextKit2Layout alloc] initWithTextStorage:textStorage + size:size + maximumNumberOfLines:paragraphAttributes.maximumNumberOfLines + lineBreakMode:paragraphAttributes.maximumNumberOfLines > 0 + ? RCTNSLineBreakModeFromEllipsizeMode(paragraphAttributes.ellipsizeMode) + : NSLineBreakByClipping]; + + if (paragraphAttributes.adjustsFontSizeToFit) { + CGFloat minimumFontSize, maximumFontSize; + [self _getMinimumFontSize:&minimumFontSize + maximumFontSize:&maximumFontSize + forAttributedString:attributedString + paragraphAttributes:paragraphAttributes]; + [layout scaleFontSizeToFitWithMinimumFontSize:minimumFontSize maximumFontSize:maximumFontSize]; + } + + return layout; +} + +- (TextMeasurement)_textKit2MeasureLayout:(RCTTextKit2Layout *)layout + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + layoutContext:(TextLayoutContext)layoutContext API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + NSTextStorage *textStorage = layout.textStorage; + NSString *string = textStorage.string; + + __block BOOL textDidWrap = NO; + __block NSUInteger linesEnumerated = 0; + __block CGFloat enumeratedLinesHeight = 0; + [layout enumerateLineFragmentsUsingBlock:^( + NSRange range, __unused CGRect rect, CGRect usedRect, __unused CGFloat baseline, BOOL *stop) { + NSUInteger lastCharacterIndex = range.location + range.length - 1; + BOOL endsWithNewLine = [string characterAtIndex:lastCharacterIndex] == '\n'; + if (!endsWithNewLine && string.length > lastCharacterIndex + 1) { + textDidWrap = YES; + } + if (linesEnumerated++ < paragraphAttributes.maximumNumberOfLines) { + enumeratedLinesHeight = usedRect.origin.y + usedRect.size.height; + } + if (textDidWrap && + (paragraphAttributes.maximumNumberOfLines == 0 || + linesEnumerated >= paragraphAttributes.maximumNumberOfLines)) { + *stop = YES; + } + }]; + + CGSize size = layout.usedRect.size; + + if (textDidWrap && paragraphAttributes.textWidthMode == TextWidthMode::Auto) { + size.width = layout.textContainer.size.width; + } + + if (paragraphAttributes.maximumNumberOfLines != 0) { + // See the TextKit 1 implementation: the height of the lines that fit is used rather than the height of the + // container's contents. + if (linesEnumerated < paragraphAttributes.maximumNumberOfLines) { + enumeratedLinesHeight += layout.extraLineFragmentUsedRect.size.height; + } + size.height = enumeratedLinesHeight; + } + + facebook::react::Size roundedSize = facebook::react::internal_roundTextMeasurementToPixelGrid( + {.width = static_cast(size.width), + .height = static_cast(size.height)}, + layoutContext.pointScaleFactor); + size = CGSize{roundedSize.width, roundedSize.height}; + + NSRange visibleCharacterRange = layout.characterRangeOfLaidOutText; + NSRange truncatedCharacterRange = layout.truncatedCharacterRange; + + __block auto attachments = TextMeasurement::Attachments{}; + + [textStorage + enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, textStorage.length) + options:0 + usingBlock:^(NSTextAttachment *attachment, NSRange range, BOOL *stop) { + if (attachment == nullptr) { + return; + } + + CGRect glyphRect = [layout boundingRectForAttachmentAtCharacterIndex:range.location]; + + // Attachment on a line that did not fit (e.g. on the 4th line when the container is limited to 3 lines) + BOOL isOutsideVisibleRange = + !NSLocationInRange(range.location, visibleCharacterRange) || CGRectIsNull(glyphRect); + // Attachment in the ellipsis range of the last visible line (line truncated with "..." and the + // attachment falls in that portion) + BOOL isInTruncatedRange = truncatedCharacterRange.location != NSNotFound && + range.location >= truncatedCharacterRange.location; + + if (isOutsideVisibleRange || isInTruncatedRange) { + attachments.push_back(TextMeasurement::Attachment{.isClipped = true}); + } else { + CGSize attachmentSize = attachment.bounds.size; + UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil]; + CGRect frame = { + .origin = + {glyphRect.origin.x, + glyphRect.origin.y + glyphRect.size.height - attachmentSize.height + font.descender}, + .size = attachmentSize}; + + auto rect = facebook::react::Rect{ + .origin = facebook::react::Point{.x = frame.origin.x, .y = frame.origin.y}, + .size = facebook::react::Size{.width = frame.size.width, .height = frame.size.height}}; + + attachments.push_back(TextMeasurement::Attachment{.frame = rect, .isClipped = false}); + } + }]; + + return TextMeasurement{.size = {.width = size.width, .height = size.height}, .attachments = attachments}; +} + +- (CGRect)_textKit2DrawingFrameForAttributedString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + frame:(CGRect)frame + containerFrame:(CGRect *)containerFrame API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + RCTTextKit2Layout *layout = + [self _textKit2LayoutWithAttributedString:[self _nsAttributedStringFromAttributedString:attributedString] + paragraphAttributes:paragraphAttributes + size:frame.size]; + + if (RCTMaximumLineHeight(layout.textStorage) == 0 || layout.characterRangeOfLaidOutText.length == 0) { + return frame; + } + + return RCTDrawingFrameForGlyphBounds(frame, layout.glyphBoundingRect, containerFrame); +} + +- (void)_textKit2DrawAttributedString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + frame:(CGRect)frame + drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block + API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + RCTTextKit2Layout *layout = + [self _textKit2LayoutWithAttributedString:[self _nsAttributedStringFromAttributedString:attributedString] + paragraphAttributes:paragraphAttributes + size:frame.size]; + NSTextStorage *textStorage = layout.textStorage; + + NSRange truncatedCharacterRange = layout.truncatedCharacterRange; + if (truncatedCharacterRange.location != NSNotFound) { + RCTApplyColorsOfLastVisibleCharacterToTruncatedRange(textStorage, truncatedCharacterRange); + } + + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context != nullptr) { +#if TARGET_OS_MACCATALYST + CGContextSaveGState(context); + CGContextSetShouldSmoothFonts(context, NO); +#endif + + [layout drawAtPoint:frame.origin inContext:context]; + +#if TARGET_OS_MACCATALYST + CGContextRestoreGState(context); +#endif + } + + NSRange characterRange = layout.characterRangeOfLaidOutText; + + RCTDrawCustomDecorations( + textStorage, + characterRange, + frame.origin, + ^(NSRange decoratedCharacterRange, RCTCustomDecorationLineSegmentBlock segmentBlock) { + [layout enumerateTextSegmentsForCharacterRange:decoratedCharacterRange + usingBlock:^(CGRect segmentRect, __unused BOOL *stop) { + segmentBlock( + segmentRect.origin.y, + CGRectGetMinX(segmentRect), + CGRectGetMaxX(segmentRect)); + }]; + }); + + if (block != nil) { + __block UIBezierPath *highlightPath = nil; + + [textStorage + enumerateAttribute:RCTAttributedStringIsHighlightedAttributeName + inRange:characterRange + options:0 + usingBlock:^(NSNumber *value, NSRange range, __unused BOOL *stop) { + if (!value.boolValue) { + return; + } + + [layout + enumerateEnclosingRectsForCharacterRange:range + usingBlock:^(CGRect enclosingRect, __unused BOOL *anotherStop) { + UIBezierPath *path = [UIBezierPath + bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2) + cornerRadius:2]; + if (highlightPath != nullptr) { + [highlightPath appendPath:path]; + } else { + highlightPath = path; + } + }]; + }]; + + block(highlightPath); + } +} + +- (LinesMeasurements)_textKit2GetLinesForAttributedString:(const facebook::react::AttributedString &)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + RCTTextKit2Layout *layout = + [self _textKit2LayoutWithAttributedString:[self _nsAttributedStringFromAttributedString:attributedString] + paragraphAttributes:paragraphAttributes + size:size]; + NSTextStorage *textStorage = layout.textStorage; + + std::vector paragraphLines{}; + auto blockParagraphLines = ¶graphLines; + + [layout enumerateLineFragmentsUsingBlock:^( + NSRange range, CGRect overallRect, CGRect usedRect, CGFloat baseline, __unused BOOL *stop) { + NSString *renderedString = [textStorage.string substringWithRange:range]; + UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil]; + auto rect = facebook::react::Rect{ + .origin = facebook::react::Point{.x = usedRect.origin.x, .y = usedRect.origin.y}, + .size = facebook::react::Size{.width = usedRect.size.width, .height = usedRect.size.height}}; + + NSData *renderedData = [renderedString dataUsingEncoding:NSUTF8StringEncoding]; + auto line = LineMeasurement{ + std::string( + renderedData != nil ? static_cast(renderedData.bytes) : "", + renderedData != nil ? renderedData.length : 0), + rect, + overallRect.size.height - baseline, + font.capHeight, + baseline, + font.xHeight}; + blockParagraphLines->push_back(line); + }]; + return paragraphLines; +} + +- (std::shared_ptr) + _textKit2GetEventEmitterWithAttributeString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + frame:(CGRect)frame + atPoint:(CGPoint)point API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + RCTTextKit2Layout *layout = + [self _textKit2LayoutWithAttributedString:[self _nsAttributedStringFromAttributedString:attributedString] + paragraphAttributes:paragraphAttributes + size:frame.size]; + NSTextStorage *textStorage = layout.textStorage; + + CGFloat fraction; + NSUInteger characterIndex = [layout characterIndexForPoint:point fractionOfDistanceBetweenInsertionPoints:&fraction]; + + // If the point is not before (fraction == 0.0) the first character and not + // after (fraction == 1.0) the last character, then the attribute is valid. + if (textStorage.length > 0 && (fraction > 0 || characterIndex > 0) && + (fraction < 1 || characterIndex < textStorage.length - 1)) { + NSData *eventEmitterWrapper = (NSData *)[textStorage attribute:RCTAttributedStringEventEmitterKey + atIndex:characterIndex + effectiveRange:NULL]; + return RCTUnwrapEventEmitter(eventEmitterWrapper); + } + + return nil; +} + +- (void)_textKit2GetRectWithAttributedString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + enumerateAttribute:(NSString *)enumerateAttribute + frame:(CGRect)frame + usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block + API_AVAILABLE(ios(16.0), tvos(16.0)) +{ + RCTTextKit2Layout *layout = + [self _textKit2LayoutWithAttributedString:[self _nsAttributedStringFromAttributedString:attributedString] + paragraphAttributes:paragraphAttributes + size:frame.size]; + NSTextStorage *textStorage = layout.textStorage; + + [textStorage + enumerateAttribute:enumerateAttribute + inRange:layout.characterRangeOfLaidOutText + options:0 + usingBlock:^(NSString *value, NSRange range, BOOL *pause) { + if (value == nullptr) { + return; + } + + [layout enumerateEnclosingRectsForCharacterRange:range + usingBlock:^(CGRect enclosingRect, BOOL *_Nonnull stop) { + block( + enclosingRect, + [textStorage attributedSubstringFromRange:range].string, + value); + *stop = YES; + }]; + }]; +} + @end diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 2c0786728b99..852f592cbf7a 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -385,6 +385,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableIOSTextKit2: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-19', + description: + 'Lays out, measures and draws iOS Text with TextKit 2 (NSTextLayoutManager) instead of TextKit 1 (NSLayoutManager). Requires iOS 16 or later; older versions keep using TextKit 1.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enableIOSViewClipToPaddingBox: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index c190a2488b08..b28115981d64 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<432678103ed00404892a32355743d484>> * @flow strict * @noformat */ @@ -80,6 +80,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableFontScaleChangesUpdatingLayout: Getter, enableIOSCompressedTextFrameAdjustment: Getter, enableIOSTextBaselineOffsetPerLine: Getter, + enableIOSTextKit2: Getter, enableIOSViewClipToPaddingBox: Getter, enableImagePrefetchingAndroid: Getter, enableImageTransparentTintColor: Getter, @@ -343,6 +344,10 @@ export const enableIOSCompressedTextFrameAdjustment: Getter = createNat * Applies base offset for each line of text separately on iOS. */ export const enableIOSTextBaselineOffsetPerLine: Getter = createNativeFlagGetter('enableIOSTextBaselineOffsetPerLine', false); +/** + * Lays out, measures and draws iOS Text with TextKit 2 (NSTextLayoutManager) instead of TextKit 1 (NSLayoutManager). Requires iOS 16 or later; older versions keep using TextKit 1. + */ +export const enableIOSTextKit2: Getter = createNativeFlagGetter('enableIOSTextKit2', false); /** * iOS Views will clip to their padding box vs border box */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 673dc0ed3bf8..5a0b4a0267f0 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<40cc7b87d7658fb5aa3d33f468eff3fc>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -56,6 +56,7 @@ export interface Spec extends TurboModule { readonly enableFontScaleChangesUpdatingLayout?: () => boolean; readonly enableIOSCompressedTextFrameAdjustment?: () => boolean; readonly enableIOSTextBaselineOffsetPerLine?: () => boolean; + readonly enableIOSTextKit2?: () => boolean; readonly enableIOSViewClipToPaddingBox?: () => boolean; readonly enableImagePrefetchingAndroid?: () => boolean; readonly enableImageTransparentTintColor?: () => boolean; diff --git a/packages/rn-tester/RNTester/SceneDelegate.mm b/packages/rn-tester/RNTester/SceneDelegate.mm index 26c4b217677f..5bace50e14c8 100644 --- a/packages/rn-tester/RNTester/SceneDelegate.mm +++ b/packages/rn-tester/RNTester/SceneDelegate.mm @@ -29,7 +29,24 @@ #import #endif +#import +#import + static NSString *const kBundlePath = @"js/RNTesterApp.ios"; +static NSString *const kTextKit2EnabledKey = @"RCTTextKit2Enabled"; + +namespace { + +// Lets RNTester switch Text between TextKit 1 and TextKit 2 with the `-RCTTextKit2Enabled YES|NO` launch argument. +class RNTesterFeatureFlags : public facebook::react::ReactNativeFeatureFlagsOverridesOSSStable { + public: + bool enableIOSTextKit2() override + { + return [[NSUserDefaults standardUserDefaults] boolForKey:kTextKit2EnabledKey]; + } +}; + +} // namespace @implementation SceneDelegate @@ -58,6 +75,10 @@ - (void)scene:(UIScene *)scene self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self releaseLevel:[self releaseLevel]]; + if ([[NSUserDefaults standardUserDefaults] objectForKey:kTextKit2EnabledKey] != nil) { + facebook::react::ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique()); + } + auto *windowScene = (UIWindowScene *)scene; self.window = [[UIWindow alloc] initWithWindowScene:windowScene]; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 483efc9b46d2..03f2a9121d13 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2,6 +2,7 @@ category NSTextStorage(FontScaling) { public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize); + public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:measureUsingBlock:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize, NS_NOESCAPE CGSize(^)(BOOL* isTruncated) measureBlock); public virtual void scaleFontSizeWithRatio:minimumFontSize:maximumFontSize:(CGFloat ratio, CGFloat minimumFontSize, CGFloat maximumFontSize); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index b1f98e0f87fc..b2b356c1e2d6 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -2,6 +2,7 @@ category NSTextStorage(FontScaling) { public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize); + public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:measureUsingBlock:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize, NS_NOESCAPE CGSize(^)(BOOL* isTruncated) measureBlock); public virtual void scaleFontSizeWithRatio:minimumFontSize:maximumFontSize:(CGFloat ratio, CGFloat minimumFontSize, CGFloat maximumFontSize); } @@ -1930,9 +1931,12 @@ interface RCTTextInputComponentView : public RCTViewComponentView { } interface RCTTextLayoutManager : public NSObject { + public @property (assign, readonly) BOOL usesTextKit2; public virtual facebook::react::LinesMeasurements getLinesForAttributedString:paragraphAttributes:size:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGSize size); public virtual facebook::react::TextMeasurement measureAttributedString:paragraphAttributes:layoutContext:layoutConstraints:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, facebook::react::TextLayoutContext layoutContext, facebook::react::LayoutConstraints layoutConstraints); public virtual facebook::react::TextMeasurement measureNSAttributedString:paragraphAttributes:layoutContext:layoutConstraints:(NSAttributedString* attributedString, facebook::react::ParagraphAttributes paragraphAttributes, facebook::react::TextLayoutContext layoutContext, facebook::react::LayoutConstraints layoutConstraints); + public virtual instancetype init(); + public virtual instancetype initWithTextKit2Enabled:(BOOL textKit2Enabled); public virtual std::shared_ptr getEventEmitterWithAttributeString:paragraphAttributes:frame:atPoint:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, CGPoint point); public virtual void drawAttributedString:paragraphAttributes:frame:drawHighlightPath:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, void(^)(UIBezierPath* highlightPath) block); public virtual void getRectWithAttributedString:paragraphAttributes:enumerateAttribute:frame:usingBlock:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, NSString* enumerateAttribute, CGRect frame, RCTTextLayoutFragmentEnumerationBlock block); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index e9ea4c1f0530..1e40e209e386 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -2,6 +2,7 @@ category NSTextStorage(FontScaling) { public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize); + public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:measureUsingBlock:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize, NS_NOESCAPE CGSize(^)(BOOL* isTruncated) measureBlock); public virtual void scaleFontSizeWithRatio:minimumFontSize:maximumFontSize:(CGFloat ratio, CGFloat minimumFontSize, CGFloat maximumFontSize); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 57197975be4b..1c725804233c 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -2,6 +2,7 @@ category NSTextStorage(FontScaling) { public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize); + public virtual void scaleFontSizeToFitSize:minimumFontSize:maximumFontSize:measureUsingBlock:(CGSize size, CGFloat minimumFontSize, CGFloat maximumFontSize, NS_NOESCAPE CGSize(^)(BOOL* isTruncated) measureBlock); public virtual void scaleFontSizeWithRatio:minimumFontSize:maximumFontSize:(CGFloat ratio, CGFloat minimumFontSize, CGFloat maximumFontSize); }