Skip to content

UI glitches when Impeller is enabled on some Android devices. #171772

@gvozditskiy

Description

@gvozditskiy

Steps to reproduce

  1. Launch the app with Impeller rendering enabled (reproduced on Samsung S25 Ultra)
  2. On the main screen, scroll up and down repeatedly
  3. Open the second page

Expected results

Absence of visual glitches, matching the behaviour observed with Impeller disabled

Actual results

UI glitches occur while scrolling or overscrolling, and also when opening the second screen

Code sample

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Impeller Glitch Reproduction',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const ChatPage(),
    );
  }
}

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

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  final List<MessageData> messages = [];
  final ScrollController _scrollController = ScrollController();
  final Set<String> _placedItems = {};

  @override
  void initState() {
    super.initState();
    for (var i = 0; i < 50; i++) {
      _addMessage();
    }
  }

  void _addMessage() {
    final newMessage = MessageData(
      id: 'msg_${messages.length}',
      text: 'Message ${messages.length + 1}',
      isFromUser: messages.length % 2 == 0,
    );

    setState(() {
      messages.insert(0, newMessage);
    });
  }

  void _onItemPlaced(String itemId) {
    setState(() {
      _placedItems.add(itemId);
    });
  }

  void _navigateToBlurScreen() {
    Navigator.of(context).push(
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) => const BlurEffectsScreen(),
        transitionDuration: const Duration(milliseconds: 800),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return Stack(
            children: [
              BackdropFilter(
                filter: ImageFilter.blur(
                  sigmaX: animation.value * 15,
                  sigmaY: animation.value * 15,
                ),
                child: Container(color: Colors.black.withValues(alpha: animation.value * 0.3)),
              ),
              SlideTransition(
                position: Tween<Offset>(
                  begin: const Offset(1.0, 0.0),
                  end: Offset.zero,
                ).animate(animation),
                child: FadeTransition(opacity: animation, child: child),
              ),
            ],
          );
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey,
      appBar: AppBar(
        title: const Text('Impeller Glitch Test'),
        actions: [IconButton(icon: const Icon(Icons.blur_on), onPressed: _navigateToBlurScreen)],
      ),
      body: Stack(
        children: [
          // Essential shader mask with gradient
          Padding(
            padding: const EdgeInsets.only(top: 48, bottom: 60),
            child: ShaderMask(
              shaderCallback: (bounds) => LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [
                  Colors.black,
                  Colors.black.withValues(alpha: 0.6),
                  Colors.black.withValues(alpha: 0.2),
                  Colors.black.withValues(alpha: 0),
                ],
                stops: const [0, 0.5, 0.65, 0.9],
              ).createShader(Rect.fromLTWH(0, 0, bounds.width, 130)),
              blendMode: BlendMode.dstOut,
              child: ShaderMask(
                shaderCallback: (bounds) => LinearGradient(
                  begin: Alignment.bottomCenter,
                  end: Alignment.topCenter,
                  colors: [
                    Colors.black,
                    Colors.black.withValues(alpha: 0.6),
                    Colors.black.withValues(alpha: 0.2),
                    Colors.black.withValues(alpha: 0),
                  ],
                  stops: const [0, 0.5, 0.65, 0.9],
                ).createShader(Rect.fromLTRB(0, bounds.height - 24, bounds.width, bounds.height)),
                blendMode: BlendMode.dstOut,
                child: ListView.builder(
                  controller: _scrollController,
                  reverse: true,
                  padding: const EdgeInsets.only(top: 154, bottom: 60),
                  itemCount: messages.length,
                  itemBuilder: (context, index) {
                    final message = messages[index];
                    final isPlaced = _placedItems.contains(message.id);
                    final isLast = index == 0;

                    if (!isPlaced && mounted) {
                      Future.delayed(Duration(milliseconds: 100 + (index * 50)), () {
                        if (mounted) _onItemPlaced(message.id);
                      });
                    }

                    return MessageBubble(
                      key: ValueKey(message.id),
                      message: message,
                      maxWidth: MediaQuery.of(context).size.width * 0.75,
                      placed: isPlaced,
                      last: isLast,
                    );
                  },
                ),
              ),
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addMessage,
        child: const Icon(Icons.add),
      ),
    );
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
}

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

  @override
  State<BlurEffectsScreen> createState() => _BlurEffectsScreenState();
}

class _BlurEffectsScreenState extends State<BlurEffectsScreen> with TickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(seconds: 2), vsync: this)..repeat();
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        // Essential backdrop filter that triggers glitches
        AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return BackdropFilter(
              filter: ImageFilter.blur(
                sigmaX: 15 + (_animation.value * 10),
                sigmaY: 15 + (_animation.value * 10),
              ),
              child: Container(color: Colors.white.withValues(alpha: 0.1)),
            );
          },
        ),

        Center(
          child: AnimatedBuilder(
            animation: _animation,
            builder: (context, child) {
              return BackdropFilter(
                filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Icon(Icons.auto_awesome, color: Colors.white, size: 60),
                    const SizedBox(height: 20),
                    const Text(
                      'Blur Effects Screen',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 24,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 20),
                    ElevatedButton(
                      onPressed: () => Navigator.of(context).pop(),
                      child: const Text('Back to Chat'),
                    ),
                  ],
                ),
              );
            },
          ),
        ),

        Positioned(
          top: MediaQuery.of(context).padding.top + 10,
          right: 16,
          child: IconButton(
            icon: const Icon(Icons.close, color: Colors.white, size: 30),
            onPressed: () => Navigator.of(context).pop(),
          ),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

class MessageData {
  final String id;
  final String text;
  final bool isFromUser;

  MessageData({required this.id, required this.text, required this.isFromUser});
}

class MessageBubble extends StatefulWidget {
  const MessageBubble({
    required this.message,
    required this.maxWidth,
    required this.placed,
    required this.last,
    super.key,
  });

  final MessageData message;
  final double maxWidth;
  final bool placed;
  final bool last;

  @override
  State<MessageBubble> createState() => _MessageBubbleState();
}

class _MessageBubbleState extends State<MessageBubble> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      lowerBound: widget.placed ? 1 : 0.000001,
      duration: const Duration(milliseconds: 500),
      vsync: this,
    );
    _animation = _animationController.drive(CurveTween(curve: Curves.linearToEaseOut));

    Future.delayed(const Duration(milliseconds: 100)).whenComplete(() {
      if (mounted) {
        if (!widget.placed) {
          _animationController.forward();
        } else {
          _animationController.value = 1;
        }
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final alignment = widget.message.isFromUser ? Alignment.centerRight : Alignment.centerLeft;
    final color = widget.message.isFromUser
        ? Colors.blue.withValues(alpha: 0.9)
        : Colors.grey.shade200;

    return Align(
      alignment: alignment,
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, _) {
          return Padding(
            padding: EdgeInsets.only(top: 5, bottom: widget.last ? 20 * _animation.value : 0),
            child: FittedBox(
              fit: BoxFit.scaleDown,
              child: SizeTransition(
                sizeFactor: _animation,
                child: ScaleTransition(
                  scale: _animation,
                  alignment: alignment,
                  child: FadeTransition(
                    opacity: _animation,
                    child: BackdropFilter(
                      filter: ImageFilter.blur(sigmaX: 0.5, sigmaY: 0.5),
                      child: _BubbleContent(
                        message: widget.message,
                        color: color,
                        maxWidth: widget.maxWidth,
                      ),
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

class _BubbleContent extends StatelessWidget {
  const _BubbleContent({required this.message, required this.color, required this.maxWidth});

  final MessageData message;
  final Color color;
  final double maxWidth;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        right: message.isFromUser ? 15 : 10,
        left: message.isFromUser ? 14 : 15,
      ),
      child: ClipPath(
        clipper: BubbleClipper(fromRobot: !message.isFromUser),
        child: DecoratedBox(
          decoration: BoxDecoration(color: color),
          child: Container(
            constraints: BoxConstraints(maxWidth: maxWidth * 0.8),
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
            child: Text(
              message.text,
              style: TextStyle(color: message.isFromUser ? Colors.white : Colors.black),
            ),
          ),
        ),
      ),
    );
  }
}

class BubbleClipper extends CustomClipper<Path> {
  const BubbleClipper({required this.fromRobot});

  final bool fromRobot;

  @override
  Path getClip(Size size) {
    final path = Path();
    const radius = 20.0;
    const tailSize = 8.0;

    if (fromRobot) {
      path.addRRect(
        RRect.fromLTRBAndCorners(
          tailSize,
          0,
          size.width,
          size.height,
          topLeft: const Radius.circular(radius),
          topRight: const Radius.circular(radius),
          bottomLeft: const Radius.circular(radius),
          bottomRight: const Radius.circular(radius),
        ),
      );
      path.moveTo(0, size.height - tailSize);
      path.lineTo(tailSize, size.height - tailSize);
      path.lineTo(tailSize, size.height);
      path.close();
    } else {
      path.addRRect(
        RRect.fromLTRBAndCorners(
          0,
          0,
          size.width - tailSize,
          size.height,
          topLeft: const Radius.circular(radius),
          topRight: const Radius.circular(radius),
          bottomLeft: const Radius.circular(radius),
          bottomRight: const Radius.circular(radius),
        ),
      );
      path.moveTo(size.width - tailSize, size.height - tailSize);
      path.lineTo(size.width - tailSize, size.height);
      path.lineTo(size.width, size.height - tailSize);
      path.close();
    }

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

Screenshots or Video

Screenshots / Video demonstration Impeller disabled:
telegram-cloud-document-2-5433647737025362042.mp4

Impeller enabled

telegram-cloud-document-2-5433647737025362045.mp4

Logs

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.32.5, on macOS 15.5 24F74 darwin-arm64, locale en-RU)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
[!] Xcode - develop for iOS and macOS (Xcode 16.4)
    ! CocoaPods 1.15.0 out of date (1.16.2 is recommended).
        CocoaPods is a package manager for iOS or macOS platform code.
        Without CocoaPods, plugins will not work on iOS or macOS.
        For more info, see https://flutter.dev/to/platform-plugins
      To update CocoaPods, see
      https://guides.cocoapods.org/using/getting-started.html#updating-cocoapods
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.3)
[✓] Android Studio (version 2024.1)
[✓] Android Studio (version 2024.2.2)
[✓] IntelliJ IDEA Community Edition (version 2024.2.3)
[✓] VS Code (version 1.101.2)
[✓] Connected device (2 available)
[✓] Network resources

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: renderingUI glitches reported at the engine/skia or impeller rendering levele: device-specificOnly manifests on certain devicese: impellerImpeller rendering backend issues and features requestsengineflutter/engine related. See also e: labels.platform-androidAndroid applications specificallyr: fixedIssue is closed as already fixed in a newer versionslimpellerEngine binary size reduction. go/slimpellerteam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions