custom emoji tooltip

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-07-08 17:04:46 +08:00
parent e3337f1e7c
commit b51c6b65a1
4 changed files with 488 additions and 29 deletions

View File

@@ -0,0 +1,378 @@
import 'dart:math' as math;
import 'dart:ui' show clampDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
enum TooltipType { top, right }
class CustomTooltip extends StatefulWidget {
const CustomTooltip({
super.key,
this.type = TooltipType.top,
required this.overlayWidget,
required this.child,
this.indicator,
});
final TooltipType type;
final Widget child;
final Widget Function() overlayWidget;
final Widget Function()? indicator;
static final List<CustomTooltipState> _openedTooltips =
<CustomTooltipState>[];
static bool dismissAllToolTips() {
if (_openedTooltips.isNotEmpty) {
final List<CustomTooltipState> openedTooltips = _openedTooltips.toList();
for (final CustomTooltipState state in openedTooltips) {
assert(state.mounted);
state._scheduleDismissTooltip();
}
return true;
}
return false;
}
@override
State<CustomTooltip> createState() => CustomTooltipState();
}
class CustomTooltipState extends State<CustomTooltip>
with SingleTickerProviderStateMixin {
static const Duration _fadeInDuration = Duration(milliseconds: 150);
static const Duration _fadeOutDuration = Duration(milliseconds: 75);
final OverlayPortalController _overlayController = OverlayPortalController();
AnimationController? _backingController;
AnimationController get _controller {
return _backingController ??= AnimationController(
duration: _fadeInDuration,
reverseDuration: _fadeOutDuration,
vsync: this,
)..addStatusListener(_handleStatusChanged);
}
CurvedAnimation? _backingOverlayAnimation;
CurvedAnimation get _overlayAnimation {
return _backingOverlayAnimation ??= CurvedAnimation(
parent: _controller,
curve: Curves.fastOutSlowIn,
);
}
LongPressGestureRecognizer? _longPressRecognizer;
AnimationStatus _animationStatus = AnimationStatus.dismissed;
void _handleStatusChanged(AnimationStatus status) {
assert(mounted);
switch ((_animationStatus.isDismissed, status.isDismissed)) {
case (false, true):
CustomTooltip._openedTooltips.remove(this);
_overlayController.hide();
case (true, false):
_overlayController.show();
CustomTooltip._openedTooltips.add(this);
case (true, true) || (false, false):
break;
}
_animationStatus = status;
}
void _scheduleShowTooltip() {
_controller.forward();
}
void _scheduleDismissTooltip() {
_controller.reverse();
}
void _handlePointerDown(PointerDownEvent event) {
assert(mounted);
const Set<PointerDeviceKind> triggerModeDeviceKinds = <PointerDeviceKind>{
PointerDeviceKind.invertedStylus,
PointerDeviceKind.stylus,
PointerDeviceKind.touch,
PointerDeviceKind.unknown,
PointerDeviceKind.trackpad,
};
_longPressRecognizer ??= LongPressGestureRecognizer(
debugOwner: this,
supportedDevices: triggerModeDeviceKinds,
);
_longPressRecognizer!
..onLongPress = _scheduleShowTooltip
..addPointer(event);
}
Widget _buildCustomTooltipOverlay(BuildContext context) {
final OverlayState overlayState =
Overlay.of(context, debugRequiredFor: widget);
final RenderBox box = this.context.findRenderObject()! as RenderBox;
final Offset target = box.localToGlobal(
box.size.center(Offset.zero),
ancestor: overlayState.context.findRenderObject(),
);
final _CustomTooltipOverlay overlayChild = _CustomTooltipOverlay(
verticalOffset: box.size.height / 2,
horizontslOffset: box.size.width / 2,
type: widget.type,
animation: _overlayAnimation,
target: target,
onDismiss: _scheduleDismissTooltip,
overlayWidget: widget.overlayWidget,
indicator: widget.indicator,
);
return SelectionContainer.maybeOf(context) == null
? overlayChild
: SelectionContainer.disabled(child: overlayChild);
}
@protected
@override
void dispose() {
CustomTooltip._openedTooltips.remove(this);
_longPressRecognizer?.onLongPressCancel = null;
_longPressRecognizer?.dispose();
_backingController?.dispose();
_backingOverlayAnimation?.dispose();
super.dispose();
}
@protected
@override
Widget build(BuildContext context) {
Widget result = Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.opaque,
child: widget.child,
);
return OverlayPortal(
controller: _overlayController,
overlayChildBuilder: _buildCustomTooltipOverlay,
child: result,
);
}
}
class _CustomTooltipOverlay extends StatelessWidget {
const _CustomTooltipOverlay({
required this.verticalOffset,
required this.horizontslOffset,
required this.type,
required this.animation,
required this.target,
required this.onDismiss,
required this.overlayWidget,
this.indicator,
});
final double verticalOffset;
final double horizontslOffset;
final TooltipType type;
final Animation<double> animation;
final Offset target;
final VoidCallback onDismiss;
final Widget Function() overlayWidget;
final Widget Function()? indicator;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onDismiss,
child: CustomMultiChildLayout(
delegate: _CustomMultiTooltipPositionDelegate(
type: type,
target: target,
verticalOffset: verticalOffset,
horizontslOffset: horizontslOffset,
preferBelow: false,
),
children: [
LayoutId(
id: 'overlay',
child: overlayWidget(),
),
if (indicator != null)
LayoutId(
id: 'indicator',
child: indicator!(),
),
],
),
);
}
}
class _CustomMultiTooltipPositionDelegate extends MultiChildLayoutDelegate {
_CustomMultiTooltipPositionDelegate({
required this.type,
required this.target,
required this.verticalOffset,
required this.horizontslOffset,
required this.preferBelow,
});
final TooltipType type;
final Offset target;
final double verticalOffset;
final double horizontslOffset;
final bool preferBelow;
@override
void performLayout(Size size) {
switch (type) {
case TooltipType.top:
Size? indicatorSize;
if (hasChild('indicator')) {
indicatorSize = layoutChild('indicator', BoxConstraints.loose(size));
}
if (hasChild('overlay')) {
final overlaySize =
layoutChild('overlay', BoxConstraints.loose(size));
Offset offset = positionDependentBox(
type: type,
size: size,
childSize: overlaySize,
target: target,
verticalOffset: verticalOffset,
horizontslOffset: horizontslOffset,
preferBelow: preferBelow,
);
if (indicatorSize != null) {
offset = Offset(offset.dx, offset.dy - indicatorSize.height + 1);
positionChild(
'indicator',
Offset(
target.dx - indicatorSize.width / 2,
offset.dy + overlaySize.height - 1,
),
);
}
positionChild('overlay', offset);
}
case TooltipType.right:
Size? indicatorSize;
if (hasChild('indicator')) {
indicatorSize = layoutChild('indicator', BoxConstraints.loose(size));
}
if (hasChild('overlay')) {
final overlaySize =
layoutChild('overlay', BoxConstraints.loose(size));
Offset offset = positionDependentBox(
type: type,
size: size,
childSize: overlaySize,
target: target,
verticalOffset: verticalOffset,
horizontslOffset: horizontslOffset,
preferBelow: preferBelow,
);
if (indicatorSize != null) {
offset = Offset(offset.dx + indicatorSize.height - 1, offset.dy);
positionChild(
'indicator',
Offset(
offset.dx - indicatorSize.width + 1,
target.dy - indicatorSize.height / 2,
),
);
}
positionChild('overlay', offset);
}
}
}
@override
bool shouldRelayout(_CustomMultiTooltipPositionDelegate oldDelegate) {
return target != oldDelegate.target ||
verticalOffset != oldDelegate.verticalOffset ||
preferBelow != oldDelegate.preferBelow;
}
}
class TrianglePainter extends CustomPainter {
TrianglePainter(this.color, {this.type = TooltipType.top});
final TooltipType type;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
Path path;
switch (type) {
case TooltipType.top:
path = Path()
..moveTo(0, 0)
..lineTo(size.width, 0)
..lineTo(size.width / 2, size.height)
..close();
case TooltipType.right:
path = Path()
..moveTo(0, size.height / 2)
..lineTo(size.width, 0)
..lineTo(size.width, size.height)
..close();
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(TrianglePainter oldDelegate) => color != oldDelegate.color;
}
Offset positionDependentBox({
required TooltipType type,
required Size size,
required Size childSize,
required Offset target,
required bool preferBelow,
double verticalOffset = 0.0,
double horizontslOffset = 0.0,
double margin = 10.0,
}) {
switch (type) {
case TooltipType.top:
// VERTICAL DIRECTION
final bool fitsBelow =
target.dy + verticalOffset + childSize.height <= size.height - margin;
final bool fitsAbove =
target.dy - verticalOffset - childSize.height >= margin;
final bool tooltipBelow =
fitsAbove == fitsBelow ? preferBelow : fitsBelow;
final double y;
if (tooltipBelow) {
y = math.min(target.dy + verticalOffset, size.height - margin);
} else {
y = math.max(target.dy - verticalOffset - childSize.height, margin);
} // HORIZONTAL DIRECTION
final double flexibleSpace = size.width - childSize.width;
final double x = flexibleSpace <= 2 * margin
// If there's not enough horizontal space for margin + child, center the
// child.
? flexibleSpace / 2.0
: clampDouble(
target.dx - childSize.width / 2, margin, flexibleSpace - margin);
return Offset(x, y);
case TooltipType.right:
final double dy = math.max(margin, target.dy - childSize.height / 2);
final double dx = math.min(
target.dx + horizontslOffset, size.width - childSize.width - margin);
return Offset(dx, dy);
}
}

View File

@@ -1,9 +1,14 @@
class Meta {
int? size;
String? alias;
Meta({this.size});
Meta({
this.size,
this.alias,
});
factory Meta.fromJson(Map<String, dynamic> json) => Meta(
size: json['size'] as int?,
alias: json['alias'] as String?,
);
}

View File

@@ -1,4 +1,5 @@
import 'package:PiliPlus/common/widgets/button/icon_button.dart';
import 'package:PiliPlus/common/widgets/custom_tooltip.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
@@ -37,6 +38,9 @@ class _EmotePanelState extends State<EmotePanel>
Widget _buildBody(
ThemeData theme, LoadingState<List<Package>?> loadingState) {
late final color = Get.currentRoute.startsWith('/whisperDetail')
? theme.colorScheme.surface
: theme.colorScheme.onInverseSurface;
return switch (loadingState) {
Loading() => loadingWidget,
Success(:var response) => response?.isNotEmpty == true
@@ -62,20 +66,7 @@ class _EmotePanelState extends State<EmotePanel>
itemCount: e.emote!.length,
itemBuilder: (context, index) {
final item = e.emote![index];
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
onTap: () => widget.onChoose(
item,
isTextEmote
? null
: e.emote!.first.meta!.size == 1
? 24
: 42,
null),
child: Padding(
Widget child = Padding(
padding: const EdgeInsets.all(6),
child: isTextEmote
? Center(
@@ -89,11 +80,60 @@ class _EmotePanelState extends State<EmotePanel>
src: item.url!,
width: size,
height: size,
semanticsLabel: item.text!,
type: ImageType.emote,
boxFit: BoxFit.contain,
),
);
if (!isTextEmote) {
child = CustomTooltip(
indicator: () => CustomPaint(
size: const Size(14, 8),
painter: TrianglePainter(color),
),
overlayWidget: () => Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.all(
Radius.circular(8)),
),
child: Column(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
NetworkImgLayer(
src: item.url!,
width: 65,
height: 65,
type: ImageType.emote,
boxFit: BoxFit.contain,
),
Text(
item.meta?.alias ??
item.text!.substring(
1, item.text!.length - 1),
style: const TextStyle(fontSize: 12),
),
],
),
),
child: child,
);
}
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
onTap: () => widget.onChoose(
item,
isTextEmote
? null
: e.emote!.first.meta!.size == 1
? 24
: 42,
null),
child: child,
),
);
},

View File

@@ -1,5 +1,6 @@
import 'dart:math';
import 'package:PiliPlus/common/widgets/custom_tooltip.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
@@ -44,6 +45,7 @@ class _LiveEmotePanelState extends State<LiveEmotePanel>
}
Widget _buildBody(LoadingState<List<LiveEmoteDatum>?> loadingState) {
late final color = Theme.of(context).colorScheme.onInverseSurface;
return switch (loadingState) {
Loading() => loadingWidget,
Success(:var response) => response?.isNotEmpty == true
@@ -88,6 +90,39 @@ class _LiveEmotePanelState extends State<LiveEmotePanel>
widget.onSendEmoticonUnique(e);
}
},
child: CustomTooltip(
indicator: () => CustomPaint(
size: const Size(14, 8),
painter: TrianglePainter(color),
),
overlayWidget: () => Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.all(
Radius.circular(8)),
),
child: Column(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
NetworkImgLayer(
src: e.url!,
width: 65,
height: 65,
type: ImageType.emote,
boxFit: BoxFit.contain,
),
Text(
e.emoji!.startsWith('[')
? e.emoji!.substring(
1, e.emoji!.length - 1)
: e.emoji!,
style: const TextStyle(fontSize: 12),
),
],
),
),
child: Padding(
padding: const EdgeInsets.all(6),
child: NetworkImgLayer(
@@ -100,6 +135,7 @@ class _LiveEmotePanelState extends State<LiveEmotePanel>
),
),
),
),
);
},
);