diff --git a/lib/src/view/notifier/scribble_notifier.dart b/lib/src/view/notifier/scribble_notifier.dart index 748a197..f4fe5b9 100644 --- a/lib/src/view/notifier/scribble_notifier.dart +++ b/lib/src/view/notifier/scribble_notifier.dart @@ -26,6 +26,19 @@ abstract class ScribbleNotifierBase extends ValueNotifier { /// access it from the [renderImage] method. GlobalKey get repaintBoundaryKey; + /// Attaches a [GlobalKey] representing a [RepaintBoundary] used by a + /// [Scribble] widget instance. + /// + /// Default implementation is a no-op so external implementations of the + /// notifier don't need to override this method. + void attachRepaintBoundaryKey(GlobalKey key) {} + + /// Detaches a previously attached [GlobalKey]. + /// + /// Default implementation is a no-op so external implementations of the + /// notifier don't need to override this method. + void detachRepaintBoundaryKey(GlobalKey key) {} + /// Should be called when the pointer hovers over the canvas with the /// corresponding [event]. void onPointerHover(PointerHoverEvent event); @@ -130,10 +143,50 @@ class ScribbleNotifier extends ScribbleNotifierBase /// receive a map. Sketch get currentSketch => value.sketch; + // Fallback key for backward compatibility when no widget-attached key exists. final GlobalKey _repaintBoundaryKey = GlobalKey(); + // Tracks keys of mounted Scribble widgets using this notifier. + // The last attached key is preferred for image rendering to avoid collisions + // when the same notifier is used by multiple widgets concurrently. + final List _attachedRepaintBoundaryKeys = []; + + @override + GlobalKey get repaintBoundaryKey => _attachedRepaintBoundaryKeys.isNotEmpty + ? _attachedRepaintBoundaryKeys.last + : _repaintBoundaryKey; + + @override + void attachRepaintBoundaryKey(GlobalKey key) { + // Avoid duplicates while preserving order semantics + if (!_attachedRepaintBoundaryKeys.contains(key)) { + _attachedRepaintBoundaryKeys.add(key); + } + } + @override - GlobalKey get repaintBoundaryKey => _repaintBoundaryKey; + void detachRepaintBoundaryKey(GlobalKey key) { + _attachedRepaintBoundaryKeys.remove(key); + } + + @override + Future renderImage({ + double pixelRatio = 1.0, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + }) { + assert(() { + if (_attachedRepaintBoundaryKeys.isEmpty) { + debugPrint( + '[scribble] renderImage() is falling back to an internal GlobalKey. ' + 'If you implement ScribbleNotifierBase yourself, override ' + 'attachRepaintBoundaryKey/detachRepaintBoundaryKey so Scribble widgets ' + 'can register their RepaintBoundary. This fallback will be removed in a future release.', + ); + } + return true; + }()); + return super.renderImage(pixelRatio: pixelRatio, format: format); + } /// The [SketchSimplifier] that is used to simplify the lines of the sketch. /// diff --git a/lib/src/view/scribble.dart b/lib/src/view/scribble.dart index 75d523e..51b73a6 100644 --- a/lib/src/view/scribble.dart +++ b/lib/src/view/scribble.dart @@ -14,7 +14,7 @@ import 'package:scribble/src/view/state/scribble.state.dart'; /// You can control its behavior from code using the [notifier] instance you /// pass in. /// {@endtemplate} -class Scribble extends StatelessWidget { +class Scribble extends StatefulWidget { /// {@macro scribble} const Scribble({ /// The notifier that controls this canvas. @@ -46,28 +46,59 @@ class Scribble extends StatelessWidget { /// {@endtemplate} final bool simulatePressure; + @override + State createState() => _ScribbleState(); +} + +class _ScribbleState extends State { + late final GlobalKey _localRepaintBoundaryKey; + + @override + void initState() { + super.initState(); + _localRepaintBoundaryKey = GlobalKey( + debugLabel: 'scribble_${identityHashCode(this)}', + ); + widget.notifier.attachRepaintBoundaryKey(_localRepaintBoundaryKey); + } + + @override + void didUpdateWidget(covariant Scribble oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.notifier, widget.notifier)) { + oldWidget.notifier.detachRepaintBoundaryKey(_localRepaintBoundaryKey); + widget.notifier.attachRepaintBoundaryKey(_localRepaintBoundaryKey); + } + } + + @override + void dispose() { + widget.notifier.detachRepaintBoundaryKey(_localRepaintBoundaryKey); + super.dispose(); + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: notifier, + valueListenable: widget.notifier, builder: (context, state, _) { final drawCurrentTool = - drawPen && state is Drawing || drawEraser && state is Erasing; + widget.drawPen && state is Drawing || widget.drawEraser && state is Erasing; final child = SizedBox.expand( child: CustomPaint( foregroundPainter: ScribbleEditingPainter( state: state, - drawPointer: drawPen, - drawEraser: drawEraser, - simulatePressure: simulatePressure, + drawPointer: widget.drawPen, + drawEraser: widget.drawEraser, + simulatePressure: widget.simulatePressure, ), child: RepaintBoundary( - key: notifier.repaintBoundaryKey, + key: _localRepaintBoundaryKey, child: CustomPaint( painter: ScribblePainter( sketch: state.sketch, scaleFactor: state.scaleFactor, - simulatePressure: simulatePressure, + simulatePressure: widget.simulatePressure, ), ), ), @@ -83,13 +114,13 @@ class Scribble extends StatelessWidget { .contains(PointerDeviceKind.mouse) ? SystemMouseCursors.none : MouseCursor.defer, - onExit: notifier.onPointerExit, + onExit: widget.notifier.onPointerExit, child: Listener( - onPointerDown: notifier.onPointerDown, - onPointerMove: notifier.onPointerUpdate, - onPointerUp: notifier.onPointerUp, - onPointerHover: notifier.onPointerHover, - onPointerCancel: notifier.onPointerCancel, + onPointerDown: widget.notifier.onPointerDown, + onPointerMove: widget.notifier.onPointerUpdate, + onPointerUp: widget.notifier.onPointerUp, + onPointerHover: widget.notifier.onPointerHover, + onPointerCancel: widget.notifier.onPointerCancel, child: child, ), ), diff --git a/test/src/view/scribble_test.dart b/test/src/view/scribble_test.dart index 6120b99..35c9aa6 100644 --- a/test/src/view/scribble_test.dart +++ b/test/src/view/scribble_test.dart @@ -44,5 +44,30 @@ void main() { }, ); }); + + testWidgets( + 'does not throw when sharing a notifier across multiple instances', + (WidgetTester tester) async { + final notifier = ScribbleNotifier(); + addTearDown(notifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Expanded(child: Scribble(notifier: notifier)), + Expanded(child: Scribble(notifier: notifier)), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + ); }); }