react-native-advanced-text 0.2.7 → 0.2.10

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.
@@ -5,6 +5,7 @@ import android.graphics.Canvas
5
5
  import android.graphics.Color
6
6
  import android.graphics.Point
7
7
  import android.graphics.RectF
8
+ import android.os.Build
8
9
  import android.text.SpannableString
9
10
  import android.text.Spannable
10
11
  import android.text.Spanned
@@ -63,6 +64,15 @@ class AdvancedTextView : TextView {
63
64
  // React Native `style` instead, exactly like <Text>.
64
65
  setPadding(0, 0, 0, 0)
65
66
  setTextIsSelectable(true)
67
+ // Fabric measures this component's text via RN's own TextLayoutManager,
68
+ // which explicitly builds its StaticLayout with
69
+ // `setUseLineSpacingFromFallbacks(true)`. TextView's own equivalent
70
+ // (`setFallbackLineSpacing`) defaults differently depending on
71
+ // targetSdkVersion, so without this the two engines can compute
72
+ // slightly different natural line heights for the same font/text.
73
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
74
+ setFallbackLineSpacing(true)
75
+ }
66
76
 
67
77
  movementMethod = SmartMovementMethod
68
78
 
@@ -288,10 +298,16 @@ class AdvancedTextView : TextView {
288
298
  }
289
299
 
290
300
 
291
- post {
292
- setText(spannableString, BufferType.SPANNABLE)
293
- Log.d(TAG, "Text updated with ${wordPositions.size} spans")
294
- }
301
+ // Set synchronously, not via `post {}`: in a virtualized list (e.g.
302
+ // FlashList) this view gets recycled for a new row on every scroll
303
+ // tick. A posted `setText` races the *next* recycle -- if this view
304
+ // is reused again before the queued runnable fires, whichever row's
305
+ // runnable happens to run last wins, and a row can end up rendering
306
+ // blank (with its correctly-measured height still reserved by
307
+ // Fabric) while its own `setText` was dropped. `setText` doesn't
308
+ // need to wait for anything here, so there's nothing to defer for.
309
+ setText(spannableString, BufferType.SPANNABLE)
310
+ Log.d(TAG, "Text updated with ${wordPositions.size} spans")
295
311
  }
296
312
 
297
313
  override fun onDraw(canvas: Canvas) {
@@ -0,0 +1,69 @@
1
+ package com.advancedtext
2
+
3
+ import android.content.res.Resources
4
+ import android.graphics.Typeface
5
+ import android.text.StaticLayout
6
+ import android.text.TextPaint
7
+ import android.util.TypedValue
8
+
9
+ /**
10
+ * JNI bridge so the Fabric ShadowNode (C++ `measureContent()`) can ask
11
+ * Android's own `StaticLayout` -- the exact class `AdvancedTextView`'s real
12
+ * rendering is built on -- for this text's real height, instead of going
13
+ * through React Native's own text-measurement engine (`TextLayoutManager`).
14
+ *
15
+ * RN's own engine and Android's native `TextView` rendering are two
16
+ * independently-implemented paths that can drift apart by a few px per line
17
+ * (verified: RN's `TextLayoutManager` takes a different code path --
18
+ * `BoringLayout` vs `StaticLayout` -- for short/simple text than for real,
19
+ * multi-line paragraphs, and even StaticLayout-to-StaticLayout the two
20
+ * engines' resolved `Paint`/`Typeface` don't perfectly agree). Since this
21
+ * component doesn't reuse RN's own prepared text layout for drawing anyway
22
+ * (it re-renders through its own `TextView`), measuring with the same
23
+ * `StaticLayout` call the view itself is built on removes the drift
24
+ * entirely, rather than approximating it from the C++ side.
25
+ *
26
+ * Every parameter below is chosen to mirror exactly what `AdvancedTextView`
27
+ * itself configures on its own `TextView`/`Paint` in `updateTextWithHighlights()`
28
+ * -- font resolution, text size (SP, not raw px), `setLineSpacing`, and
29
+ * letter spacing. Spans (highlight/click color) are omitted: they don't
30
+ * affect font metrics or line breaking, only drawing.
31
+ */
32
+ object NativeTextMeasurement {
33
+
34
+ @JvmStatic
35
+ fun measureHeight(
36
+ text: String,
37
+ fontFamily: String,
38
+ typefaceStyle: Int,
39
+ fontSizeSp: Float,
40
+ lineHeightMultiplier: Float,
41
+ letterSpacingDp: Float,
42
+ widthPx: Float
43
+ ): Float {
44
+ if (text.isEmpty() || widthPx <= 0f) {
45
+ return 0f
46
+ }
47
+
48
+ val displayMetrics = Resources.getSystem().displayMetrics
49
+ val fontSizePx =
50
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, fontSizeSp, displayMetrics)
51
+
52
+ val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG)
53
+ paint.typeface = Typeface.create(fontFamily, typefaceStyle)
54
+ paint.textSize = fontSizePx
55
+ // Mirrors AdvancedTextView's own letterSpacing conversion: dp/points
56
+ // prop -> em, scale-invariant so no density conversion is needed.
57
+ if (letterSpacingDp != 0f && fontSizeSp > 0f) {
58
+ paint.letterSpacing = letterSpacingDp / fontSizeSp
59
+ }
60
+
61
+ val width = widthPx.toInt().coerceAtLeast(1)
62
+ val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
63
+ // `setLineSpacing(0, multiplier)` is exactly what AdvancedTextView
64
+ // itself applies for rendering -- see updateTextWithHighlights().
65
+ builder.setLineSpacing(0f, if (lineHeightMultiplier > 0f) lineHeightMultiplier else 1f)
66
+ val layout = builder.build()
67
+ return layout.height.toFloat()
68
+ }
69
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  #include <algorithm>
13
13
  #include <cctype>
14
+ #include <cmath>
14
15
  #include <string>
15
16
 
16
17
  #include <react/renderer/attributedstring/AttributedStringBox.h>
@@ -21,6 +22,10 @@
21
22
  #include <react/renderer/textlayoutmanager/TextLayoutContext.h>
22
23
  #include <react/renderer/textlayoutmanager/TextLayoutManager.h>
23
24
 
25
+ #if defined(ANDROID)
26
+ #include <fbjni/fbjni.h>
27
+ #endif
28
+
24
29
  namespace facebook::react {
25
30
 
26
31
  namespace {
@@ -68,14 +73,18 @@ AttributedString AdvancedTextViewShadowNode::getAttributedString() const {
68
73
  textAttributes.letterSpacing = props.letterSpacing;
69
74
  }
70
75
 
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.
76
- textAttributes.lineHeight =
77
- props.lineHeight * textAttributes.fontSize * 1.2;
78
- }
76
+ // Deliberately never sets `textAttributes.lineHeight`: the component's
77
+ // `lineHeight` prop is a *multiple* of the font's natural line height (see
78
+ // `setLineSpacing(0, multiplier)` in AdvancedTextView.kt / the
79
+ // NSParagraphStyle-based spacing in AdvancedTextView.mm on iOS), not the
80
+ // absolute per-line value `TextAttributes::lineHeight` expects. Forcing an
81
+ // absolute value here would make Fabric apply RN's own CSS-style
82
+ // forced-line-height algorithm, which is a *different* algorithm from
83
+ // `setLineSpacing`'s multiplicative one and drifts from it by an amount
84
+ // that compounds with every wrapped line. Measuring naturally instead and
85
+ // scaling the *result* in `measureContent()` uses the same "multiply the
86
+ // natural total" math `setLineSpacing` itself uses, so they agree exactly
87
+ // regardless of paragraph length.
79
88
 
80
89
  auto attributedString = AttributedString{};
81
90
  attributedString.appendFragment(AttributedString::Fragment{
@@ -86,6 +95,61 @@ AttributedString AdvancedTextViewShadowNode::getAttributedString() const {
86
95
  return attributedString;
87
96
  }
88
97
 
98
+ #if defined(ANDROID)
99
+ Float AdvancedTextViewShadowNode::measureHeightViaAndroidNative(
100
+ Float widthDp,
101
+ Float pointScaleFactor) const {
102
+ if (!std::isfinite(widthDp) || widthDp <= 0 || pointScaleFactor <= 0) {
103
+ return -1;
104
+ }
105
+
106
+ const auto& props = getConcreteProps();
107
+
108
+ const auto weight = toLowerAscii(props.fontWeight);
109
+ // Matches Typeface.NORMAL/BOLD/ITALIC -- the same three-way mapping
110
+ // AdvancedTextView.kt uses for `typeface = when (fontWeight) { ... }`.
111
+ int typefaceStyle = 0; // Typeface.NORMAL
112
+ if (weight == "bold") {
113
+ typefaceStyle = 1; // Typeface.BOLD
114
+ } else if (weight == "italic") {
115
+ typefaceStyle = 2; // Typeface.ITALIC
116
+ }
117
+
118
+ std::string fontFamily =
119
+ props.fontFamily.empty() ? "sans-serif" : props.fontFamily;
120
+ Float fontSize = props.fontSize > 0 ? props.fontSize : 16.0;
121
+ Float lineHeightMultiplier = props.lineHeight > 0 ? props.lineHeight : 1.0;
122
+ Float widthPx = widthDp * pointScaleFactor;
123
+
124
+ static const auto cls =
125
+ facebook::jni::findClassStatic("com/advancedtext/NativeTextMeasurement");
126
+ static const auto method = cls->getStaticMethod<jfloat(
127
+ std::string,
128
+ std::string,
129
+ jint,
130
+ jfloat,
131
+ jfloat,
132
+ jfloat,
133
+ jfloat)>("measureHeight");
134
+
135
+ Float heightPx = method(
136
+ cls,
137
+ props.text,
138
+ fontFamily,
139
+ typefaceStyle,
140
+ static_cast<jfloat>(fontSize),
141
+ static_cast<jfloat>(lineHeightMultiplier),
142
+ static_cast<jfloat>(props.letterSpacing),
143
+ static_cast<jfloat>(widthPx));
144
+
145
+ if (heightPx <= 0) {
146
+ return -1;
147
+ }
148
+
149
+ return heightPx / pointScaleFactor;
150
+ }
151
+ #endif
152
+
89
153
  Size AdvancedTextViewShadowNode::measureContent(
90
154
  const LayoutContext& layoutContext,
91
155
  const LayoutConstraints& layoutConstraints) const {
@@ -100,21 +164,49 @@ 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
176
  AttributedStringBox{getAttributedString()},
113
177
  paragraphAttributes,
114
178
  textLayoutContext,
115
179
  layoutConstraints);
116
180
 
117
- return layoutConstraints.clamp(measurement.size);
181
+ auto size = measurement.size;
182
+
183
+ #if defined(ANDROID)
184
+ // Prefer asking Android's own `StaticLayout` for the height directly (see
185
+ // `measureHeightViaAndroidNative()`) -- it's the same class
186
+ // `AdvancedTextView`'s real rendering is built on, so there's no
187
+ // measurement-vs-render drift to compound over long paragraphs. `size`
188
+ // (from RN's own TextLayoutManager, above) still supplies the width --
189
+ // this only replaces the height. Falls back to the `lineHeight`-scaling
190
+ // math below when the width isn't a finite, positive number (e.g. an
191
+ // unconstrained/intrinsic-width measure pass).
192
+ auto nativeHeight =
193
+ measureHeightViaAndroidNative(size.width, textLayoutContext.pointScaleFactor);
194
+ if (nativeHeight > 0) {
195
+ size.height = nativeHeight;
196
+ return layoutConstraints.clamp(size);
197
+ }
198
+ #endif
199
+
200
+ // `lineHeight` scales the font's own natural line height (see
201
+ // `getAttributedString()`); since `size.height` above is the *natural*
202
+ // (multiplier == 1) total, `setLineSpacing(0, multiplier)` scaling the
203
+ // same natural total on the view side means multiplying it here
204
+ // reproduces exactly the same number, for any paragraph length.
205
+ if (props.lineHeight > 0) {
206
+ size.height *= props.lineHeight;
207
+ }
208
+
209
+ return layoutConstraints.clamp(size);
118
210
  }
119
211
 
120
212
  } // namespace facebook::react
@@ -85,9 +85,24 @@ class AdvancedTextViewShadowNode final : public ConcreteViewShadowNode<
85
85
  /*
86
86
  * Builds a single-fragment `AttributedString` from the component props
87
87
  * (text + font attributes), mirroring how the native views render it.
88
+ * Deliberately never sets `lineHeight` -- see the .cpp for why.
88
89
  */
89
90
  AttributedString getAttributedString() const;
90
91
 
92
+ #if defined(ANDROID)
93
+ /*
94
+ * Asks Android's own `StaticLayout` (via a JNI call into
95
+ * `NativeTextMeasurement.measureHeight()`) for this text's real rendered
96
+ * height at the given width, instead of approximating it through RN's own
97
+ * `TextLayoutManager`. Returns height in dp, or a negative value if it
98
+ * couldn't be computed (e.g. unconstrained width), signaling the caller to
99
+ * fall back to the `TextLayoutManager`-based path.
100
+ */
101
+ Float measureHeightViaAndroidNative(
102
+ Float widthDp,
103
+ Float pointScaleFactor) const;
104
+ #endif
105
+
91
106
  // Lazily created on first measure, then reused / copied across clones.
92
107
  mutable std::shared_ptr<const TextLayoutManager> textLayoutManager_;
93
108
  };
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.10",
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",