825 lines
28 KiB
Dart
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.');
|
|
}
|
|
}
|
|
}
|