Skip to content

Commit a16c4d0

Browse files
authored
feat: android - measurement improvements (#221)
## Description In order to provide auto-grow mechanism on Android we use `StaticLayout` which calculates desired component height based on the text paint, spannable and available width. Current logic comes with a huge limitation, it does work properly only for single instance of the component (as we store last created component instance directly in the view manager). In order to solve this limitation, I've implemented an enhanced version of this mechanism: - we do not store component instance as a view manager property - instead we have a static class, which stores spannable and text paint params for each active component instance - stored parameters are associated with component by `id` which equals to `viewTag` - every time component needs a new measurement, we update those values - when Yoga asks for new measurements (via `measureContent` and `measure` on the view manager) we retrieve a measurement value from the static class store (called `MeasurementStore`) - additionally measurements are cached, we don't calculate it again if not needed (for example padding of the component changes -> it does not affect content size, we can reuse previous value)
1 parent 6abe6d5 commit a16c4d0

File tree

10 files changed

+121
-90
lines changed

10 files changed

+121
-90
lines changed

android/src/main/java/com/swmansion/enriched/EnrichedTextInputView.kt

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ class EnrichedTextInputView : AppCompatEditText {
306306

307307
// This ensured that newly created spans will take the new font size into account
308308
htmlStyle.invalidateStyles()
309-
layoutManager.invalidateLayout(text)
309+
layoutManager.invalidateLayout()
310310
}
311311

312312
fun setFontFamily(family: String?) {
@@ -368,7 +368,7 @@ class EnrichedTextInputView : AppCompatEditText {
368368
typeface = newTypeface
369369
paint.typeface = newTypeface
370370

371-
layoutManager.invalidateLayout(text)
371+
layoutManager.invalidateLayout()
372372
}
373373

374374
private fun toggleStyle(name: String) {
@@ -388,7 +388,7 @@ class EnrichedTextInputView : AppCompatEditText {
388388
else -> Log.w("EnrichedTextInputView", "Unknown style: $name")
389389
}
390390

391-
layoutManager.invalidateLayout(text)
391+
layoutManager.invalidateLayout()
392392
}
393393

394394
private fun removeStyle(name: String, start: Int, end: Int): Boolean {
@@ -504,6 +504,7 @@ class EnrichedTextInputView : AppCompatEditText {
504504
if (!isValid) return
505505

506506
parametrizedStyles?.setImageSpan(src)
507+
layoutManager.invalidateLayout()
507508
}
508509

509510
fun startMention(indicator: String) {
@@ -542,11 +543,6 @@ class EnrichedTextInputView : AppCompatEditText {
542543
didAttachToWindow = true
543544
}
544545

545-
override fun onDetachedFromWindow() {
546-
layoutManager.cleanup()
547-
super.onDetachedFromWindow()
548-
}
549-
550546
companion object {
551547
const val CLIPBOARD_TAG = "react-native-enriched-clipboard"
552548
}
Lines changed: 7 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
11
package com.swmansion.enriched
22

3-
import android.graphics.text.LineBreaker
4-
import android.os.Build
5-
import android.text.Editable
6-
import android.text.StaticLayout
73
import com.facebook.react.bridge.Arguments
8-
import com.facebook.react.uimanager.PixelUtil
94

105
class EnrichedTextInputViewLayoutManager(private val view: EnrichedTextInputView) {
11-
private var cachedSize: Pair<Float, Float> = Pair(0f, 0f)
12-
private var cachedYogaWidth: Float = 0f
136
private var forceHeightRecalculationCounter: Int = 0
147

15-
fun cleanup() {
16-
forceHeightRecalculationCounter = 0
17-
}
8+
fun invalidateLayout() {
9+
val text = view.text
10+
val paint = view.paint
1811

19-
// Update shadow node's state in order to recalculate layout
20-
fun invalidateLayout(text: Editable?) {
21-
measureSize(text ?: "")
12+
val needUpdate = MeasurementStore.store(view.id, text, paint)
13+
if (!needUpdate) return
2214

2315
val counter = forceHeightRecalculationCounter
2416
forceHeightRecalculationCounter++
@@ -27,48 +19,7 @@ class EnrichedTextInputViewLayoutManager(private val view: EnrichedTextInputView
2719
view.stateWrapper?.updateState(state)
2820
}
2921

30-
fun getMeasuredSize(maxWidth: Float): Pair<Float, Float> {
31-
if (maxWidth == cachedYogaWidth) {
32-
return cachedSize
33-
}
34-
35-
val text = view.text ?: ""
36-
val result = measureAndCacheSize(text, maxWidth)
37-
cachedYogaWidth = maxWidth
38-
return result
39-
}
40-
41-
fun measureSize(text: CharSequence): Pair<Float, Float> {
42-
return measureAndCacheSize(text, cachedYogaWidth)
43-
}
44-
45-
private fun measureAndCacheSize(text: CharSequence, maxWidth: Float): Pair<Float, Float> {
46-
val result = measureSize(text, maxWidth)
47-
cachedSize = result
48-
return result
49-
}
50-
51-
private fun measureSize(text: CharSequence, maxWidth: Float): Pair<Float, Float> {
52-
val paint = view.paint
53-
val textLength = text.length
54-
55-
val builder = StaticLayout.Builder
56-
.obtain(text, 0, textLength, paint, maxWidth.toInt())
57-
.setIncludePad(true)
58-
.setLineSpacing(0f, 1f)
59-
60-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
61-
builder.setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
62-
}
63-
64-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
65-
builder.setUseLineSpacingFromFallbacks(true)
66-
}
67-
68-
val staticLayout = builder.build()
69-
val heightInSP = PixelUtil.toDIPFromPixel(staticLayout.height.toFloat())
70-
val widthInSP = PixelUtil.toDIPFromPixel(maxWidth)
71-
72-
return Pair(widthInSP, heightInSP)
22+
fun releaseMeasurementStore() {
23+
MeasurementStore.release(view.id)
7324
}
7425
}

android/src/main/java/com/swmansion/enriched/EnrichedTextInputViewManager.kt

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.swmansion.enriched
22

33
import android.content.Context
4+
import android.util.Log
45
import com.facebook.react.bridge.ReadableArray
56
import com.facebook.react.bridge.ReadableMap
67
import com.facebook.react.module.annotations.ReactModule
@@ -15,7 +16,6 @@ import com.facebook.react.uimanager.annotations.ReactProp
1516
import com.facebook.react.viewmanagers.EnrichedTextInputViewManagerDelegate
1617
import com.facebook.react.viewmanagers.EnrichedTextInputViewManagerInterface
1718
import com.facebook.yoga.YogaMeasureMode
18-
import com.facebook.yoga.YogaMeasureOutput
1919
import com.swmansion.enriched.events.OnInputBlurEvent
2020
import com.swmansion.enriched.events.OnChangeHtmlEvent
2121
import com.swmansion.enriched.events.OnChangeSelectionEvent
@@ -32,12 +32,8 @@ import com.swmansion.enriched.utils.jsonStringToStringMap
3232
@ReactModule(name = EnrichedTextInputViewManager.NAME)
3333
class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
3434
EnrichedTextInputViewManagerInterface<EnrichedTextInputView> {
35-
private val mDelegate: ViewManagerDelegate<EnrichedTextInputView>
36-
private var view: EnrichedTextInputView? = null
37-
38-
init {
39-
mDelegate = EnrichedTextInputViewManagerDelegate(this)
40-
}
35+
private val mDelegate: ViewManagerDelegate<EnrichedTextInputView> =
36+
EnrichedTextInputViewManagerDelegate(this)
4137

4238
override fun getDelegate(): ViewManagerDelegate<EnrichedTextInputView>? {
4339
return mDelegate
@@ -48,10 +44,12 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
4844
}
4945

5046
public override fun createViewInstance(context: ThemedReactContext): EnrichedTextInputView {
51-
val view = EnrichedTextInputView(context)
52-
this.view = view
47+
return EnrichedTextInputView(context)
48+
}
5349

54-
return view
50+
override fun onDropViewInstance(view: EnrichedTextInputView) {
51+
super.onDropViewInstance(view)
52+
view.layoutManager.releaseMeasurementStore()
5553
}
5654

5755
override fun updateState(
@@ -277,13 +275,7 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
277275
heightMode: YogaMeasureMode?,
278276
attachmentsPositions: FloatArray?
279277
): Long {
280-
val size = this.view?.layoutManager?.getMeasuredSize(width)
281-
282-
if (size != null) {
283-
return YogaMeasureOutput.make(size.first, size.second)
284-
}
285-
286-
return YogaMeasureOutput.make(0, 0)
278+
return MeasurementStore.getMeasureById(localData?.getInt("viewTag"), width)
287279
}
288280

289281
companion object {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.swmansion.enriched
2+
3+
import android.graphics.Typeface
4+
import android.graphics.text.LineBreaker
5+
import android.os.Build
6+
import android.text.Spannable
7+
import android.text.StaticLayout
8+
import android.text.TextPaint
9+
import com.facebook.react.uimanager.PixelUtil
10+
import com.facebook.yoga.YogaMeasureOutput
11+
import java.util.concurrent.ConcurrentHashMap
12+
13+
object MeasurementStore {
14+
data class PaintParams(
15+
val typeface: Typeface,
16+
val fontSize: Float,
17+
)
18+
19+
data class MeasurementParams(
20+
val cachedWidth: Float,
21+
val cachedSize: Long,
22+
23+
val spannable: Spannable?,
24+
val paintParams: PaintParams,
25+
)
26+
27+
private val data = ConcurrentHashMap<Int, MeasurementParams>()
28+
29+
fun store(id: Int, spannable: Spannable?, paint: TextPaint): Boolean {
30+
val cachedWidth = data[id]?.cachedWidth ?: 0f
31+
val cachedSize = data[id]?.cachedSize ?: 0L
32+
val size = measure(cachedWidth, spannable, paint)
33+
val paintParams = PaintParams(paint.typeface, paint.textSize)
34+
35+
data[id] = MeasurementParams(cachedWidth, size, spannable, paintParams)
36+
return cachedSize != size
37+
}
38+
39+
fun release(id: Int) {
40+
data.remove(id)
41+
}
42+
43+
fun measure(maxWidth: Float, spannable: Spannable?, paintParams: PaintParams): Long {
44+
val paint = TextPaint().apply {
45+
typeface = paintParams.typeface
46+
textSize = paintParams.fontSize
47+
}
48+
49+
return measure(maxWidth, spannable, paint)
50+
}
51+
52+
fun measure(maxWidth: Float, spannable: Spannable?, paint: TextPaint): Long {
53+
val text = spannable ?: ""
54+
val textLength = text.length
55+
val builder = StaticLayout.Builder
56+
.obtain(text, 0, textLength, paint, maxWidth.toInt())
57+
.setIncludePad(true)
58+
.setLineSpacing(0f, 1f)
59+
60+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
61+
builder.setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
62+
}
63+
64+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
65+
builder.setUseLineSpacingFromFallbacks(true)
66+
}
67+
68+
val staticLayout = builder.build()
69+
val heightInSP = PixelUtil.toDIPFromPixel(staticLayout.height.toFloat())
70+
val widthInSP = PixelUtil.toDIPFromPixel(maxWidth)
71+
return YogaMeasureOutput.make(widthInSP, heightInSP)
72+
}
73+
74+
fun getMeasureById(id: Int?, width: Float): Long {
75+
val id = id ?: return YogaMeasureOutput.make(0, 0)
76+
val value = data[id] ?: return YogaMeasureOutput.make(0, 0)
77+
78+
if (width == value.cachedWidth) {
79+
return value.cachedSize
80+
}
81+
82+
val paint = TextPaint().apply {
83+
typeface = value.paintParams.typeface
84+
textSize = value.paintParams.fontSize
85+
}
86+
val size = measure(width, value.spannable, paint)
87+
data[id] = MeasurementParams(width, size, value.spannable, value.paintParams)
88+
return size
89+
}
90+
}

android/src/main/java/com/swmansion/enriched/watchers/EnrichedSpanWatcher.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ class EnrichedSpanWatcher(private val view: EnrichedTextInputView) : SpanWatcher
5959
if (html == previousHtml) return
6060

6161
previousHtml = html
62-
view.layoutManager.invalidateLayout(view.text)
6362
val context = view.context as ReactContext
6463
val surfaceId = UIManagerHelper.getSurfaceId(context)
6564
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)

android/src/main/java/com/swmansion/enriched/watchers/EnrichedTextWatcher.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class EnrichedTextWatcher(private val view: EnrichedTextInputView) : TextWatcher
1717

1818
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
1919
endCursorPosition = start + count
20-
view.layoutManager.measureSize(s ?: "")
20+
view.layoutManager.invalidateLayout()
2121
view.isRemovingMany = !view.isDuringTransaction && before > count + 1
2222
}
2323

android/src/main/new_arch/react/renderer/components/RNEnrichedTextInputViewSpec/EnrichedTextInputMeasurementManager.cpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace facebook::react {
1010

1111
Size EnrichedTextInputMeasurementManager::measure(
1212
SurfaceId surfaceId,
13-
const EnrichedTextInputViewProps& props,
13+
int viewTag,
1414
LayoutConstraints layoutConstraints) const {
1515
const jni::global_ref<jobject>& fabricUIManager =
1616
contextContainer_->at<jni::global_ref<jobject>>("FabricUIManager");
@@ -33,11 +33,16 @@ namespace facebook::react {
3333

3434
local_ref<JString> componentName = make_jstring("EnrichedTextInputView");
3535

36+
folly::dynamic extra = folly::dynamic::object();
37+
extra["viewTag"] = viewTag;
38+
local_ref<ReadableNativeMap::javaobject> extraData = ReadableNativeMap::newObjectCxxArgs(extra);
39+
local_ref<ReadableMap::javaobject> extraDataRM = make_local(reinterpret_cast<ReadableMap::javaobject>(extraData.get()));
40+
3641
auto measurement = yogaMeassureToSize(measure(
3742
fabricUIManager,
3843
surfaceId,
3944
componentName.get(),
40-
nullptr,
45+
extraDataRM.get(),
4146
nullptr,
4247
nullptr,
4348
minimumSize.width,

android/src/main/new_arch/react/renderer/components/RNEnrichedTextInputViewSpec/EnrichedTextInputMeasurementManager.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace facebook::react {
1616

1717
Size measure(
1818
SurfaceId surfaceId,
19-
const EnrichedTextInputViewProps& props,
19+
int viewTag,
2020
LayoutConstraints layoutConstraints) const;
2121

2222
private:

android/src/main/new_arch/react/renderer/components/RNEnrichedTextInputViewSpec/EnrichedTextInputShadowNode.cpp

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ extern const char EnrichedTextInputComponentName[] = "EnrichedTextInputView";
2727
Size EnrichedTextInputShadowNode::measureContent(
2828
const LayoutContext &layoutContext,
2929
const LayoutConstraints &layoutConstraints) const {
30-
31-
return measurementsManager_->measure(getSurfaceId(), getConcreteProps(), layoutConstraints);
30+
return measurementsManager_->measure(getSurfaceId(), getTag(), layoutConstraints);
3231
}
3332

3433
} // namespace facebook::react

example/src/App.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,8 @@ export default function App() {
263263
<Text style={styles.label}>Enriched Text Input</Text>
264264
<View style={styles.editor}>
265265
<EnrichedTextInput
266-
key={key}
267-
autoFocus
268266
ref={ref}
267+
key={key}
269268
mentionIndicators={['@', '#']}
270269
style={styles.editorInput}
271270
htmlStyle={htmlStyle}

0 commit comments

Comments
 (0)