diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 16b942dadfe9..2a1f2a890496 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -421,6 +421,10 @@ void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText) string = [NSString stringWithFormat:@"%C", (unichar)NSAttachmentCharacter]; attributes = [NSMutableDictionary dictionaryWithObject:attachment forKey:NSAttachmentAttributeName]; + UIFont *font = RCTEffectiveFontFromTextAttributes(fragment.textAttributes); + if (font) { + attributes[NSFontAttributeName] = font; + } } else { NSString *decoded = [[NSString alloc] initWithBytes:fragment.string.data() length:fragment.string.size() 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 0d9100187bd2..d47460c0ce1a 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 @@ -703,13 +703,15 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage CGRect glyphRect = [layoutManager boundingRectForGlyphRange:range inTextContainer:textContainer]; CGRect frame; - UIFont *font = [[textStorage attributedSubstringFromRange:range] attribute:NSFontAttributeName - atIndex:0 - effectiveRange:nil]; + NSDictionary *attributes = [textStorage attributesAtIndex:range.location + effectiveRange:nil]; + UIFont *font = attributes[NSFontAttributeName]; + CGFloat baselineOffset = [attributes[NSBaselineOffsetAttributeName] doubleValue]; frame = { .origin = {glyphRect.origin.x, - glyphRect.origin.y + glyphRect.size.height - attachmentSize.height + font.descender}, + glyphRect.origin.y + glyphRect.size.height - attachmentSize.height + font.descender - + baselineOffset}, .size = attachmentSize}; auto rect = facebook::react::Rect{ diff --git a/packages/rn-tester/js/examples/Text/TextExample.ios.js b/packages/rn-tester/js/examples/Text/TextExample.ios.js index c3c44c305ace..9bd165f0fb98 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.ios.js +++ b/packages/rn-tester/js/examples/Text/TextExample.ios.js @@ -15,6 +15,7 @@ import type {RNTesterModule} from '../../types/RNTesterTypes'; import hotdog from '../../assets/hotdog.jpg'; import RNTesterText from '../../components/RNTesterText'; import TextLegend from '../../components/TextLegend'; +import {TextInlineViewBaselineExample} from './TextInlineViewBaselineExample'; import TextSharedExamples from './TextSharedExamples'; import TextWidthModeExample from './TextWidthModeExample'; @@ -1354,6 +1355,11 @@ const examples = [ title: 'Inline views', render: (): React.Node => , }, + { + title: 'Inline view baseline alignment', + name: 'inline-view-baseline', + render: (): React.Node => , + }, { title: 'Inline views with line height', name: 'inlineViewsLineHeight', diff --git a/packages/rn-tester/js/examples/Text/TextInlineViewBaselineExample.js b/packages/rn-tester/js/examples/Text/TextInlineViewBaselineExample.js new file mode 100644 index 000000000000..6047d00c3d65 --- /dev/null +++ b/packages/rn-tester/js/examples/Text/TextInlineViewBaselineExample.js @@ -0,0 +1,197 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import type {TextStyle} from 'react-native'; + +import RNTesterText from '../../components/RNTesterText'; +import * as React from 'react'; +import { + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, +} from 'react-native'; + +const INLINE_SIZE = 18; +const DEFAULT_FONT_SIZE = 20; +const MEDIUM_FONT_SIZE = 24; +const LARGE_FONT_SIZE = 32; +const TALL_LINE_HEIGHT = 48; +const FONT_FAMILY = 'Helvetica'; +const SERIF_FONT_FAMILY = 'TimesNewRomanPSMT'; +const SPACING = 8; +const DISPLAY_DECIMAL_PLACES = 3; + +type Sample = Readonly<{ + name: string, + textStyle: TextStyle, + height?: number, + isNested?: boolean, + isWrapped?: boolean, + isScaled?: boolean, +}>; + +const SAMPLES: Sample[] = [ + { + name: 'Helvetica 12 (control)', + textStyle: {fontFamily: FONT_FAMILY, fontSize: 12}, + }, + {name: 'System 20', textStyle: {fontSize: DEFAULT_FONT_SIZE}}, + { + name: 'Helvetica 32', + textStyle: {fontFamily: FONT_FAMILY, fontSize: LARGE_FONT_SIZE}, + }, + { + name: 'Times New Roman 32', + textStyle: {fontFamily: SERIF_FONT_FAMILY, fontSize: LARGE_FONT_SIZE}, + }, + { + name: 'Courier bold 24', + textStyle: { + fontFamily: 'Courier', + fontSize: MEDIUM_FONT_SIZE, + fontWeight: 'bold', + }, + }, + { + name: 'Helvetica 32 / lineHeight 48', + textStyle: { + fontFamily: FONT_FAMILY, + fontSize: LARGE_FONT_SIZE, + lineHeight: TALL_LINE_HEIGHT, + }, + }, + { + name: 'Nested Times New Roman 32', + textStyle: {fontFamily: SERIF_FONT_FAMILY, fontSize: LARGE_FONT_SIZE}, + isNested: true, + }, + { + name: 'Tall View / Helvetica 20', + textStyle: {fontFamily: FONT_FAMILY, fontSize: DEFAULT_FONT_SIZE}, + height: TALL_LINE_HEIGHT, + }, + { + name: 'Wrapped / Helvetica 24', + textStyle: {fontFamily: FONT_FAMILY, fontSize: MEDIUM_FONT_SIZE}, + isWrapped: true, + }, + { + name: 'Scaled / Helvetica 20', + textStyle: {fontFamily: FONT_FAMILY, fontSize: DEFAULT_FONT_SIZE}, + isScaled: true, + }, +]; + +function InlineViewBaselineSample({ + name, + textStyle, + height = INLINE_SIZE, + isNested = false, + isWrapped = false, + isScaled = false, +}: Sample): React.Node { + const [baseline, setBaseline] = React.useState(); + const [bottom, setBottom] = React.useState(); + const inlineRef = React.useRef | null>(null); + + React.useEffect(() => { + inlineRef.current?.measure((x, y, width, measuredHeight) => { + setBottom(measuredHeight > 0 ? y + measuredHeight : undefined); + }); + }, [baseline]); + + const content = ( + <> + {'Hg '} + + {' Hg'} + + ); + + return ( + + + {name} + + + { + const line = event.nativeEvent.lines[isWrapped ? 1 : 0]; + if (line != null) { + setBaseline(line.y + line.ascender); + } + }}> + {isWrapped ? 'First line\n' : ''} + {isNested ? {content} : content} + + {baseline != null ? ( + + ) : null} + + + {baseline == null + ? 'Measuring...' + : bottom == null + ? 'View measurement unavailable' + : `baseline ${baseline.toFixed(DISPLAY_DECIMAL_PLACES)} | bottom ${bottom.toFixed(DISPLAY_DECIMAL_PLACES)} | error ${(bottom - baseline).toFixed(DISPLAY_DECIMAL_PLACES)} pt`} + + + ); +} + +export function TextInlineViewBaselineExample(): React.Node { + const {scale, fontScale} = useWindowDimensions(); + + return ( + + + Each blue View should end at the red text baseline. Allow one physical + pixel for rounding. Enlarge the system text size to check the scaled + sample. + + + {`One pixel: ${(1 / scale).toFixed(DISPLAY_DECIMAL_PLACES)} pt | font scale: ${fontScale.toFixed(DISPLAY_DECIMAL_PLACES)} (last sample only)`} + + {SAMPLES.map(sample => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + example: {gap: SPACING, padding: SPACING * 2}, + sample: {gap: SPACING / 2}, + paragraph: {backgroundColor: '#f1f5f9'}, + text: {color: 'black'}, + inline: {width: INLINE_SIZE, backgroundColor: '#2563eb'}, + baseline: { + position: 'absolute', + left: 0, + right: 0, + height: 1, + backgroundColor: '#dc2626', + }, + nestedParent: {fontFamily: FONT_FAMILY, fontSize: 16}, + metrics: {fontSize: 10, fontFamily: 'Menlo'}, +});