add timeline pkg to flutter
This commit is contained in:
242
packages/z-timeline/lib/src/widgets/timeline_interactor.dart
Normal file
242
packages/z-timeline/lib/src/widgets/timeline_interactor.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/gestures.dart'
|
||||
show PointerScrollEvent, PointerSignalEvent;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
/// Handles pan/zoom gestures for the timeline.
|
||||
///
|
||||
/// Must be used within a [ZTimelineScope]. Supports:
|
||||
/// - Two-finger pinch-to-zoom (with focal point)
|
||||
/// - Single-finger horizontal pan
|
||||
/// - Ctrl/Cmd + mouse wheel zoom
|
||||
/// - Horizontal mouse scroll for pan
|
||||
/// - Keyboard shortcuts: arrows (pan), +/- (zoom)
|
||||
/// - Mouse cursor feedback (grab/grabbing)
|
||||
///
|
||||
/// ```dart
|
||||
/// ZTimelineScope(
|
||||
/// viewport: viewport,
|
||||
/// child: ZTimelineInteractor(
|
||||
/// child: ZTimelineView(...),
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ZTimelineInteractor extends StatefulWidget {
|
||||
const ZTimelineInteractor({
|
||||
required this.child,
|
||||
this.autofocus = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The widget to wrap with gesture handling.
|
||||
final Widget child;
|
||||
|
||||
/// Whether to automatically focus this widget for keyboard input.
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
|
||||
}
|
||||
|
||||
class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
double _prevScaleValue = 1.0;
|
||||
Offset? _lastFocalPoint;
|
||||
late FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _width {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
return renderBox?.size.width ?? 1.0;
|
||||
}
|
||||
|
||||
void _handleScaleStart(ScaleStartDetails details) {
|
||||
_prevScaleValue = 1.0;
|
||||
_lastFocalPoint = details.focalPoint;
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setGrabbing(true);
|
||||
}
|
||||
|
||||
void _handleScaleUpdate(ScaleUpdateDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final config = scope.config;
|
||||
final width = _width;
|
||||
|
||||
// Two-finger pinch-to-zoom
|
||||
if (details.pointerCount >= 2 && config.enablePinchZoom) {
|
||||
if (details.scale != _prevScaleValue) {
|
||||
final scaleFactor = details.scale / _prevScaleValue;
|
||||
_prevScaleValue = details.scale;
|
||||
|
||||
final focalPosition = (details.focalPoint.dx / width).clamp(0.0, 1.0);
|
||||
_performZoom(scaleFactor, focusPosition: focalPosition);
|
||||
}
|
||||
}
|
||||
// Single-finger pan
|
||||
else if (details.pointerCount == 1 &&
|
||||
config.enablePan &&
|
||||
!scope.interaction.isDraggingEntry) {
|
||||
if (_lastFocalPoint != null) {
|
||||
final diff = details.focalPoint - _lastFocalPoint!;
|
||||
final ratio = -diff.dx / width;
|
||||
|
||||
if (ratio != 0) {
|
||||
scope.viewport.pan(ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lastFocalPoint = details.focalPoint;
|
||||
}
|
||||
|
||||
void _handleScaleEnd(ScaleEndDetails details) {
|
||||
_lastFocalPoint = null;
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setGrabbing(false);
|
||||
}
|
||||
|
||||
void _handlePointerSignal(PointerSignalEvent event) {
|
||||
if (event is! PointerScrollEvent) return;
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final config = scope.config;
|
||||
final width = _width;
|
||||
|
||||
final pressed = HardwareKeyboard.instance.logicalKeysPressed;
|
||||
final isCtrlOrMeta =
|
||||
pressed.contains(LogicalKeyboardKey.controlLeft) ||
|
||||
pressed.contains(LogicalKeyboardKey.controlRight) ||
|
||||
pressed.contains(LogicalKeyboardKey.metaLeft) ||
|
||||
pressed.contains(LogicalKeyboardKey.metaRight);
|
||||
|
||||
// Ctrl/Cmd + scroll = zoom
|
||||
if (isCtrlOrMeta && config.enableMouseWheelZoom) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final local = renderBox?.globalToLocal(event.position) ?? Offset.zero;
|
||||
final focusPosition = (local.dx / width).clamp(0.0, 1.0);
|
||||
final factor = event.scrollDelta.dy < 0
|
||||
? config.zoomFactorIn
|
||||
: config.zoomFactorOut;
|
||||
_performZoom(factor, focusPosition: focusPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal scroll = pan
|
||||
if (config.enablePan) {
|
||||
final dx = event.scrollDelta.dx;
|
||||
if (dx == 0.0) return;
|
||||
|
||||
final ratio = dx / width;
|
||||
if (ratio != 0) {
|
||||
scope.viewport.pan(ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _performZoom(double factor, {double focusPosition = 0.5}) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final config = scope.config;
|
||||
final viewport = scope.viewport;
|
||||
|
||||
// Check limits before zooming
|
||||
final currentDuration = viewport.end.difference(viewport.start);
|
||||
final newDurationMs = (currentDuration.inMilliseconds / factor).round();
|
||||
final newDuration = Duration(milliseconds: newDurationMs);
|
||||
|
||||
// Prevent zooming in too far
|
||||
if (factor > 1 && newDuration < config.minZoomDuration) return;
|
||||
// Prevent zooming out too far
|
||||
if (factor < 1 && newDuration > config.maxZoomDuration) return;
|
||||
|
||||
viewport.zoom(factor, focusPosition: focusPosition);
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, VoidCallback> _buildKeyboardBindings(
|
||||
ZTimelineScopeData scope,
|
||||
) {
|
||||
final config = scope.config;
|
||||
|
||||
if (!config.enableKeyboardShortcuts) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
return {
|
||||
// Pan left
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () {
|
||||
if (config.enablePan) {
|
||||
scope.viewport.pan(-config.keyboardPanRatio);
|
||||
}
|
||||
},
|
||||
// Pan right
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () {
|
||||
if (config.enablePan) {
|
||||
scope.viewport.pan(config.keyboardPanRatio);
|
||||
}
|
||||
},
|
||||
// Zoom in (equals key, typically + without shift)
|
||||
const SingleActivator(LogicalKeyboardKey.equal): () {
|
||||
_performZoom(config.zoomFactorIn);
|
||||
},
|
||||
// Zoom out
|
||||
const SingleActivator(LogicalKeyboardKey.minus): () {
|
||||
_performZoom(config.zoomFactorOut);
|
||||
},
|
||||
// Zoom in (numpad +)
|
||||
const SingleActivator(LogicalKeyboardKey.numpadAdd): () {
|
||||
_performZoom(config.zoomFactorIn);
|
||||
},
|
||||
// Zoom out (numpad -)
|
||||
const SingleActivator(LogicalKeyboardKey.numpadSubtract): () {
|
||||
_performZoom(config.zoomFactorOut);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
return CallbackShortcuts(
|
||||
bindings: _buildKeyboardBindings(scope),
|
||||
child: Focus(
|
||||
autofocus: widget.autofocus,
|
||||
focusNode: _focusNode,
|
||||
child: Listener(
|
||||
onPointerSignal: _handlePointerSignal,
|
||||
child: ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) {
|
||||
return MouseRegion(
|
||||
cursor: scope.interaction.isGrabbing
|
||||
? SystemMouseCursors.grabbing
|
||||
: SystemMouseCursors.grab,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onScaleStart: _handleScaleStart,
|
||||
onScaleUpdate: _handleScaleUpdate,
|
||||
onScaleEnd: _handleScaleEnd,
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user