Skip to content

Commit f09dc74

Browse files
authored
fix: android layout calculation delay (#269)
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please follow the template so that the reviewers can easily understand what the code changes affect --> # Summary Fixes initial layout calculation delay on Android - see #238 ## Test Plan - Run example app - Provide default value, so EnrichedTextInput is bigger than initial render (eg. two lines, line with heading) - Observe that there is no jumping, initial component height is proper one See video below and compare it with video from linked issue ## Screenshots / Videos https://github.com/user-attachments/assets/b280a853-c1c6-4b73-9d8f-77de4751e9cc ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ✅ |
1 parent 4a51e61 commit f09dc74

File tree

6 files changed

+120
-15
lines changed

6 files changed

+120
-15
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,8 @@ class EnrichedTextInputViewManager : SimpleViewManager<EnrichedTextInputView>(),
279279
heightMode: YogaMeasureMode?,
280280
attachmentsPositions: FloatArray?
281281
): Long {
282-
return MeasurementStore.getMeasureById(localData?.getInt("viewTag"), width)
282+
val id = localData?.getInt("viewTag")
283+
return MeasurementStore.getMeasureById(context, id, width, props)
283284
}
284285

285286
companion object {

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

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
package com.swmansion.enriched
22

3+
import android.content.Context
34
import android.graphics.Typeface
45
import android.graphics.text.LineBreaker
56
import android.os.Build
67
import android.text.Spannable
78
import android.text.StaticLayout
89
import android.text.TextPaint
10+
import android.util.Log
11+
import com.facebook.react.bridge.ReadableMap
912
import com.facebook.react.uimanager.PixelUtil
13+
import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles
14+
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle
15+
import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
1016
import com.facebook.yoga.YogaMeasureOutput
17+
import com.swmansion.enriched.styles.HtmlStyle
18+
import com.swmansion.enriched.utils.EnrichedParser
1119
import java.util.concurrent.ConcurrentHashMap
20+
import kotlin.math.ceil
1221

1322
object MeasurementStore {
1423
data class PaintParams(
@@ -17,10 +26,12 @@ object MeasurementStore {
1726
)
1827

1928
data class MeasurementParams(
29+
val initialized: Boolean,
30+
2031
val cachedWidth: Float,
2132
val cachedSize: Long,
2233

23-
val spannable: Spannable?,
34+
val spannable: CharSequence?,
2435
val paintParams: PaintParams,
2536
)
2637

@@ -29,18 +40,20 @@ object MeasurementStore {
2940
fun store(id: Int, spannable: Spannable?, paint: TextPaint): Boolean {
3041
val cachedWidth = data[id]?.cachedWidth ?: 0f
3142
val cachedSize = data[id]?.cachedSize ?: 0L
43+
val initialized = data[id]?.initialized ?: true
44+
3245
val size = measure(cachedWidth, spannable, paint)
3346
val paintParams = PaintParams(paint.typeface, paint.textSize)
3447

35-
data[id] = MeasurementParams(cachedWidth, size, spannable, paintParams)
48+
data[id] = MeasurementParams(initialized, cachedWidth, size, spannable, paintParams)
3649
return cachedSize != size
3750
}
3851

3952
fun release(id: Int) {
4053
data.remove(id)
4154
}
4255

43-
fun measure(maxWidth: Float, spannable: Spannable?, paintParams: PaintParams): Long {
56+
private fun measure(maxWidth: Float, spannable: CharSequence?, paintParams: PaintParams): Long {
4457
val paint = TextPaint().apply {
4558
typeface = paintParams.typeface
4659
textSize = paintParams.fontSize
@@ -49,7 +62,7 @@ object MeasurementStore {
4962
return measure(maxWidth, spannable, paint)
5063
}
5164

52-
fun measure(maxWidth: Float, spannable: Spannable?, paint: TextPaint): Long {
65+
private fun measure(maxWidth: Float, spannable: CharSequence?, paint: TextPaint): Long {
5366
val text = spannable ?: ""
5467
val textLength = text.length
5568
val builder = StaticLayout.Builder
@@ -71,9 +84,63 @@ object MeasurementStore {
7184
return YogaMeasureOutput.make(widthInSP, heightInSP)
7285
}
7386

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)
87+
// Returns either: Spannable parsed from HTML defaultValue, or plain text defaultValue, or "I" if no defaultValue
88+
private fun getInitialText(defaultView: EnrichedTextInputView, props: ReadableMap?): CharSequence {
89+
val defaultValue = props?.getString("defaultValue")
90+
91+
// If there is no default value, assume text is one line, "I" is a good approximation of height
92+
if (defaultValue == null) return "I"
93+
94+
val isHtml = defaultValue.startsWith("<html>") && defaultValue.endsWith("</html>")
95+
if (!isHtml) return defaultValue
96+
97+
try {
98+
val htmlStyle = HtmlStyle(defaultView, props.getMap("htmlStyle"))
99+
val parsed = EnrichedParser.fromHtml(defaultValue, htmlStyle, null)
100+
return parsed.trimEnd('\n')
101+
} catch (e: Exception) {
102+
Log.w("MeasurementStore", "Error parsing initial HTML text: ${e.message}")
103+
return defaultValue
104+
}
105+
}
106+
107+
private fun getInitialFontSize(defaultView: EnrichedTextInputView, props: ReadableMap?): Float {
108+
val propsFontSize = props?.getDouble("fontSize")?.toFloat()
109+
if (propsFontSize == null) return defaultView.textSize
110+
111+
return ceil(PixelUtil.toPixelFromSP(propsFontSize))
112+
}
113+
114+
// Called when view measurements are not available in the store
115+
// Most likely first measurement, we can use defaultValue, as no native state is set yet
116+
private fun initialMeasure(context: Context, id: Int?, width: Float, props: ReadableMap?): Long {
117+
val defaultView = EnrichedTextInputView(context)
118+
119+
val text = getInitialText(defaultView, props)
120+
val fontSize = getInitialFontSize(defaultView, props)
121+
122+
val fontFamily = props?.getString("fontFamily")
123+
val fontStyle = parseFontStyle(props?.getString("fontStyle"))
124+
val fontWeight = parseFontWeight(props?.getString("fontWeight"))
125+
126+
val typeface = applyStyles(defaultView.typeface, fontStyle, fontWeight, fontFamily, context.assets)
127+
val paintParams = PaintParams(typeface, fontSize)
128+
val size = measure(width, text, PaintParams(typeface, fontSize))
129+
130+
if (id != null) {
131+
data[id] = MeasurementParams(true, width, size, text, paintParams)
132+
}
133+
134+
return size
135+
}
136+
137+
fun getMeasureById(context: Context, id: Int?, width: Float, props: ReadableMap?): Long {
138+
val id = id ?: return initialMeasure(context, id, width, props)
139+
val value = data[id] ?: return initialMeasure(context, id, width, props)
140+
141+
// First measure has to be done using initialMeasure
142+
// That way it's free of any side effects and async initializations
143+
if (!value.initialized) return initialMeasure(context, id, width, props)
77144

78145
if (width == value.cachedWidth) {
79146
return value.cachedSize
@@ -83,8 +150,9 @@ object MeasurementStore {
83150
typeface = value.paintParams.typeface
84151
textSize = value.paintParams.fontSize
85152
}
153+
86154
val size = measure(width, value.spannable, paint)
87-
data[id] = MeasurementParams(width, size, value.spannable, value.paintParams)
155+
data[id] = MeasurementParams(true, width, size, value.spannable, value.paintParams)
88156
return size
89157
}
90158
}

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "EnrichedTextInputMeasurementManager.h"
2+
#include "conversions.h"
23

34
#include <fbjni/fbjni.h>
45
#include <react/jni/ReadableNativeMap.h>
@@ -11,6 +12,7 @@ namespace facebook::react {
1112
Size EnrichedTextInputMeasurementManager::measure(
1213
SurfaceId surfaceId,
1314
int viewTag,
15+
const EnrichedTextInputViewProps& props,
1416
LayoutConstraints layoutConstraints) const {
1517
const jni::global_ref<jobject>& fabricUIManager =
1618
contextContainer_->at<jni::global_ref<jobject>>("FabricUIManager");
@@ -33,17 +35,23 @@ namespace facebook::react {
3335

3436
local_ref<JString> componentName = make_jstring("EnrichedTextInputView");
3537

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()));
38+
// Prepare extraData map with viewTag
39+
folly::dynamic extraData = folly::dynamic::object();
40+
extraData["viewTag"] = viewTag;
41+
local_ref<ReadableNativeMap::javaobject> extraDataRNM = ReadableNativeMap::newObjectCxxArgs(extraData);
42+
local_ref<ReadableMap::javaobject> extraDataRM = make_local(reinterpret_cast<ReadableMap::javaobject>(extraDataRNM.get()));
43+
44+
// Prepare layout metrics affecting props
45+
auto serializedProps = toDynamic(props);
46+
local_ref<ReadableNativeMap::javaobject> propsRNM = ReadableNativeMap::newObjectCxxArgs(serializedProps);
47+
local_ref<ReadableMap::javaobject> propsRM = make_local(reinterpret_cast<ReadableMap::javaobject>(propsRNM.get()));
4048

4149
auto measurement = yogaMeassureToSize(measure(
4250
fabricUIManager,
4351
surfaceId,
4452
componentName.get(),
4553
extraDataRM.get(),
46-
nullptr,
54+
propsRM.get(),
4755
nullptr,
4856
minimumSize.width,
4957
maximumSize.width,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ namespace facebook::react {
1717
Size measure(
1818
SurfaceId surfaceId,
1919
int viewTag,
20+
const EnrichedTextInputViewProps& props,
2021
LayoutConstraints layoutConstraints) const;
2122

2223
private:

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

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

3333
} // namespace facebook::react
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#pragma once
2+
3+
#include <folly/dynamic.h>
4+
#include <react/renderer/components/FBReactNativeSpec/Props.h>
5+
#include <react/renderer/core/propsConversions.h>
6+
#include <react/renderer/components/RNEnrichedTextInputViewSpec/Props.h>
7+
8+
namespace facebook::react {
9+
10+
#ifdef RN_SERIALIZABLE_STATE
11+
inline folly::dynamic toDynamic(const EnrichedTextInputViewProps &props)
12+
{
13+
// Serialize only metrics affecting props
14+
folly::dynamic serializedProps = folly::dynamic::object();
15+
serializedProps["defaultValue"] = props.defaultValue;
16+
serializedProps["placeholder"] = props.placeholder;
17+
serializedProps["fontSize"] = props.fontSize;
18+
serializedProps["fontWeight"] = props.fontWeight;
19+
serializedProps["fontStyle"] = props.fontStyle;
20+
serializedProps["fontFamily"] = props.fontFamily;
21+
serializedProps["htmlStyle"] = toDynamic(props.htmlStyle);
22+
23+
return serializedProps;
24+
}
25+
#endif
26+
27+
} // namespace facebook::react

0 commit comments

Comments
 (0)