Skip to content

BackdropFilter over a inappwebview can swallow onPointerUp/onPointerCancel on iOS #191203

Closed

Description

@E2-Athish

Steps to reproduce

  1. Run the attached minimal reproduction (main.dart) on a REAL physical device
  2. Tap once anywhere on the webview to reveal the button (starts a 5-second auto-hide countdown).
  3. Press and hold the button continuously so the 5-second countdown lapses WHILE your finger is still down (hold 6-10+ seconds).
  4. Release.
  5. Repeat 10-20+ times, mixing short taps and long holds.
  6. Watch the on-screen counters (visible directly in the app, no debug console needed): "btnDown", "btnUp", "btnCancel".

This is intermittent, not guaranteed every time — that's expected. What confirms the bug: over the course of 15-20+ repetitions, "btnDown" ends up permanently higher than "btnUp" + "btnCancel" combined, meaning at least one press was never followed by either a release or a cancel event.

Expected results

Every onPointerDown on the button is eventually followed by a matching onPointerUp (if released normally) or onPointerCancel (if the touch is interrupted) — regardless of what animation is running or what's rendered underneath.

Actual results

Intermittently, a btnDown is never followed by either event. On-screen counters (see code sample) show btnDown permanently ahead of btnUp + btnCancel combined, with no recovery, in the majority of test sessions with all four ingredients present.

Code sample

Code sample
import 'dart:async';
import 'dart:ui';

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

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

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

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

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

  @override
  State<ReproPage> createState() => _ReproPageState();
}

class _ReproPageState extends State<ReproPage> {
  Duration? _downAt;

  int _globalDownCount = 0;
  int _buttonDownCount = 0;
  int _buttonUpCount = 0;
  int _buttonCancelCount = 0;
  String _lastEvent = '(none yet)';

  int _rebuildTicks = 0;
  Timer? _tickTimer;

  int _webviewGeneration = 0;
  Timer? _swapTimer;

  // --- Auto-hide: reveal on tap, hide again 5s later regardless of an
  // in-flight press — only reset by a NEW pointer-down, never refreshed
  // continuously while held. ---
  static const _autoHideDuration = Duration(seconds: 5);
  bool _controlsVisible = false;
  Timer? _hideTimer;

  void _scheduleAutoHide() {
    _hideTimer?.cancel();
    _hideTimer = Timer(_autoHideDuration, () {
      if (mounted) setState(() => _controlsVisible = false);
    });
  }

  void _revealControls() {
    if (!_controlsVisible) {
      setState(() => _controlsVisible = true);
    }
    _scheduleAutoHide();
  }

  @override
  void initState() {
    super.initState();
    // Mimics an app periodically rebuilding for unrelated reasons.
    _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (mounted) setState(() => _rebuildTicks++);
    });
    // Mimics a platform view being destroyed/recreated periodically
    // (e.g. content changing underneath, unrelated to user interaction).
    _swapTimer = Timer.periodic(const Duration(seconds: 20), (_) {
      if (mounted) setState(() => _webviewGeneration++);
    });
  }

  @override
  void dispose() {
    _tickTimer?.cancel();
    _swapTimer?.cancel();
    _hideTimer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          Listener(
            behavior: HitTestBehavior.translucent,
            onPointerDown: (event) {
              _revealControls();
              setState(() {
                _globalDownCount++;
                _lastEvent = 'GLOBAL down at ${event.position}';
              });
            },
            child: InAppWebView(
              key: ValueKey(_webviewGeneration),
              initialUrlRequest: URLRequest(
                url: WebUri('https://flutter.dev'),
              ),
              initialSettings: InAppWebViewSettings(
                disableContextMenu: true,
                allowsLinkPreview: false,
              ),
            ),
          ),
          Positioned(
            right: 24,
            top: 0,
            bottom: 0,
            child: Center(
              // Three ingredients together: BackdropFilter, IgnorePointer
              // actually changing hit-testability, AND an active
              // AnimatedOpacity transition, over a platform view.
              // NOTE: no InkWell/Material here at all - this is enough
              // on its own to reproduce the loss.
              child: IgnorePointer(
                ignoring: !_controlsVisible,
                child: AnimatedOpacity(
                  opacity: _controlsVisible ? 1 : 0,
                  duration: const Duration(milliseconds: 200),
                  child: Listener(
                    behavior: HitTestBehavior.translucent,
                    onPointerDown: (event) {
                      _downAt = event.timeStamp;
                      setState(() {
                        _buttonDownCount++;
                        _lastEvent = 'BUTTON down';
                      });
                    },
                    onPointerUp: (event) {
                      final heldMs =
                          (event.timeStamp - _downAt!).inMilliseconds;
                      setState(() {
                        _buttonUpCount++;
                        _lastEvent = 'BUTTON up (heldMs=$heldMs)';
                      });
                    },
                    onPointerCancel: (event) {
                      final heldMs =
                          (event.timeStamp - _downAt!).inMilliseconds;
                      setState(() {
                        _buttonCancelCount++;
                        _lastEvent = 'BUTTON cancel (heldMs=$heldMs)';
                      });
                    },
                    child: SizedBox(
                      width: 64,
                      height: 64,
                      child: ClipRRect(
                        borderRadius: BorderRadius.circular(16),
                        child: BackdropFilter(
                          filter: ImageFilter.blur(sigmaX: 1, sigmaY: 1),
                          child: Container(
                            decoration: BoxDecoration(
                              borderRadius: BorderRadius.circular(16),
                              color: Colors.black.withValues(alpha: 0.2),
                            ),
                            child: const Center(
                              child: Icon(
                                Icons.chevron_right_rounded,
                                color: Colors.white,
                                size: 36,
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
          // On-screen counters — verify purely by looking at the device,
          // no terminal/log access required.
          Positioned(
            left: 0,
            right: 0,
            top: 0,
            child: IgnorePointer(
              child: Container(
                color: Colors.black87,
                padding: const EdgeInsets.all(12),
                child: Text(
                  'globalDown=$_globalDownCount  '
                  'btnDown=$_buttonDownCount  '
                  'btnUp=$_buttonUpCount  '
                  'btnCancel=$_buttonCancelCount  '
                  'ticks=$_rebuildTicks  '
                  'webviewGen=$_webviewGeneration\n'
                  'visible=$_controlsVisible  '
                  'last: $_lastEvent',
                  style: const TextStyle(color: Colors.yellow, fontSize: 16),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

Screenshots or Video

Screenshots / Video demonstration Actual result:
RPReplay_Final1786951196.MP4

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
Flutter 3.35.6 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 9f455d2486 (2025-10-08 14:55:31 -0500)
Engine • hash a5f2c36e367c13f868cfe98db5806f562c52c35e (revision d2913632a4)
Tools • Dart 3.9.2 • DevTools 2.48.0

Also reproduced separately on Flutter 3.41.4 / Dart 3.11.1 (production app).
Reproduced on: physical iPad running iOS 16.7.11. flutter_inappwebview: ^6.1.5 (flutter_inappwebview_ios: 1.1.2).

Activity

  1. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Aug 17, 2026
  2. mbcorona commented on Aug 17, 2026

    @mbcorona
    Member

    HI @E2-Athish , thanks for reporting.

    The repro depends on flutter_inappwebview, a third party package.

    Could you reproduce with a first-party platform view (e.g. webview_flutter, or a minimal UiKitView with no third-party plugin) and on the latest stable (3.47.0)? If it still drops the events there, that points at Flutter, and we'll route it properly.

  3. E2-Athish commented on Aug 18, 2026

    @E2-Athish
    Author

    Update: reproduces with webview_flutter on Flutter 3.47.0 stable

    Hi @mbcorona, Confirmed. This reproduces the same way using webview_flutter instead of flutter_inappwebview, on the latest stable release (3.47.0).

    Code sample (webview_flutter version)

    Code sample
    import 'dart:async';
    import 'dart:ui';
    
    import 'package:flutter/material.dart';
    import 'package:webview_flutter/webview_flutter.dart';
    
    void main() => runApp(const ReproApp());
    
    class ReproApp extends StatelessWidget {
      const ReproApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(home: ReproPage());
      }
    }
    
    class ReproPage extends StatefulWidget {
      const ReproPage({super.key});
    
      @override
      State<ReproPage> createState() => _ReproPageState();
    }
    
    class _ReproPageState extends State<ReproPage> {
      Duration? _downAt;
    
      int _globalDownCount = 0;
      int _buttonDownCount = 0;
      int _buttonUpCount = 0;
      int _buttonCancelCount = 0;
      String _lastEvent = '(none yet)';
    
      int _rebuildTicks = 0;
      Timer? _tickTimer;
    
      int _webviewGeneration = 0;
      Timer? _swapTimer;
      late WebViewController _controller;
    
      static const _autoHideDuration = Duration(seconds: 5);
      bool _controlsVisible = false;
      Timer? _hideTimer;
    
      void _scheduleAutoHide() {
        _hideTimer?.cancel();
        _hideTimer = Timer(_autoHideDuration, () {
          if (mounted) setState(() => _controlsVisible = false);
        });
      }
    
      void _revealControls() {
        if (!_controlsVisible) {
          setState(() => _controlsVisible = true);
        }
        _scheduleAutoHide();
      }
    
      void _createController() {
        _controller = WebViewController()
          ..setJavaScriptMode(JavaScriptMode.unrestricted)
          ..loadRequest(Uri.parse('https://flutter.dev'));
      }
    
      @override
      void initState() {
        super.initState();
        _createController();
        _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
          if (mounted) setState(() => _rebuildTicks++);
        });
        _swapTimer = Timer.periodic(const Duration(seconds: 20), (_) {
          if (mounted) {
            setState(() {
              _webviewGeneration++;
              _createController();
            });
          }
        });
      }
    
      @override
      void dispose() {
        _tickTimer?.cancel();
        _swapTimer?.cancel();
        _hideTimer?.cancel();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Stack(
            children: [
              Listener(
                behavior: HitTestBehavior.translucent,
                onPointerDown: (event) {
                  _revealControls();
                  setState(() {
                    _globalDownCount++;
                    _lastEvent = 'GLOBAL down at ${event.position}';
                  });
                },
                child: WebViewWidget(
                  key: ValueKey(_webviewGeneration),
                  controller: _controller,
                ),
              ),
              Positioned(
                right: 24,
                top: 0,
                bottom: 0,
                child: Center(
                  child: IgnorePointer(
                    ignoring: !_controlsVisible,
                    child: AnimatedOpacity(
                      opacity: _controlsVisible ? 1 : 0,
                      duration: const Duration(milliseconds: 200),
                      child: Listener(
                        behavior: HitTestBehavior.translucent,
                        onPointerDown: (event) {
                          _downAt = event.timeStamp;
                          setState(() {
                            _buttonDownCount++;
                            _lastEvent = 'BUTTON down';
                          });
                        },
                        onPointerUp: (event) {
                          final heldMs =
                              (event.timeStamp - _downAt!).inMilliseconds;
                          setState(() {
                            _buttonUpCount++;
                            _lastEvent = 'BUTTON up (heldMs=$heldMs)';
                          });
                        },
                        onPointerCancel: (event) {
                          final heldMs =
                              (event.timeStamp - _downAt!).inMilliseconds;
                          setState(() {
                            _buttonCancelCount++;
                            _lastEvent = 'BUTTON cancel (heldMs=$heldMs)';
                          });
                        },
                        child: SizedBox(
                          width: 64,
                          height: 64,
                          child: ClipRRect(
                            borderRadius: BorderRadius.circular(16),
                            child: BackdropFilter(
                              filter: ImageFilter.blur(sigmaX: 1, sigmaY: 1),
                              child: Container(
                                decoration: BoxDecoration(
                                  borderRadius: BorderRadius.circular(16),
                                  color: Colors.black.withValues(alpha: 0.2),
                                ),
                                child: const Center(
                                  child: Icon(
                                    Icons.chevron_right_rounded,
                                    color: Colors.white,
                                    size: 36,
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
              Positioned(
                left: 0,
                right: 0,
                top: 0,
                child: IgnorePointer(
                  child: Container(
                    color: Colors.black87,
                    padding: const EdgeInsets.all(12),
                    child: Text(
                      'globalDown=$_globalDownCount  '
                      'btnDown=$_buttonDownCount  '
                      'btnUp=$_buttonUpCount  '
                      'btnCancel=$_buttonCancelCount  '
                      'ticks=$_rebuildTicks  '
                      'webviewGen=$_webviewGeneration\n'
                      'visible=$_controlsVisible  '
                      'last: $_lastEvent',
                      style: const TextStyle(color: Colors.yellow, fontSize: 16),
                    ),
                  ),
                ),
              ),
            ],
          ),
        );
      }
    }
    
    Flutter Doctor output:
    [✓] Flutter (Channel stable, 3.47.0, on macOS 26.3.1 25D2128 darwin-arm64, locale en-IN) [401ms]
        • Flutter version 3.47.0 on channel stable at /Users/athish/fvm/versions/3.47.0
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 4cf2416426 (6 days ago), 2026-08-11 11:53:49 -0700
        • Engine revision 5f77625673
        • Dart version 3.13.0
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android,
          enable-ios, cli-animations, enable-native-assets, enable-record-use, 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,571ms]
        • Android SDK at /Users/athish/Library/Android/sdk
        • Emulator version 36.4.9.0 (build_id 14788078) (CL:N/A)
        • Platform android-36, build-tools 36.1.0
        • ANDROID_HOME = /Users/athish/Library/Android/sdk
        ! Multiple adb binaries found. This can cause conflicts and device detection issues:
            - /Users/athish/Library/Android/sdk/platform-tools/adb
            - /opt/homebrew/Caskroom/android-platform-tools/35.0.1/platform-tools/adb
        • 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.10+-117844308-b1163.108)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 26.1.1) [1,112ms]
        • Xcode at /Applications/Xcode-26.1.1.app/Contents/Developer
        • Build 17B100
        • CocoaPods version 1.16.2
    
    [✓] Chrome - develop for the web [6ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    
  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Aug 18, 2026
  5. added
    in triagePresently being triaged by the triage team
    platform-iosiOS applications specifically
    engineflutter/engine related. See also e: labels.
    team-iosOwned by iOS platform team
    a: platform-viewsEmbedding Android/iOS views in Flutter apps
    and removed
    in triagePresently being triaged by the triage team
    on Aug 19, 2026
  6. mbcorona commented on Aug 19, 2026

    @mbcorona
    Member

    Hi @E2-Athish, thanks for the repro.
    Reproduced the lost onPointerUp/onPointerCancel on a physical iPhone. This looks like the same iOS platform-view touch loss as #191267, which already isolates the cause to the engine's ForwardingGestureRecognizer
    Routing to the iOS team.

  7. hellohuanlin commented on Aug 20, 2026

    @hellohuanlin
    Contributor

    @E2-Athish Does the problem exist for regular flutter widget (e.g. a box) rather than a BackdropFilter?

  8. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Aug 20, 2026
  9. E2-Athish commented on Aug 31, 2026

    @E2-Athish
    Author

    @hellohuanlin Tested. No, doesn't reproduce with a regular widget. Swapped BackdropFilter for a plain Container (same size/shape/color), kept everything else identical, stays clean across repeated testing.

  10. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Aug 31, 2026
  11. 4 remaining items

  12. added
    triaged-iosTriaged by iOS platform team
    and removed
    platform-iosiOS applications specifically
    engineflutter/engine related. See also e: labels.
    a: platform-viewsEmbedding Android/iOS views in Flutter apps
    P2Important issues not at the top of the work list
    on Sep 3, 2026
  13. flutter-triage-bot commented on Sep 3, 2026

    @flutter-triage-bot

    This issue is missing a priority label. Please set a priority label when adding the triaged-ios label.

  14. added
    P2Important issues not at the top of the work list
    on Sep 4, 2026
  15. okorohelijah commented on Sep 10, 2026

    @okorohelijah
    Contributor

    from triage: cc @hellohuanlin

  16. github-actions commented on Nov 5, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  17. locked as resolved and limited conversation to collaborators on Nov 5, 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 listteam-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