Skip to content

[iOS] FlutterMetalLayer re-presents stale frames: transitions visibly run backwards #189373

Description

@mcorcuera

Steps to reproduce

  1. Run the code sample below on a physical iPhone (tested on an iPhone 16, iOS 26.5.2, Flutter 3.44.3 stable, both debug and profile).
  2. Tap the button. It pushes a plain MaterialPageRoute. The pushed page has an autofocused TextField; the keyboard opening makes the first frames of the transition slow enough to trigger the issue reliably
  3. Pop and repeat a few times. For me it happens roughly 1 out of 5 opens.
  4. Watch the start of the push transition. On an affected open the transition visibly stutters and jumps back for an instant: the red number in the center (the route's animation value, painted into each frame) flips back and forth between two values instead of counting up. It's visible with the naked eye, but the clearest way to check is a screen recording stepped through frame by frame.

A third party keyboard (Microsoft SwiftKey in my case) makes the first frames slower and the repro more frequent, but it also happens with the stock keyboard.

Check the screen recording and the frame-by-frame grid attached to the issue.

Expected results

The transition may drop frames when the app is busy, but it should only ever move forward: if a frame is late, the previous frame is held until the next one is ready.

Concretely: the red number painted into each frame should never decrease on screen.

Actual results

When the first frames of the transition are slow, the display alternates between two frames that were both already shown. In the attached recording the on-screen values of the first frames of the push are:

0.03329, 0.03329, 0.19972, 0.03329, 0.19972, 0.03329, 0.19972, 0.03329, 0.19972, 0.03329, 0.19972, 0.43274, 0.46602, ...

and from there perfect uniform steps to 1.0. The 0.03329 frame is shown again five times after the newer 0.19972 frame was already on screen. You can also see it without the numbers: the incoming page jumps back and forth while the keyboard (rendered by iOS, separate layer) rises smoothly through the same frames.

The sample also checks animation.value monotonicity on the Dart side and prints if it ever decreases. It never prints, so the framework-side animation is monotonic. The reversal happens at presentation time.

Setting FLTUseFlutterMetalLayer=false in Info.plist makes this not reproducible for me: stalls then show up as a held frame followed by a forward jump, as expected. So this looks specific to the FlutterMetalLayer presentation path.

Code sample

Code sample
import 'package:flutter/material.dart';

void main() => runApp(const ReproApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: HomeScreen());
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Duplicated frames repro')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => Navigator.of(context).push(
            ValueLoggingMaterialRoute<void>(
              builder: (_) => const KeyboardScreen(),
            ),
          ),
          child: const Text('Open route with keyboard'),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Keyboard route')),
      body: const Padding(
        padding: EdgeInsets.all(24),
        child: TextField(
          autofocus: true,
          decoration: InputDecoration(
            border: OutlineInputBorder(),
            hintText: 'Autofocused field',
          ),
        ),
      ),
    );
  }
}

class ValueLoggingMaterialRoute<T> extends MaterialPageRoute<T> {
  ValueLoggingMaterialRoute({required super.builder});

  double _lastForwardValue = 0;

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    return Stack(
      children: [
        super.buildTransitions(context, animation, secondaryAnimation, child),
        IgnorePointer(
          child: Center(
            child: AnimatedBuilder(
              animation: animation,
              builder: (context, _) {
                // Gesture-driven pops set controller.value directly, which
                // reports a stale `forward` status while the value decreases.
                // Only trust the status when no user gesture drives the route.
                if (animation.status == AnimationStatus.forward &&
                    !(navigator?.userGestureInProgress ?? false)) {
                  if (animation.value < _lastForwardValue) {
                    debugPrint(
                      'NON-MONOTONIC DART VALUE: ${animation.value} after '
                      '$_lastForwardValue — bug would be in the framework, '
                      'not the compositor',
                    );
                  }
                  _lastForwardValue = animation.value;
                } else {
                  _lastForwardValue = 0;
                }
                if (!animation.isAnimating) {
                  return const SizedBox.shrink();
                }
                return Text(
                  animation.value.toStringAsFixed(5),
                  style: const TextStyle(
                    color: Color(0xFFFF0000),
                    fontSize: 40,
                    fontWeight: FontWeight.bold,
                    decoration: TextDecoration.none,
                    fontFeatures: [FontFeature.tabularFigures()],
                  ),
                );
              },
            ),
          ),
        ),
      ],
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

https://github.com/user-attachments/assets/5270a51c-3d6f-467c-a3f8-f8adb071b6a7
Image

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.44.3, on macOS 26.3.1 25D771280a darwin-arm64, locale en-US) [983ms]
    • Flutter version 3.44.3 on channel stable at /Users/mcorcuera/fvm/versions/3.44.3
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision e1fd963c6f (4 weeks ago), 2026-06-18 14:59:18 -0700
    • Engine revision a4ce257c68
    • Dart version 3.12.2
    • DevTools version 2.57.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, no-enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [1,739ms]
    • Android SDK at /Users/mcorcuera/Library/Android/sdk
    • Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
    • Platform android-36, build-tools 36.1.0
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 26.2) [1,427ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 17C52
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [5ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (3 available) [6.1s]
    • Mikel's iPhone (mobile) • 00008140-000D28893E51801C • ios            • iOS 26.5.2 23F84
    • macOS (desktop)         • macos                     • darwin-arm64   • macOS 26.3.1 25D771280a darwin-arm64
    • Chrome (web)            • chrome                    • web-javascript • Google Chrome 149.0.7827.201

[✓] Network resources [214ms]
    • All expected network resources are available.

• No issues found!

Activity

  1. mbcorona commented on Jul 13, 2026

    @mbcorona
    Member

    Thanks for the clear repro and the frame-by-frame grid.

    The keyboard makes it obvious: it's drawn by iOS on a separate layer and rises smoothly, so it works as an independent clock. In the grid the 0.19972 frame shows the keyboard lower than the 0.03329 frame — so an older Flutter frame is being presented after a newer one, i.e. the reversal is at presentation, not in the framework.

    I couldn't catch the single-frame glitch by eye on an iPhone 15, but the attached grid shows it clearly. Routing to the engine team.

  2. added
    platform-iosiOS applications specifically
    engineflutter/engine related. See also e: labels.
    c: renderingUI glitches reported at the engine/skia or impeller rendering level
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    fyi-iosFor the attention of iOS platform team
    and removed
    in triagePresently being triaged by the triage team
    on Jul 13, 2026
  3. added
    fyi-engineFor the attention of Engine team
    team-iosOwned by iOS platform team
    and removed
    fyi-iosFor the attention of iOS platform team
    on Jul 13, 2026
  4. loic-sharma commented on Jul 13, 2026

    @loic-sharma
    Member

    Redirecting this to the iOS team as this is likely an iOS embedder issue. Feel free to reassign the issue if I got that incorrectly!

  5. mcorcuera commented on Jul 13, 2026

    @mcorcuera
    Author

    Thanks for your fast reply!

    I couldn't catch the single-frame glitch by eye on an iPhone 15, but the attached grid shows it clearly. Routing to the engine team.

    In my case it happens much more often if I use SwiftKey keyboard, as it seems to block the thread longer when initializing. In our actual app, you can also notice it with the default iOS keyboard but again, less often.

  6. LongCatIsLooong commented on Jul 23, 2026

    @LongCatIsLooong
    Contributor
  7. knopp commented on Jul 23, 2026

    @knopp
    Member

    I still need to get to reproducing this. There are two possible reasons I can think of: We need a hop to the main thread to set the surface content and when UIKit janks two present blocks get executed in the same frame which can cause some weirdness.

    Another possibility is that the assumption FlutterMetalLayer makes about how long the compositor holds on to the surface doesn't work when UIKit janks.

  8. added
    P2Important issues not at the top of the work list
    on Aug 13, 2026
  9. knopp commented on Aug 27, 2026

    @knopp
    Member

    Finally had a chance to look into this. This only seems to be reproducible with the SwiftKey keyboard. It is quite strange - the keyboard is not stalling our main thread (it is, but only for a couple of frames), but it is stalling the display server (a lot).

    • Regular CAMetalLayer is not affected by this, because it by default presents without transactions. It manages to output frames even when the display server is stalled, but this doesn't really help us since it doesn't work with platform views.
    • CAMetalLayer + presentsWithTransaction + dispatch_async (because of raster thread) - this is affected by display server stalling, drops a lot of frames, but it doesn't display out of order content. This is not very useful to us, as this configuration was tested before adding FlutterMetalLayer, but for some weird reason presentsWithTransaction + raster thread seems to preform quite badly even if the final present is scheduled on main thread.
    • FlutterMetalLayer: Right now FlutterMetalLayer is hard capped at 3 surfaces, mostly because we use it as overlays in a very memory inefficient way. Unfortunately we don't have as good insight into which surfaces are actually being held by the compositor as CAMetalLayer does. Only thing we have is IOSurface.isInUse, which traditionally is overly pessimistic. In order to improve performance, `[FlutterMetalLayer nextTexture] will sometimes return surface that is in use. Otherwise we would either have to increase the pool, or block. Both have trade-offs and seem unnecessary under normal conditions, but I guess with the display server being stalled like this we are not operating under normal conditions.

    Note that on macOS we never return surface that is in use. We can afford doing this, because we have much cheaper overlays. Once platform views on iOS get same treatment, we can retire FlutterMetalLayer and always return unused IOSurface from the pool, which would solve this issue.

  10. knopp commented on Aug 27, 2026

    @knopp
    Member

    While working on #191388 I think I figured out why we couldn't get the combination of CAMetalLayer + raster thread + presentsWithTransaction working. It seems that when presenting with transaction CAMetalLayer is especially unhappy when the regular UIKit CA commit is missed. When that happens things get really jittery. This issue disappears if we delay the CA commit until we have the surface. I built a prototype based on #191388 where the CA commit is delayed and suddenly CAMetalLayer + raster thread + presentsWithTransaction works as well as FlutterMetalLayer.

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 listc: renderingUI glitches reported at the engine/skia or impeller rendering levelengineflutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-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