410 lines
11 KiB
Dart
410 lines
11 KiB
Dart
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
enum PressType {
|
|
longPress,
|
|
singleClick,
|
|
}
|
|
|
|
enum PreferredPosition {
|
|
top,
|
|
bottom,
|
|
}
|
|
|
|
class CustomPopupMenuController extends ChangeNotifier {
|
|
bool menuIsShowing = false;
|
|
|
|
void showMenu() {
|
|
menuIsShowing = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
void hideMenu() {
|
|
menuIsShowing = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
void toggleMenu() {
|
|
menuIsShowing = !menuIsShowing;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Rect _menuRect = Rect.zero;
|
|
|
|
class CustomPopup extends StatefulWidget {
|
|
CustomPopup({
|
|
required this.child,
|
|
required this.menuBuilder,
|
|
required this.pressType,
|
|
this.controller,
|
|
this.arrowColor = const Color(0xFFF14343),
|
|
this.showArrow = true,
|
|
this.barrierColor = Colors.black12,
|
|
this.arrowSize = 10.0,
|
|
this.horizontalMargin = 10.0,
|
|
this.verticalMargin = 1.0,
|
|
this.position,
|
|
this.menuOnChange,
|
|
this.enablePassEvent = true,
|
|
});
|
|
|
|
final Widget child;
|
|
final PressType pressType;
|
|
final bool showArrow;
|
|
final Color arrowColor;
|
|
final Color barrierColor;
|
|
final double horizontalMargin;
|
|
final double verticalMargin;
|
|
final double arrowSize;
|
|
final CustomPopupMenuController? controller;
|
|
final Widget Function() menuBuilder;
|
|
final PreferredPosition? position;
|
|
final void Function(bool)? menuOnChange;
|
|
|
|
/// Pass tap event to the widgets below the mask.
|
|
/// It only works when [barrierColor] is transparent.
|
|
final bool enablePassEvent;
|
|
|
|
@override
|
|
_CustomPopupState createState() => _CustomPopupState();
|
|
}
|
|
|
|
class _CustomPopupState extends State<CustomPopup> {
|
|
RenderBox? _childBox;
|
|
RenderBox? _parentBox;
|
|
OverlayEntry? _overlayEntry;
|
|
CustomPopupMenuController? _controller;
|
|
bool _canResponse = true;
|
|
|
|
_showMenu() {
|
|
_overlayEntry = OverlayEntry(
|
|
builder: (context) {
|
|
Widget menu = Center(
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: _parentBox!.size.width - 2 * widget.horizontalMargin,
|
|
minWidth: 0,
|
|
),
|
|
child: CustomMultiChildLayout(
|
|
delegate: _MenuLayoutDelegate(
|
|
anchorSize: _childBox!.size,
|
|
anchorOffset: _childBox!.localToGlobal(
|
|
Offset(-widget.horizontalMargin, 0),
|
|
),
|
|
verticalMargin: widget.verticalMargin,
|
|
position: widget.position,
|
|
),
|
|
children: <Widget>[
|
|
LayoutId(
|
|
id: _MenuLayoutId.content,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Material(
|
|
child: widget.menuBuilder(),
|
|
color: Colors.transparent,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
return Listener(
|
|
behavior: widget.enablePassEvent
|
|
? HitTestBehavior.translucent
|
|
: HitTestBehavior.opaque,
|
|
onPointerDown: (PointerDownEvent event) {
|
|
Offset offset = event.localPosition;
|
|
// If tap position in menu
|
|
if (_menuRect.contains(
|
|
Offset(offset.dx - widget.horizontalMargin, offset.dy))) {
|
|
return;
|
|
}
|
|
_controller?.hideMenu();
|
|
// When [enablePassEvent] works and we tap the [child] to [hideMenu],
|
|
// but the passed event would trigger [showMenu] again.
|
|
// So, we use time threshold to solve this bug.
|
|
_canResponse = false;
|
|
Future.delayed(Duration(milliseconds: 300))
|
|
.then((_) => _canResponse = true);
|
|
},
|
|
child: widget.barrierColor == Colors.transparent
|
|
? menu
|
|
: Container(
|
|
color: widget.barrierColor,
|
|
child: menu,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
if (_overlayEntry != null) {
|
|
Overlay.of(context)!.insert(_overlayEntry!);
|
|
}
|
|
}
|
|
|
|
_hideMenu() {
|
|
if (_overlayEntry != null) {
|
|
_overlayEntry?.remove();
|
|
_overlayEntry = null;
|
|
}
|
|
}
|
|
|
|
_updateView() {
|
|
bool menuIsShowing = _controller?.menuIsShowing ?? false;
|
|
widget.menuOnChange?.call(menuIsShowing);
|
|
if (menuIsShowing) {
|
|
_showMenu();
|
|
} else {
|
|
_hideMenu();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = widget.controller;
|
|
if (_controller == null) _controller = CustomPopupMenuController();
|
|
_controller?.addListener(_updateView);
|
|
WidgetsBinding.instance.addPostFrameCallback((call) {
|
|
if (mounted) {
|
|
_childBox = context.findRenderObject() as RenderBox?;
|
|
_parentBox =
|
|
Overlay.of(context)?.context.findRenderObject() as RenderBox?;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_hideMenu();
|
|
_controller?.removeListener(_updateView);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var child = Material(
|
|
child: InkWell(
|
|
hoverColor: Colors.transparent,
|
|
focusColor: Colors.transparent,
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
child: widget.child,
|
|
onTap: () {
|
|
if (widget.pressType == PressType.singleClick && _canResponse) {
|
|
_controller?.showMenu();
|
|
}
|
|
},
|
|
onLongPress: () {
|
|
if (widget.pressType == PressType.longPress && _canResponse) {
|
|
_controller?.showMenu();
|
|
}
|
|
},
|
|
),
|
|
color: Colors.transparent,
|
|
);
|
|
if (Platform.isIOS) {
|
|
return child;
|
|
} else {
|
|
return WillPopScope(
|
|
onWillPop: () {
|
|
_hideMenu();
|
|
return Future.value(true);
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
enum _MenuLayoutId {
|
|
arrow,
|
|
downArrow,
|
|
content,
|
|
}
|
|
|
|
enum _MenuPosition {
|
|
bottomLeft,
|
|
bottomCenter,
|
|
bottomRight,
|
|
topLeft,
|
|
topCenter,
|
|
topRight,
|
|
}
|
|
|
|
class _MenuLayoutDelegate extends MultiChildLayoutDelegate {
|
|
_MenuLayoutDelegate({
|
|
required this.anchorSize,
|
|
required this.anchorOffset,
|
|
required this.verticalMargin,
|
|
this.position,
|
|
});
|
|
|
|
final Size anchorSize;
|
|
final Offset anchorOffset;
|
|
final double verticalMargin;
|
|
final PreferredPosition? position;
|
|
|
|
@override
|
|
void performLayout(Size size) {
|
|
Size contentSize = Size.zero;
|
|
Size arrowSize = Size.zero;
|
|
Offset contentOffset = Offset(0, 0);
|
|
// Offset arrowOffset = Offset(0, 0);
|
|
|
|
double anchorCenterX = anchorOffset.dx + anchorSize.width / 2;
|
|
double anchorTopY = anchorOffset.dy;
|
|
double anchorBottomY = anchorTopY + anchorSize.height;
|
|
_MenuPosition menuPosition = _MenuPosition.bottomCenter;
|
|
|
|
if (hasChild(_MenuLayoutId.content)) {
|
|
contentSize = layoutChild(
|
|
_MenuLayoutId.content,
|
|
BoxConstraints.loose(size),
|
|
);
|
|
}
|
|
if (hasChild(_MenuLayoutId.arrow)) {
|
|
arrowSize = layoutChild(
|
|
_MenuLayoutId.arrow,
|
|
BoxConstraints.loose(size),
|
|
);
|
|
}
|
|
if (hasChild(_MenuLayoutId.downArrow)) {
|
|
layoutChild(
|
|
_MenuLayoutId.downArrow,
|
|
BoxConstraints.loose(size),
|
|
);
|
|
}
|
|
|
|
bool isTop = false;
|
|
if (position == null) {
|
|
// auto calculate position
|
|
isTop = anchorBottomY > size.height / 2;
|
|
} else {
|
|
isTop = position == PreferredPosition.top;
|
|
}
|
|
if (anchorCenterX - contentSize.width / 2 < 0) {
|
|
menuPosition = isTop ? _MenuPosition.topLeft : _MenuPosition.bottomLeft;
|
|
} else if (anchorCenterX + contentSize.width / 2 > size.width) {
|
|
menuPosition = isTop ? _MenuPosition.topRight : _MenuPosition.bottomRight;
|
|
} else {
|
|
menuPosition =
|
|
isTop ? _MenuPosition.topCenter : _MenuPosition.bottomCenter;
|
|
}
|
|
|
|
switch (menuPosition) {
|
|
case _MenuPosition.bottomCenter:
|
|
// arrowOffset = Offset(
|
|
// anchorCenterX - arrowSize.width / 2,
|
|
// anchorBottomY + verticalMargin,
|
|
// );
|
|
contentOffset = Offset(
|
|
anchorCenterX - contentSize.width / 2,
|
|
anchorBottomY + verticalMargin + arrowSize.height,
|
|
);
|
|
break;
|
|
case _MenuPosition.bottomLeft:
|
|
// arrowOffset = Offset(anchorCenterX - arrowSize.width / 2,
|
|
// anchorBottomY + verticalMargin);
|
|
contentOffset = Offset(
|
|
0,
|
|
anchorBottomY + verticalMargin + arrowSize.height,
|
|
);
|
|
break;
|
|
case _MenuPosition.bottomRight:
|
|
// arrowOffset = Offset(anchorCenterX - arrowSize.width / 2,
|
|
// anchorBottomY + verticalMargin);
|
|
contentOffset = Offset(
|
|
size.width - contentSize.width,
|
|
anchorBottomY + verticalMargin + arrowSize.height,
|
|
);
|
|
break;
|
|
case _MenuPosition.topCenter:
|
|
// arrowOffset = Offset(
|
|
// anchorCenterX - arrowSize.width / 2,
|
|
// anchorTopY - verticalMargin - arrowSize.height,
|
|
// );
|
|
contentOffset = Offset(
|
|
anchorCenterX - contentSize.width / 2,
|
|
anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
|
|
);
|
|
break;
|
|
case _MenuPosition.topLeft:
|
|
// arrowOffset = Offset(
|
|
// anchorCenterX - arrowSize.width / 2,
|
|
// anchorTopY - verticalMargin - arrowSize.height,
|
|
// );
|
|
contentOffset = Offset(
|
|
0,
|
|
anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
|
|
);
|
|
break;
|
|
case _MenuPosition.topRight:
|
|
// arrowOffset = Offset(
|
|
// anchorCenterX - arrowSize.width / 2,
|
|
// anchorTopY - verticalMargin - arrowSize.height,
|
|
// );
|
|
contentOffset = Offset(
|
|
size.width - contentSize.width,
|
|
anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
|
|
);
|
|
break;
|
|
}
|
|
if (hasChild(_MenuLayoutId.content)) {
|
|
positionChild(_MenuLayoutId.content, contentOffset);
|
|
}
|
|
|
|
_menuRect = Rect.fromLTWH(
|
|
contentOffset.dx,
|
|
contentOffset.dy,
|
|
contentSize.width,
|
|
contentSize.height,
|
|
);
|
|
bool isBottom = false;
|
|
if (_MenuPosition.values.indexOf(menuPosition) < 3) {
|
|
// bottom
|
|
isBottom = true;
|
|
}
|
|
// if (hasChild(_MenuLayoutId.arrow)) {
|
|
// positionChild(
|
|
// _MenuLayoutId.arrow,
|
|
// isBottom
|
|
// ? Offset(arrowOffset.dx, arrowOffset.dy + 0.1)
|
|
// : Offset(-100, 0),
|
|
// );
|
|
// }
|
|
// if (hasChild(_MenuLayoutId.downArrow)) {
|
|
// positionChild(
|
|
// _MenuLayoutId.downArrow,
|
|
// !isBottom
|
|
// ? Offset(arrowOffset.dx, arrowOffset.dy - 0.1)
|
|
// : Offset(-100, 0),
|
|
// );
|
|
// }
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
|
|
}
|
|
|
|
class _ArrowClipper extends CustomClipper<Path> {
|
|
@override
|
|
Path getClip(Size size) {
|
|
Path path = Path();
|
|
path.moveTo(0, size.height);
|
|
path.lineTo(size.width / 2, size.height / 2);
|
|
path.lineTo(size.width, size.height);
|
|
return path;
|
|
}
|
|
|
|
@override
|
|
bool shouldReclip(CustomClipper<Path> oldClipper) {
|
|
return true;
|
|
}
|
|
}
|