Skip to content

Commit 829e111

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 Pull Request resolved: facebook#45978 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 3e0a886 commit 829e111

File tree

17 files changed

+177
-32
lines changed

17 files changed

+177
-32
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/src/main/java/com/facebook/react/fabric/SurfaceHandlerBinding.kt

+6-3
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ internal open class SurfaceHandlerBinding(moduleName: String) : HybridClassBase(
4242
offsetY: Int,
4343
doLeftAndRightSwapInRTL: Boolean,
4444
isRTL: Boolean,
45-
pixelDensity: Float
45+
pixelDensity: Float,
46+
fontScale: Float
4647
) {
4748
setLayoutConstraintsNative(
4849
LayoutMetricsConversions.getMinSize(widthMeasureSpec) / pixelDensity,
@@ -53,7 +54,8 @@ internal open class SurfaceHandlerBinding(moduleName: String) : HybridClassBase(
5354
offsetY / pixelDensity,
5455
doLeftAndRightSwapInRTL,
5556
isRTL,
56-
pixelDensity)
57+
pixelDensity,
58+
fontScale)
5759
}
5860

5961
private external fun setLayoutConstraintsNative(
@@ -65,7 +67,8 @@ internal open class SurfaceHandlerBinding(moduleName: String) : HybridClassBase(
6567
offsetY: Float,
6668
doLeftAndRightSwapInRTL: Boolean,
6769
isRTL: Boolean,
68-
pixelDensity: Float
70+
pixelDensity: Float,
71+
fontScale: Float
6972
)
7073

7174
external override fun setProps(props: NativeMap)

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public interface SurfaceHandler {
3333
offsetY: Int,
3434
doLeftAndRightSwapInRTL: Boolean,
3535
isRTL: Boolean,
36-
pixelDensity: Float
36+
pixelDensity: Float,
37+
fontScale: Float
3738
)
3839

3940
public fun setMountable(mountable: Boolean)

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

+2
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,7 @@ public void onNewIntent(Intent intent) {
828829
public void onConfigurationChanged(Context updatedContext) {
829830
ReactContext currentReactContext = getCurrentReactContext();
830831
if (currentReactContext != null) {
832+
DisplayMetricsHolder.initDisplayMetrics(currentReactContext);
831833
AppearanceModule appearanceModule =
832834
currentReactContext.getNativeModule(AppearanceModule.class);
833835

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

+13-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.facebook.react.interfaces.TaskInterface;
2727
import com.facebook.react.interfaces.fabric.ReactSurface;
2828
import com.facebook.react.interfaces.fabric.SurfaceHandler;
29+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
2930
import com.facebook.react.modules.i18nmanager.I18nUtil;
3031
import com.facebook.react.runtime.internal.bolts.Task;
3132
import com.facebook.react.uimanager.events.EventDispatcher;
@@ -80,7 +81,8 @@ public ReactSurfaceImpl(Context context, String moduleName, @Nullable Bundle ini
8081
0,
8182
doRTLSwap(context),
8283
isRTL(context),
83-
getPixelDensity(context));
84+
getPixelDensity(context),
85+
getFontScale(context));
8486
}
8587

8688
@VisibleForTesting
@@ -214,7 +216,8 @@ public void clear() {
214216
offsetY,
215217
doRTLSwap(mContext),
216218
isRTL(mContext),
217-
getPixelDensity(mContext));
219+
getPixelDensity(mContext),
220+
getFontScale(mContext));
218221
}
219222

220223
/* package */ @Nullable
@@ -245,6 +248,14 @@ private static float getPixelDensity(Context context) {
245248
return context.getResources().getDisplayMetrics().density;
246249
}
247250

251+
private static float getFontScale(Context context) {
252+
if (ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout()) {
253+
return context.getResources().getConfiguration().fontScale;
254+
}
255+
256+
return 1f;
257+
}
258+
248259
private static boolean isRTL(Context context) {
249260
return I18nUtil.getInstance().isRTL(context);
250261
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ class ReactSurfaceTest {
178178
offsetY: Int,
179179
doLeftAndRightSwapInRTL: Boolean,
180180
isRTL: Boolean,
181-
pixelDensity: Float
181+
pixelDensity: Float,
182+
fontScale: Float
182183
) {
183184
this.widthMeasureSpec = widthMeasureSpec
184185
this.heightMeasureSpec = heightMeasureSpec

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

+9-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,14 @@ ParagraphShadowNode::ParagraphShadowNode(
3132
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
3233
auto& sourceParagraphShadowNode =
3334
static_cast<const ParagraphShadowNode&>(sourceShadowNode);
35+
auto& state = getStateData();
36+
fontSizeMultiplier_ = sourceParagraphShadowNode.fontSizeMultiplier_;
37+
3438
if (!fragment.children && !fragment.props &&
35-
sourceParagraphShadowNode.getIsLayoutClean()) {
39+
sourceParagraphShadowNode.getIsLayoutClean() &&
40+
(!ReactNativeFeatureFlags::enableFontScaleChangesUpdatingLayout() ||
41+
fontSizeMultiplier_ ==
42+
state.attributedString.getBaseTextAttributes().fontSizeMultiplier)) {
3643
// This ParagraphShadowNode was cloned but did not change
3744
// in a way that affects its layout. Let's mark it clean
3845
// to stop Yoga from traversing it.
@@ -202,6 +209,7 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) {
202209
getContentWithMeasuredAttachments(layoutContext, layoutConstraints);
203210

204211
updateStateIfNeeded(content);
212+
fontSizeMultiplier_ = layoutContext.fontSizeMultiplier;
205213

206214
TextLayoutContext textLayoutContext{};
207215
textLayoutContext.pointScaleFactor = layoutContext.pointScaleFactor;

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

+5
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ class ParagraphShadowNode final : public ConcreteViewShadowNode<
108108
* Cached content of the subtree started from the node.
109109
*/
110110
mutable std::optional<Content> content_{};
111+
112+
/*
113+
* FontSizeMultiplier that was used to layout this node.
114+
*/
115+
mutable float fontSizeMultiplier_{};
111116
};
112117

113118
} // namespace facebook::react

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)