From 0659cb0e6fa8940b09eba264e405fbb7d277e3eb Mon Sep 17 00:00:00 2001 From: Sana Ullah Date: Fri, 11 Sep 2026 11:33:44 +0500 Subject: [PATCH 1/2] [material_ui] Allow custom MaterialInkController implementations InkFeature cast the controller to the private _RenderInkFeatures type, so any custom MaterialInkController threw a TypeError. Store the public interface, add removeInkFeature, and skip painting when the controller is not a RenderObject. Fixes https://github.com/flutter/flutter/issues/192060 --- packages/material_ui/lib/src/material.dart | 34 +++++++++---- .../change_2026_09_11_1789108378458.yaml | 3 ++ packages/material_ui/test/material_test.dart | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 packages/material_ui/pending_changelogs/change_2026_09_11_1789108378458.yaml diff --git a/packages/material_ui/lib/src/material.dart b/packages/material_ui/lib/src/material.dart index d625a461a807..18a71ecfd6ea 100644 --- a/packages/material_ui/lib/src/material.dart +++ b/packages/material_ui/lib/src/material.dart @@ -95,6 +95,13 @@ abstract class MaterialInkController { /// The ink feature will paint as part of this controller. void addInkFeature(InkFeature feature); + /// Stop painting the given ink feature and release any references to it. + /// + /// Called from [InkFeature.dispose]. Controllers that implement this + /// interface (including custom ones) must remove the feature from any + /// internal list used for painting. + void removeInkFeature(InkFeature feature); + /// Notifies the controller that one of its ink features needs to repaint. void markNeedsPaint(); } @@ -590,14 +597,15 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController @override void addInkFeature(InkFeature feature) { assert(!feature._debugDisposed); - assert(feature._controller == this); + assert(identical(feature.controller, this)); _inkFeatures ??= []; assert(!_inkFeatures!.contains(feature)); _inkFeatures!.add(feature); markNeedsPaint(); } - void _removeFeature(InkFeature feature) { + @override + void removeInkFeature(InkFeature feature) { assert(_inkFeatures != null); _inkFeatures!.remove(feature); markNeedsPaint(); @@ -670,10 +678,10 @@ class _InkFeatures extends SingleChildRenderObjectWidget { abstract class InkFeature { /// Initializes fields for subclasses. InkFeature({ - required MaterialInkController controller, + required this.controller, required this.referenceBox, this.onRemoved, - }) : _controller = controller as _RenderInkFeatures { + }) { assert(debugMaybeDispatchCreated('material', 'InkFeature', this)); } @@ -681,8 +689,11 @@ abstract class InkFeature { /// /// Typically used by subclasses to call /// [MaterialInkController.markNeedsPaint] when they need to repaint. - MaterialInkController get controller => _controller; - final _RenderInkFeatures _controller; + /// + /// Controllers returned by [Material.of] are [RenderObject]s and can paint + /// ink. Custom [MaterialInkController] implementations are supported; ink + /// painting is skipped when the controller is not a [RenderObject]. + final MaterialInkController controller; /// The render box whose visual position defines the frame of reference for this ink feature. final RenderBox referenceBox; @@ -701,7 +712,7 @@ abstract class InkFeature { return true; }()); assert(debugMaybeDispatchDisposed(this)); - _controller._removeFeature(this); + controller.removeInkFeature(this); onRemoved?.call(); } @@ -762,8 +773,15 @@ abstract class InkFeature { void _paint(Canvas canvas) { assert(referenceBox.attached); assert(!_debugDisposed); + // Painting requires a render-object controller so the ink can be + // transformed into the material's coordinate space. Custom controllers that + // are not RenderObjects cannot provide that transform. + if (controller is! RenderObject) { + return; + } + final paintContext = controller as RenderObject; // determine the transform that gets our coordinate system to be like theirs - final Matrix4? transform = _getPaintTransform(_controller, referenceBox); + final Matrix4? transform = _getPaintTransform(paintContext, referenceBox); if (transform != null) { paintFeature(canvas, transform); } diff --git a/packages/material_ui/pending_changelogs/change_2026_09_11_1789108378458.yaml b/packages/material_ui/pending_changelogs/change_2026_09_11_1789108378458.yaml new file mode 100644 index 000000000000..e276b5413e86 --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_09_11_1789108378458.yaml @@ -0,0 +1,3 @@ +changelog: | + - Allows custom `MaterialInkController` implementations by removing a private type cast in `InkFeature` and adding `MaterialInkController.removeInkFeature`. +version: patch diff --git a/packages/material_ui/test/material_test.dart b/packages/material_ui/test/material_test.dart index 864ce20054e4..6f1088276670 100644 --- a/packages/material_ui/test/material_test.dart +++ b/packages/material_ui/test/material_test.dart @@ -1128,6 +1128,25 @@ void main() { ); }); + testWidgets('InkFeature accepts a custom MaterialInkController', (WidgetTester tester) async { + await tester.pumpWidget(const Material(child: SizedBox(width: 40, height: 40))); + + final Element element = tester.element(find.byType(SizedBox)); + final MaterialInkController host = Material.of(element); + final referenceBox = element.findRenderObject()! as RenderBox; + final customController = _DelegatingMaterialInkController(host); + + expect( + () => _InkFeature(controller: customController, referenceBox: referenceBox), + returnsNormally, + ); + expect(customController.added, hasLength(1)); + + customController.added.single.dispose(); + expect(customController.removed, hasLength(1)); + expect(identical(customController.added.single, customController.removed.single), isTrue); + }); + group('LookupBoundary', () { testWidgets('hides Material from Material.maybeOf', (WidgetTester tester) async { MaterialInkController? material; @@ -1289,3 +1308,32 @@ class _InkFeature extends InkFeature { @override void paintFeature(Canvas canvas, Matrix4 transform) {} } + +class _DelegatingMaterialInkController implements MaterialInkController { + _DelegatingMaterialInkController(this._host); + + final MaterialInkController _host; + final List added = []; + final List removed = []; + + @override + Color? get color => _host.color; + + @override + TickerProvider get vsync => _host.vsync; + + @override + void addInkFeature(InkFeature feature) { + added.add(feature); + } + + @override + void removeInkFeature(InkFeature feature) { + removed.add(feature); + } + + @override + void markNeedsPaint() { + _host.markNeedsPaint(); + } +} From 4f68bc6280998daf7da05c0dc622af03182dacea Mon Sep 17 00:00:00 2001 From: Sana Ullah Date: Fri, 11 Sep 2026 12:00:59 +0500 Subject: [PATCH 2/2] [material_ui] Support painting via delegating MaterialInkController Relax addInkFeature identity checks for non-RenderObject controllers and resolve the paint transform from the enclosing Material when the feature controller only delegates. Adds a paint regression test. --- packages/material_ui/lib/src/material.dart | 29 ++++++++++++++------ packages/material_ui/test/material_test.dart | 17 ++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/material_ui/lib/src/material.dart b/packages/material_ui/lib/src/material.dart index 18a71ecfd6ea..c641331e1f9d 100644 --- a/packages/material_ui/lib/src/material.dart +++ b/packages/material_ui/lib/src/material.dart @@ -597,7 +597,10 @@ class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController @override void addInkFeature(InkFeature feature) { assert(!feature._debugDisposed); - assert(identical(feature.controller, this)); + // Custom controllers that are not RenderObjects may delegate here so ink + // can still paint on the host Material. Only require identity when the + // feature's controller is itself a RenderObject. + assert(feature.controller is! RenderObject || identical(feature.controller, this)); _inkFeatures ??= []; assert(!_inkFeatures!.contains(feature)); _inkFeatures!.add(feature); @@ -691,8 +694,9 @@ abstract class InkFeature { /// [MaterialInkController.markNeedsPaint] when they need to repaint. /// /// Controllers returned by [Material.of] are [RenderObject]s and can paint - /// ink. Custom [MaterialInkController] implementations are supported; ink - /// painting is skipped when the controller is not a [RenderObject]. + /// ink. Custom [MaterialInkController] implementations may delegate + /// [MaterialInkController.addInkFeature] to the host controller; painting + /// then uses the enclosing Material render object. final MaterialInkController controller; /// The render box whose visual position defines the frame of reference for this ink feature. @@ -773,13 +777,22 @@ abstract class InkFeature { void _paint(Canvas canvas) { assert(referenceBox.attached); assert(!_debugDisposed); - // Painting requires a render-object controller so the ink can be - // transformed into the material's coordinate space. Custom controllers that - // are not RenderObjects cannot provide that transform. - if (controller is! RenderObject) { + // Prefer the controller when it is a RenderObject (Material.of). Custom + // delegating controllers are not RenderObjects, so fall back to the + // enclosing _RenderInkFeatures ancestor of the reference box. + RenderObject? paintContext; + if (controller is RenderObject) { + paintContext = controller as RenderObject; + } else { + RenderObject? ancestor = referenceBox.parent; + while (ancestor != null && ancestor is! _RenderInkFeatures) { + ancestor = ancestor.parent; + } + paintContext = ancestor; + } + if (paintContext == null) { return; } - final paintContext = controller as RenderObject; // determine the transform that gets our coordinate system to be like theirs final Matrix4? transform = _getPaintTransform(paintContext, referenceBox); if (transform != null) { diff --git a/packages/material_ui/test/material_test.dart b/packages/material_ui/test/material_test.dart index 6f1088276670..13ac4119157d 100644 --- a/packages/material_ui/test/material_test.dart +++ b/packages/material_ui/test/material_test.dart @@ -1147,6 +1147,21 @@ void main() { expect(identical(customController.added.single, customController.removed.single), isTrue); }); + testWidgets('InkFeature with delegating controller still paints', (WidgetTester tester) async { + await tester.pumpWidget(const Material(child: SizedBox(width: 40, height: 40))); + + final Element element = tester.element(find.byType(SizedBox)); + final MaterialInkController host = Material.of(element); + final referenceBox = element.findRenderObject()! as RenderBox; + final customController = _DelegatingMaterialInkController(host); + final tracker = TrackPaintInkFeature(controller: customController, referenceBox: referenceBox); + customController.addInkFeature(tracker); + + await tester.pump(); + expect(tracker.paintCount, greaterThan(0)); + tracker.dispose(); + }); + group('LookupBoundary', () { testWidgets('hides Material from Material.maybeOf', (WidgetTester tester) async { MaterialInkController? material; @@ -1325,11 +1340,13 @@ class _DelegatingMaterialInkController implements MaterialInkController { @override void addInkFeature(InkFeature feature) { added.add(feature); + _host.addInkFeature(feature); } @override void removeInkFeature(InkFeature feature) { removed.add(feature); + _host.removeInkFeature(feature); } @override