Skip to content

Commit f4ec65b

Browse files
j-piaseckifacebook-github-bot
authored andcommitted
Fix changing font scale breaking text (facebook#45978)
Summary: Fixes facebook#45857 The general idea behind this PR is the same for both platforms: dirty all nodes with `MeasurableYogaNode` trait when the layout is constrained with a new `fontSizeMultiplier`. There were a few caveats: - `ParagraphShadowNode` marks its layout as clean in the constructor in most cases. To prevent that from using a stale measurement, I've added a `fontSizeMultiplier_` field in the node that keeps track of the font scale it was last laid out with. That value is then compared with the scale used to create the attributed string kept in the node's state. If those differ, the layout is not cleared. - On Android, font scale wasn't passed down to the `SurfaceHandler` - On Android, text measurement relies on cached `DisplayMetrics` which were not updated when the system font scale changed. - `AndroidTextInputShadowNode` wasn't using `fontSizeMultiplier` at all. I needed to add it in all places where an `AttributedString` is constructed. - When the `fontSizeMultiplier` is changed, the entire `ShadowTree` is cloned. I'm not sure if there's a reliable way of determining whether the node is mutable or not. - Changing font scale and navigating back to the app on Android has the potential to cause a `SIGSEGV` but it also happens without changes in this PR. ## Changelog: [GENERAL] [FIXED] - Fixed text not updating correctly after changing font scale in settings Test Plan: So far tested on the following code: ```jsx function App() { const [counter,setCounter] = useState(0); const [text,setText] = useState('TextInput'); const [flag,setFlag] = useState(true); return ( <SafeAreaView style={{ flex: 1, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center', }} > <Text style={{fontSize: 24}}>RN 24 Label Testing {flag ? 'A' : 'B'}</Text> <TextInput value={text} onChangeText={setText} style={{fontSize: 24, borderWidth: 1}} placeholder="Placeholder" /> <Pressable onPress={() => setCounter(prevState => prevState + 1)} style={{backgroundColor: counter % 2 === 0 ? 'red' : 'blue', width: 200, height: 50}} /> <Pressable onPress={() => setFlag(!flag)} style={{backgroundColor: 'green', width: 200, height: 50}} /> </SafeAreaView> ); } ``` Differential Revision: D71727907 Pulled By: j-piasecki
1 parent 163a373 commit f4ec65b

File tree

16 files changed

+196
-31
lines changed

16 files changed

+196
-31
lines changed

packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm

+15
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#import "RCTTextInputComponentView.h"
99

10+
#import <react/featureflags/ReactNativeFeatureFlags.h>
1011
#import <react/renderer/components/iostextinput/TextInputComponentDescriptor.h>
1112
#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
1213
#import <react/renderer/textlayoutmanager/TextLayoutManager.h>
@@ -123,6 +124,20 @@ - (void)didMoveToWindow
123124
[self _restoreTextSelection];
124125
}
125126

127+
// TODO: replace with registerForTraitChanges once iOS 17.0 is the lowest supported version
128+
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
129+
{
130+
[super traitCollectionDidChange:previousTraitCollection];
131+
132+
if (facebook::react::ReactNativeFeatureFlags::enableFontScaleChangesUpdatingLayout() &&
133+
UITraitCollection.currentTraitCollection.preferredContentSizeCategory !=
134+
previousTraitCollection.preferredContentSizeCategory) {
135+
const auto &newTextInputProps = static_cast<const TextInputProps &>(*_props);
136+
_backedTextInputView.defaultTextAttributes =
137+
RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier()));
138+
}
139+
}
140+
126141
- (void)reactUpdateResponderOffsetForScrollView:(RCTScrollViewComponentView *)scrollView
127142
{
128143
if (![self isDescendantOfView:scrollView.scrollView] || !_backedTextInputView.isFirstResponder) {

packages/react-native/ReactAndroid/api/ReactAndroid.api

+1
Original file line numberDiff line numberDiff line change
@@ -6788,6 +6788,7 @@ public class com/facebook/react/views/textinput/ReactEditText : androidx/appcomp
67886788
public fun maybeSetTextFromState (Lcom/facebook/react/views/text/ReactTextUpdate;)V
67896789
public fun maybeUpdateTypeface ()V
67906790
public fun onAttachedToWindow ()V
6791+
public fun onConfigurationChanged (Landroid/content/res/Configuration;)V
67916792
public fun onCreateInputConnection (Landroid/view/inputmethod/EditorInfo;)Landroid/view/inputmethod/InputConnection;
67926793
public fun onDetachedFromWindow ()V
67936794
public fun onDraw (Landroid/graphics/Canvas;)V

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/SurfaceHandlerBinding.kt

+6-3
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ internal open class SurfaceHandlerBinding(moduleName: String) : HybridClassBase(
4141
offsetY: Int,
4242
doLeftAndRightSwapInRTL: Boolean,
4343
isRTL: Boolean,
44-
pixelDensity: Float
44+
pixelDensity: Float,
45+
fontScale: Float
4546
) {
4647
setLayoutConstraintsNative(
4748
LayoutMetricsConversions.getMinSize(widthMeasureSpec) / pixelDensity,
@@ -52,7 +53,8 @@ internal open class SurfaceHandlerBinding(moduleName: String) : HybridClassBase(
5253
offsetY / pixelDensity,
5354
doLeftAndRightSwapInRTL,
5455
isRTL,
55-
pixelDensity)
56+
pixelDensity,
57+
fontScale)
5658
}
5759

5860
private external fun setLayoutConstraintsNative(
@@ -64,7 +66,8 @@ internal open class SurfaceHandlerBinding(moduleName: String) : HybridClassBase(
6466
offsetY: Float,
6567
doLeftAndRightSwapInRTL: Boolean,
6668
isRTL: Boolean,
67-
pixelDensity: Float
69+
pixelDensity: Float,
70+
fontScale: Float
6871
)
6972

7073
external fun setProps(props: NativeMap?)

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java

+5
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import com.facebook.react.runtime.internal.bolts.Task;
7070
import com.facebook.react.runtime.internal.bolts.TaskCompletionSource;
7171
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder;
72+
import com.facebook.react.uimanager.DisplayMetricsHolder;
7273
import com.facebook.react.uimanager.UIManagerModule;
7374
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
7475
import com.facebook.react.uimanager.events.EventDispatcher;
@@ -828,6 +829,10 @@ public void onNewIntent(Intent intent) {
828829
public void onConfigurationChanged(Context updatedContext) {
829830
ReactContext currentReactContext = getCurrentReactContext();
830831
if (currentReactContext != null) {
832+
if (ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout()) {
833+
DisplayMetricsHolder.initDisplayMetrics(currentReactContext);
834+
}
835+
831836
AppearanceModule appearanceModule =
832837
currentReactContext.getNativeModule(AppearanceModule.class);
833838

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceImpl.java

+13-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.facebook.react.fabric.SurfaceHandlerBinding;
2525
import com.facebook.react.interfaces.TaskInterface;
2626
import com.facebook.react.interfaces.fabric.ReactSurface;
27+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
2728
import com.facebook.react.modules.i18nmanager.I18nUtil;
2829
import com.facebook.react.runtime.internal.bolts.Task;
2930
import com.facebook.react.uimanager.events.EventDispatcher;
@@ -76,7 +77,8 @@ public ReactSurfaceImpl(Context context, String moduleName, @Nullable Bundle ini
7677
0,
7778
doRTLSwap(context),
7879
isRTL(context),
79-
getPixelDensity(context));
80+
getPixelDensity(context),
81+
getFontScale(context));
8082
}
8183

8284
@VisibleForTesting
@@ -210,7 +212,8 @@ public void clear() {
210212
offsetY,
211213
doRTLSwap(mContext),
212214
isRTL(mContext),
213-
getPixelDensity(mContext));
215+
getPixelDensity(mContext),
216+
getFontScale(mContext));
214217
}
215218

216219
/* package */ @Nullable
@@ -241,6 +244,14 @@ private static float getPixelDensity(Context context) {
241244
return context.getResources().getDisplayMetrics().density;
242245
}
243246

247+
private static float getFontScale(Context context) {
248+
if (ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout()) {
249+
return context.getResources().getConfiguration().fontScale;
250+
}
251+
252+
return 1f;
253+
}
254+
244255
private static boolean isRTL(Context context) {
245256
return I18nUtil.getInstance().isRTL(context);
246257
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java

+11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import static com.facebook.react.uimanager.UIManagerHelper.getReactContext;
1111

1212
import android.content.Context;
13+
import android.content.res.Configuration;
1314
import android.graphics.Canvas;
1415
import android.graphics.Color;
1516
import android.graphics.Paint;
@@ -1080,6 +1081,16 @@ public void onStartTemporaryDetach() {
10801081
}
10811082
}
10821083

1084+
@Override
1085+
public void onConfigurationChanged(Configuration newConfig) {
1086+
super.onConfigurationChanged(newConfig);
1087+
1088+
if (ReactNativeFeatureFlags.enableBridgelessArchitecture()
1089+
&& ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout()) {
1090+
applyTextAttributes();
1091+
}
1092+
}
1093+
10831094
@Override
10841095
public void onAttachedToWindow() {
10851096
super.onAttachedToWindow();

packages/react-native/ReactAndroid/src/main/jni/react/fabric/SurfaceHandlerBinding.cpp

+3-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ void SurfaceHandlerBinding::setLayoutConstraints(
4747
jfloat offsetY,
4848
jboolean doLeftAndRightSwapInRTL,
4949
jboolean isRTL,
50-
jfloat pixelDensity) {
50+
jfloat pixelDensity,
51+
jfloat fontScale) {
5152
LayoutConstraints constraints = {};
5253
constraints.minimumSize = {minWidth, minHeight};
5354
constraints.maximumSize = {maxWidth, maxHeight};
@@ -58,6 +59,7 @@ void SurfaceHandlerBinding::setLayoutConstraints(
5859
context.swapLeftAndRightInRTL = doLeftAndRightSwapInRTL;
5960
context.pointScaleFactor = pixelDensity;
6061
context.viewportOffset = {offsetX, offsetY};
62+
context.fontSizeMultiplier = fontScale;
6163

6264
surfaceHandler_.constraintLayout(constraints, context);
6365
}

packages/react-native/ReactAndroid/src/main/jni/react/fabric/SurfaceHandlerBinding.h

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ class SurfaceHandlerBinding : public jni::HybridClass<SurfaceHandlerBinding> {
4040
jfloat offsetY,
4141
jboolean doLeftAndRightSwapInRTL,
4242
jboolean isRTL,
43-
jfloat pixelDensity);
43+
jfloat pixelDensity,
44+
jfloat fontScale);
4445

4546
void setProps(NativeMap* props);
4647

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactSurfaceTest.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import android.content.Context
1212
import android.view.View
1313
import com.facebook.react.common.annotations.UnstableReactNativeAPI
1414
import com.facebook.react.fabric.SurfaceHandlerBinding
15+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
16+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
17+
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlagsDefaults
1518
import com.facebook.react.runtime.internal.bolts.Task
1619
import com.facebook.react.uimanager.events.EventDispatcher
1720
import com.facebook.testutils.shadows.ShadowNativeLoader
@@ -44,6 +47,9 @@ class ReactSurfaceTest {
4447

4548
@Before
4649
fun setUp() {
50+
ReactNativeFeatureFlagsForTests.setUp()
51+
ReactNativeFeatureFlags.override(ReactNativeNewArchitectureFeatureFlagsDefaults())
52+
4753
eventDispatcher = mock()
4854
context = Robolectric.buildActivity(Activity::class.java).create().get()
4955

@@ -122,7 +128,7 @@ class ReactSurfaceTest {
122128
reactSurface.attach(reactHost)
123129
reactSurface.updateLayoutSpecs(measureSpecWidth, measureSpecHeight, 2, 3)
124130
verify(surfaceHandler)
125-
.setLayoutConstraints(measureSpecWidth, measureSpecHeight, 2, 3, true, false, 1.0f)
131+
.setLayoutConstraints(measureSpecWidth, measureSpecHeight, 2, 3, true, false, 1.0f, 1.0f)
126132
}
127133

128134
@Test

packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp

+11-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <cmath>
1111

1212
#include <react/debug/react_native_assert.h>
13+
#include <react/featureflags/ReactNativeFeatureFlags.h>
1314
#include <react/renderer/attributedstring/AttributedStringBox.h>
1415
#include <react/renderer/components/view/ViewShadowNode.h>
1516
#include <react/renderer/components/view/conversions.h>
@@ -31,8 +32,17 @@ ParagraphShadowNode::ParagraphShadowNode(
3132
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
3233
auto& sourceParagraphShadowNode =
3334
static_cast<const ParagraphShadowNode&>(sourceShadowNode);
35+
auto& state = getStateData();
36+
3437
if (!fragment.children && !fragment.props &&
35-
sourceParagraphShadowNode.getIsLayoutClean()) {
38+
sourceParagraphShadowNode.getIsLayoutClean() &&
39+
(!ReactNativeFeatureFlags::enableFontScaleChangesUpdatingLayout() ||
40+
(content_.has_value() &&
41+
content_.value()
42+
.attributedString.getBaseTextAttributes()
43+
.fontSizeMultiplier ==
44+
state.attributedString.getBaseTextAttributes()
45+
.fontSizeMultiplier))) {
3646
// This ParagraphShadowNode was cloned but did not change
3747
// in a way that affects its layout. Let's mark it clean
3848
// to stop Yoga from traversing it.

packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputShadowNode.h

+4-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ class BaseTextInputShadowNode : public ConcreteViewShadowNode<
193193
const LayoutContext& layoutContext) const {
194194
bool meaningfulState = BaseShadowNode::getState() &&
195195
BaseShadowNode::getState()->getRevision() !=
196-
State::initialRevisionValue;
196+
State::initialRevisionValue &&
197+
BaseShadowNode::getStateData()
198+
.reactTreeAttributedString.getBaseTextAttributes()
199+
.fontSizeMultiplier == layoutContext.fontSizeMultiplier;
197200
if (meaningfulState) {
198201
const auto& stateData = BaseShadowNode::getStateData();
199202
auto attributedStringBox = stateData.attributedStringBox;

packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp

+22-15
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ Size AndroidTextInputShadowNode::measureContent(
4747
// in layout, it's too late: measure will have already operated on old
4848
// State. Thus, we use the same value here that we *will* use in layout to
4949
// update the state.
50-
AttributedString attributedString = getMostRecentAttributedString();
50+
AttributedString attributedString =
51+
getMostRecentAttributedString(layoutContext);
5152

5253
if (attributedString.isEmpty()) {
53-
attributedString = getPlaceholderAttributedString();
54+
attributedString = getPlaceholderAttributedString(layoutContext);
5455
}
5556

5657
if (attributedString.isEmpty() && getStateData().mostRecentEventCount != 0) {
@@ -70,17 +71,18 @@ Size AndroidTextInputShadowNode::measureContent(
7071
}
7172

7273
void AndroidTextInputShadowNode::layout(LayoutContext layoutContext) {
73-
updateStateIfNeeded();
74+
updateStateIfNeeded(layoutContext);
7475
ConcreteViewShadowNode::layout(layoutContext);
7576
}
7677

7778
Float AndroidTextInputShadowNode::baseline(
78-
const LayoutContext& /*layoutContext*/,
79+
const LayoutContext& layoutContext,
7980
Size size) const {
80-
AttributedString attributedString = getMostRecentAttributedString();
81+
AttributedString attributedString =
82+
getMostRecentAttributedString(layoutContext);
8183

8284
if (attributedString.isEmpty()) {
83-
attributedString = getPlaceholderAttributedString();
85+
attributedString = getPlaceholderAttributedString(layoutContext);
8486
}
8587

8688
// Yoga expects a baseline relative to the Node's border-box edge instead of
@@ -118,10 +120,11 @@ LayoutConstraints AndroidTextInputShadowNode::getTextConstraints(
118120
}
119121
}
120122

121-
void AndroidTextInputShadowNode::updateStateIfNeeded() {
123+
void AndroidTextInputShadowNode::updateStateIfNeeded(
124+
const LayoutContext& layoutContext) {
122125
ensureUnsealed();
123126
const auto& stateData = getStateData();
124-
auto reactTreeAttributedString = getAttributedString();
127+
auto reactTreeAttributedString = getAttributedString(layoutContext);
125128

126129
// Tree is often out of sync with the value of the TextInput.
127130
// This is by design - don't change the value of the TextInput in the State,
@@ -145,7 +148,7 @@ void AndroidTextInputShadowNode::updateStateIfNeeded() {
145148
reactTreeAttributedString)
146149
? 0
147150
: props.mostRecentEventCount;
148-
auto newAttributedString = getMostRecentAttributedString();
151+
auto newAttributedString = getMostRecentAttributedString(layoutContext);
149152

150153
setStateData(TextInputState{
151154
AttributedStringBox(newAttributedString),
@@ -154,9 +157,11 @@ void AndroidTextInputShadowNode::updateStateIfNeeded() {
154157
newEventCount});
155158
}
156159

157-
AttributedString AndroidTextInputShadowNode::getAttributedString() const {
160+
AttributedString AndroidTextInputShadowNode::getAttributedString(
161+
const LayoutContext& layoutContext) const {
158162
// Use BaseTextShadowNode to get attributed string from children
159163
auto childTextAttributes = TextAttributes::defaultTextAttributes();
164+
childTextAttributes.fontSizeMultiplier = layoutContext.fontSizeMultiplier;
160165
childTextAttributes.apply(getConcreteProps().textAttributes);
161166
// Don't propagate the background color of the TextInput onto the attributed
162167
// string. Android tries to render shadow of the background alongside the
@@ -174,6 +179,7 @@ AttributedString AndroidTextInputShadowNode::getAttributedString() const {
174179
if (!getConcreteProps().text.empty()) {
175180
auto textAttributes = TextAttributes::defaultTextAttributes();
176181
textAttributes.apply(getConcreteProps().textAttributes);
182+
textAttributes.fontSizeMultiplier = layoutContext.fontSizeMultiplier;
177183
auto fragment = AttributedString::Fragment{};
178184
fragment.string = getConcreteProps().text;
179185
fragment.textAttributes = textAttributes;
@@ -188,11 +194,11 @@ AttributedString AndroidTextInputShadowNode::getAttributedString() const {
188194
return attributedString;
189195
}
190196

191-
AttributedString AndroidTextInputShadowNode::getMostRecentAttributedString()
192-
const {
197+
AttributedString AndroidTextInputShadowNode::getMostRecentAttributedString(
198+
const LayoutContext& layoutContext) const {
193199
const auto& state = getStateData();
194200

195-
auto reactTreeAttributedString = getAttributedString();
201+
auto reactTreeAttributedString = getAttributedString(layoutContext);
196202

197203
// Sometimes the treeAttributedString will only differ from the state
198204
// not by inherent properties (string or prop attributes), but by the frame of
@@ -213,15 +219,16 @@ AttributedString AndroidTextInputShadowNode::getMostRecentAttributedString()
213219
// display at all.
214220
// TODO T67606511: We will redefine the measurement of empty strings as part
215221
// of T67606511
216-
AttributedString AndroidTextInputShadowNode::getPlaceholderAttributedString()
217-
const {
222+
AttributedString AndroidTextInputShadowNode::getPlaceholderAttributedString(
223+
const LayoutContext& layoutContext) const {
218224
const auto& props = BaseShadowNode::getConcreteProps();
219225

220226
AttributedString attributedString;
221227
auto placeholderString = !props.placeholder.empty()
222228
? props.placeholder
223229
: BaseTextShadowNode::getEmptyPlaceholder();
224230
auto textAttributes = TextAttributes::defaultTextAttributes();
231+
textAttributes.fontSizeMultiplier = layoutContext.fontSizeMultiplier;
225232
textAttributes.apply(props.textAttributes);
226233
attributedString.appendFragment(
227234
{.string = std::move(placeholderString),

packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.h

+7-4
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,22 @@ class AndroidTextInputShadowNode final
7070
* Creates a `State` object (with `AttributedText` and
7171
* `TextLayoutManager`) if needed.
7272
*/
73-
void updateStateIfNeeded();
73+
void updateStateIfNeeded(const LayoutContext& layoutContext);
7474

7575
/*
7676
* Returns a `AttributedString` which represents text content of the node.
7777
*/
78-
AttributedString getAttributedString() const;
78+
AttributedString getAttributedString(
79+
const LayoutContext& layoutContext) const;
7980

8081
/**
8182
* Get the most up-to-date attributed string for measurement and State.
8283
*/
83-
AttributedString getMostRecentAttributedString() const;
84+
AttributedString getMostRecentAttributedString(
85+
const LayoutContext& layoutContext) const;
8486

85-
AttributedString getPlaceholderAttributedString() const;
87+
AttributedString getPlaceholderAttributedString(
88+
const LayoutContext& layoutContext) const;
8689
};
8790

8891
} // namespace facebook::react

0 commit comments

Comments
 (0)