react-native-advanced-text 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ import com.facebook.react.uimanager.events.RCTEventEmitter
25
25
  import android.text.Selection
26
26
  import android.graphics.Typeface
27
27
  import androidx.core.text.getSpans
28
+ import kotlin.math.ceil
28
29
 
29
30
  class AdvancedTextView : TextView {
30
31
 
@@ -277,7 +278,7 @@ class AdvancedTextView : TextView {
277
278
  }
278
279
 
279
280
 
280
- setLineSpacing(0f, lineHeightMultiplier)
281
+ applyLineHeight(spannableString)
281
282
 
282
283
  // Android's letterSpacing is in em; the prop is in dp/points. The ratio
283
284
  // dp / fontSize is scale-invariant, so no density conversion is needed.
@@ -294,6 +295,41 @@ class AdvancedTextView : TextView {
294
295
  }
295
296
  }
296
297
 
298
+ /**
299
+ * Applies `lineHeightMultiplier` as a CSS-style, exact-per-line-height span
300
+ * instead of `setLineSpacing(0, multiplier)`.
301
+ *
302
+ * The Fabric ShadowNode measures `lineHeight` by asking React Native's own
303
+ * TextLayoutManager to lay out a fragment with that lineHeight -- which RN
304
+ * implements with its own CSS-style line-height span (every line's box is
305
+ * forced to exactly `lineHeight`, ascent/descent redistributed evenly).
306
+ * That is a *different* algorithm from `setLineSpacing`, which scales the
307
+ * font's natural per-line advance instead; the two do not produce the same
308
+ * total height, and the gap between them is added on every wrapped line,
309
+ * so it grows with text length. Using the same CSS-style algorithm here
310
+ * (see CssLineHeightSpan) keeps this view's rendered height identical to
311
+ * what was measured, regardless of how many lines the text wraps onto.
312
+ */
313
+ private fun applyLineHeight(spannableString: SpannableString) {
314
+ if (lineHeightMultiplier == 1.0f || spannableString.isEmpty()) {
315
+ return
316
+ }
317
+
318
+ val metrics = paint.fontMetricsInt
319
+ val naturalLineHeight = -metrics.ascent + metrics.descent
320
+ if (naturalLineHeight <= 0) {
321
+ return
322
+ }
323
+
324
+ val targetLineHeight = ceil(lineHeightMultiplier * naturalLineHeight).toInt()
325
+ spannableString.setSpan(
326
+ CssLineHeightSpan(targetLineHeight),
327
+ 0,
328
+ spannableString.length,
329
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
330
+ )
331
+ }
332
+
297
333
  override fun onDraw(canvas: Canvas) {
298
334
  drawHighlightedWordBackgrounds(canvas)
299
335
  super.onDraw(canvas)
@@ -0,0 +1,54 @@
1
+ package com.advancedtext
2
+
3
+ import android.graphics.Paint
4
+ import android.text.style.LineHeightSpan
5
+ import kotlin.math.ceil
6
+ import kotlin.math.floor
7
+
8
+ /**
9
+ * A [LineHeightSpan] that forces every line's box to exactly [lineHeightPx],
10
+ * following CSS `line-height` semantics: the difference between the target
11
+ * height and the font's natural ascent+descent ("leading") is split evenly
12
+ * above and below, and the very first line's top / very last line's bottom
13
+ * are pinned back to the font's own ascent/descent so the half-leading at
14
+ * the block's own edges doesn't inflate its total height.
15
+ *
16
+ * This exists because `AdvancedTextView`'s Fabric ShadowNode measures the
17
+ * `lineHeight` prop through React Native's `TextLayoutManager`, which applies
18
+ * `lineHeight` using this exact CSS-style algorithm internally -- a different
19
+ * one from Android's own `TextView.setLineSpacing(0, multiplier)`. Rendering
20
+ * with `setLineSpacing` instead of this span made the real height diverge
21
+ * from the measured one by a per-line amount, which is why the discrepancy
22
+ * grew with the number of wrapped lines.
23
+ *
24
+ * `start`/`end` here are the offsets of the *current line*, not the span's
25
+ * own range, so the first/last checks below correctly fire only once each
26
+ * per paragraph. Mirrors React Native's own
27
+ * `com.facebook.react.views.text.internal.span.CustomLineHeightSpan`
28
+ * (used internally for `<Text lineHeight={}>`) verbatim, so it inherits that
29
+ * implementation's on-device track record instead of a new one.
30
+ *
31
+ * See: https://www.w3.org/TR/css-inline-3/#inline-height
32
+ */
33
+ internal class CssLineHeightSpan(private val lineHeightPx: Int) : LineHeightSpan {
34
+
35
+ override fun chooseHeight(
36
+ text: CharSequence,
37
+ start: Int,
38
+ end: Int,
39
+ spanstartv: Int,
40
+ v: Int,
41
+ fm: Paint.FontMetricsInt
42
+ ) {
43
+ val leading = lineHeightPx - ((-fm.ascent) + fm.descent)
44
+ fm.ascent -= ceil(leading / 2.0f).toInt()
45
+ fm.descent += floor(leading / 2.0f).toInt()
46
+
47
+ if (start == 0) {
48
+ fm.top = fm.ascent
49
+ }
50
+ if (end == text.length) {
51
+ fm.bottom = fm.descent
52
+ }
53
+ }
54
+ }
@@ -42,7 +42,7 @@ AdvancedTextViewShadowNode::AdvancedTextViewShadowNode(
42
42
  static_cast<const AdvancedTextViewShadowNode&>(sourceShadowNode)
43
43
  .textLayoutManager_) {}
44
44
 
45
- AttributedString AdvancedTextViewShadowNode::getAttributedString() const {
45
+ TextAttributes AdvancedTextViewShadowNode::baseTextAttributes() const {
46
46
  const auto& props = getConcreteProps();
47
47
 
48
48
  auto textAttributes = TextAttributes::defaultTextAttributes();
@@ -68,13 +68,77 @@ AttributedString AdvancedTextViewShadowNode::getAttributedString() const {
68
68
  textAttributes.letterSpacing = props.letterSpacing;
69
69
  }
70
70
 
71
- if (props.lineHeight > 0) {
72
- // The component treats `lineHeight` as a multiple of the font size
73
- // (see AdvancedTextView.mm / AdvancedTextView.kt). `TextAttributes`
74
- // expects an absolute value; approximate the platform default single
75
- // line height as 1.2 * fontSize.
71
+ return textAttributes;
72
+ }
73
+
74
+ #if defined(ANDROID)
75
+ Float AdvancedTextViewShadowNode::measureNaturalLineHeight(
76
+ const TextAttributes& baseTextAttributes,
77
+ const TextLayoutContext& textLayoutContext) const {
78
+ // `baseTextAttributes` deliberately has no `lineHeight` set, so this
79
+ // resolves to the font's own natural single-line height -- exactly what
80
+ // CssLineHeightSpan.kt multiplies on the view side.
81
+ auto probeString = AttributedString{};
82
+ probeString.appendFragment(AttributedString::Fragment{
83
+ .string = "M",
84
+ .textAttributes = baseTextAttributes,
85
+ .parentShadowView = ShadowView(*this),
86
+ });
87
+
88
+ auto probeParagraphAttributes = ParagraphAttributes{};
89
+ probeParagraphAttributes.maximumNumberOfLines = 1;
90
+ // Measure the tight ascent+descent box (no extra top/bottom accent
91
+ // padding). This is the same "natural line height" basis
92
+ // CssLineHeightSpan.kt derives from `Paint.getFontMetricsInt()` on the
93
+ // view side (which is unaffected by `includeFontPadding`, a Layout-level
94
+ // setting) -- measuring with padding included here would double count it
95
+ // once per rendered line once the resulting value is multiplied and
96
+ // applied to the whole paragraph.
97
+ probeParagraphAttributes.includeFontPadding = false;
98
+
99
+ auto measurement = textLayoutManager_->measure(
100
+ AttributedStringBox{probeString},
101
+ probeParagraphAttributes,
102
+ textLayoutContext,
103
+ LayoutConstraints{});
104
+
105
+ return measurement.size.height;
106
+ }
107
+ #endif
108
+
109
+ AttributedString AdvancedTextViewShadowNode::getAttributedString(
110
+ const TextAttributes& baseTextAttributes,
111
+ const TextLayoutContext& textLayoutContext) const {
112
+ const auto& props = getConcreteProps();
113
+
114
+ auto textAttributes = baseTextAttributes;
115
+
116
+ // The component treats `lineHeight` as a multiple of the font's own
117
+ // natural line height (see CssLineHeightSpan.kt on Android / the
118
+ // NSParagraphStyle-based spacing in AdvancedTextView.mm on iOS) -- NOT the
119
+ // absolute per-line value `TextAttributes::lineHeight` expects. A
120
+ // multiplier of 1 *is* the natural line height, so there is nothing to
121
+ // override in that case (overriding it with an approximation, as this
122
+ // used to do unconditionally, made even the "no lineHeight override" case
123
+ // measure taller or shorter than the real render).
124
+ if (props.lineHeight > 0 && props.lineHeight != 1.0) {
125
+ #if defined(ANDROID)
126
+ // Android's natural single-line height depends on the resolved
127
+ // font/size/weight and isn't a fixed ratio of fontSize, so a constant
128
+ // approximation drifts from the real value CssLineHeightSpan.kt derives
129
+ // from Paint.getFontMetricsInt() -- by an amount that compounds with
130
+ // every wrapped line, which is why the error scaled with paragraph
131
+ // length. Measure the real natural height for these exact attributes
132
+ // instead, the same way the view derives it.
133
+ auto naturalLineHeight =
134
+ measureNaturalLineHeight(textAttributes, textLayoutContext);
135
+ textAttributes.lineHeight = naturalLineHeight > 0
136
+ ? props.lineHeight * naturalLineHeight
137
+ : props.lineHeight * textAttributes.fontSize * 1.2;
138
+ #else
76
139
  textAttributes.lineHeight =
77
140
  props.lineHeight * textAttributes.fontSize * 1.2;
141
+ #endif
78
142
  }
79
143
 
80
144
  auto attributedString = AttributedString{};
@@ -100,16 +164,17 @@ Size AdvancedTextViewShadowNode::measureContent(
100
164
  std::make_shared<const TextLayoutManager>(getContextContainer());
101
165
  }
102
166
 
103
- auto paragraphAttributes = ParagraphAttributes{};
104
- paragraphAttributes.maximumNumberOfLines = 0; // unlimited -> natural wrapping
105
- paragraphAttributes.adjustsFontSizeToFit = false;
106
-
107
167
  auto textLayoutContext = TextLayoutContext{};
108
168
  textLayoutContext.pointScaleFactor = layoutContext.pointScaleFactor;
109
169
  textLayoutContext.surfaceId = getSurfaceId();
110
170
 
171
+ auto paragraphAttributes = ParagraphAttributes{};
172
+ paragraphAttributes.maximumNumberOfLines = 0; // unlimited -> natural wrapping
173
+ paragraphAttributes.adjustsFontSizeToFit = false;
174
+
111
175
  auto measurement = textLayoutManager_->measure(
112
- AttributedStringBox{getAttributedString()},
176
+ AttributedStringBox{
177
+ getAttributedString(baseTextAttributes(), textLayoutContext)},
113
178
  paragraphAttributes,
114
179
  textLayoutContext,
115
180
  layoutConstraints);
@@ -34,6 +34,7 @@
34
34
 
35
35
  #include <jsi/jsi.h>
36
36
  #include <react/renderer/attributedstring/AttributedString.h>
37
+ #include <react/renderer/attributedstring/TextAttributes.h>
37
38
  #include <react/renderer/components/AdvancedTextViewSpec/EventEmitters.h>
38
39
  #include <react/renderer/components/AdvancedTextViewSpec/Props.h>
39
40
  #include <react/renderer/components/AdvancedTextViewSpec/States.h>
@@ -42,6 +43,7 @@
42
43
  #include <react/renderer/core/LayoutContext.h>
43
44
  #include <react/renderer/core/ShadowNode.h>
44
45
  #include <react/renderer/core/ShadowNodeFragment.h>
46
+ #include <react/renderer/textlayoutmanager/TextLayoutContext.h>
45
47
 
46
48
  namespace facebook::react {
47
49
 
@@ -82,11 +84,42 @@ class AdvancedTextViewShadowNode final : public ConcreteViewShadowNode<
82
84
  const LayoutConstraints& layoutConstraints) const override;
83
85
 
84
86
  private:
87
+ /*
88
+ * Builds the font-related `TextAttributes` from the component props
89
+ * (family/weight/style/size/letterSpacing), *excluding* `lineHeight`.
90
+ * Shared between the natural-line-height probe and the real measurement so
91
+ * both resolve to the exact same font.
92
+ */
93
+ TextAttributes baseTextAttributes() const;
94
+
95
+ /*
96
+ * `lineHeight` is exposed to JS as a multiple of the font's own natural
97
+ * line height (see CssLineHeightSpan.kt on Android), NOT the absolute
98
+ * per-line value `TextAttributes::lineHeight` expects. A hardcoded ratio
99
+ * (e.g. `fontSize * 1.2`) is only an approximation of a font's real
100
+ * natural single-line height, and native font tables (especially OEM
101
+ * ones) can deviate from it enough to make Yoga's measured block height
102
+ * visibly disagree with what the native view actually draws. To keep them
103
+ * in sync, measure a single line of the same resolved font (no
104
+ * `lineHeight` override) and use its height as the real multiplier base --
105
+ * with `includeFontPadding = false`, matching the tight ascent+descent
106
+ * basis `Paint.getFontMetricsInt()` gives `CssLineHeightSpan.kt` on the
107
+ * view side (Android only; iOS's own NSParagraphStyle-based spacing in
108
+ * AdvancedTextView.mm doesn't need this).
109
+ */
110
+ #if defined(ANDROID)
111
+ Float measureNaturalLineHeight(
112
+ const TextAttributes& baseTextAttributes,
113
+ const TextLayoutContext& textLayoutContext) const;
114
+ #endif
115
+
85
116
  /*
86
117
  * Builds a single-fragment `AttributedString` from the component props
87
118
  * (text + font attributes), mirroring how the native views render it.
88
119
  */
89
- AttributedString getAttributedString() const;
120
+ AttributedString getAttributedString(
121
+ const TextAttributes& baseTextAttributes,
122
+ const TextLayoutContext& textLayoutContext) const;
90
123
 
91
124
  // Lazily created on first measure, then reused / copied across clones.
92
125
  mutable std::shared_ptr<const TextLayoutManager> textLayoutManager_;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-advanced-text",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": " Advanced text component for React Native with custom select options.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",