feat: enable mobile view item drag-and-drop reordering (#3812)

* feat: enable mobile view item draggable

* fix: dragging the view doesn't disable hover effect
This commit is contained in:
Lucas.Xu 2023-10-27 22:42:35 +08:00 committed by GitHub
parent 653c831473
commit 09b4e19c9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 204 additions and 47 deletions

View File

@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/base/box_container.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class BottomSheetActionWidget extends StatelessWidget {
const BottomSheetActionWidget({
@ -19,7 +20,10 @@ class BottomSheetActionWidget extends StatelessWidget {
Widget build(BuildContext context) {
return FlowyBoxContainer(
child: InkWell(
onTap: onTap,
onTap: () {
HapticFeedback.mediumImpact();
onTap();
},
enableFeedback: true,
child: Padding(
padding: const EdgeInsets.symmetric(

View File

@ -46,7 +46,7 @@ class MobilePersonalFolder extends StatelessWidget {
key: ValueKey(
'${FolderCategoryType.personal.name} ${view.id}',
),
isDraggable: false,
isDraggable: true,
categoryType: FolderCategoryType.personal,
isFirstChild: view.id == views.first.id,
view: view,

View File

@ -231,7 +231,10 @@ class InnerMobileViewItem extends StatelessWidget {
child = DraggableViewItem(
isFirstChild: isFirstChild,
view: view,
child: child,
// FIXME: use better color
centerHighlightColor: Colors.blue.shade200,
topHighlightColor: Colors.blue.shade200,
bottomHighlightColor: Colors.blue.shade200,
feedback: (context) {
return MobileViewItem(
view: view,
@ -246,6 +249,7 @@ class InnerMobileViewItem extends StatelessWidget {
endActionPane: endActionPane,
);
},
child: child,
);
}
@ -319,13 +323,14 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
// ··· more action button
// children.add(_buildViewMoreActionButton(context));
// only support add button for document layout
if (widget.view.layout == ViewLayoutPB.Document) {
if (!widget.isFeedback && widget.view.layout == ViewLayoutPB.Document) {
// + button
children.add(_buildViewAddButton(context));
}
Widget child = GestureDetector(
behavior: HitTestBehavior.translucent,
Widget child = InkWell(
borderRadius: BorderRadius.circular(4.0),
enableFeedback: true,
onTap: () => widget.onSelected(widget.view),
child: SizedBox(
height: _itemHeight,

View File

@ -1,9 +1,11 @@
import 'package:appflowy/util/platform_extension.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
enum DraggableHoverPosition {
@ -20,12 +22,20 @@ class DraggableViewItem extends StatefulWidget {
this.feedback,
required this.child,
this.isFirstChild = false,
this.centerHighlightColor,
this.topHighlightColor,
this.bottomHighlightColor,
this.onDragging,
});
final Widget child;
final WidgetBuilder? feedback;
final ViewPB view;
final bool isFirstChild;
final Color? centerHighlightColor;
final Color? topHighlightColor;
final Color? bottomHighlightColor;
final void Function(bool isDragging)? onDragging;
@override
State<DraggableViewItem> createState() => _DraggableViewItemState();
@ -34,41 +44,20 @@ class DraggableViewItem extends StatefulWidget {
class _DraggableViewItemState extends State<DraggableViewItem> {
DraggableHoverPosition position = DraggableHoverPosition.none;
final _dividerHeight = 2.0;
@override
Widget build(BuildContext context) {
// add top border if the draggable item is on the top of the list
// highlight the draggable item if the draggable item is on the center
// add bottom border if the draggable item is on the bottom of the list
final child = Column(
mainAxisSize: MainAxisSize.min,
children: [
// only show the top border when the draggable item is the first child
if (widget.isFirstChild)
Divider(
height: 2,
thickness: 2,
color: position == DraggableHoverPosition.top
? Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
Container(
color: position == DraggableHoverPosition.center
? Theme.of(context).colorScheme.secondary.withOpacity(0.5)
: Colors.transparent,
child: widget.child,
),
Divider(
height: 2,
thickness: 2,
color: position == DraggableHoverPosition.bottom
? Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
],
);
final child = PlatformExtension.isMobile
? _buildMobileDraggableItem()
: _buildDesktopDraggableItem();
return DraggableItem<ViewPB>(
data: widget.view,
onDragging: widget.onDragging,
onWillAccept: (data) => true,
onMove: (data) {
final renderBox = context.findRenderObject() as RenderBox;
@ -77,20 +66,21 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
if (!_shouldAccept(data.data, position)) {
return;
}
setState(() {
Log.debug(
'offset: $offset, position: $position, size: ${renderBox.size}',
);
this.position = position;
});
Log.debug(
'offset: $offset, position: $position, size: ${renderBox.size}',
);
_updatePosition(position);
},
onLeave: (_) => setState(
() => position = DraggableHoverPosition.none,
onLeave: (_) => _updatePosition(
DraggableHoverPosition.none,
),
onAccept: (data) {
_move(data, widget.view);
setState(
() => position = DraggableHoverPosition.none,
_move(
data,
widget.view,
);
_updatePosition(
DraggableHoverPosition.none,
);
},
feedback: IntrinsicWidth(
@ -103,6 +93,97 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
);
}
Widget _buildDesktopDraggableItem() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// only show the top border when the draggable item is the first child
if (widget.isFirstChild)
Divider(
height: _dividerHeight,
thickness: _dividerHeight,
color: position == DraggableHoverPosition.top
? widget.topHighlightColor ??
Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: position == DraggableHoverPosition.center
? widget.centerHighlightColor ??
Theme.of(context).colorScheme.secondary.withOpacity(0.5)
: Colors.transparent,
),
child: widget.child,
),
Divider(
height: _dividerHeight,
thickness: _dividerHeight,
color: position == DraggableHoverPosition.bottom
? widget.bottomHighlightColor ??
Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
],
);
}
Widget _buildMobileDraggableItem() {
return Stack(
children: [
if (widget.isFirstChild)
Positioned(
top: 0,
left: 0,
right: 0,
height: _dividerHeight,
child: Divider(
height: _dividerHeight,
thickness: _dividerHeight,
color: position == DraggableHoverPosition.top
? widget.topHighlightColor ??
Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0),
color: position == DraggableHoverPosition.center
? widget.centerHighlightColor ??
Theme.of(context).colorScheme.secondary.withOpacity(0.5)
: Colors.transparent,
),
child: widget.child,
),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: _dividerHeight,
child: Divider(
height: _dividerHeight,
thickness: _dividerHeight,
color: position == DraggableHoverPosition.bottom
? widget.bottomHighlightColor ??
Theme.of(context).colorScheme.secondary
: Colors.transparent,
),
),
],
);
}
void _updatePosition(DraggableHoverPosition position) {
if (PlatformExtension.isMobile && position != this.position) {
HapticFeedback.mediumImpact();
}
setState(
() => this.position = position,
);
}
void _move(ViewPB from, ViewPB to) {
if (position == DraggableHoverPosition.center &&
to.layout != ViewLayoutPB.Document) {
@ -144,7 +225,7 @@ class _DraggableViewItemState extends State<DraggableViewItem> {
}
DraggableHoverPosition _computeHoverPosition(Offset offset, Size size) {
final threshold = size.height / 3.0;
final threshold = size.height / 5.0;
if (widget.isFirstChild && offset.dy < -5.0) {
return DraggableHoverPosition.top;
}

View File

@ -103,6 +103,8 @@ class ViewItem extends StatelessWidget {
}
}
bool _isDragging = false;
class InnerViewItem extends StatelessWidget {
const InnerViewItem({
super.key,
@ -188,6 +190,9 @@ class InnerViewItem extends StatelessWidget {
isFirstChild: isFirstChild,
view: view,
child: child,
onDragging: (isDragging) {
_isDragging = isDragging;
},
feedback: (context) {
return ViewItem(
view: view,
@ -261,7 +266,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
hoverColor: Theme.of(context).colorScheme.secondary,
),
resetHoverOnRebuild: widget.showActions,
buildWhenOnHover: () => !widget.showActions,
buildWhenOnHover: () => !widget.showActions && !_isDragging,
builder: (_, onHover) => _buildViewItem(onHover),
isSelected: () =>
widget.showActions ||

View File

@ -1,3 +1,4 @@
import 'package:appflowy/util/platform_extension.dart';
import 'package:flutter/material.dart';
class DraggableItem<T extends Object> extends StatefulWidget {
@ -13,6 +14,7 @@ class DraggableItem<T extends Object> extends StatefulWidget {
this.onLeave,
this.enableAutoScroll = true,
this.hitTestSize = const Size(100, 100),
this.onDragging,
});
final T data;
@ -32,6 +34,8 @@ class DraggableItem<T extends Object> extends StatefulWidget {
final bool enableAutoScroll;
final Size hitTestSize;
final void Function(bool isDragging)? onDragging;
@override
State<DraggableItem<T>> createState() => _DraggableItemState<T>();
}
@ -57,7 +61,7 @@ class _DraggableItemState<T extends Object> extends State<DraggableItem<T>> {
onWillAccept: widget.onWillAccept,
onMove: widget.onMove,
onLeave: widget.onLeave,
builder: (_, __, ___) => Draggable<T>(
builder: (_, __, ___) => _Draggable<T>(
data: widget.data,
feedback: widget.feedback ?? widget.child,
childWhenDragging: widget.childWhenDragging ?? widget.child,
@ -67,14 +71,17 @@ class _DraggableItemState<T extends Object> extends State<DraggableItem<T>> {
dragTarget = details.globalPosition & widget.hitTestSize;
autoScroller?.startAutoScrollIfNecessary(dragTarget!);
}
widget.onDragging?.call(true);
},
onDragEnd: (details) {
autoScroller?.stopAutoScroll();
dragTarget = null;
widget.onDragging?.call(false);
},
onDraggableCanceled: (_, __) {
autoScroller?.stopAutoScroll();
dragTarget = null;
widget.onDragging?.call(false);
},
),
);
@ -105,3 +112,58 @@ class _DraggableItemState<T extends Object> extends State<DraggableItem<T>> {
);
}
}
class _Draggable<T extends Object> extends StatelessWidget {
const _Draggable({
required this.child,
required this.feedback,
this.data,
this.childWhenDragging,
this.onDragStarted,
this.onDragUpdate,
this.onDraggableCanceled,
this.onDragEnd,
this.onDragCompleted,
});
/// The data that will be dropped by this draggable.
final T? data;
final Widget child;
final Widget? childWhenDragging;
final Widget feedback;
/// Called when the draggable starts being dragged.
final VoidCallback? onDragStarted;
final DragUpdateCallback? onDragUpdate;
final DraggableCanceledCallback? onDraggableCanceled;
final VoidCallback? onDragCompleted;
final DragEndCallback? onDragEnd;
@override
Widget build(BuildContext context) {
return PlatformExtension.isMobile
? LongPressDraggable<T>(
data: data,
feedback: feedback,
childWhenDragging: childWhenDragging,
onDragUpdate: onDragUpdate,
onDragEnd: onDragEnd,
onDraggableCanceled: onDraggableCanceled,
child: child,
)
: Draggable<T>(
data: data,
feedback: feedback,
childWhenDragging: childWhenDragging,
onDragUpdate: onDragUpdate,
onDragEnd: onDragEnd,
onDraggableCanceled: onDraggableCanceled,
child: child,
);
}
}