Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Last active September 27, 2023 06:47
Show Gist options
  • Save rodydavis/e1ead4f4e314691ac8497b004ee679a5 to your computer and use it in GitHub Desktop.
Save rodydavis/e1ead4f4e314691ac8497b004ee679a5 to your computer and use it in GitHub Desktop.
Flutter Multi Touch Canvas Demo
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
visualDensity: VisualDensity.adaptivePlatformDensity,
),
darkTheme: ThemeData.dark().copyWith(
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _controller = CanvasController();
@override
void initState() {
_controller.init();
_dummyData();
super.initState();
}
void _dummyData() {
_controller.addObject(
CanvasObject(
dx: 20,
dy: 20,
width: 100,
height: 100,
child: Container(color: Colors.red),
),
);
_controller.addObject(
CanvasObject(
dx: 80,
dy: 60,
width: 100,
height: 200,
child: Container(color: Colors.green),
),
);
_controller.addObject(
CanvasObject(
dx: 100,
dy: 40,
width: 100,
height: 50,
child: Container(color: Colors.blue),
),
);
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<CanvasController>(
stream: _controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: CircularProgressIndicator()),
);
}
final instance = snapshot.data;
return Scaffold(
appBar: AppBar(
actions: [
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Selection',
icon: const Icon(Icons.select_all),
color: instance!.shiftPressed
? Theme.of(context).colorScheme.secondary
: null,
onPressed: _controller.shiftSelect,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Meta Key',
color: instance.metaPressed
? Theme.of(context).colorScheme.secondary
: null,
icon: const Icon(Icons.category),
onPressed: _controller.metaSelect,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Zoom In',
icon: const Icon(Icons.zoom_in),
onPressed: _controller.zoomIn,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Zoom Out',
icon: const Icon(Icons.zoom_out),
onPressed: _controller.zoomOut,
),
),
FocusScope(
canRequestFocus: false,
child: IconButton(
tooltip: 'Reset the Scale and Offset',
icon: const Icon(Icons.restore),
onPressed: _controller.reset,
),
),
],
),
body: Listener(
behavior: HitTestBehavior.opaque,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
GestureBinding.instance!.pointerSignalResolver
.register(details, (event) {
if (event is PointerScrollEvent) {
if (_controller.shiftPressed) {
double zoomDelta = (-event.scrollDelta.dy / 300);
_controller.scale = _controller.scale + zoomDelta;
} else {
_controller.offset =
_controller.offset - event.scrollDelta;
}
}
});
}
},
onPointerMove: (details) {
_controller.updateTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerDown: (details) {
_controller.addTouch(
details.pointer,
details.localPosition,
details.position,
);
},
onPointerUp: (details) {
_controller.removeTouch(details.pointer);
},
onPointerCancel: (details) {
_controller.removeTouch(details.pointer);
},
child: RawKeyboardListener(
autofocus: true,
focusNode: _controller.focusNode,
onKey: (key) => _controller.rawKeyEvent(context, key),
child: SizedBox.expand(
child: Stack(
children: [
for (var i = instance.objects.length - 1; i > -1; i--)
Positioned.fromRect(
rect: instance.objects[i].rect.adjusted(
_controller.offset,
_controller.scale,
),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: instance.isObjectSelected(i)
? Colors.grey
: Colors.transparent,
)),
child: GestureDetector(
onTapDown: (_) => _controller.selectObject(i),
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox.fromSize(
size: instance.objects[i].size,
child: instance.objects[i].child,
),
),
),
),
),
if (instance.marquee != null)
Positioned.fromRect(
rect: instance.marquee!.rect
.adjusted(instance.offset, instance.scale),
child: Container(
color: Colors.blueAccent.withOpacity(0.3),
),
),
],
),
),
),
),
);
});
}
}
extension RectUtils on Rect {
Rect adjusted(Offset offset, double scale) {
final left = (this.left + offset.dx) * scale;
final top = (this.top + offset.dy) * scale;
final width = this.width * scale;
final height = this.height * scale;
return Rect.fromLTWH(left, top, width, height);
}
}
/// Control the canvas and the objects on it
class CanvasController {
/// Controller for the stream output
final _controller = StreamController<CanvasController>();
/// Reference to the stream to update the UI
Stream<CanvasController> get stream => _controller.stream;
/// Emit a new event to rebuild the UI
void add([CanvasController? val]) => _controller.add(val ?? this);
/// Stop the stream and finish
void close() {
_controller.close();
focusNode.dispose();
}
/// Start the stream
void init() => add();
// -- Canvas Objects --
final List<CanvasObject<Widget>> _objects = [];
/// Current Objects on the canvas
List<CanvasObject<Widget>> get objects => _objects;
/// Add an object to the canvas
void addObject(CanvasObject<Widget> value) => _update(() {
_objects.add(value);
});
/// Add an object to the canvas
void updateObject(int i, CanvasObject<Widget> value) => _update(() {
_objects[i] = value;
});
/// Remove an object from the canvas
void removeObject(int i) => _update(() {
_objects.removeAt(i);
});
/// Focus node for listening for keyboard shortcuts
final focusNode = FocusNode();
/// Raw events from keys pressed
void rawKeyEvent(BuildContext context, RawKeyEvent key) {
// Scale keys
if (key.isKeyPressed(LogicalKeyboardKey.minus)) {
zoomOut();
}
if (key.isKeyPressed(LogicalKeyboardKey.equal)) {
zoomIn();
}
// Directional Keys
if (key.isKeyPressed(LogicalKeyboardKey.arrowLeft)) {
offset = offset + const Offset(offsetAdjust, 0.0);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowRight)) {
offset = offset + const Offset(-offsetAdjust, 0.0);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
offset = offset + const Offset(0.0, offsetAdjust);
}
if (key.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
offset = offset + const Offset(0.0, -offsetAdjust);
}
_shiftPressed = key.isShiftPressed;
_metaPressed = key.isMetaPressed;
/// Update Controller Instance
add(this);
}
/// Trigger Shift Press
void shiftSelect() {
_shiftPressed = true;
}
/// Trigger Meta Press
void metaSelect() {
_metaPressed = true;
}
final Map<int, Offset> _pointerMap = {};
/// Number of inputs currently on the screen
int get touchCount => _pointerMap.values.length;
/// Marquee selection on the canvas
RectPoints? get marquee => _marquee;
RectPoints? _marquee;
/// Dragging a canvas object
bool get isMovingCanvasObject => _isMovingCanvasObject;
bool _isMovingCanvasObject = false;
final List<int> _selectedObjects = [];
List<int> get selectedObjectsIndices => _selectedObjects;
List<CanvasObject<Widget>> get selectedObjects =>
_selectedObjects.map((i) => _objects[i]).toList();
bool isObjectSelected(int i) => _selectedObjects.contains(i);
/// Called every time a new input touches the screen
void addTouch(int pointer, Offset offsetVal, Offset globalVal) {
_pointerMap[pointer] = offsetVal;
if (shiftPressed) {
final pt = (offsetVal / scale) - (offset);
_marquee = RectPoints(pt, pt);
}
/// Update Controller Instance
add(this);
}
/// Called when any of the inputs update position
void updateTouch(int pointer, Offset offsetVal, Offset globalVal) {
if (_marquee != null) {
// Update New Widget Rect
final _pts = _marquee!;
final a = _pointerMap.values.first;
_pointerMap[pointer] = offsetVal;
final b = _pointerMap.values.first;
final delta = (b - a) / scale;
_pts.end = _pts.end + delta;
_marquee = _pts;
final _rect = Rect.fromPoints(_pts.start, _pts.end);
_selectedObjects.clear();
for (var i = 0; i < _objects.length; i++) {
if (_rect.overlaps(_objects[i].rect)) {
_selectedObjects.add(i);
}
}
} else if (touchCount == 1) {
// Widget Move
_isMovingCanvasObject = true;
final a = _pointerMap.values.first;
_pointerMap[pointer] = offsetVal;
final b = _pointerMap.values.first;
if (_selectedObjects.isEmpty) {
add(this);
return;
}
for (final idx in _selectedObjects) {
final widget = _objects[idx];
final delta = (b - a) / scale;
final _newOffset = widget.offset + delta;
_objects[idx] = widget.copyWith(dx: _newOffset.dx, dy: _newOffset.dy);
}
} else if (touchCount == 2) {
// Scale and Rotate Update
_isMovingCanvasObject = false;
final _rectA = _getRectFromPoints(_pointerMap.values.toList());
_pointerMap[pointer] = offsetVal;
final _rectB = _getRectFromPoints(_pointerMap.values.toList());
final _delta = _rectB.center - _rectA.center;
final _newOffset = offset + (_delta / scale);
offset = _newOffset;
final aDistance = (_rectA.topLeft - _rectA.bottomRight).distance;
final bDistance = (_rectB.topLeft - _rectB.bottomRight).distance;
final change = (bDistance / aDistance);
scale = scale * change;
} else {
// Pan Update
_isMovingCanvasObject = false;
final _rectA = _getRectFromPoints(_pointerMap.values.toList());
_pointerMap[pointer] = offsetVal;
final _rectB = _getRectFromPoints(_pointerMap.values.toList());
final _delta = _rectB.center - _rectA.center;
offset = offset + (_delta / scale);
}
_pointerMap[pointer] = offsetVal;
/// Update Controller Instance
add(this);
}
/// Called when a input is removed from the screen
void removeTouch(int pointer) {
_pointerMap.remove(pointer);
if (touchCount < 1) {
_isMovingCanvasObject = false;
}
if (_marquee != null) {
_marquee = null;
_shiftPressed = false;
}
/// Update Controller Instance
add(this);
}
void selectObject(int i) => _update(() {
if (!_metaPressed) {
_selectedObjects.clear();
}
_selectedObjects.add(0);
final item = _objects.removeAt(i);
_objects.insert(0, item);
});
/// Checks if the shift key on the keyboard is pressed
bool get shiftPressed => _shiftPressed;
bool _shiftPressed = false;
/// Checks if the meta key on the keyboard is pressed
bool get metaPressed => _metaPressed;
bool _metaPressed = false;
/// Scale of the canvas
double get scale => _scale;
double _scale = 1;
set scale(double value) => _update(() {
if (value <= minScale) {
value = minScale;
} else if (value >= maxScale) {
value = maxScale;
}
_scale = value;
});
/// Max possible scale
static const double maxScale = 3.0;
/// Min possible scale
static const double minScale = 0.2;
/// How much to scale the canvas in increments
static const double scaleAdjust = 0.05;
/// How much to shift the canvas in increments
static const double offsetAdjust = 15;
/// Current offset of the canvas
Offset get offset => _offset;
Offset _offset = Offset.zero;
set offset(Offset value) => _update(() {
_offset = value;
});
static const double _scaleDefault = 1;
static const Offset _offsetDefault = Offset.zero;
/// Reset the canvas zoom and offset
void reset() {
scale = _scaleDefault;
offset = _offsetDefault;
}
/// Zoom in the canvas
void zoomIn() {
scale += scaleAdjust;
}
/// Zoom out the canvas
void zoomOut() {
scale -= scaleAdjust;
}
void _update(void Function() action) {
action();
add(this);
}
Rect _getRectFromPoints(List<Offset> offsets) {
if (offsets.length == 2) {
return Rect.fromPoints(offsets.first, offsets.last);
}
final dxs = offsets.map((e) => e.dx).toList();
final dys = offsets.map((e) => e.dy).toList();
double left = _minFromList(dxs);
double top = _minFromList(dys);
double bottom = _maxFromList(dys);
double right = _maxFromList(dxs);
return Rect.fromLTRB(left, top, right, bottom);
}
double _minFromList(List<double> values) {
double value = double.infinity;
for (final item in values) {
value = math.min(item, value);
}
return value;
}
double _maxFromList(List<double> values) {
double value = -double.infinity;
for (final item in values) {
value = math.max(item, value);
}
return value;
}
}
class CanvasObject<T> {
final double dx;
final double dy;
final double width;
final double height;
final T child;
CanvasObject({
this.dx = 0,
this.dy = 0,
this.width = 100,
this.height = 100,
required this.child,
});
CanvasObject<T> copyWith({
double? dx,
double? dy,
double? width,
double? height,
T? child,
}) {
return CanvasObject<T>(
dx: dx ?? this.dx,
dy: dy ?? this.dy,
width: width ?? this.width,
height: height ?? this.height,
child: child ?? this.child,
);
}
Size get size => Size(width, height);
Offset get offset => Offset(dx, dy);
Rect get rect => offset & size;
}
class RectPoints {
RectPoints(this.start, this.end);
Offset start, end;
Rect get rect => Rect.fromPoints(start, end);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment