|
| 1 | +import 'dart:convert'; |
| 2 | +import 'dart:ui' as ui; |
| 3 | + |
| 4 | +import 'package:collection/collection.dart'; |
| 5 | +import 'package:flutter/material.dart'; |
| 6 | +import 'package:flutter_redux/flutter_redux.dart'; |
| 7 | + |
| 8 | +import '../flet_app_services.dart'; |
| 9 | +import '../models/app_state.dart'; |
| 10 | +import '../models/canvas_view_model.dart'; |
| 11 | +import '../models/control.dart'; |
| 12 | +import '../models/control_tree_view_model.dart'; |
| 13 | +import '../utils/alignment.dart'; |
| 14 | +import '../utils/borders.dart'; |
| 15 | +import '../utils/colors.dart'; |
| 16 | +import '../utils/dash_path.dart'; |
| 17 | +import '../utils/drawing.dart'; |
| 18 | +import '../utils/numbers.dart'; |
| 19 | +import '../utils/text.dart'; |
| 20 | +import '../utils/transforms.dart'; |
| 21 | +import 'create_control.dart'; |
| 22 | + |
| 23 | +typedef CanvasControlOnPaintCallback = void Function(Size size); |
| 24 | + |
| 25 | +class CanvasControl extends StatefulWidget { |
| 26 | + final Control? parent; |
| 27 | + final Control control; |
| 28 | + final List<Control> children; |
| 29 | + final bool parentDisabled; |
| 30 | + |
| 31 | + const CanvasControl( |
| 32 | + {Key? key, |
| 33 | + this.parent, |
| 34 | + required this.control, |
| 35 | + required this.children, |
| 36 | + required this.parentDisabled}) |
| 37 | + : super(key: key); |
| 38 | + |
| 39 | + @override |
| 40 | + State<CanvasControl> createState() => _CanvasControlState(); |
| 41 | +} |
| 42 | + |
| 43 | +class _CanvasControlState extends State<CanvasControl> { |
| 44 | + int _lastResize = DateTime.now().millisecondsSinceEpoch; |
| 45 | + Size? _lastSize; |
| 46 | + |
| 47 | + @override |
| 48 | + Widget build(BuildContext context) { |
| 49 | + debugPrint("CustomPaint build: ${widget.control.id}"); |
| 50 | + |
| 51 | + var result = StoreConnector<AppState, CanvasViewModel>( |
| 52 | + distinct: true, |
| 53 | + converter: (store) => |
| 54 | + CanvasViewModel.fromStore(store, widget.control, widget.children), |
| 55 | + builder: (context, viewModel) { |
| 56 | + var onResize = viewModel.control.attrBool("onResize", false)!; |
| 57 | + var resizeInterval = viewModel.control.attrInt("resizeInterval", 10)!; |
| 58 | + |
| 59 | + var paint = CustomPaint( |
| 60 | + painter: FletCustomPainter( |
| 61 | + theme: Theme.of(context), |
| 62 | + shapes: viewModel.shapes, |
| 63 | + onPaintCallback: (size) { |
| 64 | + if (onResize) { |
| 65 | + var now = DateTime.now().millisecondsSinceEpoch; |
| 66 | + if ((now - _lastResize > resizeInterval && |
| 67 | + _lastSize != size) || |
| 68 | + _lastSize == null) { |
| 69 | + _lastResize = now; |
| 70 | + _lastSize = size; |
| 71 | + FletAppServices.of(context).server.sendPageEvent( |
| 72 | + eventTarget: viewModel.control.id, |
| 73 | + eventName: "resize", |
| 74 | + eventData: |
| 75 | + json.encode({"w": size.width, "h": size.height})); |
| 76 | + } |
| 77 | + } |
| 78 | + }, |
| 79 | + ), |
| 80 | + child: viewModel.child != null |
| 81 | + ? createControl(viewModel.control, viewModel.child!.id, |
| 82 | + viewModel.control.isDisabled) |
| 83 | + : null, |
| 84 | + ); |
| 85 | + |
| 86 | + return paint; |
| 87 | + }); |
| 88 | + |
| 89 | + return constrainedControl(context, result, widget.parent, widget.control); |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +class FletCustomPainter extends CustomPainter { |
| 94 | + final ThemeData theme; |
| 95 | + final List<ControlTreeViewModel> shapes; |
| 96 | + final CanvasControlOnPaintCallback onPaintCallback; |
| 97 | + |
| 98 | + const FletCustomPainter( |
| 99 | + {required this.theme, |
| 100 | + required this.shapes, |
| 101 | + required this.onPaintCallback}); |
| 102 | + |
| 103 | + @override |
| 104 | + void paint(Canvas canvas, Size size) { |
| 105 | + onPaintCallback(size); |
| 106 | + |
| 107 | + //debugPrint("SHAPE CONTROLS: $shapes"); |
| 108 | + |
| 109 | + for (var shape in shapes) { |
| 110 | + if (shape.control.type == "line") { |
| 111 | + drawLine(canvas, shape); |
| 112 | + } else if (shape.control.type == "circle") { |
| 113 | + drawCircle(canvas, shape); |
| 114 | + } else if (shape.control.type == "arc") { |
| 115 | + drawArc(canvas, shape); |
| 116 | + } else if (shape.control.type == "color") { |
| 117 | + drawColor(canvas, shape); |
| 118 | + } else if (shape.control.type == "oval") { |
| 119 | + drawOval(canvas, shape); |
| 120 | + } else if (shape.control.type == "fill") { |
| 121 | + drawFill(canvas, shape); |
| 122 | + } else if (shape.control.type == "points") { |
| 123 | + drawPoints(canvas, shape); |
| 124 | + } else if (shape.control.type == "rect") { |
| 125 | + drawRect(canvas, shape); |
| 126 | + } else if (shape.control.type == "path") { |
| 127 | + drawPath(canvas, shape); |
| 128 | + } else if (shape.control.type == "shadow") { |
| 129 | + drawShadow(canvas, shape); |
| 130 | + } else if (shape.control.type == "text") { |
| 131 | + drawText(canvas, shape); |
| 132 | + } |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + @override |
| 137 | + bool shouldRepaint(FletCustomPainter oldDelegate) { |
| 138 | + return true; |
| 139 | + } |
| 140 | + |
| 141 | + void drawLine(Canvas canvas, ControlTreeViewModel shape) { |
| 142 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 143 | + var dashPattern = parsePaintStrokeDashPattern(shape.control, "paint"); |
| 144 | + paint.style = ui.PaintingStyle.stroke; |
| 145 | + var path = ui.Path(); |
| 146 | + path.moveTo( |
| 147 | + shape.control.attrDouble("x1")!, shape.control.attrDouble("y1")!); |
| 148 | + path.lineTo( |
| 149 | + shape.control.attrDouble("x2")!, shape.control.attrDouble("y2")!); |
| 150 | + |
| 151 | + if (dashPattern != null) { |
| 152 | + path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); |
| 153 | + } |
| 154 | + canvas.drawPath(path, paint); |
| 155 | + } |
| 156 | + |
| 157 | + void drawCircle(Canvas canvas, ControlTreeViewModel shape) { |
| 158 | + var radius = shape.control.attrDouble("radius", 0)!; |
| 159 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 160 | + canvas.drawCircle( |
| 161 | + Offset(shape.control.attrDouble("x")!, shape.control.attrDouble("y")!), |
| 162 | + radius, |
| 163 | + paint); |
| 164 | + } |
| 165 | + |
| 166 | + void drawOval(Canvas canvas, ControlTreeViewModel shape) { |
| 167 | + var width = shape.control.attrDouble("width", 0)!; |
| 168 | + var height = shape.control.attrDouble("height", 0)!; |
| 169 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 170 | + canvas.drawOval( |
| 171 | + Rect.fromLTWH(shape.control.attrDouble("x")!, |
| 172 | + shape.control.attrDouble("y")!, width, height), |
| 173 | + paint); |
| 174 | + } |
| 175 | + |
| 176 | + void drawArc(Canvas canvas, ControlTreeViewModel shape) { |
| 177 | + var width = shape.control.attrDouble("width", 0)!; |
| 178 | + var height = shape.control.attrDouble("height", 0)!; |
| 179 | + var startAngle = shape.control.attrDouble("startAngle", 0)!; |
| 180 | + var sweepAngle = shape.control.attrDouble("sweepAngle", 0)!; |
| 181 | + var useCenter = shape.control.attrBool("useCenter", false)!; |
| 182 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 183 | + canvas.drawArc( |
| 184 | + Rect.fromLTWH(shape.control.attrDouble("x")!, |
| 185 | + shape.control.attrDouble("y")!, width, height), |
| 186 | + startAngle, |
| 187 | + sweepAngle, |
| 188 | + useCenter, |
| 189 | + paint); |
| 190 | + } |
| 191 | + |
| 192 | + void drawFill(Canvas canvas, ControlTreeViewModel shape) { |
| 193 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 194 | + canvas.drawPaint(paint); |
| 195 | + } |
| 196 | + |
| 197 | + void drawColor(Canvas canvas, ControlTreeViewModel shape) { |
| 198 | + var color = |
| 199 | + HexColor.fromString(theme, shape.control.attrString("color", "")!) ?? |
| 200 | + Colors.black; |
| 201 | + var blendMode = BlendMode.values.firstWhere( |
| 202 | + (e) => |
| 203 | + e.name.toLowerCase() == |
| 204 | + shape.control.attrString("blendMode", "")!.toLowerCase(), |
| 205 | + orElse: () => BlendMode.srcOver); |
| 206 | + canvas.drawColor(color, blendMode); |
| 207 | + } |
| 208 | + |
| 209 | + void drawPoints(Canvas canvas, ControlTreeViewModel shape) { |
| 210 | + var points = parseOffsetList(shape.control, "points")!; |
| 211 | + var pointMode = ui.PointMode.values.firstWhere( |
| 212 | + (e) => |
| 213 | + e.name.toLowerCase() == |
| 214 | + shape.control.attrString("pointMode", "")!.toLowerCase(), |
| 215 | + orElse: () => ui.PointMode.points); |
| 216 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 217 | + canvas.drawPoints(pointMode, points, paint); |
| 218 | + } |
| 219 | + |
| 220 | + void drawRect(Canvas canvas, ControlTreeViewModel shape) { |
| 221 | + var width = shape.control.attrDouble("width", 0)!; |
| 222 | + var height = shape.control.attrDouble("height", 0)!; |
| 223 | + var borderRadius = parseBorderRadius(shape.control, "borderRadius"); |
| 224 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 225 | + canvas.drawRRect( |
| 226 | + RRect.fromRectAndCorners( |
| 227 | + Rect.fromLTWH(shape.control.attrDouble("x")!, |
| 228 | + shape.control.attrDouble("y")!, width, height), |
| 229 | + topLeft: borderRadius?.topLeft ?? Radius.zero, |
| 230 | + topRight: borderRadius?.topRight ?? Radius.zero, |
| 231 | + bottomLeft: borderRadius?.bottomLeft ?? Radius.zero, |
| 232 | + bottomRight: borderRadius?.bottomRight ?? Radius.zero), |
| 233 | + paint); |
| 234 | + } |
| 235 | + |
| 236 | + void drawText(Canvas canvas, ControlTreeViewModel shape) { |
| 237 | + var offset = |
| 238 | + Offset(shape.control.attrDouble("x")!, shape.control.attrDouble("y")!); |
| 239 | + var alignment = |
| 240 | + parseAlignment(shape.control, "alignment") ?? Alignment.topLeft; |
| 241 | + var text = shape.control.attrString("text", "")!; |
| 242 | + TextStyle style = parseTextStyle(theme, shape.control, "style") ?? |
| 243 | + theme.textTheme.bodyMedium!; |
| 244 | + |
| 245 | + if (style.color == null) { |
| 246 | + style = style.copyWith(color: theme.textTheme.bodyMedium!.color); |
| 247 | + } |
| 248 | + |
| 249 | + TextAlign? textAlign = TextAlign.values.firstWhereOrNull((a) => |
| 250 | + a.name.toLowerCase() == |
| 251 | + shape.control.attrString("textAlign", "")!.toLowerCase()); |
| 252 | + TextSpan span = TextSpan( |
| 253 | + text: text, |
| 254 | + style: style, |
| 255 | + children: parseTextSpans(theme, shape, false, null)); |
| 256 | + |
| 257 | + var maxLines = shape.control.attrInt("maxLines"); |
| 258 | + var maxWidth = shape.control.attrDouble("maxWidth"); |
| 259 | + var ellipsis = shape.control.attrString("ellipsis"); |
| 260 | + |
| 261 | + // paint |
| 262 | + TextPainter textPainter = TextPainter( |
| 263 | + text: span, |
| 264 | + textAlign: textAlign ?? TextAlign.start, |
| 265 | + maxLines: maxLines, |
| 266 | + ellipsis: ellipsis); |
| 267 | + textPainter.layout(maxWidth: maxWidth ?? double.infinity); |
| 268 | + |
| 269 | + var angle = shape.control.attrDouble("rotate", 0)!; |
| 270 | + |
| 271 | + final delta = Offset( |
| 272 | + offset.dx - textPainter.size.width / 2 * (alignment.x + 1.0), |
| 273 | + offset.dy - textPainter.size.height / 2 * (alignment.y + 1.0)); |
| 274 | + |
| 275 | + // rotate the text around offset point |
| 276 | + canvas.save(); |
| 277 | + canvas.translate(offset.dx, offset.dy); |
| 278 | + canvas.rotate(angle); |
| 279 | + canvas.translate(-offset.dx, -offset.dy); |
| 280 | + textPainter.paint(canvas, delta); |
| 281 | + canvas.restore(); |
| 282 | + } |
| 283 | + |
| 284 | + void drawPath(Canvas canvas, ControlTreeViewModel shape) { |
| 285 | + var path = |
| 286 | + buildPath(json.decode(shape.control.attrString("elements", "[]")!)); |
| 287 | + Paint paint = parsePaint(theme, shape.control, "paint"); |
| 288 | + var dashPattern = parsePaintStrokeDashPattern(shape.control, "paint"); |
| 289 | + if (dashPattern != null) { |
| 290 | + path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); |
| 291 | + } |
| 292 | + canvas.drawPath(path, paint); |
| 293 | + } |
| 294 | + |
| 295 | + void drawShadow(Canvas canvas, ControlTreeViewModel shape) { |
| 296 | + var path = buildPath(json.decode(shape.control.attrString("path", "[]")!)); |
| 297 | + var color = |
| 298 | + HexColor.fromString(theme, shape.control.attrString("color", "")!) ?? |
| 299 | + Colors.black; |
| 300 | + var elevation = shape.control.attrDouble("elevation", 0)!; |
| 301 | + var transparentOccluder = |
| 302 | + shape.control.attrBool("transparentOccluder", false)!; |
| 303 | + canvas.drawShadow(path, color, elevation, transparentOccluder); |
| 304 | + } |
| 305 | + |
| 306 | + ui.Path buildPath(dynamic j) { |
| 307 | + var path = ui.Path(); |
| 308 | + if (j == null) { |
| 309 | + return path; |
| 310 | + } |
| 311 | + for (var elem in (j as List)) { |
| 312 | + var type = elem["type"]; |
| 313 | + if (type == "moveto") { |
| 314 | + path.moveTo(parseDouble(elem["x"]), parseDouble(elem["y"])); |
| 315 | + } else if (type == "lineto") { |
| 316 | + path.lineTo(parseDouble(elem["x"]), parseDouble(elem["y"])); |
| 317 | + } else if (type == "arc") { |
| 318 | + path.addArc( |
| 319 | + Rect.fromLTWH(parseDouble(elem["x"]), parseDouble(elem["y"]), |
| 320 | + parseDouble(elem["width"]), parseDouble(elem["height"])), |
| 321 | + parseDouble(elem["start_angle"]), |
| 322 | + parseDouble(elem["sweep_angle"])); |
| 323 | + } else if (type == "arcto") { |
| 324 | + path.arcToPoint(Offset(parseDouble(elem["x"]), parseDouble(elem["y"])), |
| 325 | + radius: Radius.circular(parseDouble(elem["radius"])), |
| 326 | + rotation: parseDouble(elem["rotation"]), |
| 327 | + largeArc: parseBool(elem["large_arc"]), |
| 328 | + clockwise: parseBool(elem["clockwise"])); |
| 329 | + } else if (type == "oval") { |
| 330 | + path.addOval(Rect.fromLTWH( |
| 331 | + parseDouble(elem["x"]), |
| 332 | + parseDouble(elem["y"]), |
| 333 | + parseDouble(elem["width"]), |
| 334 | + parseDouble(elem["height"]))); |
| 335 | + } else if (type == "rect") { |
| 336 | + var borderRadius = elem["border_radius"] != null |
| 337 | + ? borderRadiusFromJSON(elem["border_radius"]) |
| 338 | + : null; |
| 339 | + path.addRRect(RRect.fromRectAndCorners( |
| 340 | + Rect.fromLTWH(parseDouble(elem["x"]), parseDouble(elem["y"]), |
| 341 | + parseDouble(elem["width"]), parseDouble(elem["height"])), |
| 342 | + topLeft: borderRadius?.topLeft ?? Radius.zero, |
| 343 | + topRight: borderRadius?.topRight ?? Radius.zero, |
| 344 | + bottomLeft: borderRadius?.bottomLeft ?? Radius.zero, |
| 345 | + bottomRight: borderRadius?.bottomRight ?? Radius.zero)); |
| 346 | + } else if (type == "conicto") { |
| 347 | + path.conicTo( |
| 348 | + parseDouble(elem["cp1x"]), |
| 349 | + parseDouble(elem["cp1y"]), |
| 350 | + parseDouble(elem["x"]), |
| 351 | + parseDouble(elem["y"]), |
| 352 | + parseDouble(elem["w"])); |
| 353 | + } else if (type == "cubicto") { |
| 354 | + path.cubicTo( |
| 355 | + parseDouble(elem["cp1x"]), |
| 356 | + parseDouble(elem["cp1y"]), |
| 357 | + parseDouble(elem["cp2x"]), |
| 358 | + parseDouble(elem["cp2y"]), |
| 359 | + parseDouble(elem["x"]), |
| 360 | + parseDouble(elem["y"])); |
| 361 | + } else if (type == "subpath") { |
| 362 | + path.addPath(buildPath(elem["elements"]), |
| 363 | + Offset(parseDouble(elem["x"]), parseDouble(elem["y"]))); |
| 364 | + } else if (type == "close") { |
| 365 | + path.close(); |
| 366 | + } |
| 367 | + } |
| 368 | + return path; |
| 369 | + } |
| 370 | +} |
0 commit comments