oc-front/lib/widgets/lib/tranformablebox.dart
2024-08-22 15:46:16 +02:00

825 lines
28 KiB
Dart

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