Skip to content

Commit db9ab31

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'm using the font scale multiplier stored in `content_` property of the node. 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. ## 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> ); } ``` Reviewed By: NickGerleman Differential Revision: D71727907 Pulled By: j-piasecki
1 parent 70cdf12 commit db9ab31

File tree

16 files changed

+199
-31
lines changed

16 files changed

+199
-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
@@ -6752,6 +6752,7 @@ public class com/facebook/react/views/textinput/ReactEditText : androidx/appcomp
67526752
public fun maybeSetTextFromState (Lcom/facebook/react/views/text/ReactTextUpdate;)V
67536753
public fun maybeUpdateTypeface ()V
67546754
public fun onAttachedToWindow ()V
6755+
public fun onConfigurationChanged (Landroid/content/res/Configuration;)V
67556756
public fun onCreateInputConnection (Landroid/view/inputmethod/EditorInfo;)Landroid/view/inputmethod/InputConnection;
67566757
public fun onDetachedFromWindow ()V
67576758
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

+6
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
import com.facebook.react.fabric.FabricUIManager;
6060
import com.facebook.react.interfaces.TaskInterface;
6161
import com.facebook.react.interfaces.fabric.ReactSurface;
62+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
6263
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags;
6364
import com.facebook.react.modules.appearance.AppearanceModule;
6465
import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler;
@@ -68,6 +69,7 @@
6869
import com.facebook.react.runtime.internal.bolts.Task;
6970
import com.facebook.react.runtime.internal.bolts.TaskCompletionSource;
7071
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder;
72+
import com.facebook.react.uimanager.DisplayMetricsHolder;
7173
import com.facebook.react.uimanager.UIManagerModule;
7274
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
7375
import com.facebook.react.uimanager.events.EventDispatcher;
@@ -823,6 +825,10 @@ public void onNewIntent(Intent intent) {
823825
public void onConfigurationChanged(Context updatedContext) {
824826
ReactContext currentReactContext = getCurrentReactContext();
825827
if (currentReactContext != null) {
828+
if (ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout()) {
829+
DisplayMetricsHolder.initDisplayMetrics(currentReactContext);
830+
}
831+
826832
AppearanceModule appearanceModule =
827833
currentReactContext.getNativeModule(AppearanceModule.class);
828834

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

+10-2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.facebook.react.common.annotations.VisibleForTesting
2222
import com.facebook.react.fabric.SurfaceHandlerBinding
2323
import com.facebook.react.interfaces.TaskInterface
2424
import com.facebook.react.interfaces.fabric.ReactSurface
25+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
2526
import com.facebook.react.modules.i18nmanager.I18nUtil
2627
import com.facebook.react.runtime.internal.bolts.Task
2728
import com.facebook.react.uimanager.events.EventDispatcher
@@ -75,7 +76,8 @@ internal constructor(
7576
0,
7677
doRTLSwap(context),
7778
isRTL(context),
78-
displayMetrics.density)
79+
displayMetrics.density,
80+
getFontScale(context))
7981
}
8082

8183
/**
@@ -176,7 +178,8 @@ internal constructor(
176178
offsetY,
177179
doRTLSwap(context),
178180
isRTL(context),
179-
context.resources.displayMetrics.density)
181+
context.resources.displayMetrics.density,
182+
getFontScale(context))
180183
}
181184

182185
internal val eventDispatcher: EventDispatcher?
@@ -203,6 +206,11 @@ internal constructor(
203206

204207
private fun isRTL(context: Context): Boolean = I18nUtil.instance.isRTL(context)
205208

209+
private fun getFontScale(context: Context): Float =
210+
if (ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout())
211+
context.getResources().getConfiguration().fontScale
212+
else 1f
213+
206214
private fun doRTLSwap(context: Context): Boolean =
207215
I18nUtil.instance.doLeftAndRightSwapInRTL(context)
208216
}

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

+12
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;
@@ -51,6 +52,7 @@
5152
import com.facebook.react.common.ReactConstants;
5253
import com.facebook.react.common.build.ReactBuildConfig;
5354
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
55+
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags;
5456
import com.facebook.react.uimanager.BackgroundStyleApplicator;
5557
import com.facebook.react.uimanager.LengthPercentage;
5658
import com.facebook.react.uimanager.LengthPercentageType;
@@ -1080,6 +1082,16 @@ public void onStartTemporaryDetach() {
10801082
}
10811083
}
10821084

1085+
@Override
1086+
public void onConfigurationChanged(Configuration newConfig) {
1087+
super.onConfigurationChanged(newConfig);
1088+
1089+
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()
1090+
&& ReactNativeFeatureFlags.enableFontScaleChangesUpdatingLayout()) {
1091+
applyTextAttributes();
1092+
}
1093+
}
1094+
10831095
@Override
10841096
public void onAttachedToWindow() {
10851097
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

+12-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,18 @@ ParagraphShadowNode::ParagraphShadowNode(
3132
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
3233
auto& sourceParagraphShadowNode =
3334
static_cast<const ParagraphShadowNode&>(sourceShadowNode);
35+
auto& state = getStateData();
36+
const auto& sourceContent = sourceParagraphShadowNode.content_;
37+
3438
if (!fragment.children && !fragment.props &&
35-
sourceParagraphShadowNode.getIsLayoutClean()) {
39+
sourceParagraphShadowNode.getIsLayoutClean() &&
40+
(!ReactNativeFeatureFlags::enableFontScaleChangesUpdatingLayout() ||
41+
(sourceContent.has_value() &&
42+
sourceContent.value()
43+
.attributedString.getBaseTextAttributes()
44+
.fontSizeMultiplier ==
45+
state.attributedString.getBaseTextAttributes()
46+
.fontSizeMultiplier))) {
3647
// This ParagraphShadowNode was cloned but did not change
3748
// in a way that affects its layout. Let's mark it clean
3849
// 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),

0 commit comments

Comments
 (0)