DEV Community

Ayush Raina
Ayush Raina

Posted on

Optimizing High-Frequency Stylus Input and Real-Time Canvas Rendering in Flutter

  1. The Problem with Default Canvas Rendering
    When building interactive whiteboard applications in Flutter for large touch panels and tablets, standard widget rebuilds and naive CustomPainter implementations often introduce noticeable input latency (50–70ms+). When hundreds of stylus points are captured per second alongside real-time WebSocket synchronization, calling setState() on every pointer move triggers unnecessary widget tree rebuilds and frame drops.

  2. Decoupling Input Capture from Widget Tree Rebuilds
    Instead of calling setState() on every onPanUpdate or onPointerMove event, high-frequency stylus points should be buffered and passed directly to the canvas repaint listener using a ChangeNotifier:

import 'package:flutter/material.dart';

class StrokeController extends ChangeNotifier {
final List _points = [];
List get points => List.unmodifiable(_points);

void addPoint(Offset point) {
_points.add(point);
notifyListeners(); // Marks only RenderCustomPaint as dirty
}

void clear() {
_points.clear();
notifyListeners();
}
}

  1. Passing the Repaint Listenable to CustomPainter By passing the StrokeController directly to super(repaint: controller), Flutter bypasses the widget build and layout phases completely during active drawing and jumps straight to the paint phase:

class WhiteboardPainter extends CustomPainter {
final StrokeController controller;

WhiteboardPainter(this.controller) : super(repaint: controller);

@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black87
..strokeWidth = 3.5
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;

final points = controller.points;
for (int i = 0; i < points.length - 1; i++) {
  canvas.drawLine(points[i], points[i + 1], paint);
}
Enter fullscreen mode Exit fullscreen mode

}

@override
bool shouldRepaint(covariant WhiteboardPainter oldDelegate) => false;
}

  1. Capturing Raw Pointer Events Without Gesture Arena Delay Standard GestureDetector widgets wait briefly to disambiguate taps from drags. For zero-latency stylus input, wrapping the CustomPaint in a raw Listener widget captures hardware pointer events at the exact millisecond they touch the glass:

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

@override
State createState() => _WhiteboardCanvasState();
}

class _WhiteboardCanvasState extends State {
final StrokeController _controller = StrokeController();

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Listener(
onPointerMove: (PointerMoveEvent event) {
_controller.addPoint(event.localPosition);
},
child: CustomPaint(
painter: WhiteboardPainter(_controller),
size: Size.infinite,
),
);
}
}

  1. Batching WebSocket Synchronization Rather than awaiting network acknowledgment before rendering ink on the screen, render strokes locally immediately (optimistic rendering) and stream batched coordinate deltas over WebSockets every 10–16ms (matching a 60–120Hz refresh cycle). Combined with direct hardware-accelerated overlay rendering on Android interactive panels, this architecture reduced our production stylus latency by 88% (from 70.7ms down to 8.7ms) while keeping up to 300 connected participants in real-time sync.

Top comments (0)