[web] Fix onTextScaleFactorChanged not getting called. (#178862)

Fixes https://github.com/flutter/flutter/issues/178856
Fixes https://github.com/flutter/flutter/issues/178271
Fixes https://github.com/flutter/flutter/issues/178238

### Description

- Fixes an issue in `lineHeightScaleFactorOverride` calculation, causing
it to have abnormal values.
- Integrates text scale factor update into recently added
`_addTypographySettingsObserver`. The `ResizeObserver` on the typography
probe element is getting notified each time the font size changes,
because it leads to the element's size change.
- Removes `DomMutationObserver` previously used for font size
observations.
- Adds/Updates tests to verify the fixes.

| BEFORE | AFTER |
| - | - |
| <video
src="https://github.com/user-attachments/assets/f62fb3c6-63e1-447f-b4ef-8f22ad944a0a"/>
| <video
src="https://github.com/user-attachments/assets/c220dd30-89d9-4f76-a43f-b908a940fafb"/>
|

### Demo

https://flutter-text-scale-factor.web.app

<details closed><summary>Code sample</summary>

```dart
import 'package:flutter/material.dart';

void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Screen(),
    );
  }
}

class Screen extends StatefulWidget {
  const Screen({super.key});

  @override
  State<Screen> createState() => _ScreenState();
}

class _ScreenState extends State<Screen> with SingleTickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            spacing: 10,
            children: [
              Builder(
                builder: (context) {
                  print(
                    'lineHeightScaleFactorOverride: ${MediaQuery.maybeLineHeightScaleFactorOverrideOf(context)}',
                  );
                  final scale = MediaQuery.textScalerOf(context).scale(1);

                  return Text(
                    'Scale ${scale.toStringAsFixed(3)}',
                    style: TextStyle(
                      fontSize: 30,
                      backgroundColor: Colors.grey.shade200,
                    ),
                    textAlign: TextAlign.center,
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

</details>

## Pre-launch Checklist

- [X] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [X] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [X] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [X] I signed the [CLA].
- [X] I listed at least one issue that this PR fixes in the description
above.
- [X] I updated/added relevant documentation (doc comments with `///`).
- [X] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [X] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [X] All existing and new tests are passing.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Kostia Sokolovskyi 2025-12-02 19:23:19 +01:00 committed by GitHub
parent bf7866fba8
commit daddf7ab8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 107 additions and 59 deletions

View File

@ -28,7 +28,6 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
EnginePlatformDispatcher() {
_addBrightnessMediaQueryListener();
HighContrastSupport.instance.addListener(_updateHighContrast);
_addFontSizeObserver();
_addTypographySettingsObserver();
_addLocaleChangedListener();
registerHotRestartListener(dispose);
@ -80,7 +79,6 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
void dispose() {
_removeBrightnessMediaQueryListener();
_disconnectFontSizeObserver();
_disconnectTypographySettingsObserver();
_removeLocaleChangedListener();
HighContrastSupport.instance.removeListener(_updateHighContrast);
@ -995,52 +993,6 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
@override
bool get alwaysUse24HourFormat => configuration.alwaysUse24HourFormat;
/// Updates [textScaleFactor] and invokes [onTextScaleFactorChanged] and
/// [onPlatformConfigurationChanged] callbacks if [textScaleFactor] changed.
void _updateTextScaleFactor(double value) {
if (configuration.textScaleFactor != value) {
configuration = configuration.copyWith(textScaleFactor: value);
invokeOnPlatformConfigurationChanged();
invokeOnTextScaleFactorChanged();
}
}
/// Watches for font-size changes in the browser's <html> element to
/// recalculate [textScaleFactor].
///
/// Updates [textScaleFactor] with the new value.
DomMutationObserver? _fontSizeObserver;
/// Set the callback function for updating [textScaleFactor] based on
/// font-size changes in the browser's <html> element.
void _addFontSizeObserver() {
const styleAttribute = 'style';
_fontSizeObserver = createDomMutationObserver((
JSArray<JSAny?> mutations,
DomMutationObserver _,
) {
for (final JSAny? mutation in mutations.toDart) {
final record = mutation! as DomMutationRecord;
if (record.type == 'attributes' && record.attributeName == styleAttribute) {
final double newTextScaleFactor = findBrowserTextScaleFactor();
_updateTextScaleFactor(newTextScaleFactor);
}
}
});
_fontSizeObserver!.observe(
domDocument.documentElement!,
attributes: true,
attributeFilter: <String>[styleAttribute],
);
}
/// Remove the observer for font-size changes in the browser's <html> element.
void _disconnectFontSizeObserver() {
_fontSizeObserver?.disconnect();
_fontSizeObserver = null;
}
/// Watches for resize changes on an off-screen invisible element to
/// recalculate [lineHeightScaleFactorOverride], [letterSpacingOverride],
/// [wordSpacingOverride], and [paragraphSpacingOverride].
@ -1050,6 +1002,16 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
DomResizeObserver? _typographySettingsObserver;
DomElement? _typographyMeasurementElement;
/// Updates [textScaleFactor] and returns true if [textScaleFactor] changed.
/// If not then returns false.
bool _updateTextScaleFactor(double value) {
if (configuration.textScaleFactor != value) {
configuration = configuration.apply(textScaleFactor: value);
return true;
}
return false;
}
/// Updates [lineHeightScaleFactorOverride] and return true if
/// [lineHeightScaleFactorOverride] changed. If not then returns false.
bool _updateLineHeightScaleFactorOverride(double? value) {
@ -1090,9 +1052,10 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
return false;
}
/// Set the callback function for updating [lineHeightScaleFactorOverride],
/// [letterSpacingOverride], [wordSpacingOverride], and [paragraphSpacingOverride]
/// based on the sizing changes of an off-screen element with text.
/// Sets the callback function for updating [textScaleFactor],
/// [lineHeightScaleFactorOverride], [letterSpacingOverride],
/// [wordSpacingOverride], and [paragraphSpacingOverride] based on the sizing
/// changes of an off-screen element with text.
void _addTypographySettingsObserver() {
_typographyMeasurementElement = createDomHTMLParagraphElement();
_typographyMeasurementElement!.text = 'flutter typography measurement';
@ -1125,12 +1088,14 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
List<DomResizeObserverEntry> entries,
DomResizeObserver observer,
) {
final double computedTextScaleFactor = findBrowserTextScaleFactor();
final double? lineHeight = parseNumericStyleProperty(
_typographyMeasurementElement!,
'line-height',
)?.toDouble();
final double? fontSize = parseFontSize(_typographyMeasurementElement!)?.toDouble();
final double? computedLineHeightScaleFactor = fontSize != null && lineHeight != null
final double? computedLineHeightScaleFactor =
fontSize != null && lineHeight != null && lineHeight != spacingDefault
? lineHeight / fontSize
: null;
final double? computedWordSpacing = parseNumericStyleProperty(
@ -1150,11 +1115,13 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
'margin-bottom',
)?.toDouble();
var computedTextScaleFactorChanged = false;
var computedLineHeightScaleFactorChanged = false;
var computedLetterSpacingChanged = false;
var computedWordSpacingChanged = false;
var computedParagraphSpacingChanged = false;
computedTextScaleFactorChanged = _updateTextScaleFactor(computedTextScaleFactor);
computedLineHeightScaleFactorChanged = _updateLineHeightScaleFactorOverride(
computedLineHeightScaleFactor == defaultLineHeightFactor
? null
@ -1170,11 +1137,23 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
computedParagraphSpacing == spacingDefault ? null : computedParagraphSpacing,
);
if (computedLineHeightScaleFactorChanged ||
final bool metricsChanged =
computedLineHeightScaleFactorChanged ||
computedLetterSpacingChanged ||
computedWordSpacingChanged ||
computedParagraphSpacingChanged) {
invokeOnPlatformConfigurationChanged();
computedParagraphSpacingChanged;
if (!computedTextScaleFactorChanged && !metricsChanged) {
return;
}
invokeOnPlatformConfigurationChanged();
if (computedTextScaleFactorChanged) {
invokeOnTextScaleFactorChanged();
}
if (metricsChanged) {
invokeOnMetricsChanged();
}
});

View File

@ -271,6 +271,7 @@ void testMain() {
expect(findBrowserTextScaleFactor(), 1.0);
});
// Regression test for https://github.com/flutter/flutter/issues/178271.
test("calls onTextScaleFactorChanged when the <html> element's font-size changes", () async {
final DomElement root = domDocument.documentElement!;
final String oldFontSize = root.style.fontSize;
@ -281,6 +282,15 @@ void testMain() {
ui.PlatformDispatcher.instance.onTextScaleFactorChanged = oldCallback;
});
// Wait for next frame.
Future<void> waitForResizeObserver() {
final completer = Completer<void>();
domWindow.requestAnimationFrame((_) {
Timer.run(completer.complete);
});
return completer.future;
}
root.style.fontSize = '16px';
var isCalled = false;
@ -289,18 +299,18 @@ void testMain() {
};
root.style.fontSize = '20px';
await Future<void>.delayed(Duration.zero);
await waitForResizeObserver();
expect(root.style.fontSize, '20px');
expect(isCalled, isTrue);
expect(ui.PlatformDispatcher.instance.textScaleFactor, findBrowserTextScaleFactor());
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.25); // = 20px / 16px
isCalled = false;
root.style.fontSize = '16px';
await Future<void>.delayed(Duration.zero);
await waitForResizeObserver();
expect(root.style.fontSize, '16px');
expect(isCalled, isTrue);
expect(ui.PlatformDispatcher.instance.textScaleFactor, findBrowserTextScaleFactor());
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.0); // = 16px / 16px
});
test('calls onMetricsChanged when the typography measurement element size changes', () async {
@ -373,6 +383,65 @@ void testMain() {
expect(ui.PlatformDispatcher.instance.paragraphSpacingOverride, expectedParagraphSpacing);
});
// Regression test for https://github.com/flutter/flutter/issues/178856.
test('updates lineHeightScaleFactorOverride only when line-height is explicitly set', () async {
final DomElement root = domDocument.documentElement!;
final DomElement style = createDomHTMLStyleElement(null);
// Wait for next frame.
Future<void> waitForResizeObserver() {
final completer = Completer<void>();
domWindow.requestAnimationFrame((_) {
Timer.run(completer.complete);
});
return completer.future;
}
addTearDown(() {
style.text = null;
style.remove();
});
style.text = '*{ font-size: 20px !important; }';
root.append(style);
await waitForResizeObserver();
expect(root.contains(style), isTrue);
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.25); // = 20px / 16px
expect(ui.PlatformDispatcher.instance.lineHeightScaleFactorOverride, null);
style.remove();
await waitForResizeObserver();
expect(root.contains(style), isFalse);
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.0); // = 16px / 16px
expect(ui.PlatformDispatcher.instance.lineHeightScaleFactorOverride, null);
style.text = '*{ font-size: 20px !important; line-height: 2 !important; }';
root.append(style);
await waitForResizeObserver();
expect(root.contains(style), isTrue);
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.25); // = 20px / 16px
expect(ui.PlatformDispatcher.instance.lineHeightScaleFactorOverride, 2.0);
style.remove();
await waitForResizeObserver();
expect(root.contains(style), isFalse);
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.0); // = 16px / 16px
expect(ui.PlatformDispatcher.instance.lineHeightScaleFactorOverride, null);
style.text = '*{ font-size: 32px !important; line-height: 3 !important; }';
root.append(style);
await waitForResizeObserver();
expect(root.contains(style), isTrue);
expect(ui.PlatformDispatcher.instance.textScaleFactor, 2.0); // = 32px / 16px
expect(ui.PlatformDispatcher.instance.lineHeightScaleFactorOverride, 3.0);
style.remove();
await waitForResizeObserver();
expect(root.contains(style), isFalse);
expect(ui.PlatformDispatcher.instance.textScaleFactor, 1.0); // = 16px / 16px
expect(ui.PlatformDispatcher.instance.lineHeightScaleFactorOverride, null);
});
test('disposes all its views', () {
final view1 = EngineFlutterView(dispatcher, createDomHTMLDivElement());
final view2 = EngineFlutterView(dispatcher, createDomHTMLDivElement());