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.
- package/android/src/main/java/com/advancedtext/AdvancedTextView.kt +20 -4
- package/android/src/main/java/com/advancedtext/NativeTextMeasurement.kt +69 -0
- package/common/cpp/react/renderer/components/AdvancedTextViewSpec/ShadowNodes.cpp +105 -13
- package/common/cpp/react/renderer/components/AdvancedTextViewSpec/ShadowNodes.h +15 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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