Custom drawing implementation

This commit is contained in:
2026-02-05 17:47:03 +01:00
parent e1d72de718
commit d4d6e41a9d
14 changed files with 1291 additions and 147 deletions

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'drawing_canvas.dart';
import 'drawing_controller.dart';
/// A drawing board that overlays a child widget with a drawing canvas.
///
/// Supports:
/// - Drawing with normalized coordinates that scale correctly
/// - Zooming and panning via InteractiveViewer
/// - Toggle between view-only and drawing modes
class DrawingBoard extends StatefulWidget {
/// The widget to display behind the drawing (e.g., PDF page)
final Widget child;
/// Size of the drawing area (should match child size)
final Size boardSize;
/// Controller for managing drawing state
final DrawingController controller;
/// Whether drawing is enabled (false = view only)
final bool drawingEnabled;
/// Minimum zoom scale
final double minScale;
/// Maximum zoom scale
final double maxScale;
/// Alignment of the board within available space
final Alignment alignment;
const DrawingBoard({
super.key,
required this.child,
required this.boardSize,
required this.controller,
this.drawingEnabled = true,
this.minScale = 1.0,
this.maxScale = 3.0,
this.alignment = Alignment.topCenter,
});
@override
State<DrawingBoard> createState() => _DrawingBoardState();
}
class _DrawingBoardState extends State<DrawingBoard> {
final TransformationController _transformationController =
TransformationController();
/// Tracks whether we're currently in a drawing gesture
bool _isDrawing = false;
/// Tracks the number of active pointers for gesture disambiguation
int _pointerCount = 0;
@override
void dispose() {
_transformationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The content that will be transformed (zoomed/panned)
final content = SizedBox(
width: widget.boardSize.width,
height: widget.boardSize.height,
child: Stack(
children: [
// Background child (e.g., PDF)
Positioned.fill(child: widget.child),
// Drawing overlay
Positioned.fill(
child: widget.drawingEnabled
? _buildDrawingLayer()
: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
),
],
),
);
if (!widget.drawingEnabled) {
// View-only mode: just show the content (no zoom/pan here,
// let parent handle navigation)
return Align(
alignment: widget.alignment,
child: content,
);
}
// Drawing mode: wrap with InteractiveViewer for zoom/pan
return Align(
alignment: widget.alignment,
child: InteractiveViewer(
transformationController: _transformationController,
minScale: widget.minScale,
maxScale: widget.maxScale,
boundaryMargin: EdgeInsets.zero,
constrained: true,
panEnabled: !_isDrawing,
scaleEnabled: !_isDrawing,
child: content,
),
);
}
/// Builds the drawing layer with gesture handling.
///
/// Uses Listener for drawing and distinguishes between:
/// - Single finger: Draw
/// - Two+ fingers: Pan/Zoom (handled by InteractiveViewer)
Widget _buildDrawingLayer() {
return Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
behavior: HitTestBehavior.translucent,
child: DrawingOverlay(
controller: widget.controller,
canvasSize: widget.boardSize,
),
);
}
void _onPointerDown(PointerDownEvent event) {
_pointerCount++;
// Only start drawing with single finger touch
if (_pointerCount == 1) {
_isDrawing = true;
final normalized = _toNormalized(event.localPosition);
widget.controller.startLine(normalized);
setState(() {}); // Update to disable pan/scale
} else {
// Multiple fingers: cancel drawing, enable pan/zoom
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
}
void _onPointerMove(PointerMoveEvent event) {
if (_isDrawing && _pointerCount == 1) {
final normalized = _toNormalized(event.localPosition);
widget.controller.addPoint(normalized);
}
}
void _onPointerUp(PointerUpEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {}); // Re-enable pan/scale
}
}
void _onPointerCancel(PointerCancelEvent event) {
_pointerCount = (_pointerCount - 1).clamp(0, 10);
if (_isDrawing) {
widget.controller.endLine();
_isDrawing = false;
setState(() {});
}
}
/// Converts local position to normalized coordinates (0-1).
Offset _toNormalized(Offset localPosition) {
return Offset(
(localPosition.dx / widget.boardSize.width).clamp(0.0, 1.0),
(localPosition.dy / widget.boardSize.height).clamp(0.0, 1.0),
);
}
}