From 34840e8a1afaea1bd3c7208002777a9ab8fbf7e6 Mon Sep 17 00:00:00 2001 From: y-ploni <7353755@gmail.com> Date: Mon, 20 Apr 2026 01:20:44 +0300 Subject: [PATCH 1/6] fix(windows): prevent crash on app exit caused by WinRT COM release during DLL unload Static WinRT/Composition objects (ICompositor, IDispatcherQueueController, GraphicsContext, RoHelper) were held in static smart pointers (winrt::com_ptr, std::unique_ptr, std::shared_ptr). Their destructors called Release() and RoUninitialize() during DLL_PROCESS_DETACH, after the WinRT DLLs had already started unloading, triggering RaiseFailFastException in KernelBase.dll and causing Windows Error Reporting to appear on exit. Changes: - Convert all four static WinRT/COM resource pointers to raw pointers so no RAII destructor runs Release() or RoUninitialize() at static-destruction time; the OS reclaims them at process exit instead. - Add instance_count_ (protected by shared_resources_mutex_) to track the number of live InAppWebViewManager instances and trigger cleanup only when the last one is destroyed. - Add detachSharedResourcesForShutdown() which nulls all raw pointers under the mutex (without delete/Release) and returns the dispatcher queue controller for subsequent use. - Add shutdownDispatcherQueue() which calls ShutdownQueueAsync() outside the mutex and intentionally leaks both the IAsyncAction and the controller as process-lifetime objects, avoiding any Release() race with WinRT teardown. - Remove the earlier AddRef() workaround on ICompositor, which did not actually prevent the crash. - Replace the std::unique_ptr/shared_ptr allocations of RoHelper and GraphicsContext with raw new, consistent with the process-lifetime-leak design for all shared resources. Fixes: https://github.com/pichillilorenzo/flutter_inappwebview/issues/2733 Co-Authored-By: Claude Sonnet 4.6 --- .../in_app_webview/in_app_webview_manager.cpp | 89 ++++++++++++++----- .../in_app_webview/in_app_webview_manager.h | 28 ++++-- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.cpp b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.cpp index fb42f7badc..d78de1d869 100644 --- a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.cpp +++ b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.cpp @@ -1,7 +1,9 @@ #include #include #include +#include #include +#include #include #include "../in_app_webview/in_app_webview_settings.h" @@ -21,34 +23,35 @@ namespace flutter_inappwebview_plugin : plugin(plugin), ChannelDelegate(plugin->registrar->messenger(), InAppWebViewManager::METHOD_CHANNEL_NAME) { - if (!rohelper_) { - rohelper_ = std::make_unique(RO_INIT_SINGLETHREADED); + { + const std::lock_guard lock(shared_resources_mutex_); + ++instance_count_; - if (rohelper_->WinRtAvailable()) { - DispatcherQueueOptions options{ sizeof(DispatcherQueueOptions), - DQTYPE_THREAD_CURRENT, DQTAT_COM_STA }; + if (!rohelper_) { + rohelper_ = new rx::RoHelper(RO_INIT_SINGLETHREADED); - if (FAILED(rohelper_->CreateDispatcherQueueController( - options, dispatcher_queue_controller_.put()))) { - std::cerr << "Creating DispatcherQueueController failed." << std::endl; - return; - } + if (rohelper_->WinRtAvailable()) { + DispatcherQueueOptions options{ sizeof(DispatcherQueueOptions), + DQTYPE_THREAD_CURRENT, DQTAT_COM_STA }; - if (!isGraphicsCaptureSessionSupported()) { - std::cerr << "Windows::Graphics::Capture::GraphicsCaptureSession is not " - "supported." - << std::endl; - return; - } + if (FAILED(rohelper_->CreateDispatcherQueueController( + options, &dispatcher_queue_controller_))) { + std::cerr << "Creating DispatcherQueueController failed." << std::endl; + return; + } + + if (!isGraphicsCaptureSessionSupported()) { + std::cerr << "Windows::Graphics::Capture::GraphicsCaptureSession is not " + "supported." + << std::endl; + return; + } - graphics_context_ = std::make_unique(rohelper_.get()); - compositor_ = graphics_context_->CreateCompositor(); - if (compositor_) { - // fix for KernelBase.dll RaiseFailFastException - // when app is closing - compositor_->AddRef(); + graphics_context_ = new GraphicsContext(rohelper_); + auto compositor = graphics_context_->CreateCompositor(); + compositor_ = compositor.detach(); + valid_ = graphics_context_->IsValid(); } - valid_ = graphics_context_->IsValid(); } } @@ -250,6 +253,34 @@ namespace flutter_inappwebview_plugin return !!is_supported; } + ABI::Windows::System::IDispatcherQueueController* InAppWebViewManager::detachSharedResourcesForShutdown() + { + // These WinRT/Composition objects are intentionally process-lifetime + // objects. Releasing them during Flutter engine teardown can call into + // WinRT DLLs while they are unloading. Null the cached pointers so the + // plugin won't use them again; the OS will reclaim them at process exit. + valid_ = false; + const auto dispatcherQueueController = dispatcher_queue_controller_; + dispatcher_queue_controller_ = nullptr; + compositor_ = nullptr; + graphics_context_ = nullptr; + rohelper_ = nullptr; + return dispatcherQueueController; + } + + void InAppWebViewManager::shutdownDispatcherQueue(ABI::Windows::System::IDispatcherQueueController* dispatcherQueueController) + { + if (!dispatcherQueueController) { + return; + } + + ABI::Windows::Foundation::IAsyncAction* shutdownOperation = nullptr; + failedLog(dispatcherQueueController->ShutdownQueueAsync(&shutdownOperation)); + // Do not release the async operation during shutdown; keep it alive for + // the remaining process lifetime so it cannot race with queue shutdown. + (void)shutdownOperation; + } + InAppWebViewManager::~InAppWebViewManager() { debugLog("dealloc InAppWebViewManager"); @@ -258,5 +289,17 @@ namespace flutter_inappwebview_plugin windowWebViews.clear(); UnregisterClass(windowClass_.lpszClassName, nullptr); plugin = nullptr; + + ABI::Windows::System::IDispatcherQueueController* dispatcherQueueController = nullptr; + { + const std::lock_guard lock(shared_resources_mutex_); + assert(instance_count_ > 0); + --instance_count_; + if (instance_count_ == 0) { + dispatcherQueueController = detachSharedResourcesForShutdown(); + } + } + + shutdownDispatcherQueue(dispatcherQueueController); } } diff --git a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.h b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.h index b11a380741..379c4c8517 100644 --- a/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.h +++ b/flutter_inappwebview_windows/windows/in_app_webview/in_app_webview_manager.h @@ -3,7 +3,9 @@ #include #include +#include #include +#include #include #include #include @@ -34,12 +36,16 @@ namespace flutter_inappwebview_plugin bool isGraphicsCaptureSessionSupported(); GraphicsContext* graphics_context() const { - return graphics_context_.get(); + return graphics_context_; }; - rx::RoHelper* rohelper() const { return rohelper_.get(); } + rx::RoHelper* rohelper() const { return rohelper_; } winrt::com_ptr compositor() const { - return compositor_; + winrt::com_ptr compositor; + if (compositor_) { + compositor.copy_from(compositor_); + } + return compositor; } InAppWebViewManager(const FlutterInappwebviewWindowsPlugin* plugin); @@ -52,13 +58,17 @@ namespace flutter_inappwebview_plugin void createInAppWebView(const flutter::EncodableMap* arguments, std::unique_ptr> result); void disposeKeepAlive(const std::string& keepAliveId); private: - inline static std::shared_ptr rohelper_ = nullptr; - inline static winrt::com_ptr - dispatcher_queue_controller_; - inline static std::unique_ptr graphics_context_ = nullptr; - inline static winrt::com_ptr compositor_; + inline static rx::RoHelper* rohelper_ = nullptr; + inline static ABI::Windows::System::IDispatcherQueueController* dispatcher_queue_controller_ = nullptr; + inline static GraphicsContext* graphics_context_ = nullptr; + inline static ABI::Windows::UI::Composition::ICompositor* compositor_ = nullptr; WNDCLASS windowClass_ = {}; inline static bool valid_ = false; + inline static std::size_t instance_count_ = 0; + inline static std::mutex shared_resources_mutex_; + + static ABI::Windows::System::IDispatcherQueueController* detachSharedResourcesForShutdown(); + static void shutdownDispatcherQueue(ABI::Windows::System::IDispatcherQueueController* dispatcherQueueController); }; } -#endif //FLUTTER_INAPPWEBVIEW_PLUGIN_IN_APP_WEBVIEW_MANAGER_H_ \ No newline at end of file +#endif //FLUTTER_INAPPWEBVIEW_PLUGIN_IN_APP_WEBVIEW_MANAGER_H_ From 328064358b82f6372a6322e613f3cafd2500be35 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 10 Jun 2026 21:14:00 +0300 Subject: [PATCH 2/6] feat(windows): natural trackpad scrolling - inertia and 1:1 calibration --- .../in_app_webview/custom_platform_view.dart | 122 +++++- .../custom_platform_view_scroll_test.dart | 381 ++++++++++++++++++ 2 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart diff --git a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart index 12cb3dd025..d4941b06a9 100644 --- a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart +++ b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import '../platform_util.dart'; import '_static_channel.dart'; @@ -50,6 +51,10 @@ const Map _cursors = { SystemMouseCursor _getCursorByName(String name) => _cursors[name] ?? SystemMouseCursors.basic; +/// Converts trackpad pan pixels to WebView2 wheel units. +/// Mouse-wheel deltas are already wheel-derived and are not scaled. +double _panToWheelUnits(double pan) => (pan * 120.0) / 100.0; + /// Pointer button type // Order must match InAppWebViewPointerEventKind (see in_app_webview.h) enum PointerButton { none, primary, secondary, tertiary } @@ -294,12 +299,26 @@ class CustomPlatformView extends StatefulWidget { } class _CustomPlatformViewState extends State - with PlatformUtilListener { + with PlatformUtilListener, SingleTickerProviderStateMixin { final GlobalKey _key = GlobalKey(); final _downButtons = {}; PointerDeviceKind _pointerKind = PointerDeviceKind.unknown; + // Accumulates fractional wheel deltas so sub-pixel trackpad movement is not + // lost when the native side truncates to short. + double _scrollRemainderX = 0; + double _scrollRemainderY = 0; + bool _scrollFlushScheduled = false; + + // Synthetic trackpad inertia; this view bypasses Flutter Scrollable, so it + // must continue a fast pan after the fingers lift. + VelocityTracker? _panVelocityTracker; + Ticker? _flingTicker; + ClampingScrollSimulation? _flingSimulation; + Offset _flingDirection = Offset.zero; + double _flingLastDistance = 0; + MouseCursor _cursor = SystemMouseCursors.basic; final _controller = CustomPlatformViewController(); @@ -388,6 +407,7 @@ class _CustomPlatformViewState extends State _controller._setCursorPos(ev.localPosition); }, onPointerDown: (ev) { + _stopFling(); _reportSurfaceSize(); _reportWidgetPosition(); @@ -464,14 +484,34 @@ class _CustomPlatformViewState extends State }, onPointerSignal: (signal) { if (signal is PointerScrollEvent) { - _controller._setScrollDelta( + _stopFling(); + _sendScrollDelta( -signal.scrollDelta.dx, -signal.scrollDelta.dy, ); } }, + onPointerPanZoomStart: (ev) { + _stopFling(); + _scrollRemainderX = 0; + _scrollRemainderY = 0; + _panVelocityTracker = VelocityTracker.withKind( + PointerDeviceKind.trackpad, + ); + }, onPointerPanZoomUpdate: (ev) { - _controller._setScrollDelta(ev.panDelta.dx, ev.panDelta.dy); + _panVelocityTracker?.addPosition(ev.timeStamp, ev.pan); + _sendScrollDelta( + _panToWheelUnits(ev.panDelta.dx), + _panToWheelUnits(ev.panDelta.dy), + ); + }, + onPointerPanZoomEnd: (ev) { + final tracker = _panVelocityTracker; + _panVelocityTracker = null; + if (tracker != null) { + _startFling(tracker.getVelocity()); + } }, child: MouseRegion( cursor: _cursor, @@ -500,6 +540,81 @@ class _CustomPlatformViewState extends State ); } + /// Forwards scroll deltas, preserving fractional remainders. + /// Sends are coalesced to at most one platform message per frame. + void _sendScrollDelta(double dx, double dy) { + _scrollRemainderX += dx; + _scrollRemainderY += dy; + if (_scrollFlushScheduled) { + return; + } + _scrollFlushScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _flushScrollDelta(); + }); + SchedulerBinding.instance.scheduleFrame(); + } + + /// Starts synthetic inertia after a fast lifted pan. + /// The decay follows Flutter's clamping scroll simulation. + void _startFling(Velocity velocity) { + final speed = velocity.pixelsPerSecond.distance.clamp( + 0.0, + kMaxFlingVelocity, + ); + if (speed < kMinFlingVelocity) { + return; + } + _flingDirection = + velocity.pixelsPerSecond / velocity.pixelsPerSecond.distance; + _flingSimulation = ClampingScrollSimulation(position: 0, velocity: speed); + _flingLastDistance = 0; + final ticker = _flingTicker ??= createTicker(_onFlingTick); + ticker.stop(); + ticker.start(); + } + + void _stopFling() { + _flingSimulation = null; + _flingTicker?.stop(); + } + + void _onFlingTick(Duration elapsed) { + final simulation = _flingSimulation; + if (simulation == null) { + _flingTicker?.stop(); + return; + } + final seconds = elapsed.inMicroseconds / Duration.microsecondsPerSecond; + final distance = simulation.x(seconds); + final step = distance - _flingLastDistance; + _flingLastDistance = distance; + if (simulation.isDone(seconds)) { + _stopFling(); + } + if (step != 0) { + _sendScrollDelta( + _panToWheelUnits(_flingDirection.dx * step), + _panToWheelUnits(_flingDirection.dy * step), + ); + } + } + + void _flushScrollDelta() { + _scrollFlushScheduled = false; + if (!mounted) { + return; + } + final flushX = _scrollRemainderX.truncateToDouble(); + final flushY = _scrollRemainderY.truncateToDouble(); + if (flushX == 0 && flushY == 0) { + return; + } + _scrollRemainderX -= flushX; + _scrollRemainderY -= flushY; + _controller._setScrollDelta(flushX, flushY); + } + void _reportSurfaceSize() async { final box = _key.currentContext?.findRenderObject() as RenderBox?; if (box != null) { @@ -530,6 +645,7 @@ class _CustomPlatformViewState extends State @override void dispose() { super.dispose(); + _flingTicker?.dispose(); _platformUtil.removeListener(this); _cursorSubscription?.cancel(); _controller.dispose(); diff --git a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart new file mode 100644 index 0000000000..cac8ffcda1 --- /dev/null +++ b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart @@ -0,0 +1,381 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_inappwebview_windows/src/in_app_webview/_static_channel.dart'; +import 'package:flutter_inappwebview_windows/src/in_app_webview/custom_platform_view.dart'; + +const int _kTextureId = 1; +const MethodChannel _viewChannel = MethodChannel( + 'com.pichillilorenzo/custom_platform_view_$_kTextureId', +); +const EventChannel _viewEventChannel = EventChannel( + 'com.pichillilorenzo/custom_platform_view_${_kTextureId}_events', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List viewChannelCalls; + + /// Returns the `[dx, dy]` arguments of every `setScrollDelta` call sent to + /// the native side so far. + List> scrollDeltaCalls() => viewChannelCalls + .where((call) => call.method == 'setScrollDelta') + .map( + (call) => + (call.arguments as List).map((e) => (e as num).toDouble()).toList(), + ) + .toList(); + + setUp(() { + viewChannelCalls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(IN_APP_WEBVIEW_STATIC_CHANNEL, ( + call, + ) async { + if (call.method == 'createInAppWebView') { + return _kTextureId; + } + return null; + }); + messenger.setMockMethodCallHandler(_viewChannel, (call) async { + viewChannelCalls.add(call); + return null; + }); + messenger.setMockStreamHandler( + _viewEventChannel, + MockStreamHandler.inline(onListen: (arguments, events) {}), + ); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(IN_APP_WEBVIEW_STATIC_CHANNEL, null); + messenger.setMockMethodCallHandler(_viewChannel, null); + messenger.setMockStreamHandler(_viewEventChannel, null); + }); + + Future pumpView(WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: CustomPlatformView())), + ); + await tester.pumpAndSettle(); + expect(find.byType(Texture), findsOneWidget); + return tester.getCenter(find.byType(Texture)); + } + + group('trackpad pan (PointerPanZoomUpdate)', () { + testWidgets('sub-pixel deltas accumulate instead of being lost', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + // Four 0.4px updates cross one wheel unit after calibration. + // Before accumulation, native short truncation lost each update. + for (var i = 1; i <= 4; i++) { + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: Offset(0, -0.4 * i)), + ); + } + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + expect(scrollDeltaCalls(), [ + [0.0, -1.0], + ]); + }); + + testWidgets('a long slow pan scrolls the full distance', (tester) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + // 60 updates of 0.25px = 15px finger travel. + // With 120/100 calibration this becomes about 18 wheel units. + for (var i = 1; i <= 60; i++) { + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: Offset(0, -0.25 * i)), + ); + } + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + final totalDy = scrollDeltaCalls().fold( + 0, + (sum, args) => sum + args[1], + ); + // עד יחידה אחת נשארת בצבירה (שארית עשרונית) — זה תקין. + expect(totalDy, lessThanOrEqualTo(-17.0)); + expect(totalDy, greaterThanOrEqualTo(-18.0)); + }); + + testWidgets('integral deltas are flushed each frame, unchanged', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -5)), + ); + await tester.pump(); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -15)), + ); + await tester.pump(); + // Scroll back up (sign must be preserved in both directions). + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -10)), + ); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + // Pan pixels × 120/100: -5px → -6 units, -10px → -12, +5px → +6. + expect(scrollDeltaCalls(), [ + [0.0, -6.0], + [0.0, -12.0], + [0.0, 6.0], + ]); + }); + + testWidgets('multiple updates within one frame coalesce to one message', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + // A fast pan can deliver several pointer events between two frames; + // they must reach the native side as a single batched wheel event. + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -4)), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -9)), + ); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -10)), + ); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + // 10px of finger travel × 120/100 = 12 wheel units, one message. + expect(scrollDeltaCalls(), [ + [0.0, -12.0], + ]); + }); + + testWidgets('horizontal sub-pixel deltas accumulate too', (tester) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + for (var i = 1; i <= 3; i++) { + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: Offset(-0.5 * i, 0)), + ); + } + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + expect(scrollDeltaCalls(), [ + [-1.0, 0.0], + ]); + }); + + testWidgets('fractional remainder is reset when a new gesture starts', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -0.7)), + ); + await tester.sendEventToBinding(pointer.panZoomEnd()); + + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -0.7)), + ); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + + // 0.7 + 0.7 crosses 1.0, but the remainder must not leak across + // separate gestures. + expect(scrollDeltaCalls(), isEmpty); + }); + }); + + group('trackpad inertia (synthetic fling)', () { + /// Sends a fast downward pan (high velocity) and lifts the fingers. + Future fastPan(WidgetTester tester, Offset center) async { + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding( + pointer.panZoomStart(center, timeStamp: Duration.zero), + ); + // 10px per 8ms => 1250 px/s, well above kMinFlingVelocity. + for (var i = 1; i <= 8; i++) { + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: Offset(0, -10.0 * i), + timeStamp: Duration(milliseconds: 8 * i), + ), + ); + } + await tester.sendEventToBinding( + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 72)), + ); + await tester.pump(); + } + + testWidgets('a fast pan keeps scrolling after the fingers lift', ( + tester, + ) async { + final center = await pumpView(tester); + + await fastPan(tester, center); + final callsAtRelease = scrollDeltaCalls().length; + + // Let the fling ticker run for a while. + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + final calls = scrollDeltaCalls(); + expect( + calls.length, + greaterThan(callsAtRelease), + reason: 'inertia must keep emitting scroll deltas after release', + ); + // All inertia deltas continue in the gesture direction (down). + for (final args in calls.skip(callsAtRelease)) { + expect(args[1], lessThan(0)); + } + + // And the fling must decay and stop on its own. + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + final settledCount = scrollDeltaCalls().length; + await tester.pump(const Duration(milliseconds: 200)); + expect(scrollDeltaCalls().length, settledCount); + }); + + testWidgets('a slow release does not trigger inertia', (tester) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding( + pointer.panZoomStart(center, timeStamp: Duration.zero), + ); + // 1px per 50ms => 20 px/s, below kMinFlingVelocity. + for (var i = 1; i <= 6; i++) { + await tester.sendEventToBinding( + pointer.panZoomUpdate( + center, + pan: Offset(0, -1.0 * i), + timeStamp: Duration(milliseconds: 50 * i), + ), + ); + } + await tester.sendEventToBinding( + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 300)), + ); + await tester.pump(); + final callsAtRelease = scrollDeltaCalls().length; + + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(scrollDeltaCalls().length, callsAtRelease); + }); + + testWidgets('a new gesture stops the running fling', (tester) async { + final center = await pumpView(tester); + + await fastPan(tester, center); + await tester.pump(const Duration(milliseconds: 16)); + + // Touch down again: putting the fingers back must halt the glide. + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding( + pointer.panZoomStart( + center, + timeStamp: const Duration(milliseconds: 200), + ), + ); + await tester.pump(); + final callsAfterStop = scrollDeltaCalls().length; + + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(scrollDeltaCalls().length, callsAfterStop); + await tester.sendEventToBinding( + pointer.panZoomEnd(timeStamp: const Duration(milliseconds: 600)), + ); + await tester.pump(); + }); + + testWidgets('a mouse wheel event stops the running fling', (tester) async { + final center = await pumpView(tester); + + await fastPan(tester, center); + await tester.pump(const Duration(milliseconds: 16)); + + final mouse = TestPointer(2, PointerDeviceKind.mouse); + await tester.sendEventToBinding(mouse.hover(center)); + await tester.sendEventToBinding(mouse.scroll(const Offset(0, 120))); + await tester.pump(); + final callsAfterWheel = scrollDeltaCalls().length; + + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(scrollDeltaCalls().length, callsAfterWheel); + }); + }); + + group('mouse wheel (PointerScrollEvent)', () { + testWidgets('wheel deltas are still forwarded negated', (tester) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(center)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 120))); + await tester.pump(); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120))); + await tester.pump(); + + expect(scrollDeltaCalls(), [ + [0.0, -120.0], + [0.0, 120.0], + ]); + }); + + testWidgets('fractional wheel deltas (high-resolution wheels) accumulate', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(center)); + for (var i = 0; i < 3; i++) { + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 0.5))); + } + await tester.pump(); + + expect(scrollDeltaCalls(), [ + [0.0, -1.0], + ]); + }); + }); +} From 5205d8e63306d503e4967bed0e0fa5ba01dfd024 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 10 Jun 2026 21:31:20 +0300 Subject: [PATCH 3/6] Fix CMake warning in flutter_inappwebview_windows --- flutter_inappwebview_windows/windows/CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/flutter_inappwebview_windows/windows/CMakeLists.txt b/flutter_inappwebview_windows/windows/CMakeLists.txt index 37a2233da1..a39761b576 100644 --- a/flutter_inappwebview_windows/windows/CMakeLists.txt +++ b/flutter_inappwebview_windows/windows/CMakeLists.txt @@ -35,7 +35,6 @@ add_custom_command( COMMAND ${NUGET} install Microsoft.Windows.CppWinRT -Version ${CPP_WINRT_VERSION} -ExcludeVersion -OutputDirectory ${CMAKE_BINARY_DIR}/packages COMMAND ${NUGET} install Microsoft.Web.WebView2 -Version ${WEBVIEW_VERSION} -ExcludeVersion -OutputDirectory ${CMAKE_BINARY_DIR}/packages COMMAND ${NUGET} install nlohmann.json -Version ${NLOHMANN_JSON_VERSION} -ExcludeVersion -OutputDirectory ${CMAKE_BINARY_DIR}/packages - DEPENDS ${NUGET} ) # Any new source files that you add to the plugin should be added here. From 2149072568fa8555de1c97b44eb1d085d75b5f45 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 10 Jun 2026 22:49:56 +0300 Subject: [PATCH 4/6] fix(windows): forward scroll deltas immediately instead of per frame Batching scroll sends to one platform message per Flutter frame added 0-16ms of variable latency, and in a busy app a delayed or skipped UI frame held trackpad input back and then released it in a burst - scrolling that intermittently ignored the fingers and caught up a moment later. Chromium coalesces a per-event wheel stream by itself (exactly what high-resolution mouse wheels produce natively), so the frame-tied batching only added jitter. Measured with the in-repo smoothness probe (real WebView2, identical synthetic drag): end-to-end latency dropped from 81ms to 56ms and step jitter improved (stdStep 0.66px -> 0.52px, 0% stalled frames) with Chromium smooth scrolling at its default. Also stop the synthetic fling on PointerScrollInertiaCancelEvent, which the engine sends when the user touches the trackpad during inertia. Co-Authored-By: Claude Fable 5 --- .../in_app_webview/custom_platform_view.dart | 41 ++++++++----------- .../custom_platform_view_scroll_test.dart | 27 ++++++------ 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart index d4941b06a9..3641d903d7 100644 --- a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart +++ b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart @@ -309,7 +309,6 @@ class _CustomPlatformViewState extends State // lost when the native side truncates to short. double _scrollRemainderX = 0; double _scrollRemainderY = 0; - bool _scrollFlushScheduled = false; // Synthetic trackpad inertia; this view bypasses Flutter Scrollable, so it // must continue a fast pan after the fingers lift. @@ -489,6 +488,11 @@ class _CustomPlatformViewState extends State -signal.scrollDelta.dx, -signal.scrollDelta.dy, ); + } else if (signal is PointerScrollInertiaCancelEvent) { + // Sent by the engine when the user touches the trackpad + // during inertia — that touch must also halt the + // synthetic glide. + _stopFling(); } }, onPointerPanZoomStart: (ev) { @@ -541,18 +545,24 @@ class _CustomPlatformViewState extends State } /// Forwards scroll deltas, preserving fractional remainders. - /// Sends are coalesced to at most one platform message per frame. + /// + /// Forwarding is intentionally immediate, NOT batched per Flutter frame: + /// tying sends to frame scheduling adds 0-16ms of *variable* latency, and + /// in a busy app a delayed UI frame holds scroll input back and releases it + /// in a burst — scrolling that intermittently "ignores" the fingers and + /// then catches up. Chromium coalesces a per-event wheel stream by itself + /// (this is what high-resolution mouse wheels produce natively). void _sendScrollDelta(double dx, double dy) { _scrollRemainderX += dx; _scrollRemainderY += dy; - if (_scrollFlushScheduled) { + final flushX = _scrollRemainderX.truncateToDouble(); + final flushY = _scrollRemainderY.truncateToDouble(); + if (flushX == 0 && flushY == 0) { return; } - _scrollFlushScheduled = true; - SchedulerBinding.instance.scheduleFrameCallback((_) { - _flushScrollDelta(); - }); - SchedulerBinding.instance.scheduleFrame(); + _scrollRemainderX -= flushX; + _scrollRemainderY -= flushY; + _controller._setScrollDelta(flushX, flushY); } /// Starts synthetic inertia after a fast lifted pan. @@ -600,21 +610,6 @@ class _CustomPlatformViewState extends State } } - void _flushScrollDelta() { - _scrollFlushScheduled = false; - if (!mounted) { - return; - } - final flushX = _scrollRemainderX.truncateToDouble(); - final flushY = _scrollRemainderY.truncateToDouble(); - if (flushX == 0 && flushY == 0) { - return; - } - _scrollRemainderX -= flushX; - _scrollRemainderY -= flushY; - _controller._setScrollDelta(flushX, flushY); - } - void _reportSurfaceSize() async { final box = _key.currentContext?.findRenderObject() as RenderBox?; if (box != null) { diff --git a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart index cac8ffcda1..8b7b3a287a 100644 --- a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart +++ b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart @@ -115,7 +115,7 @@ void main() { expect(totalDy, greaterThanOrEqualTo(-18.0)); }); - testWidgets('integral deltas are flushed each frame, unchanged', ( + testWidgets('integral deltas are forwarded with exact calibration', ( tester, ) async { final center = await pumpView(tester); @@ -145,31 +145,30 @@ void main() { ]); }); - testWidgets('multiple updates within one frame coalesce to one message', ( - tester, - ) async { + testWidgets('updates are forwarded immediately, without waiting for a ' + 'Flutter frame', (tester) async { final center = await pumpView(tester); final pointer = TestPointer(1, PointerDeviceKind.trackpad); await tester.sendEventToBinding(pointer.panZoomStart(center)); - // A fast pan can deliver several pointer events between two frames; - // they must reach the native side as a single batched wheel event. - await tester.sendEventToBinding( - pointer.panZoomUpdate(center, pan: const Offset(0, -4)), - ); + // No tester.pump() between the updates and the assertion: forwarding + // must not depend on Flutter's frame scheduling. Tying it to frames + // adds variable latency and lets a busy UI hold scroll input back and + // release it in bursts. await tester.sendEventToBinding( - pointer.panZoomUpdate(center, pan: const Offset(0, -9)), + pointer.panZoomUpdate(center, pan: const Offset(0, -5)), ); await tester.sendEventToBinding( pointer.panZoomUpdate(center, pan: const Offset(0, -10)), ); - await tester.sendEventToBinding(pointer.panZoomEnd()); - await tester.pump(); - // 10px of finger travel × 120/100 = 12 wheel units, one message. + // -5px × 120/100 = -6 units per update, one message per event. expect(scrollDeltaCalls(), [ - [0.0, -12.0], + [0.0, -6.0], + [0.0, -6.0], ]); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); }); testWidgets('horizontal sub-pixel deltas accumulate too', (tester) async { From c909ebf74a37c24dc2c9b58ea1e2f4620ab77f11 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Wed, 10 Jun 2026 23:59:50 +0300 Subject: [PATCH 5/6] feat(windows): add felt-gain 1.5 to trackpad scrolling 1:1 finger-to-page tracking was reported to feel sluggish compared to native precision-touchpad scrolling. Pan deltas (and synthetic fling distances, which pass through the same conversion) are now scaled by a felt gain of 1.5 on top of the measured 120-units-per-100px Chromium wheel calibration, so the page moves 1.5x the finger travel. _kTrackpadGain is the single knob for tuning scroll speed. A synthetic-touch (SendPointerInput PT_TOUCH) alternative was prototyped and measured against a real WebView2: it gives native direct manipulation and Chromium fling, but WebView2 only begins a touch scroll after ~34px of movement per gesture (282ms begin latency, gestures under ~30px scroll nothing). That dead zone kills the small precise scrolls a reading app lives on, so the wheel-injection path stays. Measured after this change (in-repo smoothness probe, real WebView2): page/finger = 1.50 exactly, 0% stalled frames, stdStep 0.44px, 42ms latency, and 8-40px micro-gestures all scroll at full gain. Co-Authored-By: Claude Fable 5 --- .../in_app_webview/custom_platform_view.dart | 13 ++++++- .../custom_platform_view_scroll_test.dart | 35 +++++++++++-------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart index 3641d903d7..f9d80c28b7 100644 --- a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart +++ b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart @@ -51,9 +51,20 @@ const Map _cursors = { SystemMouseCursor _getCursorByName(String name) => _cursors[name] ?? SystemMouseCursors.basic; +/// The trackpad scroll-speed knob: how far the page moves per pixel of +/// finger travel. 1.0 is exact 1:1 tracking (measured); it was reported to +/// feel sluggish next to native precision-touchpad scrolling, which applies +/// gain plus inertia. Synthetic fling distances pass through this factor +/// too, so the glide scales together with the drag. +const double _kTrackpadGain = 1.5; + /// Converts trackpad pan pixels to WebView2 wheel units. /// Mouse-wheel deltas are already wheel-derived and are not scaled. -double _panToWheelUnits(double pan) => (pan * 120.0) / 100.0; +/// +/// WHEEL_DELTA (120) is one wheel notch and Chromium scrolls ~100px per +/// notch (measured against a real WebView2), so pan*120/100 gives exact 1:1 +/// finger-to-page tracking, multiplied by the felt [_kTrackpadGain]. +double _panToWheelUnits(double pan) => (pan * 120.0 / 100.0) * _kTrackpadGain; /// Pointer button type // Order must match InAppWebViewPointerEventKind (see in_app_webview.h) diff --git a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart index 8b7b3a287a..766cd0e79b 100644 --- a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart +++ b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart @@ -76,8 +76,9 @@ void main() { final pointer = TestPointer(1, PointerDeviceKind.trackpad); await tester.sendEventToBinding(pointer.panZoomStart(center)); - // Four 0.4px updates cross one wheel unit after calibration. - // Before accumulation, native short truncation lost each update. + // 0.4px updates become 0.72 wheel units after the 1.2×1.5 gain; they + // cross a whole unit on the 2nd and 3rd updates. Before accumulation, + // native short truncation lost each update entirely. for (var i = 1; i <= 4; i++) { await tester.sendEventToBinding( pointer.panZoomUpdate(center, pan: Offset(0, -0.4 * i)), @@ -88,6 +89,7 @@ void main() { expect(scrollDeltaCalls(), [ [0.0, -1.0], + [0.0, -1.0], ]); }); @@ -97,7 +99,7 @@ void main() { final pointer = TestPointer(1, PointerDeviceKind.trackpad); await tester.sendEventToBinding(pointer.panZoomStart(center)); // 60 updates of 0.25px = 15px finger travel. - // With 120/100 calibration this becomes about 18 wheel units. + // With the 1.2×1.5 gain this becomes 27 wheel units. for (var i = 1; i <= 60; i++) { await tester.sendEventToBinding( pointer.panZoomUpdate(center, pan: Offset(0, -0.25 * i)), @@ -111,8 +113,8 @@ void main() { (sum, args) => sum + args[1], ); // עד יחידה אחת נשארת בצבירה (שארית עשרונית) — זה תקין. - expect(totalDy, lessThanOrEqualTo(-17.0)); - expect(totalDy, greaterThanOrEqualTo(-18.0)); + expect(totalDy, lessThanOrEqualTo(-26.0)); + expect(totalDy, greaterThanOrEqualTo(-27.0)); }); testWidgets('integral deltas are forwarded with exact calibration', ( @@ -137,11 +139,11 @@ void main() { await tester.sendEventToBinding(pointer.panZoomEnd()); await tester.pump(); - // Pan pixels × 120/100: -5px → -6 units, -10px → -12, +5px → +6. + // Pan pixels × 1.2 × 1.5: -5px → -9 units, -10px → -18, +5px → +9. expect(scrollDeltaCalls(), [ - [0.0, -6.0], - [0.0, -12.0], - [0.0, 6.0], + [0.0, -9.0], + [0.0, -18.0], + [0.0, 9.0], ]); }); @@ -162,10 +164,10 @@ void main() { pointer.panZoomUpdate(center, pan: const Offset(0, -10)), ); - // -5px × 120/100 = -6 units per update, one message per event. + // -5px × 1.2 × 1.5 = -9 units per update, one message per event. expect(scrollDeltaCalls(), [ - [0.0, -6.0], - [0.0, -6.0], + [0.0, -9.0], + [0.0, -9.0], ]); await tester.sendEventToBinding(pointer.panZoomEnd()); await tester.pump(); @@ -184,8 +186,11 @@ void main() { await tester.sendEventToBinding(pointer.panZoomEnd()); await tester.pump(); + // 0.5px × 1.2 × 1.5 = 0.9 units per update: whole units flush on the + // 2nd and 3rd updates. expect(scrollDeltaCalls(), [ [-1.0, 0.0], + [-1.0, 0.0], ]); }); @@ -197,18 +202,18 @@ void main() { final pointer = TestPointer(1, PointerDeviceKind.trackpad); await tester.sendEventToBinding(pointer.panZoomStart(center)); await tester.sendEventToBinding( - pointer.panZoomUpdate(center, pan: const Offset(0, -0.7)), + pointer.panZoomUpdate(center, pan: const Offset(0, -0.5)), ); await tester.sendEventToBinding(pointer.panZoomEnd()); await tester.sendEventToBinding(pointer.panZoomStart(center)); await tester.sendEventToBinding( - pointer.panZoomUpdate(center, pan: const Offset(0, -0.7)), + pointer.panZoomUpdate(center, pan: const Offset(0, -0.5)), ); await tester.sendEventToBinding(pointer.panZoomEnd()); await tester.pump(); - // 0.7 + 0.7 crosses 1.0, but the remainder must not leak across + // 0.9 + 0.9 units cross 1.0, but the remainder must not leak across // separate gestures. expect(scrollDeltaCalls(), isEmpty); }); From 7aa409710c13bd16bf31bf67c3d82824f299bfe2 Mon Sep 17 00:00:00 2001 From: Y-PLONI <7353755@gmail.com> Date: Thu, 11 Jun 2026 01:08:57 +0300 Subject: [PATCH 6/6] fix(windows): scroll --- .../in_app_webview/custom_platform_view.dart | 2 + .../custom_platform_view_scroll_test.dart | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart index f9d80c28b7..73a3bc34e8 100644 --- a/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart +++ b/flutter_inappwebview_windows/lib/src/in_app_webview/custom_platform_view.dart @@ -494,6 +494,7 @@ class _CustomPlatformViewState extends State }, onPointerSignal: (signal) { if (signal is PointerScrollEvent) { + _controller._setCursorPos(signal.localPosition); _stopFling(); _sendScrollDelta( -signal.scrollDelta.dx, @@ -507,6 +508,7 @@ class _CustomPlatformViewState extends State } }, onPointerPanZoomStart: (ev) { + _controller._setCursorPos(ev.localPosition); _stopFling(); _scrollRemainderX = 0; _scrollRemainderY = 0; diff --git a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart index 766cd0e79b..f985a96568 100644 --- a/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart +++ b/flutter_inappwebview_windows/test/custom_platform_view_scroll_test.dart @@ -29,6 +29,14 @@ void main() { ) .toList(); + List> cursorPosCalls() => viewChannelCalls + .where((call) => call.method == 'setCursorPos') + .map( + (call) => + (call.arguments as List).map((e) => (e as num).toDouble()).toList(), + ) + .toList(); + setUp(() { viewChannelCalls = []; final messenger = @@ -173,6 +181,35 @@ void main() { await tester.pump(); }); + testWidgets('pan zoom start updates cursor position before scrolling', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, -5)), + ); + + expect(cursorPosCalls(), [ + [center.dx, center.dy], + ]); + expect( + viewChannelCalls + .where( + (call) => + call.method == 'setCursorPos' || + call.method == 'setScrollDelta', + ) + .map((call) => call.method), + ['setCursorPos', 'setScrollDelta'], + ); + + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + }); + testWidgets('horizontal sub-pixel deltas accumulate too', (tester) async { final center = await pumpView(tester); @@ -349,6 +386,35 @@ void main() { }); group('mouse wheel (PointerScrollEvent)', () { + testWidgets('wheel updates cursor position before scrolling', ( + tester, + ) async { + final center = await pumpView(tester); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(center)); + viewChannelCalls.clear(); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, 120))); + await tester.pump(); + + expect(cursorPosCalls(), [ + [center.dx, center.dy], + ]); + expect( + viewChannelCalls + .where( + (call) => + call.method == 'setCursorPos' || + call.method == 'setScrollDelta', + ) + .map((call) => call.method), + ['setCursorPos', 'setScrollDelta'], + ); + expect(scrollDeltaCalls(), [ + [0.0, -120.0], + ]); + }); + testWidgets('wheel deltas are still forwarded negated', (tester) async { final center = await pumpView(tester);