diff --git a/packages/box_transform/lib/src/enums.dart b/packages/box_transform/lib/src/enums.dart index aa0bac5..fd65c02 100644 --- a/packages/box_transform/lib/src/enums.dart +++ b/packages/box_transform/lib/src/enums.dart @@ -1,7 +1,94 @@ import 'package:vector_math/vector_math.dart'; +import '../box_transform.dart'; import 'geometry.dart'; +/// Represents a cardinal side of a quadrilateral. +enum Side { + /// The top side of the rect. + top, + + /// The right side of the rect. + right, + + /// The bottom side of the rect. + bottom, + + /// The left side of the rect. + left; + + /// Whether the side is top or not. + bool get isTop => this == Side.top; + + /// Whether the side is right or not. + bool get isRight => this == Side.right; + + /// Whether the side is bottom or not. + bool get isBottom => this == Side.bottom; + + /// Whether the side is left or not. + bool get isLeft => this == Side.left; + + /// Returns the handle position of the side. + HandlePosition get handlePosition => switch (this) { + Side.top => HandlePosition.top, + Side.right => HandlePosition.right, + Side.bottom => HandlePosition.bottom, + Side.left => HandlePosition.left, + }; + + /// Returns the opposite side of the given side. + Side get opposite => switch (this) { + Side.top => Side.bottom, + Side.right => Side.left, + Side.bottom => Side.top, + Side.left => Side.right, + }; + + /// Returns the side that is clockwise to the given side. + Side get clockWise => switch (this) { + Side.top => Side.right, + Side.right => Side.bottom, + Side.bottom => Side.left, + Side.left => Side.top, + }; + + /// Returns the side that is counter-clockwise to the given side. + Side get counterClockWise => switch (this) { + Side.top => Side.left, + Side.right => Side.top, + Side.bottom => Side.right, + Side.left => Side.bottom, + }; + + /// Whether the side is horizontal or not. + bool get isHorizontal => this == Side.left || this == Side.right; + + /// Whether the side is vertical or not. + bool get isVertical => this == Side.top || this == Side.bottom; + + /// Returns the length of the [rect] along the side. + double getLengthOf(Box rect) => switch (this) { + Side.top || Side.bottom => rect.height, + Side.left || Side.right => rect.width, + }; +} + +/// Represents the different quadrants of a rectangle. +enum Quadrant { + /// The top left quadrant. + topLeft, + + /// The top right quadrant. + topRight, + + /// The bottom left quadrant. + bottomLeft, + + /// The bottom right quadrant. + bottomRight, +} + /// Represents a resizing handle on corners. enum HandlePosition { /// Represents no handle. An empty resize operation. @@ -106,6 +193,15 @@ enum HandlePosition { HandlePosition.right, ]; + /// returns the quadrant of a corner handle. + Quadrant get quadrant => switch (this) { + HandlePosition.topLeft => Quadrant.topLeft, + HandlePosition.topRight => Quadrant.topRight, + HandlePosition.bottomLeft => Quadrant.bottomLeft, + HandlePosition.bottomRight => Quadrant.bottomRight, + _ => throw Exception('Invalid handle position. Corners only.'), + }; + /// Returns the opposite handle position on the horizontal axis. HandlePosition flipY() { switch (this) { @@ -413,3 +509,21 @@ enum ResizeMode { /// w.r.t the center. bool get hasSymmetry => isSymmetric || isSymmetricScale; } + +/// An enum that defines how a box should be constrained and/or clamped when +/// undergoing a box transformation operation. +enum BindingStrategy { + /// When a box transformation occurs, clamps and constraints are considered + /// on the original unrotated dimensions of the box. Rotation is not + /// considered with this strategy, and therefore, if a box is rotated, + /// its vertices may leak out of its terminal dimensions and positions. + originalBox, + + /// When a box transformation occurs, clamps and constraints are considered + /// on the entire bounding box of the box. The bounding box is the smallest + /// box that can contain all the vertices of its rotated box. + /// + /// This is the default strategy to ensure that the box does not + /// leak out of its terminal dimensions and positions. + boundingBox, +} diff --git a/packages/box_transform/lib/src/geometry.dart b/packages/box_transform/lib/src/geometry.dart index 9e59cec..f99d651 100644 --- a/packages/box_transform/lib/src/geometry.dart +++ b/packages/box_transform/lib/src/geometry.dart @@ -308,6 +308,9 @@ class Dimension { /// smallest integer values. Dimension floor() => Dimension(width.floorToDouble(), height.floorToDouble()); + /// Returns a [Vector2] representation of this [Dimension]. + Vector2 toVector() => Vector2(width, height); + /// Linearly interpolate between two sizes /// /// If either size is null, this function interpolates from [Dimension.zero]. @@ -412,28 +415,26 @@ class Box { /// Construct a rectangle from given handle and its origin. factory Box.fromHandle( Vector2 origin, HandlePosition handle, double width, double height) { - switch (handle) { - case HandlePosition.none: - throw ArgumentError('HandlePosition.none is not supported!'); - case HandlePosition.topLeft: - return Box.fromLTWH(origin.x - width, origin.y - height, width, height); - case HandlePosition.topRight: - return Box.fromLTWH(origin.x, origin.y - height, width, height); - case HandlePosition.bottomLeft: - return Box.fromLTWH(origin.x - width, origin.y, width, height); - case HandlePosition.bottomRight: - return Box.fromLTWH(origin.x, origin.y, width, height); - case HandlePosition.left: - return Box.fromLTWH( - origin.x - width, origin.y - height / 2, width, height); - case HandlePosition.top: - return Box.fromLTWH( - origin.x - width / 2, origin.y - height, width, height); - case HandlePosition.right: - return Box.fromLTWH(origin.x, origin.y - height / 2, width, height); - case HandlePosition.bottom: - return Box.fromLTWH(origin.x - width / 2, origin.y, width, height); - } + return switch (handle) { + HandlePosition.none => + throw ArgumentError('HandlePosition.none is not supported!'), + HandlePosition.topLeft => + Box.fromLTWH(origin.x - width, origin.y - height, width, height), + HandlePosition.topRight => + Box.fromLTWH(origin.x, origin.y - height, width, height), + HandlePosition.bottomLeft => + Box.fromLTWH(origin.x - width, origin.y, width, height), + HandlePosition.bottomRight => + Box.fromLTWH(origin.x, origin.y, width, height), + HandlePosition.left => + Box.fromLTWH(origin.x - width, origin.y - height / 2, width, height), + HandlePosition.top => + Box.fromLTWH(origin.x - width / 2, origin.y - height, width, height), + HandlePosition.right => + Box.fromLTWH(origin.x, origin.y - height / 2, width, height), + HandlePosition.bottom => + Box.fromLTWH(origin.x - width / 2, origin.y, width, height) + }; } /// The Vector2 of the left edge of this rectangle from the x axis. @@ -474,8 +475,9 @@ class Box { static const Box largest = Box.fromLTRB(-_giantScalar, -_giantScalar, _giantScalar, _giantScalar); - /// Whether any of the coordinates of this rectangle are equal to positive infinity. - // included for consistency with Vector2 and Dimension + /// Whether any of the coordinates of this rectangle are equal to positive + /// infinity. + /// Included for consistency with Vector2 and Dimension bool get isInfinite => left >= double.infinity || top >= double.infinity || @@ -606,6 +608,23 @@ class Box { _ => size.aspectRatio, }; + /// Returns a list of the four corners of this rectangle. + List get points => [ + topLeft, + topRight, + bottomRight, + bottomLeft, + ]; + + /// Returns a map of the four corners of this rectangle mapped as + /// a [Quadrant] to a [Vector2]. + Map get sidedPoints => { + Quadrant.topLeft: topLeft, + Quadrant.topRight: topRight, + Quadrant.bottomRight: bottomRight, + Quadrant.bottomLeft: bottomLeft, + }; + /// Whether the point specified by the given Vector2 (which is assumed to be /// relative to the origin) lies between the left and right and the top and /// bottom edges of this rectangle. @@ -641,8 +660,8 @@ class Box { final double newLeft = math.max(left, clampedLeft); final double newTop = math.max(top, clampedTop); - double newWidth = math.min(width, childWidth); - double newHeight = math.min(height, childHeight); + final double newWidth = math.min(width, childWidth); + final double newHeight = math.min(height, childHeight); return Box.fromLTWH( newLeft, @@ -668,6 +687,35 @@ class Box { bottom.floorToDouble(), ); + /// Returns the relevant corner of this [Box] based on the given [quadrant]. + Vector2 pointFromQuadrant(Quadrant quadrant) => switch (quadrant) { + Quadrant.topLeft => topLeft, + Quadrant.topRight => topRight, + Quadrant.bottomRight => bottomRight, + Quadrant.bottomLeft => bottomLeft, + }; + + /// Returns a value that represents the distances of the passed + /// [point] relative to the closest edge of this [Box]. If the point is + /// inside the box, the distance will be positive. If the point is outside + /// the box, the distance will be negative. + /// + /// Returns the [side] that the point is closest to and the distance to that + /// side. + (Side side, double) distanceOfPoint(Vector2 point) { + final double left = point.x - this.left; + final double right = this.right - point.x; + final double top = point.y - this.top; + final double bottom = this.bottom - point.y; + + final double min = math.min(left, math.min(right, math.min(top, bottom))); + + if (min == left) return (Side.left, left); + if (min == right) return (Side.right, right); + if (min == top) return (Side.top, top); + return (Side.bottom, bottom); + } + /// Linearly interpolate between two rectangles. /// /// If either rect is null, [Box.zero] is used as a substitute. @@ -721,5 +769,5 @@ class Box { @override String toString() => - 'Box.fromLTRB(${left.toStringAsFixed(1)}, ${top.toStringAsFixed(1)}, ${right.toStringAsFixed(1)}, ${bottom.toStringAsFixed(1)})'; + 'Box.fromLTWH(${left.toStringAsFixed(1)}, ${top.toStringAsFixed(1)}, ${width.toStringAsFixed(1)}, ${height.toStringAsFixed(1)})'; } diff --git a/packages/box_transform/lib/src/helpers.dart b/packages/box_transform/lib/src/helpers.dart index 6285e5c..46c7a5a 100644 --- a/packages/box_transform/lib/src/helpers.dart +++ b/packages/box_transform/lib/src/helpers.dart @@ -543,25 +543,94 @@ Box getMinRectForScaling({ ); } -/// [returns] whether the given [rect] is properly confined within its -/// [constraints] but at the same time is not outside of the [clampingRect]. -bool isValidRect(Box rect, Constraints constraints, Box clampingRect) { +/// Returns the largest intersection amount between the given [rect] and the +/// [clampingRect]. If more than one side of [rect] intersects with +/// [clampingRect], only the largest intersection is returned. +/// +/// The returned value is made positive. +({Side side, double amount, bool singleIntersection}) + getLargestIntersectionDelta(Box rect, Box clampingRect) { + final Map intersections = { + Side.left: rect.left - clampingRect.left, + Side.top: rect.top - clampingRect.top, + Side.right: clampingRect.right - rect.right, + Side.bottom: clampingRect.bottom - rect.bottom, + }; + + // The largest intersection is represents by the smallest negative value. + final MapEntry largestIntersection = + intersections.entries.reduce((a, b) { + if (a.value < b.value) { + return a; + } else { + return b; + } + }); + + return ( + side: largestIntersection.key, + amount: min(0.0, largestIntersection.value).abs(), + singleIntersection: + intersections.values.where((amount) => amount < 0).length <= 1, + ); +} + +bool isRectClamped( + Box rect, + Box clampingRect, +) { if (clampingRect.left.roundToPrecision(4) > rect.left.roundToPrecision(4) || clampingRect.top.roundToPrecision(4) > rect.top.roundToPrecision(4) || clampingRect.right.roundToPrecision(4) < rect.right.roundToPrecision(4) || clampingRect.bottom.roundToPrecision(4) < rect.bottom.roundToPrecision(4)) { + print('Hit clamping rect.'); + return false; + } + + return true; +} + +bool isRectConstrained( + Box rect, + Constraints constraints, +) => + constraints.isUnconstrained || + (rect.width >= constraints.minWidth && + rect.width <= constraints.maxWidth && + rect.height >= constraints.minHeight && + rect.height <= constraints.maxHeight); + +/// [returns] whether the given [checkClamp] is properly confined within its +/// [constraints] but at the same time is not outside of the [clampingRect]. +bool isRectBound( + Box checkClamp, + Constraints constraints, + Box clampingRect, { + Box? checkConstraints, +}) { + if (clampingRect.left.roundToPrecision(4) > + checkClamp.left.roundToPrecision(4) || + clampingRect.top.roundToPrecision(4) > + checkClamp.top.roundToPrecision(4) || + clampingRect.right.roundToPrecision(4) < + checkClamp.right.roundToPrecision(4) || + clampingRect.bottom.roundToPrecision(4) < + checkClamp.bottom.roundToPrecision(4)) { + print('Hit clamping rect.'); return false; } if (!constraints.isUnconstrained) { - if (rect.width.roundToPrecision(4) < + final box = checkConstraints ?? checkClamp; + if (box.width.roundToPrecision(4) < constraints.minWidth.roundToPrecision(4) || - rect.width.roundToPrecision(4) > + box.width.roundToPrecision(4) > constraints.maxWidth.roundToPrecision(4) || - rect.height.roundToPrecision(4) < + box.height.roundToPrecision(4) < constraints.minHeight.roundToPrecision(4) || - rect.height.roundToPrecision(4) > + box.height.roundToPrecision(4) > constraints.maxHeight.roundToPrecision(4)) { + print('Hit constraints.'); return false; } } diff --git a/packages/box_transform/lib/src/resizers/freeform_resizing.dart b/packages/box_transform/lib/src/resizers/freeform_resizing.dart index 811fc7c..8114e53 100644 --- a/packages/box_transform/lib/src/resizers/freeform_resizing.dart +++ b/packages/box_transform/lib/src/resizers/freeform_resizing.dart @@ -13,53 +13,148 @@ final class FreeformResizer extends Resizer { required HandlePosition handle, required Constraints constraints, required Flip flip, + required double rotation, + required BindingStrategy bindingStrategy, }) { - final flippedHandle = handle.flip(flip); - Box effectiveInitialRect = flipRect(initialRect, flip, handle); + final Box effectiveInitialRect = flipRect(initialRect, flip, handle); + final Box initialBoundingRect = BoxTransformer.calculateBoundingRect( + rotation: rotation, + unrotatedBox: effectiveInitialRect, + ); + final Box effectiveInitialBoundingRect = + flipRect(initialBoundingRect, flip, handle); + + final HandlePosition flippedHandle = handle.flip(flip); - Box newRect = Box.fromLTRB( - max(explodedRect.left, clampingRect.left), - max(explodedRect.top, clampingRect.top), - min(explodedRect.right, clampingRect.right), - min(explodedRect.bottom, clampingRect.bottom), + Box newRect = explodedRect; + newRect = repositionRotatedResizedBox( + newRect: newRect, + initialRect: initialRect, + rotation: rotation, + ); + Box newBoundingRect = BoxTransformer.calculateBoundingRect( + rotation: rotation, + unrotatedBox: newRect, + ); + final bool isClamped = isRectClamped( + newBoundingRect, + // switch (bindingStrategy) { + // BindingStrategy.originalBox => newRect, + // BindingStrategy.boundingBox => newBoundingRect, + // }, + clampingRect, ); + if (!isClamped) { + final Vector2 correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: newRect, + clampingRect: clampingRect, + rotation: rotation, + ); + + newRect = BoxTransformer.applyDelta( + initialRect: newRect, + delta: correctiveDelta, + handle: handle, + resizeMode: ResizeMode.scale, + allowFlipping: false, + ); - bool isValid = true; + newBoundingRect = BoxTransformer.calculateBoundingRect( + rotation: rotation, + unrotatedBox: newRect, + ); + } + + bool isBound = false; if (!constraints.isUnconstrained) { - final constrainedWidth = - newRect.width.clamp(constraints.minWidth, constraints.maxWidth); - final constrainedHeight = - newRect.height.clamp(constraints.minHeight, constraints.maxHeight); + final Dimension constrainedSize = Dimension( + newRect.width.clamp(constraints.minWidth, constraints.maxWidth), + newRect.height.clamp(constraints.minHeight, constraints.maxHeight), + ); + final Dimension constrainedDelta = Dimension( + constrainedSize.width - newRect.width, + constrainedSize.height - newRect.height, + ); newRect = Box.fromHandle( flippedHandle.anchor(effectiveInitialRect), flippedHandle, - constrainedWidth, - constrainedHeight, + newRect.width + constrainedDelta.width, + newRect.height + constrainedDelta.height, + ); + newRect = repositionRotatedResizedBox( + newRect: newRect, + initialRect: initialRect, + rotation: rotation, + ); + newBoundingRect = BoxTransformer.calculateBoundingRect( + rotation: rotation, + unrotatedBox: newRect, + ); + + isBound = isRectConstrained( + newRect, + constraints, ); - isValid = isValidRect(newRect, constraints, clampingRect); - if (!isValid) { + if (!isBound) { newRect = Box.fromHandle( handle.anchor(initialRect), handle, - !handle.isSide || handle.isHorizontal + handle.influencesHorizontal ? constraints.minWidth - : constrainedWidth, - !handle.isSide || handle.isVertical + : constrainedSize.width, + handle.influencesVertical ? constraints.minHeight - : constrainedHeight, + : constrainedSize.height, + ); + newRect = repositionRotatedResizedBox( + newRect: newRect, + initialRect: initialRect, + rotation: rotation, + ); + newBoundingRect = BoxTransformer.calculateBoundingRect( + rotation: rotation, + unrotatedBox: newRect, ); } } - // Not used but calculating it for returning correct largest box. + final Box effectiveBindingRect = switch (bindingStrategy) { + BindingStrategy.originalBox => effectiveInitialRect, + BindingStrategy.boundingBox => effectiveInitialBoundingRect, + }; + final Box bindingRect = switch (bindingStrategy) { + BindingStrategy.originalBox => initialRect, + BindingStrategy.boundingBox => initialBoundingRect, + }; + + // Only used for calculating the correct largest box. final Box area = getAvailableAreaForHandle( - rect: isValid ? effectiveInitialRect : initialRect, - handle: isValid ? flippedHandle : handle, + rect: isBound ? effectiveBindingRect : bindingRect, + handle: isBound ? flippedHandle : handle, clampingRect: clampingRect, ); - return (rect: newRect, largest: area, hasValidFlip: isValid); + return (rect: newRect, largest: area, hasValidFlip: isBound); + } + + /// Repositions a rotated and resized box back to its original unrotated + /// position. + Box repositionRotatedResizedBox({ + required Box newRect, + required Box initialRect, + required double rotation, + }) { + if (rotation == 0) return newRect; + + final Vector2 positionDelta = newRect.topLeft - initialRect.topLeft; + final Vector2 newPos = BoxTransformer.calculateUnrotatedPos( + initialRect, + rotation, + positionDelta, + newRect.size, + ); + return Box.fromLTWH(newPos.x, newPos.y, newRect.width, newRect.height); } } diff --git a/packages/box_transform/lib/src/resizers/resizer.dart b/packages/box_transform/lib/src/resizers/resizer.dart index ec290cc..22a9f37 100644 --- a/packages/box_transform/lib/src/resizers/resizer.dart +++ b/packages/box_transform/lib/src/resizers/resizer.dart @@ -2,9 +2,9 @@ library resize_handlers; import 'dart:math'; -import '../enums.dart'; -import '../geometry.dart'; -import '../helpers.dart'; +import 'package:vector_math/vector_math.dart'; + +import '../../box_transform.dart'; part 'freeform_resizing.dart'; part 'scale_resizing.dart'; @@ -43,5 +43,7 @@ sealed class Resizer { required HandlePosition handle, required Constraints constraints, required Flip flip, + required double rotation, + required BindingStrategy bindingStrategy, }); } diff --git a/packages/box_transform/lib/src/resizers/scale_resizing.dart b/packages/box_transform/lib/src/resizers/scale_resizing.dart index 7d52a27..18b7047 100644 --- a/packages/box_transform/lib/src/resizers/scale_resizing.dart +++ b/packages/box_transform/lib/src/resizers/scale_resizing.dart @@ -13,6 +13,8 @@ final class ScaleResizer extends Resizer { required HandlePosition handle, required Constraints constraints, required Flip flip, + required double rotation, + required BindingStrategy bindingStrategy, }) { ({Box rect, Box largest, bool hasValidFlip}) result = _resizeRect( initialRect: initialRect, @@ -174,7 +176,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -250,7 +252,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -326,7 +328,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -402,7 +404,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -478,7 +480,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -548,7 +550,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -618,7 +620,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -688,7 +690,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = isRectBound(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } diff --git a/packages/box_transform/lib/src/resizers/symmetric_resizing.dart b/packages/box_transform/lib/src/resizers/symmetric_resizing.dart index 83c26cc..40c9282 100644 --- a/packages/box_transform/lib/src/resizers/symmetric_resizing.dart +++ b/packages/box_transform/lib/src/resizers/symmetric_resizing.dart @@ -13,6 +13,8 @@ final class SymmetricResizer extends Resizer { required HandlePosition handle, required Constraints constraints, required Flip flip, + required double rotation, + required BindingStrategy bindingStrategy, }) { final double horizontalMirrorRight = clampingRect.right - explodedRect.center.x; diff --git a/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart b/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart index d5ef597..996a28d 100644 --- a/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart +++ b/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart @@ -13,6 +13,8 @@ final class SymmetricScaleResizer extends Resizer { required HandlePosition handle, required Constraints constraints, required Flip flip, + required double rotation, + required BindingStrategy bindingStrategy, }) { switch (handle) { case HandlePosition.none: diff --git a/packages/box_transform/lib/src/result.dart b/packages/box_transform/lib/src/result.dart index 28cde93..7f4d4bb 100644 --- a/packages/box_transform/lib/src/result.dart +++ b/packages/box_transform/lib/src/result.dart @@ -12,6 +12,10 @@ typedef RawMoveResult = MoveResult; /// [Dimension] as the generic types that is used by [BoxTransformer]. typedef RawResizeResult = ResizeResult; +/// A convenient typedef for [TransformResult] with [Box], [Vector2], and +/// [Dimension] as the generic types that is used by [BoxTransformer]. +typedef RawRotateResult = RotateResult; + /// A convenient typedef for [TransformResult] with [Box], [Vector2], and /// [Dimension] as the generic types that is used by [BoxTransformer]. typedef RawTransformResult = TransformResult; @@ -44,26 +48,37 @@ abstract class RectResult { /// is usually [Dimension] or [Size]. It represents the size of the rect. class TransformResult extends RectResult { - /// The new [Box] of the node after the resize. + /// The new [Box] of the object after the resize. final B rect; - /// The old [Box] of the node before the resize. + /// The old [Box] of the object before the resize. final B oldRect; - /// The delta used to move the node. + /// The new bounding [Box] of the object after the resize. This box always + /// contains all 4 vertices of the object with its rotation applied. + final B boundingRect; + + /// The old bounding [Box] of the object before the resize. This box always + /// contains all 4 vertices of the object with its rotation applied. + final B oldBoundingRect; + + /// The delta used to move the object. final V delta; - /// The [Flip] of the node after the resize. + /// The [Flip] of the object after the resize. final Flip flip; - /// The [ResizeMode] of the node after the resize. + /// The [ResizeMode] of the object after the resize. final ResizeMode resizeMode; - /// The new [Dimension] of the node after the resize. Unlike [newRect], this - /// reflects flip state. For example, if the node is flipped horizontally, + /// The new [Dimension] of the object after the resize. Unlike [newRect], this + /// reflects flip state. For example, if the object is flipped horizontally, /// the width of the [newSize] will be negative. final D rawSize; + /// The rotation of the object after the resize. + final double rotation; + /// Whether the resizing rect hit its maximum possible width. final bool minWidthReached; @@ -87,10 +102,13 @@ class TransformResult const TransformResult({ required this.rect, required this.oldRect, + required this.boundingRect, + required this.oldBoundingRect, required this.delta, required this.flip, required this.resizeMode, required this.rawSize, + required this.rotation, required this.minWidthReached, required this.maxWidthReached, required this.minHeightReached, @@ -106,10 +124,13 @@ class TransformResult return other is TransformResult && other.rect == rect && other.oldRect == oldRect && + other.boundingRect == boundingRect && + other.oldBoundingRect == oldBoundingRect && other.delta == delta && other.flip == flip && other.resizeMode == resizeMode && other.rawSize == rawSize && + other.rotation == rotation && other.minWidthReached == minWidthReached && other.maxWidthReached == maxWidthReached && other.minHeightReached == minHeightReached && @@ -122,10 +143,13 @@ class TransformResult int get hashCode => Object.hash( rect, oldRect, + boundingRect, + oldBoundingRect, delta, flip, resizeMode, rawSize, + rotation, minWidthReached, maxWidthReached, minHeightReached, @@ -138,10 +162,13 @@ class TransformResult String toString() => 'TransformResult(' 'rect: $rect, ' 'oldBox: $oldRect, ' + 'boundingRect: $boundingRect, ' + 'oldBoundingRect: $oldBoundingRect, ' 'flip: $flip, ' 'resizeMode: $resizeMode, ' 'delta: $delta, ' 'rawSize: $rawSize, ' + 'rotation: $rotation, ' 'minWidthReached: $minWidthReached, ' 'maxWidthReached: $maxWidthReached, ' 'minHeightReached: $minHeightReached, ' @@ -159,23 +186,28 @@ class MoveResult const MoveResult({ required super.rect, required super.oldRect, + required super.boundingRect, + required super.oldBoundingRect, required super.delta, required super.rawSize, required super.largestRect, }) : super( flip: Flip.none, resizeMode: ResizeMode.freeform, + handle: HandlePosition.bottomRight, + rotation: 0, minWidthReached: false, maxWidthReached: false, minHeightReached: false, maxHeightReached: false, - handle: HandlePosition.bottomRight, ); @override String toString() => 'MoveResult(' 'rect: $rect, ' - 'oldBox: $oldRect, ' + 'oldRect: $oldRect, ' + 'boundingRect: $boundingRect, ' + 'oldBoundingRect: $oldBoundingRect, ' 'delta: $delta, ' 'rawSize: $rawSize, ' 'largestBox: $largestRect, ' @@ -190,31 +222,72 @@ class ResizeResult const ResizeResult({ required super.rect, required super.oldRect, + required super.boundingRect, + required super.oldBoundingRect, required super.delta, required super.flip, required super.resizeMode, required super.rawSize, + required super.largestRect, + required super.handle, required super.minWidthReached, required super.maxWidthReached, required super.minHeightReached, required super.maxHeightReached, - required super.largestRect, - required super.handle, - }); + }) : super(rotation: 0); @override String toString() => 'ResizeResult(' 'rect: $rect, ' - 'oldBox: $oldRect, ' + 'oldRect: $oldRect, ' + 'boundingRect: $boundingRect, ' + 'oldBoundingRect: $oldBoundingRect, ' 'flip: $flip, ' 'resizeMode: $resizeMode, ' 'delta: $delta, ' 'rawSize: $rawSize, ' + 'largestBox: $largestRect, ' + 'handle: $handle' 'minWidthReached: $minWidthReached, ' 'maxWidthReached: $maxWidthReached, ' 'minHeightReached: $minHeightReached, ' 'maxHeightReached: $maxHeightReached, ' + ')'; +} + +/// An object that represents the result of a rotate operation. +class RotateResult + extends TransformResult { + /// Creates a [RotateResult] object. + const RotateResult({ + required super.rect, + required super.boundingRect, + required super.oldBoundingRect, + required super.delta, + required super.rawSize, + required super.rotation, + }) : super( + flip: Flip.none, + resizeMode: ResizeMode.freeform, + minWidthReached: false, + maxWidthReached: false, + minHeightReached: false, + maxHeightReached: false, + handle: HandlePosition.bottomRight, + oldRect: rect, + largestRect: rect, + ); + + @override + String toString() => 'MoveResult(' + 'rect: $rect, ' + 'oldRect: $oldRect, ' + 'boundingRect: $boundingRect, ' + 'oldBoundingRect: $oldBoundingRect, ' + 'delta: $delta, ' + 'rawSize: $rawSize, ' 'largestBox: $largestRect, ' 'handle: $handle' + 'rotation: $rotation' ')'; } diff --git a/packages/box_transform/lib/src/transformer.dart b/packages/box_transform/lib/src/transformer.dart index 06872a2..6dd93d6 100644 --- a/packages/box_transform/lib/src/transformer.dart +++ b/packages/box_transform/lib/src/transformer.dart @@ -1,19 +1,55 @@ -import 'dart:developer'; import 'dart:math' hide log; import 'package:vector_math/vector_math.dart'; -import 'enums.dart'; -import 'geometry.dart'; -import 'helpers.dart'; +import '../box_transform.dart'; import 'resizers/resizer.dart'; -import 'result.dart'; /// A class that transforms a [Box] in several different supported forms. class BoxTransformer { /// A private constructor to prevent instantiation. const BoxTransformer._(); + /// Rotates the given [rect] with the given [initialLocalPosition] of + /// the mouse cursor and wherever [localPosition] of the mouse cursor is + /// currently at. + /// + /// The [clampingRect] is the rect that the [rect] is not allowed + /// to go outside of when dragging or resizing. + static RawRotateResult rotate({ + required Box rect, + required Vector2 initialLocalPosition, + required Vector2 localPosition, + required double initialRotation, + Box clampingRect = Box.largest, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, + }) { + final Vector2 delta = localPosition - initialLocalPosition; + final Vector2 from = rect.center - initialLocalPosition; + final Vector2 to = rect.center - localPosition; + double rotation = atan2(to.y, to.x) - atan2(from.y, from.x); + rotation += initialRotation; + + // Normalize the angle to the range [0, 2π). + if (rotation < 0) { + rotation += 2 * pi; + } + + final initialBoundingRect = calculateBoundingRect( + rotation: rotation, + unrotatedBox: rect, + ); + + return RotateResult( + rect: rect, + boundingRect: initialBoundingRect, + oldBoundingRect: initialBoundingRect, + delta: delta, + rawSize: rect.size, + rotation: rotation, + ); + } + /// Calculates the new position of the [initialRect] based on the /// [initialLocalPosition] of the mouse cursor and wherever [localPosition] /// of the mouse cursor is currently at. @@ -24,25 +60,126 @@ class BoxTransformer { required Box initialRect, required Vector2 initialLocalPosition, required Vector2 localPosition, + double rotation = 0.0, Box clampingRect = Box.largest, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) { final Vector2 delta = localPosition - initialLocalPosition; + final Box initialBoundingRect = calculateBoundingRect( + rotation: rotation, + unrotatedBox: initialRect, + ); - final Box unclampedRect = initialRect.translate(delta.x, delta.y); - final Box clampedRect = clampingRect.containOther(unclampedRect); - final Vector2 clampedDelta = clampedRect.topLeft - initialRect.topLeft; + // If the box is rotated, the incoming delta is also rotated. We need to + // unrotate the delta to get the actual delta. + if (rotation != 0) { + Matrix2.rotation(rotation).transform(delta); + } - final Box newRect = initialRect.translate(clampedDelta.x, clampedDelta.y); + final Box initialBinding = switch (bindingStrategy) { + BindingStrategy.originalBox => initialRect, + BindingStrategy.boundingBox => initialBoundingRect, + }; + final Box unclampedRect = initialBinding.translate(delta.x, delta.y); + + final Vector2 clampDelta = calculateRectClampingPositionDelta( + initialRect: initialBinding, + rect: unclampedRect, + clampingRect: clampingRect, + ); + final Box newRect = initialRect.translate(clampDelta.x, clampDelta.y); + final Box newBoundingRect = + initialBoundingRect.translate(clampDelta.x, clampDelta.y); return MoveResult( rect: newRect, oldRect: initialRect, + boundingRect: newBoundingRect, + oldBoundingRect: initialBoundingRect, delta: delta, rawSize: newRect.size, largestRect: clampingRect, ); } + /// Returns the delta required to move the [rect] in such a way that it + /// remains within the [clampingRect]. + static Vector2 calculateRectClampingPositionDelta({ + required Box initialRect, + required Box rect, + required Box clampingRect, + }) { + final Box clampedRect = clampingRect.containOther(rect); + return clampedRect.topLeft - initialRect.topLeft; + } + + /// Returns a [Vector2] delta that grows as the intersection between [rect] + /// and [clampingRect] grows. + static Vector2 stopRectAtClampingRect({ + required Box rect, + required Box clampingRect, + required double rotation, + }) { + final Map rotatedPoints = { + for (final MapEntry(key: quadrant, value: point) + in rect.sidedPoints.entries) + quadrant: rotatePointAroundVec(rect.center, rotation, point) + }; + + // Check if any rotated point is outside the clamping rect. + ( + Side side, + Quadrant quadrant, + Vector2 point, + double dist + )? biggestOutOfBounds; + for (final MapEntry(key: quadrant, value: point) in rotatedPoints.entries) { + if (biggestOutOfBounds == null) { + final (side, dist) = clampingRect.distanceOfPoint(point); + biggestOutOfBounds = (side, quadrant, point, dist); + } else { + final (side, dist) = clampingRect.distanceOfPoint(point); + final (_, biggestDist) = + clampingRect.distanceOfPoint(biggestOutOfBounds.$3); + if (dist < biggestDist) { + biggestOutOfBounds = (side, quadrant, point, dist); + } + } + } + + assert(biggestOutOfBounds != null); + + final side = biggestOutOfBounds!.$1; + final quadrant = biggestOutOfBounds.$2; + final point = biggestOutOfBounds.$3; + final dist = biggestOutOfBounds.$4; + + // Move the out of bounds vector by the perpendicular vector of the side + // that was hit. + final cardinalCorrection = switch (side) { + Side.left => Vector2(-dist, 0), + Side.right => Vector2(dist, 0), + Side.top => Vector2(0, -dist), + Side.bottom => Vector2(0, dist), + }; + + final correctedRotatedPoint = + Vector2(point.x + cardinalCorrection.x, point.y + cardinalCorrection.y); + + // Rotate back + final unrotated = + rotatePointAroundVec(rect.center, -rotation, correctedRotatedPoint); + final delta = unrotated - rect.pointFromQuadrant(quadrant); + + print(' quad: ${rect.pointFromQuadrant(quadrant)..round()}'); + print('unrotated: ${unrotated..round()}'); + + // Matrix2.rotation(rotation).transform(delta); + print('delta: $delta'); + + return delta; + } + /// Resizes the given [initialRect] with given [initialLocalPosition] of /// the mouse cursor and wherever [localPosition] of the mouse cursor is /// currently at. @@ -74,15 +211,21 @@ class BoxTransformer { required HandlePosition handle, required ResizeMode resizeMode, required Flip initialFlip, + double rotation = 0.0, Box clampingRect = Box.largest, Constraints constraints = const Constraints.unconstrained(), bool allowFlipping = true, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) { if (handle == HandlePosition.none) { - log('Using bottomRight handle instead of none.'); handle = HandlePosition.bottomRight; } + final Box initialBoundingRect = calculateBoundingRect( + rotation: rotation, + unrotatedBox: initialRect, + ); + Vector2 delta = localPosition - initialLocalPosition; // getFlipForRect uses delta instead of localPosition to know exactly when @@ -118,21 +261,30 @@ class BoxTransformer { ); } + final Box initialBindingRect = switch (bindingStrategy) { + BindingStrategy.originalBox => initialRect, + BindingStrategy.boundingBox => initialBoundingRect, + }; + final double initialBindingWidth = initialBindingRect.width; + final double initialBindingHeight = initialBindingRect.height; + // Check if clampingRect is smaller than initialRect. // If it is, then we return the initialRect and not resize it. - if (clampingRect.width < initialRect.width || - clampingRect.height < initialRect.height) { + if (clampingRect.width < initialBindingWidth || + clampingRect.height < initialBindingHeight) { return ResizeResult( rect: initialRect, oldRect: initialRect, + boundingRect: initialBoundingRect, + oldBoundingRect: initialBoundingRect, flip: initialFlip, resizeMode: resizeMode, delta: delta, handle: handle, rawSize: initialRect.size, + largestRect: clampingRect, minWidthReached: false, minHeightReached: false, - largestRect: clampingRect, maxHeightReached: false, maxWidthReached: false, ); @@ -144,7 +296,7 @@ class BoxTransformer { // No constraints or clamping is done. Only delta is applied to the // initial rect. - Box explodedRect = _applyDelta( + final Box explodedRect = applyDelta( initialRect: initialRect, handle: handle, delta: delta, @@ -160,33 +312,43 @@ class BoxTransformer { constraints: constraints, initialRect: initialRect, flip: currentFlip, + rotation: rotation, + bindingStrategy: bindingStrategy, ); final Box newRect = result.rect; final Box largestRect = result.largest; + final Box newBoundingRect = calculateBoundingRect( + rotation: rotation, + unrotatedBox: newRect, + ); // Detect terminal resizing, where the resizing reached a hard limit. final terminalResult = checkForTerminalSizes( rect: newRect, initialRect: initialRect, clampingRect: clampingRect, + rotation: rotation, constraints: constraints, handle: handle, + bindingStrategy: bindingStrategy, ); return ResizeResult( rect: newRect, oldRect: initialRect, + boundingRect: newBoundingRect, + oldBoundingRect: initialBoundingRect, flip: currentFlip * initialFlip, resizeMode: resizeMode, delta: delta, + handle: handle, rawSize: newRect.size, + largestRect: largestRect, minWidthReached: terminalResult.minWidthReached, maxWidthReached: terminalResult.maxWidthReached, minHeightReached: terminalResult.minHeightReached, maxHeightReached: terminalResult.maxHeightReached, - largestRect: largestRect, - handle: handle, ); } @@ -202,32 +364,56 @@ class BoxTransformer { required Box rect, required Box initialRect, required Box clampingRect, + required double rotation, required Constraints constraints, required HandlePosition handle, + required BindingStrategy bindingStrategy, }) { + final initialBoundingRect = calculateBoundingRect( + rotation: rotation, + unrotatedBox: initialRect, + ); + final boundingRect = calculateBoundingRect( + rotation: rotation, + unrotatedBox: rect, + ); + final initialBindingWidth = bindingStrategy == BindingStrategy.originalBox + ? initialRect.width + : initialBoundingRect.width; + final initialBindingHeight = bindingStrategy == BindingStrategy.originalBox + ? initialRect.height + : initialBoundingRect.height; + + final bindingWidth = bindingStrategy == BindingStrategy.originalBox + ? rect.width + : boundingRect.width; + final bindingHeight = bindingStrategy == BindingStrategy.originalBox + ? rect.height + : boundingRect.height; + bool minWidthReached = false; bool maxWidthReached = false; bool minHeightReached = false; bool maxHeightReached = false; if (handle.influencesHorizontal) { - if (rect.width <= initialRect.width && - rect.width == constraints.minWidth) { + if (bindingWidth <= initialBindingWidth && + bindingWidth == constraints.minWidth) { minWidthReached = true; } - if (rect.width >= initialRect.width && - (rect.width == constraints.maxWidth || - rect.width == clampingRect.width)) { + if (bindingWidth >= initialBindingWidth && + (bindingWidth == constraints.maxWidth || + bindingWidth == clampingRect.width)) { maxWidthReached = true; } } if (handle.influencesVertical) { - if (rect.height <= initialRect.height && - rect.height == constraints.minHeight) { + if (bindingHeight <= initialBindingHeight && + bindingHeight == constraints.minHeight) { minHeightReached = true; } - if (rect.height >= initialRect.height && - (rect.height == constraints.maxHeight || - rect.height == clampingRect.height)) { + if (bindingHeight >= initialBindingHeight && + (bindingHeight == constraints.maxHeight || + bindingHeight == clampingRect.height)) { maxHeightReached = true; } } @@ -240,7 +426,7 @@ class BoxTransformer { ); } - static Box _applyDelta({ + static Box applyDelta({ required Box initialRect, required HandlePosition handle, required Vector2 delta, @@ -300,4 +486,83 @@ class BoxTransformer { max(top, bottom), ); } + + /// Rotates a point [point] around [origin] by the given [radians] and returns + /// the new coordinates as a [Vec]. + static Vector2 rotatePointAroundVec( + Vector2 origin, + double radians, + Vector2 point, + ) { + final Matrix4 transform = Matrix4.translationValues(origin.x, origin.y, 0) + ..rotateZ(radians) + ..translate(-origin.x, -origin.y, 0); + + final List rotated = transform.applyToVector3Array( + [point.x, point.y, 0], + ); + + return Vector2(rotated[0], rotated[1]); + } + + static Vector2 calculateUnrotatedPos(Box unrotatedRect, double rotation, + Vector2 positionDelta, Dimension newSize) { + // This was our old rotated position. We will be using it as the point of + // reference. We're given the [unrotatedRect], but we need it's top left + // corner rotated to the new position. + final Vector2 oldRotatedXY = rotatePointAroundVec( + unrotatedRect.center, + rotation, + unrotatedRect.topLeft, + ); + + // This is how the rotated position changes in parents system. + final double sinA = sin(-rotation); + final double cosA = cos(-rotation); + final double xChange = cosA * positionDelta.x + sinA * positionDelta.y; + final double yChange = cosA * positionDelta.y - sinA * positionDelta.x; + + // The new position in parent's system accounting for the changes: + final Vector2 newRotatedXY = oldRotatedXY + Vector2(xChange, yChange); + + // Rotate back again because we're interested in the new unrotated position, + // not the rotated one. For that we need the new center. + final Vector2 newRotatedBR = newRotatedXY + + Vector2( + cosA * newSize.width + sinA * newSize.height, + cosA * newSize.height - sinA * newSize.width, + ); + + final Vector2 newCenter = newRotatedXY + (newRotatedBR - newRotatedXY) / 2; + // final Vector2 newCenter = Box.fromLTRB( + // newRotatedXY.x, newRotatedXY.y, newRotatedBR.x, newRotatedBR.y) + // .center; + + // Now we can rotate the top left point back. + return rotatePointAroundVec(newCenter, -rotation, newRotatedXY); + } + + static Box calculateBoundingRect({ + required double rotation, + required Box unrotatedBox, + }) { + final double sinA = sin(rotation); + final double cosA = cos(rotation); + + final double width = unrotatedBox.width; + final double height = unrotatedBox.height; + final double boundingWidth = (width * cosA).abs() + (height * sinA).abs(); + final double boundingHeight = (width * sinA).abs() + (height * cosA).abs(); + final double left = (unrotatedBox.left + (width / 2)) - (boundingWidth / 2); + final double top = (unrotatedBox.top + (height / 2)) - (boundingHeight / 2); + + final Box explodedRect = Box.fromLTWH( + left, + top, + boundingWidth, + boundingHeight, + ); + + return explodedRect; + } } diff --git a/packages/box_transform/test/extensions_test.dart b/packages/box_transform/test/extensions_test.dart index f9eb860..00cd165 100644 --- a/packages/box_transform/test/extensions_test.dart +++ b/packages/box_transform/test/extensions_test.dart @@ -6,6 +6,7 @@ void main() { test('RawTransformResult extension tests', () { final result = RawTransformResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), handle: HandlePosition.bottomRight, flip: Flip.none, maxHeightReached: false, @@ -13,10 +14,12 @@ void main() { minHeightReached: false, minWidthReached: false, oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), resizeMode: ResizeMode.freeform, largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), delta: Vector2(200, 200), + rotation: 0, ); expect(result.size, result.rect.size); diff --git a/packages/box_transform/test/helpers_test.dart b/packages/box_transform/test/helpers_test.dart index 04e9d29..8c601a1 100644 --- a/packages/box_transform/test/helpers_test.dart +++ b/packages/box_transform/test/helpers_test.dart @@ -3,6 +3,271 @@ import 'package:test/test.dart'; import 'package:vector_math/vector_math.dart'; void main() { + group('getLargestIntersectionDelta', () { + test('no intersection', () { + final rect = Box.fromLTRB(500, 500, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 0); + expect(side, Side.bottom); + expect(singleIntersection, true); + }); + + test('bottom intersection', () { + final rect = Box.fromLTRB(500, 500, 800, 1200); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 100); + expect(side, Side.bottom); + expect(singleIntersection, true); + }); + + test('right intersection', () { + final rect = Box.fromLTRB(500, 500, 1200, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 100); + expect(side, Side.right); + expect(singleIntersection, true); + }); + + test('top intersection', () { + final rect = Box.fromLTRB(500, 0, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 100); + expect(side, Side.top); + expect(singleIntersection, true); + }); + + test('left intersection', () { + final rect = Box.fromLTRB(0, 500, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 100); + expect(side, Side.left); + expect(singleIntersection, true); + }); + + test('top right intersection - top', () { + final rect = Box.fromLTRB(-99, -100, 1200, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.top); + expect(singleIntersection, false); + }); + + test('top right intersection - right', () { + final rect = Box.fromLTRB(500, -99, 1300, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.right); + expect(singleIntersection, false); + }); + + test('top left intersection - top', () { + final rect = Box.fromLTRB(-99, -100, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.top); + expect(singleIntersection, false); + }); + + test('top left intersection - left', () { + final rect = Box.fromLTRB(-100, -99, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.left); + expect(singleIntersection, false); + }); + + test('bottom right intersection - bottom', () { + final rect = Box.fromLTRB(500, 500, 1299, 1300); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.bottom); + expect(singleIntersection, false); + }); + + test('bottom right intersection - right', () { + final rect = Box.fromLTRB(500, 500, 1300, 1299); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.right); + expect(singleIntersection, false); + }); + + test('bottom left intersection - bottom', () { + final rect = Box.fromLTRB(-99, 500, 800, 1300); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.bottom); + expect(singleIntersection, false); + }); + + test('bottom left intersection - left', () { + final rect = Box.fromLTRB(-100, 500, 800, 1299); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final (:amount, :side, :singleIntersection) = + getLargestIntersectionDelta(rect, clampingRect); + + expect(amount, 200); + expect(side, Side.left); + expect(singleIntersection, false); + }); + }); + + group('stopRectAtClampingRect', () { + test('no intersection', () { + final rect = Box.fromLTRB(500, 500, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta, Vector2.zero()); + }); + + test('bottom intersection', () { + final rect = Box.fromLTRB(500, 500, 800, 1200); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta, Vector2(0, -100)); + }); + + test('right intersection', () { + final rect = Box.fromLTRB(500, 500, 1200, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta, Vector2(-100, 0)); + }); + + test('top intersection', () { + final rect = Box.fromLTRB(500, 0, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta, Vector2(0, -100)); + }); + + test('left intersection', () { + final rect = Box.fromLTRB(0, 500, 800, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta, Vector2(-100, 0)); + }); + + test('top right intersection - top', () { + final rect = Box.fromLTRB(500, -100, 1150, 800); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta..round(), Vector2(-46, -200)); + }); + + test('top right intersection - right', () { + final rect = Box.fromLTRB(500, -50, 1300, 800); // 0.727272 * 800 =581.8176 + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta..round(), Vector2(-200, -250)); + }); + + test('bottom right intersection - bottom', () { + final rect = Box.fromLTRB(500, 500, 1300, 1200); + final clampingRect = Box.fromLTRB(100, 100, 1100, 1100); + + final correctiveDelta = BoxTransformer.stopRectAtClampingRect( + rect: rect, + clampingRect: clampingRect, + rotation: 0, + ); + + expect(correctiveDelta..round(), Vector2(-200, -100)); + }); + + }); + group('flipBox tests', () { test('flipBox test with bottom-right handle', () { final box = Box.fromLTWH(500, 500, 400, 300); diff --git a/packages/box_transform/test/rect_result_test.dart b/packages/box_transform/test/rect_result_test.dart index e44c16e..9f809c2 100644 --- a/packages/box_transform/test/rect_result_test.dart +++ b/packages/box_transform/test/rect_result_test.dart @@ -6,6 +6,7 @@ void main() { test('RawTransformResult equality tests', () { final result1 = RawTransformResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), handle: HandlePosition.bottomRight, flip: Flip.none, maxHeightReached: false, @@ -13,14 +14,17 @@ void main() { minHeightReached: false, minWidthReached: false, oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), resizeMode: ResizeMode.freeform, largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), delta: Vector2(200, 200), + rotation: 0, ); final result2 = RawTransformResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), handle: HandlePosition.bottomRight, flip: Flip.none, maxHeightReached: false, @@ -28,10 +32,12 @@ void main() { minHeightReached: false, minWidthReached: false, oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), resizeMode: ResizeMode.freeform, largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), delta: Vector2(200, 200), + rotation: 0, ); expect(result1, result2); @@ -43,6 +49,7 @@ void main() { test('RawResizeResult equality tests', () { final result1 = RawResizeResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), handle: HandlePosition.bottomRight, flip: Flip.none, maxHeightReached: false, @@ -50,6 +57,7 @@ void main() { minHeightReached: false, minWidthReached: false, oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), resizeMode: ResizeMode.freeform, largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), @@ -58,6 +66,7 @@ void main() { final result2 = RawResizeResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), handle: HandlePosition.bottomRight, flip: Flip.none, maxHeightReached: false, @@ -65,6 +74,7 @@ void main() { minHeightReached: false, minWidthReached: false, oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), resizeMode: ResizeMode.freeform, largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), @@ -80,7 +90,9 @@ void main() { test('RawMoveResult equality tests', () { final result1 = RawMoveResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), delta: Vector2(200, 200), @@ -88,7 +100,9 @@ void main() { final result2 = RawMoveResult( rect: Box.fromLTRB(100, 100, 500, 400), + boundingRect: Box.fromLTRB(100, 100, 500, 400), oldRect: Box.fromLTRB(100, 100, 300, 200), + oldBoundingRect: Box.fromLTRB(100, 100, 300, 200), largestRect: Box.fromLTRB(100, 100, 1000, 1000), rawSize: Dimension(400, 300), delta: Vector2(200, 200), diff --git a/packages/flutter_box_transform/lib/src/extensions.dart b/packages/flutter_box_transform/lib/src/extensions.dart index afd4b32..9c4a93b 100644 --- a/packages/flutter_box_transform/lib/src/extensions.dart +++ b/packages/flutter_box_transform/lib/src/extensions.dart @@ -6,29 +6,15 @@ import 'package:vector_math/vector_math.dart'; import 'ui_result.dart'; -/// Provides convenient getters for [UITransformResult]. -extension UITransformResultExt on UITransformResult { - /// Convenient getter for [box.size]. - Size get size => rect.size; - - /// Convenient getter for [box.topLeft]. - Offset get position => rect.topLeft; - - /// Convenient getter for [oldBox.size]. - Size get oldSize => oldRect.size; - - /// Convenient getter for [oldBox.topLeft]. - Offset get oldPosition => oldRect.topLeft; -} - /// Provides convenient methods for [RawResizeResult]. extension ResizeResultExt on RawResizeResult { /// Converts a `ResizeResult` from `rect_resizer` to a `UIResizeResult` UIResizeResult toUI() { return UIResizeResult( - /// Creates a new `UIResizeResult` instance with the converted data rect: rect.toRect(), oldRect: oldRect.toRect(), + boundingRect: boundingRect.toRect(), + oldBoundingRect: oldBoundingRect.toRect(), flip: flip, resizeMode: resizeMode, delta: delta.toOffset(), @@ -48,9 +34,10 @@ extension MoveResultExt on RawMoveResult { /// Converts a `MoveResult` from `rect_resizer` to a `UIMoveResult` UIMoveResult toUI() { return UIMoveResult( - /// Creates a new `UIMoveResult` instance with the converted data rect: rect.toRect(), oldRect: oldRect.toRect(), + boundingRect: boundingRect.toRect(), + oldBoundingRect: oldBoundingRect.toRect(), delta: delta.toOffset(), rawSize: rawSize.toSize(), largestRect: largestRect.toRect(), @@ -58,6 +45,21 @@ extension MoveResultExt on RawMoveResult { } } +/// Provides convenient methods for [RawRotateResult]. +extension RotateResultExt on RawRotateResult { + /// Converts a `RotateResult` from `rect_resizer` to a `UIRotateResult` + UIRotateResult toUI() { + return UIRotateResult( + rect: rect.toRect(), + boundingRect: boundingRect.toRect(), + oldBoundingRect: oldBoundingRect.toRect(), + delta: delta.toOffset(), + rawSize: rawSize.toSize(), + rotation: rotation, + ); + } +} + /// Provides convenient methods for [Box]. extension BoxExt on Box { /// Converts a `Box` from `rect_resizer` to a `Rect` diff --git a/packages/flutter_box_transform/lib/src/handle_builders.dart b/packages/flutter_box_transform/lib/src/handle_builders.dart index d811450..ba0db2f 100644 --- a/packages/flutter_box_transform/lib/src/handle_builders.dart +++ b/packages/flutter_box_transform/lib/src/handle_builders.dart @@ -16,8 +16,11 @@ class CornerHandleWidget extends StatelessWidget { /// 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; + /// The size of the resize handle's gesture response area. + final double resizeHandleGestureSize; + + /// The size of the rotation handle's gesture response area. + final double rotationHandleGestureSize; /// The kind of devices that are allowed to be recognized. final Set supportedDevices; @@ -34,12 +37,27 @@ class CornerHandleWidget extends StatelessWidget { /// Called when the handle dragging is canceled. final GestureDragCancelCallback? onPanCancel; + /// Called when the handle rotates the box. + final GestureRotationStartCallback? onRotationStart; + + /// Called when the handle rotates the box. + final GestureRotationUpdateCallback? onRotationUpdate; + + /// Called when the handle rotates the box. + final GestureRotationEndCallback? onRotationEnd; + + /// Called when the handle rotates the box. + final GestureRotationCancelCallback? onRotationCancel; + /// Whether the handle is resizable. final bool enabled; /// Whether the handle is visible. final bool visible; + /// Whether the handle supports rotation. + final bool rotatable; + /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; @@ -47,15 +65,27 @@ class CornerHandleWidget extends StatelessWidget { CornerHandleWidget({ super.key, required this.handlePosition, - required this.handleTapSize, + required this.resizeHandleGestureSize, + required this.rotationHandleGestureSize, required this.supportedDevices, required this.builder, + + // Resize this.onPanStart, this.onPanUpdate, this.onPanEnd, this.onPanCancel, + + // Rotate + this.onRotationStart, + this.onRotationUpdate, + this.onRotationEnd, + this.onRotationCancel, + + // Config this.enabled = true, this.visible = true, + this.rotatable = true, this.debugPaintHandleBounds = false, }) : assert(handlePosition.isDiagonal, 'A corner handle must be diagonal.'); @@ -65,6 +95,8 @@ class CornerHandleWidget extends StatelessWidget { visible ? builder(context, handlePosition) : const SizedBox.shrink(); if (enabled) { + final double gestureGap = + (rotationHandleGestureSize - resizeHandleGestureSize) / 2; child = GestureDetector( behavior: HitTestBehavior.opaque, supportedDevices: supportedDevices, @@ -73,10 +105,41 @@ class CornerHandleWidget extends StatelessWidget { onPanEnd: onPanEnd, onPanCancel: onPanCancel, child: MouseRegion( - cursor: getCursorForHandle(handlePosition), - child: child, + cursor: getResizeCursorForHandle(handlePosition), + child: Padding( + padding: rotatable + ? getRotationCornerPadding( + handlePosition.opposite, + gestureGap, + ) + : EdgeInsets.zero, + child: child, + ), ), ); + + if (rotatable) { + if (kDebugMode && debugPaintHandleBounds) { + child = ColoredBox( + color: Colors.blue.withOpacity(0.5), + child: child, + ); + } + child = GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: onRotationStart, + onPanUpdate: onRotationUpdate, + onPanEnd: onRotationEnd, + onPanCancel: onRotationCancel, + child: MouseRegion( + cursor: getRotationCursorForHandle(handlePosition), + child: Padding( + padding: getRotationCornerPadding(handlePosition, gestureGap), + child: child, + ), + ), + ); + } } if (kDebugMode && debugPaintHandleBounds) { @@ -91,14 +154,42 @@ class CornerHandleWidget extends StatelessWidget { right: handlePosition.influencesRight ? 0 : null, top: handlePosition.influencesTop ? 0 : null, bottom: handlePosition.influencesBottom ? 0 : null, - width: handleTapSize, - height: handleTapSize, + width: rotatable ? rotationHandleGestureSize : resizeHandleGestureSize, + height: rotatable ? rotationHandleGestureSize : resizeHandleGestureSize, child: child, ); } - /// Returns the cursor for the given handle position. - MouseCursor getCursorForHandle(HandlePosition handle) { + /// Returns the padding for the rotation gesture area. + EdgeInsets getRotationCornerPadding( + HandlePosition handlePosition, double value) { + return switch (handlePosition) { + HandlePosition.topLeft => EdgeInsets.only(left: value, top: value), + HandlePosition.topRight => EdgeInsets.only(right: value, top: value), + HandlePosition.bottomLeft => EdgeInsets.only(left: value, bottom: value), + HandlePosition.bottomRight => + EdgeInsets.only(right: value, bottom: value), + _ => throw Exception('Invalid handle position. Corners only.'), + }; + } + + /// Returns the resize cursor for the given handle position. + MouseCursor getResizeCursorForHandle(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.'); + } + } + + /// Returns the rotation cursor for the given handle position. + /// TODO: No rotation cursor in Flutter. + MouseCursor getRotationCursorForHandle(HandlePosition handle) { switch (handle) { case HandlePosition.topLeft: case HandlePosition.bottomRight: @@ -123,7 +214,10 @@ class SideHandleWidget extends StatelessWidget { final HandleBuilder builder; /// The thickness of the handle that is used for gesture detection. - final double handleTapSize; + final double resizeHandleGestureSize; + + /// The size of the rotation handle's gesture response area. + final double rotationHandleGestureSize; /// The kind of devices that are allowed to be recognized. final Set supportedDevices; @@ -140,6 +234,9 @@ class SideHandleWidget extends StatelessWidget { /// Called when the handle dragging is canceled. final GestureDragCancelCallback? onPanCancel; + /// Whether the handle is rotatable. + final bool rotatable; + /// Whether the handle is resizable. final bool enabled; @@ -153,13 +250,15 @@ class SideHandleWidget extends StatelessWidget { SideHandleWidget({ super.key, required this.handlePosition, - required this.handleTapSize, + required this.resizeHandleGestureSize, + required this.rotationHandleGestureSize, required this.supportedDevices, required this.builder, this.onPanStart, this.onPanUpdate, this.onPanEnd, this.onPanCancel, + this.rotatable = true, this.enabled = true, this.visible = true, this.debugPaintHandleBounds = false, @@ -192,29 +291,34 @@ class SideHandleWidget extends StatelessWidget { ); } + final double gestureSize = + rotatable ? rotationHandleGestureSize : resizeHandleGestureSize; + final double gestureOffset = + gestureSize / 2 - (resizeHandleGestureSize / 2); + return Positioned( left: handlePosition.isVertical - ? handleTapSize + ? gestureSize : handlePosition.influencesLeft - ? 0 + ? gestureOffset : null, right: handlePosition.isVertical - ? handleTapSize + ? gestureSize : handlePosition.influencesRight - ? 0 + ? gestureOffset : null, top: handlePosition.isHorizontal - ? handleTapSize + ? gestureSize : handlePosition.influencesTop - ? 0 + ? gestureOffset : null, bottom: handlePosition.isHorizontal - ? handleTapSize + ? gestureSize : handlePosition.influencesBottom - ? 0 + ? gestureOffset : null, - width: handlePosition.isHorizontal ? handleTapSize : null, - height: handlePosition.isVertical ? handleTapSize : null, + width: handlePosition.isHorizontal ? resizeHandleGestureSize : null, + height: handlePosition.isVertical ? resizeHandleGestureSize : null, child: child, ); } diff --git a/packages/flutter_box_transform/lib/src/transformable_box.dart b/packages/flutter_box_transform/lib/src/transformable_box.dart index dd46722..36e6f60 100644 --- a/packages/flutter_box_transform/lib/src/transformable_box.dart +++ b/packages/flutter_box_transform/lib/src/transformable_box.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -45,7 +48,20 @@ class TransformableBox extends StatefulWidget { /// gesture response area to make it forgiving. /// /// The default value is 24 pixels in diameter. - final double handleTapSize; + final double resizeHandleGestureSize; + + /// The size of the gesture response area of the rotation handle. If you don't + /// specify it, the default value will be used. + /// + /// This is similar to Flutter's [MaterialTapTargetSize] property, in which + /// the actual rotation 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 64 pixels in diameter. + final double rotationHandleGestureSize; /// A set containing handles that are enabled. This is different from /// [visibleHandles]. @@ -116,6 +132,17 @@ class TransformableBox extends StatefulWidget { /// resized by the [TransformableBoxController]. final BoxConstraints constraints; + /// Whether the box is rotatable or not. Setting this to false will disable + /// all rotation operations. + final bool rotatable; + + /// The rotation of the box in radians. + final double rotation; + + /// The strategy to use when binding the box to the constraints and clamping + /// rect. + final BindingStrategy bindingStrategy; + /// 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. @@ -171,7 +198,7 @@ class TransformableBox extends StatefulWidget { final RectDragCancelEvent? onDragCancel; /// A callback function that triggers when the box is about to start resizing. - final RectResizeStart? onResizeStart; + final RectResizeStartEvent? onResizeStart; /// A callback that is called every time the [TransformableBox] is resized. /// This is called every time the [TransformableBoxController] mutates the @@ -182,10 +209,26 @@ class TransformableBox extends StatefulWidget { final RectResizeUpdateEvent? onResizeUpdate; /// A callback function that triggers when the box is about to end resizing. - final RectResizeEnd? onResizeEnd; + final RectResizeEndEvent? onResizeEnd; /// A callback function that triggers when the box cancels resizing. - final RectResizeCancel? onResizeCancel; + final RectResizeCancelEvent? onResizeCancel; + + /// A callback function that triggers when the box is about to start rotating. + final RectRotateStartEvent? onRotationStart; + + /// A callback that is called every time the [TransformableBox] is rotated. + /// This is called every time the [TransformableBoxController] mutates the + /// box through a rotation operation. + final RectRotateUpdateEvent? onRotationUpdate; + + /// A callback that is called every time the [TransformableBox] is completes + /// its rotation operation via the pan end event. + final RectRotateEndEvent? onRotationEnd; + + /// A callback that is called every time the [TransformableBox] cancels + /// its rotation operation via the pan cancel event. + final RectRotateCancelEvent? onRotationCancel; /// A callback function that triggers when the box reaches its minimum width /// when resizing. @@ -237,13 +280,15 @@ class TransformableBox extends StatefulWidget { this.controller, this.cornerHandleBuilder = _defaultCornerHandleBuilder, this.sideHandleBuilder = _defaultSideHandleBuilder, - this.handleTapSize = 24, + this.resizeHandleGestureSize = 24, + this.rotationHandleGestureSize = 64, this.allowContentFlipping = true, this.handleAlignment = HandleAlignment.center, this.enabledHandles = const {...HandlePosition.values}, this.visibleHandles = const {...HandlePosition.values}, this.supportedDragDevices = const {...PointerDeviceKind.values}, this.supportedResizeDevices = const {...PointerDeviceKind.values}, + this.bindingStrategy = BindingStrategy.boundingBox, // Raw values. Rect? rect, @@ -251,10 +296,12 @@ class TransformableBox extends StatefulWidget { Rect? clampingRect, BoxConstraints? constraints, ValueGetter? resizeModeResolver, + double? rotation, // Additional controls. this.resizable = true, this.draggable = true, + this.rotatable = true, this.allowFlippingWhileResizing = true, // Tap events @@ -269,12 +316,18 @@ class TransformableBox extends StatefulWidget { this.onResizeEnd, this.onResizeCancel, - // Drag Events. + // Drag events. this.onDragStart, this.onDragUpdate, this.onDragEnd, this.onDragCancel, + // Rotate events. + this.onRotationStart, + this.onRotationUpdate, + this.onRotationEnd, + this.onRotationCancel, + // Terminal update events. this.onMinWidthReached, this.onMaxWidthReached, @@ -297,7 +350,8 @@ class TransformableBox extends StatefulWidget { flip = flip ?? Flip.none, clampingRect = clampingRect ?? Rect.largest, constraints = constraints ?? const BoxConstraints.expand(), - resizeModeResolver = resizeModeResolver ?? defaultResizeModeResolver; + resizeModeResolver = resizeModeResolver ?? defaultResizeModeResolver, + rotation = rotation ?? 0; /// Returns the [TransformableBox] of the closest ancestor. static TransformableBox? widgetOf(BuildContext context) { @@ -317,11 +371,14 @@ class TransformableBox extends StatefulWidget { enum _PrimaryGestureOperation { resize, - drag; + drag, + rotate; bool get isDragging => this == _PrimaryGestureOperation.drag; bool get isResizing => this == _PrimaryGestureOperation.resize; + + bool get isRotating => this == _PrimaryGestureOperation.rotate; } class _TransformableBoxState extends State { @@ -335,7 +392,9 @@ class _TransformableBoxState extends State { bool get isResizing => primaryGestureOperation?.isResizing == true; - bool get isGestureActive => isDragging || isResizing; + bool get isRotating => primaryGestureOperation?.isRotating == true; + + bool get isGestureActive => isDragging || isResizing || isRotating; bool mismatchedHandle(HandlePosition handle) => lastHandle != null && lastHandle != handle; @@ -355,8 +414,10 @@ class _TransformableBoxState extends State { flip: widget.flip, clampingRect: widget.clampingRect, constraints: widget.constraints, + rotation: widget.rotation, resizeModeResolver: widget.resizeModeResolver, allowFlippingWhileResizing: widget.allowFlippingWhileResizing, + bindingStrategy: widget.bindingStrategy, ); } } @@ -378,9 +439,11 @@ class _TransformableBoxState extends State { rect: widget.rect, flip: widget.flip, clampingRect: widget.clampingRect, + rotation: widget.rotation, constraints: widget.constraints, resizeModeResolver: widget.resizeModeResolver, allowFlippingWhileResizing: widget.allowFlippingWhileResizing, + bindingStrategy: widget.bindingStrategy, ); } @@ -388,11 +451,17 @@ class _TransformableBoxState extends State { 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); + controller.setRect(widget.rect, notify: false, recalculate: false); + } + + if (oldWidget.rotation != widget.rotation) { + controller.setRotation(widget.rotation, notify: false); + } + + if (oldWidget.bindingStrategy != widget.bindingStrategy) { + controller.setBindingStrategy(widget.bindingStrategy, notify: false); } if (oldWidget.flip != widget.flip) { @@ -407,13 +476,15 @@ class _TransformableBoxState extends State { } if (oldWidget.clampingRect != widget.clampingRect) { - controller.setClampingRect(widget.clampingRect, notify: false); - shouldRecalculatePosition = true; + controller.setClampingRect( + widget.clampingRect, + notify: false, + recalculate: false, + ); } if (oldWidget.constraints != widget.constraints) { controller.setConstraints(widget.constraints, notify: false); - shouldRecalculateSize = true; } if (oldWidget.allowFlippingWhileResizing != @@ -424,13 +495,7 @@ class _TransformableBoxState extends State { ); } - if (shouldRecalculatePosition) { - controller.recalculatePosition(notify: false); - } - - if (shouldRecalculateSize) { - controller.recalculateSize(notify: false); - } + controller.recalculate(notify: false); } @override @@ -524,6 +589,114 @@ class _TransformableBoxState extends State { widget.onTerminalSizeReached?.call(false, false, false, false); } + Offset localPos = Offset.zero; + Offset initialPos = Offset.zero; + + Offset rectQuadrantOffset(Quadrant quadrant) => switch (quadrant) { + Quadrant.topLeft => controller.rect.topLeft, + Quadrant.topRight => controller.rect.topRight, + Quadrant.bottomLeft => controller.rect.bottomLeft, + Quadrant.bottomRight => controller.rect.bottomRight, + }; + + void onHandleRotateStart(DragStartDetails event, HandlePosition handle) { + if (isGestureActive) return; + + primaryGestureOperation = _PrimaryGestureOperation.rotate; + lastHandle = handle; + + final offset = + widget.handleAlignment.offset(widget.rotationHandleGestureSize); + initialPos = rectQuadrantOffset(handle.quadrant) + + event.localPosition - + Offset(offset, offset); + localPos = initialPos; + setState(() {}); + + // 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.onRotateStart(initialPos); + widget.onRotationStart?.call(handle, event); + } + + void onHandleRotateUpdate(DragUpdateDetails event, HandlePosition handle) { + if (!isGestureActive) return; + + final offset = + widget.handleAlignment.offset(widget.rotationHandleGestureSize); + localPos = rectQuadrantOffset(handle.quadrant) + + event.localPosition - + Offset(offset, offset); + setState(() {}); + + final UIRotateResult result = controller.onRotateUpdate( + localPos, + handle, + ); + + widget.onChanged?.call(result, event); + widget.onRotationUpdate?.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, + ); + } + + void onHandleRotateEnd(DragEndDetails event, HandlePosition handle) { + if (!isGestureActive) return; + + primaryGestureOperation = null; + lastHandle = null; + + controller.onRotateEnd(); + widget.onRotationEnd?.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 onHandleRotateCancel(HandlePosition handle) { + if (!isGestureActive) return; + + primaryGestureOperation = null; + lastHandle = null; + + controller.onRotateEnd(); + widget.onRotationCancel?.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 is tapped. void onTap() { if (isGestureActive) return; @@ -578,12 +751,14 @@ class _TransformableBoxState extends State { @override Widget build(BuildContext context) { final Flip flip = controller.flip; - final Rect rect = controller.rect; + final Rect unrotatedRect = controller.rect; + final Rect boundingRect = controller.boundingRect; + final double rotation = controller.rotation; Widget content = Transform.scale( scaleX: widget.allowContentFlipping && flip.isHorizontal ? -1 : 1, scaleY: widget.allowContentFlipping && flip.isVertical ? -1 : 1, - child: widget.contentBuilder(context, rect, flip), + child: widget.contentBuilder(context, unrotatedRect, flip), ); if (widget.draggable) { @@ -599,59 +774,190 @@ class _TransformableBoxState extends State { ); } - return Positioned.fromRect( - rect: rect.inflate(widget.handleAlignment.offset(widget.handleTapSize)), - child: Stack( - clipBehavior: Clip.none, - fit: StackFit.expand, - children: [ - Positioned( - left: widget.handleAlignment.offset(widget.handleTapSize), - top: widget.handleAlignment.offset(widget.handleTapSize), - width: rect.width, - height: rect.height, - child: content, + final double gestureSize = switch (widget.rotatable) { + false => widget.resizeHandleGestureSize, + true => widget.rotationHandleGestureSize, + }; + final double handleOffset = widget.handleAlignment.offset(gestureSize); + + final double gestureGap = + (widget.rotationHandleGestureSize - widget.resizeHandleGestureSize) / 2; + final double offset = widget.resizeHandleGestureSize / 2 + + (widget.rotatable ? widget.rotationHandleGestureSize / 2 : 0); + final Rect inflatedRect = unrotatedRect.inflate(handleOffset); + + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + // Positioned.fromRect( + // rect: unrotatedRect.inflate(widget.rotationGestureSize / 2), + // child: Container( + // decoration: + // BoxDecoration(border: Border.all(color: Colors.red, width: 4)), + // ), + // ), + // Positioned.fromRect( + // rect: unrotatedRect.inflate(widget.handleGestureSize / 2), + // child: Container( + // decoration: BoxDecoration( + // border: Border.all(color: Colors.green, width: 3)), + // ), + // ), + // Positioned.fromRect( + // rect: unrotatedRect, + // child: Container( + // decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2)), + // ), + // ), + // Positioned.fromRect( + // rect: unrotatedRect, + // child: Container( + // decoration: + // BoxDecoration(border: Border.all(color: Colors.green.shade900)), + // ), + // ), + + Positioned.fromRect( + rect: inflatedRect, + child: Transform.rotate( + angle: rotation, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned( + left: widget.handleAlignment.offset(gestureSize), + top: widget.handleAlignment.offset(gestureSize), + width: unrotatedRect.width, + height: unrotatedRect.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, + resizeHandleGestureSize: widget.resizeHandleGestureSize, + rotationHandleGestureSize: + widget.rotationHandleGestureSize, + supportedDevices: widget.supportedResizeDevices, + enabled: widget.enabledHandles.contains(handle), + visible: widget.visibleHandles.contains(handle), + rotatable: widget.rotatable, + // Resize + onPanStart: (event) => onHandlePanStart(event, handle), + onPanUpdate: (event) => onHandlePanUpdate(event, handle), + onPanEnd: (event) => onHandlePanEnd(event, handle), + onPanCancel: () => onHandlePanCancel(handle), + // Rotate + onRotationStart: (event) => + onHandleRotateStart(event, handle), + onRotationUpdate: (event) => + onHandleRotateUpdate(event, handle), + onRotationEnd: (event) => + onHandleRotateEnd(event, handle), + onRotationCancel: () => onHandleRotateCancel(handle), + builder: widget.cornerHandleBuilder, + debugPaintHandleBounds: kDebugMode, + ), + 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, + resizeHandleGestureSize: widget.resizeHandleGestureSize, + rotationHandleGestureSize: + widget.rotationHandleGestureSize, + rotatable: widget.rotatable, + supportedDevices: widget.supportedResizeDevices, + 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, + debugPaintHandleBounds: kDebugMode, + ), + ], + ), ), - 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, - supportedDevices: widget.supportedResizeDevices, - 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, - supportedDevices: widget.supportedResizeDevices, - 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, - ), - ], - ), + ), + // Positioned.fromRect( + // rect: unrotatedRect, + // child: IgnorePointer( + // child: Container( + // decoration: BoxDecoration( + // border: Border.all(color: Colors.green, width: 2)), + // ), + // ), + // ), + // + // Positioned.fromRect( + // rect: boundingRect, + // child: IgnorePointer( + // child: Container( + // decoration: BoxDecoration( + // border: Border.all(color: Colors.red, width: 3)), + // ), + // ), + // ), + ], ); } } +class RenderRotationArrows extends CustomPainter { + final Offset initialPosition; + final Offset currentPosition; + final Rect rect; + + const RenderRotationArrows({ + required this.initialPosition, + required this.currentPosition, + required this.rect, + }); + + @override + void paint(Canvas canvas, Size size) { + final Offset from = initialPosition; + final Offset to = currentPosition; + + final Path fromPath = Path() + ..moveTo(rect.center.dx, rect.center.dy) + ..lineTo(from.dx, from.dy) + ..addOval(Rect.fromCircle(center: from, radius: 8)) + ..close(); + + final Path toPath = Path() + ..moveTo(rect.center.dx, rect.center.dy) + ..lineTo(to.dx, to.dy) + ..addOval(Rect.fromCircle(center: to, radius: 8)) + ..close(); + + final Paint paint = Paint() + ..color = Colors.red + ..style = PaintingStyle.stroke + ..strokeWidth = 4; + + canvas.drawPath(fromPath, paint); + canvas.drawPath(toPath, paint..color = Colors.blue); + + // canvas.drawCircle(currentPosition, 24, Paint()..color = Colors.green); + } + + @override + bool shouldRepaint(covariant RenderRotationArrows oldDelegate) => + oldDelegate.initialPosition != initialPosition || + oldDelegate.currentPosition != currentPosition || + oldDelegate.rect != rect; +} + /// A default implementation of the corner [HandleBuilder] callback. Widget _defaultCornerHandleBuilder( BuildContext context, diff --git a/packages/flutter_box_transform/lib/src/transformable_box_controller.dart b/packages/flutter_box_transform/lib/src/transformable_box_controller.dart index 3fbf427..9262f33 100644 --- a/packages/flutter_box_transform/lib/src/transformable_box_controller.dart +++ b/packages/flutter_box_transform/lib/src/transformable_box_controller.dart @@ -1,9 +1,7 @@ -import 'package:box_transform/box_transform.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'ui_box_transform.dart'; -import 'ui_result.dart'; +import '../flutter_box_transform.dart'; /// Default [ResizeModeResolver] implementation. This implementation /// doesn't rely on the focus system .It resolves the [ResizeMode] based on @@ -42,14 +40,23 @@ class TransformableBoxController extends ChangeNotifier { Flip? flip, Rect? clampingRect, BoxConstraints? constraints, + double? rotation, ValueGetter? resizeModeResolver, bool allowFlippingWhileResizing = true, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) : _rect = rect ?? Rect.zero, _flip = flip ?? Flip.none, _clampingRect = clampingRect ?? Rect.largest, _constraints = constraints ?? const BoxConstraints(), + _rotation = rotation ?? 0.0, _resizeModeResolver = resizeModeResolver ?? defaultResizeModeResolver, - _allowFlippingWhileResizing = allowFlippingWhileResizing; + _allowFlippingWhileResizing = allowFlippingWhileResizing, + _bindingStrategy = bindingStrategy { + _boundingRect = BoxTransformer.calculateBoundingRect( + rotation: _rotation, + unrotatedBox: _rect.toBox(), + ).toRect(); + } /// The callback function that is used to resolve the [ResizeMode] based on /// the pressed keys on the keyboard. @@ -65,6 +72,27 @@ class TransformableBoxController extends ChangeNotifier { /// The current [Rect] of the [TransformableBox]. Rect get rect => _rect; + Rect _boundingRect = Rect.zero; + + /// The current bounding [Rect] of the [TransformableBox] that contains + /// all 4 vertices of the box, even when rotated. + Rect get boundingRect => _boundingRect; + + double _initialRotation = 0.0; + + /// The initial [rotation] of the [TransformableBox] when the resizing starts. + double get initialRotation => _initialRotation; + + double _rotation = 0.0; + + /// The current [rotation] of the [TransformableBox]. + double get rotation => _rotation; + + BindingStrategy _bindingStrategy = BindingStrategy.boundingBox; + + /// The current [BindingStrategy] of the [TransformableBox]. + BindingStrategy get bindingStrategy => _bindingStrategy; + /// The current [Flip] of the [TransformableBox]. Flip _flip = Flip.none; @@ -155,6 +183,13 @@ class TransformableBoxController extends ChangeNotifier { if (notify) notifyListeners(); } + /// Sets the initial [rotation] of the [TransformableBox]. + void setInitialRotation(double initialRotation, {bool notify = true}) { + _initialRotation = initialRotation; + + if (notify) notifyListeners(); + } + /// Sets the initial [Rect] of the [TransformableBox]. void setInitialRect(Rect initialRect, {bool notify = true}) { _initialRect = initialRect; @@ -191,6 +226,21 @@ class TransformableBoxController extends ChangeNotifier { if (notify) notifyListeners(); } + /// Sets the current [rotation] of the [TransformableBox]. + void setRotation(double rotation, {bool notify = true}) { + _rotation = rotation; + + if (notify) notifyListeners(); + } + + /// Sets the current [bindingStrategy] of the [TransformableBox]. + void setBindingStrategy(BindingStrategy bindingStrategy, + {bool notify = true}) { + _bindingStrategy = bindingStrategy; + + if (notify) notifyListeners(); + } + /// 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. @@ -228,9 +278,12 @@ class TransformableBoxController extends ChangeNotifier { initialLocalPosition: initialLocalPosition, localPosition: localPosition, clampingRect: clampingRect, + rotation: rotation, + bindingStrategy: bindingStrategy, ); _rect = result.rect; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); @@ -248,6 +301,48 @@ class TransformableBoxController extends ChangeNotifier { /// Called when the dragging of the [TransformableBox] is cancelled. void onDragCancel({bool notify = true}) => onDragEnd(notify: notify); + /// Called when the rotation of the [TransformableBox] starts. + void onRotateStart(Offset localPosition) { + _initialLocalPosition = localPosition; + _initialRect = rect; + _initialRotation = rotation; + } + + /// Called when the [TransformableBox] is being rotated. + UIRotateResult onRotateUpdate( + Offset localPosition, + HandlePosition handle, { + bool notify = true, + }) { + final UIRotateResult result = UIBoxTransform.rotate( + rect: initialRect, + initialLocalPosition: initialLocalPosition, + initialRotation: initialRotation, + localPosition: localPosition, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + ); + + _rotation = result.rotation; + _boundingRect = result.boundingRect; + + if (notify) notifyListeners(); + + return result; + } + + /// Called when the rotation of the [TransformableBox] ends. + void onRotateEnd({bool notify = true}) { + _initialLocalPosition = Offset.zero; + _initialRect = Rect.zero; + _initialRotation = 0.0; + + if (notify) notifyListeners(); + } + + /// Called when the rotation of the [TransformableBox] is cancelled. + void onRotateCancel({bool notify = true}) => onRotateEnd(notify: notify); + /// Called when the resizing starts on [TransformableBox]. /// /// [localPosition] is the position of the pointer relative to the @@ -285,15 +380,18 @@ class TransformableBoxController extends ChangeNotifier { handle: handle, initialRect: initialRect, initialLocalPosition: initialLocalPosition, + rotation: rotation, resizeMode: resizeModeResolver?.call() ?? this.resizeModeResolver(), initialFlip: initialFlip, clampingRect: clampingRect, constraints: constraints, allowFlipping: allowFlippingWhileResizing, + bindingStrategy: bindingStrategy, ); _rect = result.rect; _flip = result.flip; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); return result; @@ -319,9 +417,12 @@ class TransformableBoxController extends ChangeNotifier { initialLocalPosition: initialLocalPosition, localPosition: initialLocalPosition, clampingRect: clampingRect, + rotation: rotation, + bindingStrategy: bindingStrategy, ); _rect = result.rect; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); } @@ -334,14 +435,17 @@ class TransformableBoxController extends ChangeNotifier { initialLocalPosition: initialLocalPosition, localPosition: initialLocalPosition, clampingRect: clampingRect, + rotation: rotation, handle: HandlePosition.bottomRight, resizeMode: ResizeMode.scale, initialFlip: initialFlip, constraints: constraints, allowFlipping: allowFlippingWhileResizing, + bindingStrategy: bindingStrategy, ); _rect = result.rect; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); } diff --git a/packages/flutter_box_transform/lib/src/typedefs.dart b/packages/flutter_box_transform/lib/src/typedefs.dart index dce46d0..cfeac33 100644 --- a/packages/flutter_box_transform/lib/src/typedefs.dart +++ b/packages/flutter_box_transform/lib/src/typedefs.dart @@ -3,6 +3,11 @@ import 'package:flutter/widgets.dart'; import 'ui_result.dart'; +typedef GestureRotationStartCallback = GestureDragStartCallback; +typedef GestureRotationUpdateCallback = GestureDragUpdateCallback; +typedef GestureRotationEndCallback = GestureDragEndCallback; +typedef GestureRotationCancelCallback = GestureDragCancelCallback; + /// A callback that expects a [Widget] that represents any of the handles. /// The [handle] is the current position and size of the handle. typedef HandleBuilder = Widget Function( @@ -45,7 +50,7 @@ typedef RectDragEndEvent = void Function( typedef RectDragCancelEvent = void Function(); /// A callback that is called when the box begins a resize operation. -typedef RectResizeStart = void Function( +typedef RectResizeStartEvent = void Function( HandlePosition handle, DragStartDetails event, ); @@ -57,13 +62,36 @@ typedef RectResizeUpdateEvent = void Function( ); /// A callback that is called when the box ends a resize operation. -typedef RectResizeEnd = void Function( +typedef RectResizeEndEvent = void Function( HandlePosition handle, DragEndDetails event, ); /// A callback that is called when the box cancels a resize operation. -typedef RectResizeCancel = void Function( +typedef RectResizeCancelEvent = void Function( + HandlePosition handle, +); + +/// A callback that is called when the box begins a rotation operation. +typedef RectRotateStartEvent = void Function( + HandlePosition handle, + DragStartDetails event, +); + +/// A callback that is called when the box is being rotated. +typedef RectRotateUpdateEvent = void Function( + UIRotateResult result, + DragUpdateDetails event, +); + +/// A callback that is called when the box ends a rotation operation. +typedef RectRotateEndEvent = void Function( + HandlePosition handle, + DragEndDetails event, +); + +/// A callback that is called when the box cancels a rotation operation. +typedef RectRotateCancelEvent = void Function( HandlePosition handle, ); diff --git a/packages/flutter_box_transform/lib/src/ui_box_transform.dart b/packages/flutter_box_transform/lib/src/ui_box_transform.dart index 55df739..a66fb4d 100644 --- a/packages/flutter_box_transform/lib/src/ui_box_transform.dart +++ b/packages/flutter_box_transform/lib/src/ui_box_transform.dart @@ -3,8 +3,7 @@ import 'dart:ui' as ui; import 'package:box_transform/box_transform.dart' as transform; import 'package:flutter/rendering.dart' as widgets; -import 'extensions.dart'; -import 'ui_result.dart'; +import '../flutter_box_transform.dart'; /// A Flutter translation of [transform.BoxTransformer]. class UIBoxTransform { @@ -16,23 +15,27 @@ class UIBoxTransform { required ui.Rect initialRect, required ui.Offset initialLocalPosition, required ui.Offset localPosition, + required double rotation, required transform.HandlePosition handle, required transform.ResizeMode resizeMode, required transform.Flip initialFlip, ui.Rect clampingRect = ui.Rect.largest, widgets.BoxConstraints constraints = const widgets.BoxConstraints(), bool allowFlipping = true, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) => transform.BoxTransformer.resize( initialRect: initialRect.toBox(), initialLocalPosition: initialLocalPosition.toVector2(), localPosition: localPosition.toVector2(), + rotation: rotation, handle: handle, resizeMode: resizeMode, initialFlip: initialFlip, clampingRect: clampingRect.toBox(), constraints: constraints.toConstraints(), allowFlipping: allowFlipping, + bindingStrategy: bindingStrategy, ).toUI(); /// The Flutter wrapper for [transform.BoxTransformer.move]. @@ -40,12 +43,34 @@ class UIBoxTransform { required ui.Rect initialRect, required ui.Offset initialLocalPosition, required ui.Offset localPosition, + required double rotation, ui.Rect clampingRect = ui.Rect.largest, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) => transform.BoxTransformer.move( initialRect: initialRect.toBox(), initialLocalPosition: initialLocalPosition.toVector2(), localPosition: localPosition.toVector2(), + rotation: rotation, clampingRect: clampingRect.toBox(), + bindingStrategy: bindingStrategy, + ).toUI(); + + /// The Flutter wrapper for [transform.BoxTransformer.rotate]. + static UIRotateResult rotate({ + required ui.Rect rect, + required ui.Offset initialLocalPosition, + required ui.Offset localPosition, + required double initialRotation, + ui.Rect clampingRect = ui.Rect.largest, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, + }) => + transform.BoxTransformer.rotate( + rect: rect.toBox(), + initialLocalPosition: initialLocalPosition.toVector2(), + localPosition: localPosition.toVector2(), + clampingRect: clampingRect.toBox(), + bindingStrategy: bindingStrategy, + initialRotation: initialRotation, ).toUI(); } diff --git a/packages/flutter_box_transform/lib/src/ui_result.dart b/packages/flutter_box_transform/lib/src/ui_result.dart index da80f39..1c7eeda 100644 --- a/packages/flutter_box_transform/lib/src/ui_result.dart +++ b/packages/flutter_box_transform/lib/src/ui_result.dart @@ -13,3 +13,7 @@ typedef UIResizeResult = ResizeResult; /// A convenient type alias for a [TransformResult] with Flutter's [Rect], /// [Offset] and [Size] types. typedef UITransformResult = TransformResult; + +/// A convenient type alias for a [RotateResult] with Flutter's [Rect], [Offset] +/// and [Size] types. +typedef UIRotateResult = RotateResult; diff --git a/packages/flutter_box_transform/playground/devtools_options.yaml b/packages/flutter_box_transform/playground/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/packages/flutter_box_transform/playground/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/packages/flutter_box_transform/playground/lib/main.dart b/packages/flutter_box_transform/playground/lib/main.dart index eb5c975..f90aa3b 100644 --- a/packages/flutter_box_transform/playground/lib/main.dart +++ b/packages/flutter_box_transform/playground/lib/main.dart @@ -110,6 +110,7 @@ class PlaygroundModel with ChangeNotifier { kInitialWidth, kInitialHeight, ), + rotation: 0, flip: Flip.none, constraintsEnabled: true, constraints: const BoxConstraints( @@ -131,6 +132,10 @@ class PlaygroundModel with ChangeNotifier { if (result is UIResizeResult) { selectedBox!.flip = result.flip; } + if (result is UIRotateResult) { + selectedBox!.rotation = result.rotation; + } + notifyListeners(); } @@ -176,6 +181,7 @@ class PlaygroundModel with ChangeNotifier { BoxData( name: 'Box ${boxes.length + 1}', imageAsset: Images.values[boxes.length % Images.values.length], + rotation: 0, rect: Rect.fromLTWH( playgroundArea!.center.dx - kInitialWidth / 2, playgroundArea!.center.dy - kInitialHeight / 2, @@ -499,7 +505,9 @@ class _PlaygroundState extends State with WidgetsBindingObserver { ), if (model.clampingEnabled && model.playgroundArea != null) const ClampingRect(), - for (int index = 0; index < model.boxes.length; index++) + for (int index = 0; + index < model.boxes.length; + index++) ...[ ImageBox( key: ValueKey(model.boxes[index].name), box: model.boxes[index], @@ -507,6 +515,125 @@ class _PlaygroundState extends State with WidgetsBindingObserver { onChanged: model.onRectChanged, onSelected: () => model.onBoxSelected(index), ), + // Builder( + // builder: (context) { + // final box = model.boxes[index]; + // final rect = box.rect.toBox(); + // final bounding = + // BoxTransformer.calculateBoundingRect( + // rotation: box.rotation, + // unrotatedBox: rect, + // ); + // final clampingRect = model.clampingRect.toBox(); + // final (:side, :amount, :singleIntersection) = + // getLargestIntersectionDelta( + // bounding, clampingRect); + // + // final Map rotatedPoints = { + // for (final MapEntry(key: quadrant, value: point) + // in rect.sidedPoints.entries) + // quadrant: BoxTransformer.rotatePointAroundVec( + // rect.center, box.rotation, point) + // }; + // + // // Check if any rotated point is outside the clamping rect. + // ( + // Side side, + // Quadrant quadrant, + // Vector2 point, + // double dist + // )? biggestOutOfBounds; + // for (final MapEntry(key: quadrant, value: point) + // in rotatedPoints.entries) { + // if (biggestOutOfBounds == null) { + // final (side, dist) = + // clampingRect.distanceOfPoint(point); + // biggestOutOfBounds = + // (side, quadrant, point, dist); + // } else { + // final (side, dist) = + // clampingRect.distanceOfPoint(point); + // final (_, biggestDist) = clampingRect + // .distanceOfPoint(biggestOutOfBounds.$3); + // if (dist < biggestDist) { + // biggestOutOfBounds = + // (side, quadrant, point, dist); + // } + // } + // } + // + // assert(biggestOutOfBounds != null); + // + // final side2 = biggestOutOfBounds!.$1; + // Vector2 point = biggestOutOfBounds.$3; + // final dist = biggestOutOfBounds.$4; + // + // final correctedVector = switch (side) { + // Side.left => Vector2(-dist, 0), + // Side.right => Vector2(dist, 0), + // Side.top => Vector2(0, -dist), + // Side.bottom => Vector2(0, dist), + // }; + // + // final adjusted = Vector2( + // point.x + correctedVector.x, + // point.y + correctedVector.y); + // + // // Rotate back + // final unrotated = + // BoxTransformer.rotatePointAroundVec( + // rect.center, -box.rotation, adjusted); + // + // return Stack( + // fit: StackFit.expand, + // children: [ + // Positioned.fromRect( + // rect: Rect.fromCenter( + // center: point.toOffset(), + // width: 30, + // height: 30), + // child: IgnorePointer( + // child: Container( + // decoration: BoxDecoration( + // shape: BoxShape.circle, + // border: Border.all( + // color: Colors.yellow, width: 3)), + // ), + // ), + // ), + // Positioned.fromRect( + // rect: Rect.fromCenter( + // center: adjusted.toOffset(), + // width: 20, + // height: 20), + // child: IgnorePointer( + // child: Container( + // decoration: BoxDecoration( + // shape: BoxShape.circle, + // border: Border.all( + // color: Colors.yellow, width: 2)), + // ), + // ), + // ), + // Positioned.fromRect( + // rect: Rect.fromCenter( + // center: unrotated.toOffset(), + // width: 20, + // height: 20), + // child: IgnorePointer( + // child: Container( + // decoration: BoxDecoration( + // shape: BoxShape.circle, + // border: Border.all( + // color: Colors.green, width: 3)), + // ), + // ), + // ), + // ], + // ); + // }, + // ), + ], Positioned( left: 16, bottom: 16, @@ -593,6 +720,8 @@ class _ImageBoxState extends State { key: ValueKey('image-box-${box.name}'), rect: box.rect, flip: box.flip, + rotation: box.rotation, + handleAlignment: HandleAlignment.center, clampingRect: model.clampingEnabled ? model.clampingRect : null, constraints: box.constraintsEnabled ? box.constraints : null, onChanged: (result, event) { @@ -771,6 +900,7 @@ class _ClampingRectState extends State { rect: model.clampingRect, flip: Flip.none, clampingRect: model.playgroundArea!, + rotatable: false, allowFlippingWhileResizing: false, constraints: BoxConstraints(minWidth: minWidth, minHeight: minHeight), onChanged: (result, event) => model.setClampingRect(result.rect), @@ -2329,6 +2459,8 @@ class BoxData { Rect rect2 = Rect.zero; Flip flip2 = Flip.none; BoxConstraints constraints; + double rotation; + BindingStrategy bindingStrategy; bool flipRectWhileResizing = true; bool flipChild = true; @@ -2348,6 +2480,8 @@ class BoxData { this.rect2 = Rect.zero, this.flip2 = Flip.none, this.constraints = const BoxConstraints(minWidth: 0, minHeight: 0), + this.rotation = 0, + this.bindingStrategy = BindingStrategy.boundingBox, this.flipRectWhileResizing = true, this.flipChild = true, this.constraintsEnabled = false, diff --git a/packages/flutter_box_transform/pubspec.yaml b/packages/flutter_box_transform/pubspec.yaml index 22ea6a2..464b93e 100644 --- a/packages/flutter_box_transform/pubspec.yaml +++ b/packages/flutter_box_transform/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sdk: flutter box_transform: ^0.4.4 vector_math: ^2.1.4 + hyper_effects: ^0.2.3 dev_dependencies: flutter_test: