/* BSD 3-Clause License Copyright (c) 2023, Birju Vachhani */ import 'dart:ui'; import 'package:box_transform/box_transform.dart'; import 'package:flutter/material.dart'; import 'package:flutter_box_transform/flutter_box_transform.dart'; /// A widget that allows you to resize and drag a box around a widget. class TransformableBox extends StatefulWidget { /// If you need more control over the [TransformableBox] you can pass a /// custom [TransformableBoxController] instance through the [controller] /// parameter. /// /// If you do not specify one, a default [TransformableBoxController] instance /// will be created internally, along with its lifecycle. final TransformableBoxController? controller; /// A builder function that is used to build the content of the /// [TransformableBox]. This is the physical widget you wish to show resizable /// handles on. It's most commonly something like an image widget, but it /// could be anything you want to have resizable & draggable box handles on. final TransformableChildBuilder contentBuilder; /// A builder function that is used to build the corners handles of the /// [TransformableBox]. If you don't specify it, the default handles will be /// used. /// /// Note that this will build for all four corners of the rectangle. final HandleBuilder cornerHandleBuilder; /// A builder function that is used to build the side handles of the /// [TransformableBox]. If you don't specify it, the default handles will be /// used. /// /// Note that this will build for all four sides of the rectangle. final HandleBuilder sideHandleBuilder; /// The size of the gesture response area of the handles. If you don't /// specify it, the default value will be used. /// /// This is similar to Flutter's [MaterialTapTargetSize] property, in which /// the actual handle size is smaller than the gesture response area. This is /// done to improve accessibility and usability of the handles; users will not /// need cursor precision over the handle's pixels to be able to perform /// operations with them, they need only to be able to reach the handle's /// gesture response area to make it forgiving. /// /// The default value is 24 pixels in diameter. final double handleTapSize; /// A set containing handles that are enabled. This is different from /// [visibleHandles]. /// /// [enabledHandles] determines which handles are /// interactive and can be used to resize the box. [visibleHandles] /// determines which handles are visible. If a handle is visible but not /// enabled, it will not be interactive. If a handle is enabled but not /// visible, it will not be shown and will not be interactive. final Set enabledHandles; /// A set containing which handles to show. This is different from /// [enabledHandles]. /// /// [enabledHandles] determines which handles are /// interactive and can be used to resize the box. [visibleHandles] /// determines which handles are visible. If a handle is visible but not /// enabled, it will not be interactive. If a handle is enabled but not /// visible, it will not be shown and will not be interactive. final Set visibleHandles; /// The initial box that will be used to position set the initial size of /// the [TransformableBox] widget. /// /// This initial box will be mutated by the [TransformableBoxController] through /// different dragging, panning, and resizing operations. /// /// [Rect] is immutable, so a new [Rect] instance will be created every time /// the [TransformableBoxController] mutates the box. You can acquire your /// updated box through the [onChanged] callback or through an externally /// provided [TransformableBoxController] instance. final Rect rect; /// The initial flip that will be used to set the initial flip of the /// [TransformableBox] widget. Normally, flipping is done by the user through /// the handles, but you can set the initial flip through this parameter in /// case the initial state of the box is in a flipped state. /// /// This utility cannot predicate if a box is flipped or not, so you will /// need to provide the correct initial flip state. /// /// Note that the flip is optional, if you're resizing an image, for example, /// you might want to allow flipping of the image when the user drags the /// handles to opposite corners of the box. This flip behavior is entirely /// optional and will allow handling such cases. /// /// You can leave it at the default [Flip.none] if flipping is not desired. /// Note that this will not prevent the drag handles from crossing to /// opposite corners of the box, it will only give oyu a lack of information /// on the state of the box if flipping were to occur. final Flip flip; /// A box that will contain the [rect] inside of itself, forcing [rect] to /// be clamped inside of this [clampingRect]. final Rect clampingRect; /// A set of constraints that will be applied to the [rect] when it is /// resized by the [TransformableBoxController]. final BoxConstraints constraints; /// Whether the box is resizable or not. Setting this to false will disable /// all resizing operations. This is a convenience parameter that will ignore /// the [enabledHandles] parameter and set all handles to disabled. final bool resizable; /// Whether the box is movable or not. Setting this to false will disable /// all moving operations. final bool draggable; /// Whether to allow flipping of the box while resizing. If this is set to /// true, the box will flip when the user drags the handles to opposite /// corners of the rect. final bool allowFlippingWhileResizing; /// Decides whether to flip the contents of the box when the box is flipped. /// If this is set to true, the contents will be flipped when the box is /// flipped. final bool allowContentFlipping; /// How to align the handles. final HandleAlignment handleAlignment; /// The callback function that is used to resolve the [ResizeMode] based on /// the pressed keys on the keyboard. final ValueGetter resizeModeResolver; /// A callback that is called every time the [TransformableBox] is updated. /// This is called every time the [TransformableBoxController] mutates the box /// or the flip. final RectChangeEvent? onChanged; /// A callback that is called when [TransformableBox] triggers a pointer down /// event to begin a drag operation. final RectDragStartEvent? onDragStart; /// A callback that is called every time the [TransformableBox] is moved. /// This is called every time the [TransformableBoxController] mutates the /// box through a drag operation. /// /// This is different from [onChanged] in that it is only called when the /// box is moved, not when the box is resized. final RectDragUpdateEvent? onDragUpdate; /// A callback that is called every time the [TransformableBox] is completes /// its drag operation via the pan end event. final RectDragEndEvent? onDragEnd; /// A callback that is called every time the [TransformableBox] cancels /// its drag operation via the pan cancel event. final RectDragCancelEvent? onDragCancel; /// A callback function that triggers when the box is about to start resizing. final RectResizeStart? onResizeStart; /// A callback that is called every time the [TransformableBox] is resized. /// This is called every time the [TransformableBoxController] mutates the /// box. /// /// This is different from [onChanged] in that it is only called when the box /// is resized, not when the box is moved. final RectResizeUpdateEvent? onResizeUpdate; /// A callback function that triggers when the box is about to end resizing. final RectResizeEnd? onResizeEnd; /// A callback function that triggers when the box cancels resizing. final RectResizeCancel? onResizeCancel; /// A callback function that triggers when the box reaches its minimum width /// when resizing. final TerminalEdgeEvent? onMinWidthReached; /// A callback function that triggers when the box reaches its maximum width /// when resizing. final TerminalEdgeEvent? onMaxWidthReached; /// A callback function that triggers when the box reaches its minimum height /// when resizing. final TerminalEdgeEvent? onMinHeightReached; /// A callback function that triggers when the box reaches its maximum height /// when resizing. final TerminalEdgeEvent? onMaxHeightReached; /// A callback function that triggers when the box reaches a terminal width /// when resizing. A terminal width is a width that is either the minimum or /// maximum width of the box. /// /// This function combines both [onMinWidthReached] and [onMaxWidthReached] /// into one callback function. final TerminalAxisEvent? onTerminalWidthReached; /// A callback function that triggers when the box reaches a terminal height /// when resizing. A terminal height is a height that is either the minimum or /// maximum height of the box. /// /// This function combines both [onMinHeightReached] and [onMaxHeightReached] /// into one callback function. final TerminalAxisEvent? onTerminalHeightReached; /// A callback function that triggers when the box reaches a terminal size /// when resizing. A terminal size is a size that is either the minimum or /// maximum size of the box on either axis. /// /// This function combines both [onTerminalWidthReached] and /// [onTerminalHeightReached] into one callback function. final TerminalEvent? onTerminalSizeReached; /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; final double handleTapLeftSize; /// Creates a [TransformableBox] widget. const TransformableBox({ super.key, required this.contentBuilder, this.controller, this.cornerHandleBuilder = _defaultCornerHandleBuilder, this.sideHandleBuilder = _defaultSideHandleBuilder, this.handleTapSize = 24, this.handleTapLeftSize = 24, this.allowContentFlipping = true, this.handleAlignment = HandleAlignment.center, this.enabledHandles = const {...HandlePosition.values}, this.visibleHandles = const {...HandlePosition.values}, // Raw values. Rect? rect, Flip? flip, Rect? clampingRect, BoxConstraints? constraints, ValueGetter? resizeModeResolver, // Additional controls. this.resizable = true, this.draggable = true, this.allowFlippingWhileResizing = true, // Either resize or drag triggers. this.onChanged, // Resize events this.onResizeStart, this.onResizeUpdate, this.onResizeEnd, this.onResizeCancel, // Drag Events. this.onDragStart, this.onDragUpdate, this.onDragEnd, this.onDragCancel, // Terminal update events. this.onMinWidthReached, this.onMaxWidthReached, this.onMinHeightReached, this.onMaxHeightReached, this.onTerminalWidthReached, this.onTerminalHeightReached, this.onTerminalSizeReached, this.debugPaintHandleBounds = false, }) : assert( (controller == null) || ((rect == null) && (flip == null) && (clampingRect == null) && (constraints == null) && (resizeModeResolver == null)), 'If a controller is provided, the raw values should not be provided.', ), rect = rect ?? Rect.zero, flip = flip ?? Flip.none, clampingRect = clampingRect ?? Rect.largest, constraints = constraints ?? const BoxConstraints.expand(), resizeModeResolver = resizeModeResolver ?? defaultResizeModeResolver; /// Returns the [TransformableBox] of the closest ancestor. static TransformableBox? widgetOf(BuildContext context) { return context.findAncestorWidgetOfExactType(); } /// Returns the [TransformableBoxController] of the closest ancestor. static TransformableBoxController? controllerOf(BuildContext context) { return context .findAncestorStateOfType<_TransformableBoxState>() ?.controller; } @override State createState() => _TransformableBoxState(); } class _TransformableBoxState extends State { late TransformableBoxController controller; bool isLegalGesture = false; @override void initState() { super.initState(); if (widget.controller != null) { controller = widget.controller!; // We only want to listen to the controller if it is provided externally. controller.addListener(onControllerUpdate); } else { // If it is provided internally, we should not listen to it. controller = TransformableBoxController( rect: widget.rect, flip: widget.flip, clampingRect: widget.clampingRect, constraints: widget.constraints, resizeModeResolver: widget.resizeModeResolver, allowFlippingWhileResizing: widget.allowFlippingWhileResizing, ); } } @override void didUpdateWidget(covariant TransformableBox oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != null && oldWidget.controller == null || widget.controller != oldWidget.controller) { // New explicit controller provided or explicit controller changed. controller.removeListener(onControllerUpdate); controller = widget.controller!; controller.addListener(onControllerUpdate); } else if (oldWidget.controller != null && widget.controller == null) { // Explicit controller removed. controller.removeListener(onControllerUpdate); controller = TransformableBoxController( rect: widget.rect, flip: widget.flip, clampingRect: widget.clampingRect, constraints: widget.constraints, resizeModeResolver: widget.resizeModeResolver, allowFlippingWhileResizing: widget.allowFlippingWhileResizing, ); } // Return if the controller is external. if (widget.controller != null) return; // Below code should only be executed if the controller is internal. bool shouldRecalculatePosition = false; bool shouldRecalculateSize = false; if (oldWidget.rect != widget.rect) { controller.setRect(widget.rect, notify: false); } if (oldWidget.flip != widget.flip) { controller.setFlip(widget.flip, notify: false); } if (oldWidget.resizeModeResolver != widget.resizeModeResolver) { controller.setResizeModeResolver( widget.resizeModeResolver, notify: false, ); } if (oldWidget.clampingRect != widget.clampingRect) { controller.setClampingRect(widget.clampingRect, notify: false); shouldRecalculatePosition = true; } if (oldWidget.constraints != widget.constraints) { controller.setConstraints(widget.constraints, notify: false); shouldRecalculateSize = true; } if (oldWidget.allowFlippingWhileResizing != widget.allowFlippingWhileResizing) { controller.setAllowFlippingWhileResizing( widget.allowFlippingWhileResizing, notify: false, ); } if (shouldRecalculatePosition) { controller.recalculatePosition(notify: false); } if (shouldRecalculateSize) { controller.recalculateSize(notify: false); } } @override void dispose() { controller.removeListener(onControllerUpdate); if (widget.controller == null) controller.dispose(); super.dispose(); } /// Called when the controller is updated. void onControllerUpdate() { if (widget.rect != controller.rect || widget.flip != controller.flip) { if (mounted) setState(() {}); } } /// Called when the handle drag starts. void onHandlePanStart(DragStartDetails event, HandlePosition handle) { // Two fingers were used to start the drag. This produces issues with // the box drag event. Therefore, we ignore it. if (event.kind == PointerDeviceKind.trackpad) { isLegalGesture = false; return; } else { isLegalGesture = true; } controller.onResizeStart(event.localPosition); widget.onResizeStart?.call(handle, event); } /// Called when the handle drag updates. void onHandlePanUpdate(DragUpdateDetails event, HandlePosition handle) { if (!isLegalGesture) return; final UIResizeResult result = controller.onResizeUpdate( event.localPosition, handle, ); widget.onChanged?.call(result, event); widget.onResizeUpdate?.call(result, event); widget.onMinWidthReached?.call(result.minWidthReached); widget.onMaxWidthReached?.call(result.maxWidthReached); widget.onMinHeightReached?.call(result.minHeightReached); widget.onMaxHeightReached?.call(result.maxHeightReached); widget.onTerminalWidthReached?.call( result.minWidthReached, result.maxWidthReached, ); widget.onTerminalHeightReached?.call( result.minHeightReached, result.maxHeightReached, ); widget.onTerminalSizeReached?.call( result.minWidthReached, result.maxWidthReached, result.minHeightReached, result.maxHeightReached, ); } /// Called when the handle drag ends. void onHandlePanEnd(DragEndDetails event, HandlePosition handle) { if (!isLegalGesture) return; controller.onResizeEnd(); widget.onResizeEnd?.call(handle, event); widget.onMinWidthReached?.call(false); widget.onMaxWidthReached?.call(false); widget.onMinHeightReached?.call(false); widget.onMaxHeightReached?.call(false); widget.onTerminalWidthReached?.call(false, false); widget.onTerminalHeightReached?.call(false, false); widget.onTerminalSizeReached?.call(false, false, false, false); } void onHandlePanCancel(HandlePosition handle) { if (!isLegalGesture) return; controller.onResizeEnd(); widget.onResizeCancel?.call(handle); widget.onMinWidthReached?.call(false); widget.onMaxWidthReached?.call(false); widget.onMinHeightReached?.call(false); widget.onMaxHeightReached?.call(false); widget.onTerminalWidthReached?.call(false, false); widget.onTerminalHeightReached?.call(false, false); widget.onTerminalSizeReached?.call(false, false, false, false); } /// Called when the box drag event starts. void onDragPanStart(DragStartDetails event) { // Two fingers were used to start the drag. This produces issues with // the box drag event. Therefore, we ignore it. if (event.kind == PointerDeviceKind.trackpad) { isLegalGesture = false; return; } else { isLegalGesture = true; } controller.onDragStart(event.localPosition); widget.onDragStart?.call(event); } /// Called when the box drag event updates. void onDragPanUpdate(DragUpdateDetails event) { if (!isLegalGesture) return; final UIMoveResult result = controller.onDragUpdate( event.localPosition, ); widget.onChanged?.call(result, event); widget.onDragUpdate?.call(result, event); } /// Called when the box drag event ends. void onDragPanEnd(DragEndDetails event) { if (!isLegalGesture) return; controller.onDragEnd(); widget.onDragEnd?.call(event); } void onDragPanCancel() { if (!isLegalGesture) return; controller.onDragEnd(); widget.onDragCancel?.call(); } @override Widget build(BuildContext context) { final Flip flip = controller.flip; final Rect rect = controller.rect; Widget content = Transform.scale( scaleX: widget.allowContentFlipping && flip.isHorizontal ? -1 : 1, scaleY: widget.allowContentFlipping && flip.isVertical ? -1 : 1, child: widget.contentBuilder(context, rect, flip), ); if (widget.draggable) { content = GestureDetector( behavior: HitTestBehavior.translucent, onPanStart: onDragPanStart, onPanUpdate: onDragPanUpdate, onPanEnd: onDragPanEnd, onPanCancel: onDragPanCancel, child: content, ); } return SizedBox( width: rect.width, height: rect.height, child: Stack( clipBehavior: Clip.none, fit: StackFit.expand, children: [ Positioned( left: widget.handleAlignment.offset(widget.handleTapLeftSize), top: widget.handleAlignment.offset(widget.handleTapSize), width: rect.width, height: rect.height, child: content, ), if (widget.resizable) for (final handle in HandlePosition.corners.where((handle) => widget.visibleHandles.contains(handle) || widget.enabledHandles.contains(handle))) CornerHandleWidget( key: ValueKey(handle), handlePosition: handle, handleTapSize: widget.handleTapSize, enabled: widget.enabledHandles.contains(handle), visible: widget.visibleHandles.contains(handle), onPanStart: (event) => onHandlePanStart(event, handle), onPanUpdate: (event) => onHandlePanUpdate(event, handle), onPanEnd: (event) => onHandlePanEnd(event, handle), onPanCancel: () => onHandlePanCancel(handle), builder: widget.cornerHandleBuilder, ), if (widget.resizable) for (final handle in HandlePosition.sides.where((handle) => widget.visibleHandles.contains(handle) || widget.enabledHandles.contains(handle))) SideHandleWidget( key: ValueKey(handle), handlePosition: handle, handleTapSize: widget.handleTapSize, enabled: widget.enabledHandles.contains(handle), visible: widget.visibleHandles.contains(handle), onPanStart: (event) => onHandlePanStart(event, handle), onPanUpdate: (event) => onHandlePanUpdate(event, handle), onPanEnd: (event) => onHandlePanEnd(event, handle), onPanCancel: () => onHandlePanCancel(handle), builder: widget.sideHandleBuilder, ), ], ) , ); } } /// A default implementation of the corner [HandleBuilder] callback. Widget _defaultCornerHandleBuilder( BuildContext context, HandlePosition handle, ) => DefaultCornerHandle(handle: handle); /// A default implementation of the side [HandleBuilder] callback. Widget _defaultSideHandleBuilder( BuildContext context, HandlePosition handle, ) => DefaultSideHandle(handle: handle); @protected class CornerHandleWidget extends StatelessWidget { /// The position of the handle. final HandlePosition handlePosition; /// The builder that is used to build the handle widget. final HandleBuilder builder; /// The size of the handle's gesture response area. final double handleTapSize; /// Called when the handle dragging starts. final GestureDragStartCallback? onPanStart; /// Called when the handle dragging is updated. final GestureDragUpdateCallback? onPanUpdate; /// Called when the handle dragging ends. final GestureDragEndCallback? onPanEnd; /// Called when the handle dragging is canceled. final GestureDragCancelCallback? onPanCancel; /// Whether the handle is resizable. final bool enabled; /// Whether the handle is visible. final bool visible; /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; /// Creates a new handle widget. CornerHandleWidget({ super.key, required this.handlePosition, required this.handleTapSize, required this.builder, this.onPanStart, this.onPanUpdate, this.onPanEnd, this.onPanCancel, this.enabled = true, this.visible = true, this.debugPaintHandleBounds = false, }) : assert(handlePosition.isDiagonal, 'A corner handle must be diagonal.'); @override Widget build(BuildContext context) { Widget child = visible ? builder(context, handlePosition) : const SizedBox.shrink(); if (enabled) { child = GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: onPanStart, onPanUpdate: onPanUpdate, onPanEnd: onPanEnd, onPanCancel: onPanCancel, child: MouseRegion( cursor: getCursorForHandle(handlePosition), child: child, ), ); } return Positioned( left: handlePosition.influencesLeft ? 0 : null, right: handlePosition.influencesRight ? 0 : null, top: handlePosition.influencesTop ? 0 : null, bottom: handlePosition.influencesBottom ? 0 : null, width: handleTapSize, height: handleTapSize, child: child, ); } /// Returns the cursor for the given handle position. MouseCursor getCursorForHandle(HandlePosition handle) { switch (handle) { case HandlePosition.topLeft: case HandlePosition.bottomRight: return SystemMouseCursors.resizeUpLeftDownRight; case HandlePosition.topRight: case HandlePosition.bottomLeft: return SystemMouseCursors.resizeUpRightDownLeft; default: throw Exception('Invalid handle position.'); } } } /// Creates a new cardinal handle widget, with its appropriate gesture splash /// zone. @protected class SideHandleWidget extends StatelessWidget { /// The position of the handle. final HandlePosition handlePosition; /// The builder that is used to build the handle widget. final HandleBuilder builder; /// The thickness of the handle that is used for gesture detection. final double handleTapSize; /// Called when the handle dragging starts. final GestureDragStartCallback? onPanStart; /// Called when the handle dragging is updated. final GestureDragUpdateCallback? onPanUpdate; /// Called when the handle dragging ends. final GestureDragEndCallback? onPanEnd; /// Called when the handle dragging is canceled. final GestureDragCancelCallback? onPanCancel; /// Whether the handle is resizable. final bool enabled; /// Whether the handle is visible. final bool visible; /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; /// Creates a new handle widget. SideHandleWidget({ super.key, required this.handlePosition, required this.handleTapSize, required this.builder, this.onPanStart, this.onPanUpdate, this.onPanEnd, this.onPanCancel, this.enabled = true, this.visible = true, this.debugPaintHandleBounds = false, }) : assert(handlePosition.isSide, 'A cardinal handle must be cardinal.'); @override Widget build(BuildContext context) { Widget child = visible ? builder(context, handlePosition) : const SizedBox.shrink(); if (enabled) { child = GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: onPanStart, onPanUpdate: onPanUpdate, onPanEnd: onPanEnd, onPanCancel: onPanCancel, child: MouseRegion( cursor: getCursorForHandle(handlePosition), child: child, ), ); } return Positioned( left: handlePosition.isVertical ? handleTapSize : handlePosition.influencesLeft ? 0 : null, right: handlePosition.isVertical ? handleTapSize : handlePosition.influencesRight ? 0 : null, top: handlePosition.isHorizontal ? handleTapSize : handlePosition.influencesTop ? 0 : null, bottom: handlePosition.isHorizontal ? handleTapSize : handlePosition.influencesBottom ? 0 : null, width: handlePosition.isHorizontal ? handleTapSize : null, height: handlePosition.isVertical ? handleTapSize : null, child: child, ); } /// Returns the cursor for the given handle position. MouseCursor getCursorForHandle(HandlePosition handle) { switch (handle) { case HandlePosition.left: case HandlePosition.right: return SystemMouseCursors.resizeLeftRight; case HandlePosition.top: case HandlePosition.bottom: return SystemMouseCursors.resizeUpDown; default: throw Exception('Invalid handle position.'); } } }