Skip to content

[iPhone Duo] Make sure PlatformDispatcher.instance.displays is correct when open/close iPhone Duo #195177

Closed

Description

@hellohuanlin

Use case

When we open/close iPhone Duo, the PlatformDispatcher.instance.displays always shows the outer display's dimension.

Refer to this dummy project to print out the display size: #195177 (comment)

Proposal

This is because in the engine, we always pass in UIScreen.main, which refers to the outer display. When running on inner display, PlatformDispatcher.instance.displays should give the correct display dimension.

Activity

  1. self-assigned this
    on Oct 23, 2026
  2. hellohuanlin commented on Oct 23, 2026

    @hellohuanlin
    ContributorAuthor

    here's a dummy project used to get the display dimension (with help from gemini)

    import 'dart:async';
    import 'dart:ui' as ui;
    
    import 'package:flutter/services.dart';
    import 'package:flutter/widgets.dart';
    
    // Goal: prove that app code can read display information (`PlatformDispatcher.displays`,
    // `FlutterView.display`) at a point where the engine cannot yet know which screen the Flutter view
    // will be on. So the engine must send some non-zero value before the view is in a window, i.e. the
    // `UIScreen.mainScreen` fallback in `-[FlutterEngine updateDisplays]` is still needed (sending
    // `.zero` would give these reads a wrong value).
    //
    // How: Dart reads the display synchronously, then asks native (the "display_probe" channel) whether
    // the engine's view controller is in a window. The native answer is computed strictly *after* the
    // Dart read, and a view only goes from "not in a window" to "in a window", so if native says there
    // is no window, there was no window (and so no screen to ask) at the time of the read either.
    //
    // Two engines are exercised in one run:
    // - "implicit": the storyboard FlutterViewController's engine (`main`).
    // - "headless": an engine run in `application:didFinishLaunchingWithOptions:` before any scene
    //   connects, then attached to a FlutterViewController ~3s after the scene becomes active
    //   (`headlessMain`).
    
    const MethodChannel _probe = MethodChannel('display_probe');
    
    void main() => _run('implicit');
    
    @pragma('vm:entry-point')
    void headlessMain() => _run('headless');
    
    void _log(String tag, String message) {
      // ignore: avoid_print
      print(
        '[DISPLAY-DART $tag @${DateTime.now().millisecondsSinceEpoch}] $message',
      );
    }
    
    String _describeDisplay() {
      final ui.PlatformDispatcher dispatcher = ui.PlatformDispatcher.instance;
      final String displays = dispatcher.displays
          .map(
            (ui.Display d) => 'id=${d.id} size=${d.size} dpr=${d.devicePixelRatio}',
          )
          .join(', ');
      final ui.FlutterView? view = dispatcher.implicitView;
      final String viewInfo = view == null
          ? 'implicitView=null'
          : 'implicitView.physicalSize=${view.physicalSize}';
      return 'displays=[$displays] $viewInfo';
    }
    
    // Whether this engine's view has been seen in a window. The "no window at read time" argument only
    // holds until then: afterwards the view can leave its window (e.g. covered by a full-screen modal),
    // and the engine keeps the last known display information.
    bool _sawWindow = false;
    
    Future<void> _probeAndReport(String tag, String when, String readValue) async {
      Map<Object?, Object?>? state;
      try {
        state = await _probe.invokeMapMethod<Object?, Object?>('probe');
      } on MissingPluginException {
        _log(tag, '$when: display_probe channel not registered yet');
        return;
      }
      final bool hasWindow = state?['hasWindow'] == true;
      _log(tag, '$when read: $readValue');
      _log(tag, '$when native state (computed after the read): $state');
      final String verdict;
      if (hasWindow) {
        _sawWindow = true;
        verdict =
            'INCONCLUSIVE: the view was in a window by the time native answered.';
      } else if (_sawWindow) {
        verdict =
            'N/A: the view left its window; the value is the last known display.';
      } else {
        verdict =
            'PROOF: app code read display info before the engine\'s view was ever in a '
            'window, so the value can only have come from the fallback.';
      }
      _log(tag, '$when -> $verdict');
    }
    
    void _run(String tag) {
      // Read before anything else, as early as app code possibly can.
      final String earliestRead = _describeDisplay();
    
      WidgetsFlutterBinding.ensureInitialized();
      unawaited(_probeAndReport(tag, 'main() first line', earliestRead));
    
      // Log whenever the display information changes (e.g. the engine re-sends it once the view is on
      // a screen that differs from the fallback).
      String lastDisplay = earliestRead;
      final ui.VoidCallback? bindingHandler =
          ui.PlatformDispatcher.instance.onMetricsChanged;
      ui.PlatformDispatcher.instance.onMetricsChanged = () {
        bindingHandler?.call();
        final String display = _describeDisplay();
        if (display != lastDisplay) {
          lastDisplay = display;
          unawaited(_probeAndReport(tag, 'onMetricsChanged', display));
          // Give the framework time to relayout and send the new semantics tree.
          _scheduleA11yProbe(tag, 'after metrics change');
        }
      };
    
      // Keep sampling for a while so the headless engine's pre-attach and post-attach phases both show
      // up in the logs.
      int tick = 0;
      Timer.periodic(const Duration(seconds: 1), (Timer timer) {
        tick++;
        unawaited(_probeAndReport(tag, 't+${tick}s', _describeDisplay()));
        if (tick >= 6) {
          timer.cancel();
        }
      });
    
      _scheduleA11yProbe(tag, 'startup');
    
      runApp(_LogApp(tag));
    }
    
    // Accessibility check for the `SemanticsObjectContainer.accessibilityFrame` change. Only the
    // implicit engine registers the native side (see `A11yProbe` in SceneDelegate.swift).
    const MethodChannel _a11yProbe = MethodChannel('a11y_probe');
    Timer? _a11yProbeTimer;
    
    void _scheduleA11yProbe(String tag, String when) {
      if (tag != 'implicit') {
        return;
      }
      _a11yProbeTimer?.cancel();
      _a11yProbeTimer = Timer(
        const Duration(seconds: 2),
        () => unawaited(_probeA11y(tag, when)),
      );
    }
    
    Future<void> _probeA11y(String tag, String when) async {
      Map<Object?, Object?>? state;
      try {
        state = await _a11yProbe.invokeMapMethod<Object?, Object?>('a11y');
      } on MissingPluginException {
        _log(tag, 'a11y $when: a11y_probe channel not registered');
        return;
      }
      if (state == null || state['error'] != null) {
        _log(tag, 'a11y $when: ${state?['error']}');
        return;
      }
      final List<Object?> outside =
          (state['leavesOutsideContainer'] as List<Object?>?) ?? <Object?>[];
      _log(
        tag,
        'a11y $when: windowScreenBounds=${state['windowScreenBounds']} '
        'mainScreenBounds=${state['mainScreenBounds']}',
      );
      _log(tag, 'a11y $when: containerFrames=${state['containerFrames']}');
      _log(tag, 'a11y $when: leaves=${state['leaves']}');
      _log(
        tag,
        outside.isEmpty
            ? 'a11y $when -> PASS: every labeled element is inside its container frame.'
            : 'a11y $when -> FAIL: elements outside their container frame (Voice Control may not '
                  'label them): $outside',
      );
    }
    
    class _LogApp extends StatefulWidget {
      const _LogApp(this.tag);
    
      final String tag;
    
      @override
      State<_LogApp> createState() => _LogAppState();
    }
    
    class _LogAppState extends State<_LogApp> {
      // Always shown. The overlay child is placed far from its anchor (like the OverlayPortal case that
      // `SemanticsObjectContainer.accessibilityFrame` was written for), in the bottom-right corner so
      // that it is outside the outer display's bounds when the view is on the larger inner display.
      final OverlayPortalController _overlayController = OverlayPortalController()
        ..show();
    
      @override
      Widget build(BuildContext context) {
        return Directionality(
          textDirection: TextDirection.ltr,
          child: ColoredBox(
            color: const Color(0xFFFFFFFF),
            child: Overlay(
              initialEntries: <OverlayEntry>[
                OverlayEntry(
                  builder: (BuildContext context) => Stack(
                    children: <Widget>[
                      Center(
                        child: Text(
                          'Engine: ${widget.tag}\nDisplay verification: see console logs',
                          textAlign: TextAlign.center,
                          style: const TextStyle(
                            color: Color(0xFF000000),
                            fontSize: 18,
                          ),
                        ),
                      ),
                      Positioned(
                        left: 16,
                        top: 60,
                        child: OverlayPortal(
                          controller: _overlayController,
                          overlayChildBuilder: (BuildContext context) =>
                              const Positioned(
                                right: 16,
                                bottom: 40,
                                child: _Button(
                                  label: 'Overlay button bottom-right',
                                ),
                              ),
                          child: const _Button(label: 'Anchor button top-left'),
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class _Button extends StatelessWidget {
      const _Button({required this.label});
    
      final String label;
    
      @override
      Widget build(BuildContext context) {
        return Semantics(
          button: true,
          label: label,
          onTap: () {},
          excludeSemantics: true,
          child: ColoredBox(
            color: const Color(0xFF2196F3),
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Text(
                label,
                style: const TextStyle(color: Color(0xFFFFFFFF), fontSize: 16),
              ),
            ),
          ),
        );
      }
    }
    
    
  3. added
    P2Important issues not at the top of the work list
    triaged-iosTriaged by iOS platform team
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listplatform-iosiOS applications specificallyteam-iosOwned by iOS platform teamtriaged-iosTriaged by iOS platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions