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.
- package/android/src/main/java/com/advancedtext/AdvancedTextView.kt +37 -1
- package/android/src/main/java/com/advancedtext/CssLineHeightSpan.kt +54 -0
- package/common/cpp/react/renderer/components/AdvancedTextViewSpec/ShadowNodes.cpp +76 -11
- package/common/cpp/react/renderer/components/AdvancedTextViewSpec/ShadowNodes.h +34 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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{
|
|
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(
|
|
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