From a604a0ad6d825dfc18abb8e56745b07baebfafcf Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 14:00:36 +0900 Subject: [PATCH] refactor: replace custom _ReorderableGrid with ReorderableDragDropGrid for improved drag-and-drop functionality --- .../quick_action_configurator.widget.dart | 250 +-------------- .../common/reorderable_drag_drop_grid.dart | 288 ++++++++++++++++++ 2 files changed, 295 insertions(+), 243 deletions(-) create mode 100644 mobile/lib/widgets/common/reorderable_drag_drop_grid.dart diff --git a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart index b24ced3579..5cd8d81d4e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/quick_action_configurator.widget.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/infrastructure/viewer_quick_action_order.provider.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/action_button_visuals.dart'; +import 'package:immich_mobile/widgets/common/reorderable_drag_drop_grid.dart'; class QuickActionConfigurator extends ConsumerStatefulWidget { const QuickActionConfigurator({super.key}); @@ -108,9 +109,13 @@ class _QuickActionConfiguratorState extends ConsumerState items; - final Function(int oldIndex, int newIndex) onReorder; - final int crossAxisCount; - final double crossAxisSpacing; - final double mainAxisSpacing; - final double childAspectRatio; - final bool shouldScroll; - - const _ReorderableGrid({ - required this.scrollController, - required this.items, - required this.onReorder, - required this.crossAxisCount, - required this.crossAxisSpacing, - required this.mainAxisSpacing, - required this.childAspectRatio, - required this.shouldScroll, - }); - - @override - State<_ReorderableGrid> createState() => _ReorderableGridState(); -} - -class _ReorderableGridState extends State<_ReorderableGrid> { - int? _draggingIndex; - late List _itemOrder; - int? _lastHoveredIndex; - bool _snapNow = false; - - @override - void initState() { - super.initState(); - _itemOrder = List.generate(widget.items.length, (index) => index); - } - - @override - void didUpdateWidget(_ReorderableGrid oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.items.length != widget.items.length) { - _itemOrder = List.generate(widget.items.length, (index) => index); - } - } - - void _updateHover(int draggedIndex, int targetIndex) { - if (draggedIndex == targetIndex || _draggingIndex == null) return; - - setState(() { - _lastHoveredIndex = targetIndex; - // Temporarily reorder for visual feedback - final newOrder = List.from(_itemOrder); - final draggedOrderIndex = newOrder.indexOf(draggedIndex); - final targetOrderIndex = newOrder.indexOf(targetIndex); - - newOrder.removeAt(draggedOrderIndex); - newOrder.insert(targetOrderIndex, draggedIndex); - _itemOrder = newOrder; - }); - } - - void _handleDragEnd(int draggedIndex, int? targetIndex) { - // Use targetIndex if available, otherwise check if visual position changed - final effectiveTargetIndex = - targetIndex ?? - (() { - final currentVisualIndex = _itemOrder.indexOf(draggedIndex); - // If visual position changed from original, use the item at current visual position - if (currentVisualIndex != draggedIndex) { - return _itemOrder[currentVisualIndex]; - } - return null; - })(); - - if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) { - widget.onReorder(draggedIndex, effectiveTargetIndex); - } - - // Trigger snap animation for all items - _armSnapNow(); - - setState(() { - _draggingIndex = null; - _lastHoveredIndex = null; - _itemOrder = List.generate(widget.items.length, (i) => i); - }); - } - - void _armSnapNow() { - setState(() => _snapNow = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() => _snapNow = false); - }); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final tileWidth = - (constraints.maxWidth - (widget.crossAxisSpacing * (widget.crossAxisCount - 1))) / widget.crossAxisCount; - final tileHeight = tileWidth / widget.childAspectRatio; - final rows = (_itemOrder.length / widget.crossAxisCount).ceil(); - final totalHeight = rows * tileHeight + (rows - 1) * widget.mainAxisSpacing; - - return SingleChildScrollView( - controller: widget.scrollController, - physics: widget.shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), - child: SizedBox( - width: constraints.maxWidth, - height: totalHeight, - child: Stack( - children: List.generate(widget.items.length, (index) { - final visualIndex = _itemOrder.indexOf(index); - final item = widget.items[index]; - final isDragging = _draggingIndex == index; - - // Calculate position - final row = visualIndex ~/ widget.crossAxisCount; - final col = visualIndex % widget.crossAxisCount; - final left = col * (tileWidth + widget.crossAxisSpacing); - final top = row * (tileHeight + widget.mainAxisSpacing); - - return _AnimatedGridItem( - key: ValueKey(index), - index: index, - item: item, - isDragging: isDragging, - snapNow: _snapNow, - tileWidth: tileWidth, - tileHeight: tileHeight, - left: left, - top: top, - onDragStarted: () { - setState(() { - _draggingIndex = index; - _lastHoveredIndex = index; - }); - }, - onDragUpdate: (draggedIndex, targetIndex) { - _updateHover(draggedIndex, targetIndex); - }, - onDragCompleted: (draggedIndex) { - _handleDragEnd(draggedIndex, _lastHoveredIndex); - }, - ); - }), - ), - ), - ); - }, - ); - } -} - -class _AnimatedGridItem extends StatelessWidget { - final int index; - final ActionButtonType item; - final bool isDragging; - final bool snapNow; - final double tileWidth; - final double tileHeight; - final double left; - final double top; - final VoidCallback onDragStarted; - final Function(int draggedIndex, int targetIndex) onDragUpdate; - final Function(int draggedIndex) onDragCompleted; - - const _AnimatedGridItem({ - super.key, - required this.index, - required this.item, - required this.isDragging, - required this.snapNow, - required this.tileWidth, - required this.tileHeight, - required this.left, - required this.top, - required this.onDragStarted, - required this.onDragUpdate, - required this.onDragCompleted, - }); - - @override - Widget build(BuildContext context) { - final Duration animDuration = snapNow ? Duration.zero : const Duration(milliseconds: 150); - - return AnimatedPositioned( - duration: animDuration, - curve: Curves.easeInOut, - left: left, - top: top, - width: tileWidth, - height: tileHeight, - child: DragTarget( - onWillAcceptWithDetails: (details) { - if (details.data != index) { - onDragUpdate(details.data, index); - } - return details.data != index; - }, - builder: (context, candidateData, rejectedData) { - Widget child = _QuickActionTile(index: index, type: item); - - if (isDragging) { - child = Opacity(opacity: 0.0, child: child); - } - - return Draggable( - data: index, - feedback: Material( - color: Colors.transparent, - child: SizedBox( - width: tileWidth, - height: tileHeight, - child: Opacity( - opacity: 0.9, - child: Transform.scale( - scale: 1.05, - child: _QuickActionTile(index: index, type: item), - ), - ), - ), - ), - childWhenDragging: const SizedBox.shrink(), - onDragStarted: onDragStarted, - onDragCompleted: () { - onDragCompleted(index); - }, - onDraggableCanceled: (_, __) { - onDragCompleted(index); - }, - child: child, - ); - }, - ), - ); - } -} - class _QuickActionTile extends StatelessWidget { final int index; final ActionButtonType type; diff --git a/mobile/lib/widgets/common/reorderable_drag_drop_grid.dart b/mobile/lib/widgets/common/reorderable_drag_drop_grid.dart new file mode 100644 index 0000000000..b6bc9a65b9 --- /dev/null +++ b/mobile/lib/widgets/common/reorderable_drag_drop_grid.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; + +/// A callback that is called when items are reordered. +/// [oldIndex] is the original index of the item being moved. +/// [newIndex] is the target index where the item should be moved to. +typedef ReorderCallback = void Function(int oldIndex, int newIndex); + +/// A callback that is called during drag to update hover state. +/// [draggedIndex] is the index of the item being dragged. +/// [targetIndex] is the index of the item being hovered over. +typedef DragUpdateCallback = void Function(int draggedIndex, int targetIndex); + +/// A reorderable grid that supports drag and drop reordering with smooth animations. +/// +/// This widget provides a drag-and-drop interface for reordering items in a grid layout. +/// Items can be dragged to new positions, and the grid will animate smoothly to reflect +/// the new order. +/// +/// Features: +/// - Smooth animations during drag and drop +/// - Instant snap animation on drop completion +/// - Visual feedback during dragging +/// - Customizable grid layout parameters +class ReorderableDragDropGrid extends StatefulWidget { + /// Controller for scrolling the grid. + final ScrollController? scrollController; + + /// The number of items to display. + final int itemCount; + + /// Builder function to create each grid item. + final Widget Function(BuildContext context, int index) itemBuilder; + + /// Callback when items are reordered. + final ReorderCallback onReorder; + + /// Number of columns in the grid. + final int crossAxisCount; + + /// Horizontal spacing between grid items. + final double crossAxisSpacing; + + /// Vertical spacing between grid items. + final double mainAxisSpacing; + + /// The ratio of width to height for each grid item. + final double childAspectRatio; + + /// Whether the grid should be scrollable. + final bool shouldScroll; + + /// Scale factor for the dragged item feedback widget. + final double feedbackScaleFactor; + + /// Opacity for the dragged item feedback widget. + final double feedbackOpacity; + + const ReorderableDragDropGrid({ + super.key, + this.scrollController, + required this.itemCount, + required this.itemBuilder, + required this.onReorder, + required this.crossAxisCount, + required this.crossAxisSpacing, + required this.mainAxisSpacing, + required this.childAspectRatio, + this.shouldScroll = true, + this.feedbackScaleFactor = 1.05, + this.feedbackOpacity = 0.9, + }); + + @override + State createState() => _ReorderableDragDropGridState(); +} + +class _ReorderableDragDropGridState extends State { + int? _draggingIndex; + late List _itemOrder; + int? _lastHoveredIndex; + bool _snapNow = false; + + @override + void initState() { + super.initState(); + _itemOrder = List.generate(widget.itemCount, (index) => index); + } + + @override + void didUpdateWidget(ReorderableDragDropGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.itemCount != widget.itemCount) { + _itemOrder = List.generate(widget.itemCount, (index) => index); + } + } + + void _updateHover(int draggedIndex, int targetIndex) { + if (draggedIndex == targetIndex || _draggingIndex == null) return; + + setState(() { + _lastHoveredIndex = targetIndex; + final newOrder = List.from(_itemOrder); + final draggedOrderIndex = newOrder.indexOf(draggedIndex); + final targetOrderIndex = newOrder.indexOf(targetIndex); + + newOrder.removeAt(draggedOrderIndex); + newOrder.insert(targetOrderIndex, draggedIndex); + _itemOrder = newOrder; + }); + } + + void _handleDragEnd(int draggedIndex, int? targetIndex) { + final effectiveTargetIndex = + targetIndex ?? + (() { + final currentVisualIndex = _itemOrder.indexOf(draggedIndex); + if (currentVisualIndex != draggedIndex) { + return _itemOrder[currentVisualIndex]; + } + return null; + })(); + + if (effectiveTargetIndex != null && draggedIndex != effectiveTargetIndex) { + widget.onReorder(draggedIndex, effectiveTargetIndex); + } + + _armSnapNow(); + + setState(() { + _draggingIndex = null; + _lastHoveredIndex = null; + _itemOrder = List.generate(widget.itemCount, (i) => i); + }); + } + + void _armSnapNow() { + setState(() => _snapNow = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _snapNow = false); + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final tileWidth = + (constraints.maxWidth - (widget.crossAxisSpacing * (widget.crossAxisCount - 1))) / widget.crossAxisCount; + final tileHeight = tileWidth / widget.childAspectRatio; + final rows = (_itemOrder.length / widget.crossAxisCount).ceil(); + final totalHeight = rows * tileHeight + (rows - 1) * widget.mainAxisSpacing; + + return SingleChildScrollView( + controller: widget.scrollController, + physics: widget.shouldScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), + child: SizedBox( + width: constraints.maxWidth, + height: totalHeight, + child: Stack( + children: List.generate(widget.itemCount, (index) { + final visualIndex = _itemOrder.indexOf(index); + final isDragging = _draggingIndex == index; + + final row = visualIndex ~/ widget.crossAxisCount; + final col = visualIndex % widget.crossAxisCount; + final left = col * (tileWidth + widget.crossAxisSpacing); + final top = row * (tileHeight + widget.mainAxisSpacing); + + return _AnimatedGridItem( + key: ValueKey(index), + index: index, + isDragging: isDragging, + snapNow: _snapNow, + tileWidth: tileWidth, + tileHeight: tileHeight, + left: left, + top: top, + feedbackScaleFactor: widget.feedbackScaleFactor, + feedbackOpacity: widget.feedbackOpacity, + onDragStarted: () { + setState(() { + _draggingIndex = index; + _lastHoveredIndex = index; + }); + }, + onDragUpdate: (draggedIndex, targetIndex) { + _updateHover(draggedIndex, targetIndex); + }, + onDragCompleted: (draggedIndex) { + _handleDragEnd(draggedIndex, _lastHoveredIndex); + }, + child: widget.itemBuilder(context, index), + ); + }), + ), + ), + ); + }, + ); + } +} + +class _AnimatedGridItem extends StatelessWidget { + final int index; + final bool isDragging; + final bool snapNow; + final double tileWidth; + final double tileHeight; + final double left; + final double top; + final double feedbackScaleFactor; + final double feedbackOpacity; + final VoidCallback onDragStarted; + final DragUpdateCallback onDragUpdate; + final Function(int draggedIndex) onDragCompleted; + final Widget child; + + const _AnimatedGridItem({ + super.key, + required this.index, + required this.isDragging, + required this.snapNow, + required this.tileWidth, + required this.tileHeight, + required this.left, + required this.top, + required this.feedbackScaleFactor, + required this.feedbackOpacity, + required this.onDragStarted, + required this.onDragUpdate, + required this.onDragCompleted, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final Duration animDuration = snapNow ? Duration.zero : const Duration(milliseconds: 150); + + return AnimatedPositioned( + duration: animDuration, + curve: Curves.easeInOut, + left: left, + top: top, + width: tileWidth, + height: tileHeight, + child: DragTarget( + onWillAcceptWithDetails: (details) { + if (details.data != index) { + onDragUpdate(details.data, index); + } + return details.data != index; + }, + builder: (context, candidateData, rejectedData) { + Widget displayChild = child; + + if (isDragging) { + displayChild = Opacity(opacity: 0.0, child: child); + } + + return Draggable( + data: index, + feedback: Material( + color: Colors.transparent, + child: SizedBox( + width: tileWidth, + height: tileHeight, + child: Opacity( + opacity: feedbackOpacity, + child: Transform.scale(scale: feedbackScaleFactor, child: child), + ), + ), + ), + childWhenDragging: const SizedBox.shrink(), + onDragStarted: onDragStarted, + onDragCompleted: () { + onDragCompleted(index); + }, + onDraggableCanceled: (_, __) { + onDragCompleted(index); + }, + child: displayChild, + ); + }, + ), + ); + } +}