Files
biz_xfj_cloud/lib/common/widgets/app/app_soft_keyboard.dart
T
dzc fe88259cf2 feat:键盘颜色渐变
feat:键盘长按删除
feat:查询菜单姓名加粗颜色为主题颜色
2026-10-08 11:46:33 +08:00

824 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 自研软键盘的初始面板类型。
enum AppSoftKeyboardMode {
/// 数字面板(1-9、0、退格、清空)。
digits,
/// 字母面板(QWERTY 布局,支持大小写切换与空格)。
letters,
/// 符号面板(常用标点与特殊符号)。
symbols,
}
/// 浮层软键盘的默认停靠位置。
enum AppSoftKeyboardDock {
/// 屏幕底部水平居中;适配表单位于页面上方的常规场景。
bottomCenter,
/// 屏幕左侧垂直居中;适配表单在右侧的页面(如登录页),键盘停靠品牌区
/// 一侧,不再遮挡输入框与登录按钮。
centerLeft,
}
/// 自研软键盘面板:数字/字母/符号三面板 + 顶部切换 tab。
///
/// 定制 ROM 在检测到物理键盘连接时不渲染系统软键盘窗口,本面板以确定性
/// 自绘接管触摸输入:显式绑定目标 [TextEditingController],在光标位置插入、
/// 退格或清空,字符白名单与长度上限由调用场景声明。两种使用方式:
///
/// - 页面输入框:[AppSoftKeyboard.show] 弹出可拖动的浮层键盘;
/// - 矮弹框内的输入(如终端操作验证):直接把 [AppSoftKeyboardPanel] 放进
/// 弹框 content,键盘跟随弹框而不与弹框分离。
///
/// 目标输入框仍应保持物理输入能力:设置 [TextInputType.none] 只抑制系统
/// 软键盘,实体键盘与扫码枪的按键事件不受影响。
class AppSoftKeyboardPanel extends StatefulWidget {
/// 创建内嵌键盘面板;宽度默认跟随父级约束。
const AppSoftKeyboardPanel({
super.key,
required this.controller,
this.mode = AppSoftKeyboardMode.digits,
this.allowPattern,
this.maxLength,
this.semanticLabel = '终端软键盘',
this.onCloseRequested,
this.contentWidth,
});
/// 绑定的目标输入控制器;按键在光标位置插入或删除。
final TextEditingController controller;
/// 初始面板类型。
final AppSoftKeyboardMode mode;
/// 可插入字符白名单(正则逐字符匹配);为空时不限制。
final RegExp? allowPattern;
/// 目标文本总长度上限;超限时新插入被忽略。
final int? maxLength;
/// 无障碍标签,用于区分宿主场景中的多个键盘实例。
final String semanticLabel;
/// 请求收起键盘的回调;非空时面板显示通栏“确认”键(浮层模式),
/// 为空表示内嵌在弹框中,提交由弹框自身按钮承担。
final VoidCallback? onCloseRequested;
/// 面板内容区宽度;为空时跟随父级约束(内嵌弹框场景)。
final double? contentWidth;
@override
State<AppSoftKeyboardPanel> createState() => _AppSoftKeyboardPanelState();
}
class _AppSoftKeyboardPanelState extends State<AppSoftKeyboardPanel> {
/// 当前面板类型;由顶部 tab 与字母面板的 123 键切换。
AppSoftKeyboardMode _mode = AppSoftKeyboardMode.digits;
/// 字母面板当前是否大写;默认小写,按 ⇧ 切换。
bool _uppercase = false;
@override
void initState() {
super.initState();
_mode = widget.mode;
}
/// 校验并插入文本到光标位置;替换当前选区,插入后光标落在文本末尾。
void _insert(String text) {
if (text.isEmpty) return;
final allowPattern = widget.allowPattern;
if (allowPattern != null &&
text.runes
.any((code) => !allowPattern.hasMatch(String.fromCharCode(code)))) {
return;
}
final value = widget.controller.value;
final selection = value.selection.isValid
? value.selection
: TextSelection.collapsed(offset: value.text.length);
final removedLength = selection.end - selection.start;
final maxLength = widget.maxLength;
if (maxLength != null &&
value.text.length - removedLength + text.length > maxLength) {
return;
}
widget.controller.value = value.replaced(selection, text);
}
/// 删除选区或光标前一位字符。
void _backspace() {
final value = widget.controller.value;
if (value.text.isEmpty) return;
final selection = value.selection.isValid
? value.selection
: TextSelection.collapsed(offset: value.text.length);
final range = selection.isCollapsed
? TextRange(start: selection.start - 1, end: selection.end)
: selection;
widget.controller.value = value.replaced(range, '');
}
/// 清空全部文本。
void _clear() {
if (widget.controller.text.isEmpty) return;
widget.controller.clear();
}
void _switchMode(AppSoftKeyboardMode mode) {
if (mode == _mode) return;
setState(() => _mode = mode);
}
@override
Widget build(BuildContext context) {
return Semantics(
label: widget.semanticLabel,
child: LayoutBuilder(
builder: (context, constraints) {
// 内嵌场景宽度跟随弹框;浮层场景使用固定面板宽。
final contentWidth = widget.contentWidth ?? constraints.maxWidth;
return <Widget>[
_buildTabRow(FTheme.of(context)),
SizedBox(height: 10.h),
switch (_mode) {
AppSoftKeyboardMode.digits =>
_buildDigitsPanel(context, contentWidth),
AppSoftKeyboardMode.letters =>
_buildLettersPanel(context, contentWidth),
AppSoftKeyboardMode.symbols =>
_buildSymbolsPanel(context, contentWidth),
},
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch);
},
),
);
}
/// 顶部面板切换 tab 行。
Widget _buildTabRow(FThemeData theme) {
Widget tab(String label, AppSoftKeyboardMode mode) {
final active = _mode == mode;
return InkWell(
onTap: () => _switchMode(mode),
borderRadius: BorderRadius.circular(20.w),
child: Text(
label,
style: theme.typography.sm.copyWith(
color: active ? XfjDesignTokens.primary : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
).center().tight(width: 84.w, height: 44.h).decorated(
color: active
? XfjDesignTokens.primarySoft
: XfjDesignTokens.control,
borderRadius: BorderRadius.circular(20.w),
),
);
}
return <Widget>[
const Spacer(),
tab('123', AppSoftKeyboardMode.digits),
tab('ABC', AppSoftKeyboardMode.letters),
tab('#+=', AppSoftKeyboardMode.symbols),
].toRowSpace(space: 8.w).tight(height: 44.h).padding(horizontal: 4.w);
}
/// 数字面板:三行数字(1-9)+ 操作行(清空/0/退格),浮层模式追加通栏确认。
Widget _buildDigitsPanel(BuildContext context, double contentWidth) {
final theme = FTheme.of(context);
final keyWidth = (contentWidth - 20.w) / 3 - 1;
Widget key(
String semantic, {
Widget? content,
VoidCallback? onTap,
}) =>
_Key(
semanticLabel: semantic,
onTap: onTap ?? () {},
width: keyWidth,
child: content,
);
Widget digitRow(List<String> digits) => <Widget>[
for (final value in digits)
key(
'输入$value',
onTap: () => _insert(value),
content: Text(
value,
style: theme.typography.xl.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
].toRowSpace(space: 10.w);
return <Widget>[
digitRow(['1', '2', '3']),
digitRow(['4', '5', '6']),
digitRow(['7', '8', '9']),
<Widget>[
key(
'清空全部输入',
onTap: _clear,
content: Text(
'清空',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
),
),
key(
'输入0',
onTap: () => _insert('0'),
content: Text(
'0',
style: theme.typography.xl.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
// 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。
_Key(
semanticLabel: '退格删除,长按连续删除',
onTap: _backspace,
onLongPressRepeat: _backspace,
width: keyWidth,
child: FIcon(
FAssets.icons.delete,
color: XfjDesignTokens.muted,
size: 22.sp,
),
),
].toRowSpace(space: 10.w),
if (widget.onCloseRequested != null)
_Key(
semanticLabel: '确认并收起键盘',
onTap: widget.onCloseRequested!,
width: contentWidth,
height: 54.h,
emphasized: true,
child: Text(
'确认',
style: theme.typography.base.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
].toColumnSpace(space: 10.h);
}
/// 字母面板:QWERTY 三行(键等宽、整行居中)+ 操作行(⇧/123/空格/退格/确认)。
Widget _buildLettersPanel(BuildContext context, double contentWidth) {
final theme = FTheme.of(context);
final keyWidth = (contentWidth - 9 * 8.w) / 10 - 1;
// 操作行按比例分配“内容宽 - 4 个间隙”,杜绝固定宽度在不同弹宽下溢出。
final avail = contentWidth - 4 * 8.w - 2;
Widget letter(String value) => _Key(
semanticLabel: '输入字母$value',
onTap: () => _insert(_uppercase ? value.toUpperCase() : value),
width: keyWidth,
child: Text(
_uppercase ? value.toUpperCase() : value,
style: theme.typography.lg.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
);
Widget letterRow(List<String> letters) => <Widget>[
for (final value in letters) letter(value),
].toRowSpace(
space: 8.w,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
);
final actionStyle = theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
);
final whiteActionStyle = theme.typography.sm.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
);
Widget actionKey(
String semantic, {
required double width,
bool emphasized = false,
Widget? content,
required VoidCallback onTap,
}) =>
_Key(
semanticLabel: semantic,
onTap: onTap,
width: width,
emphasized: emphasized,
child: content,
);
return <Widget>[
letterRow(['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']),
letterRow(['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l']),
letterRow(['z', 'x', 'c', 'v', 'b', 'n', 'm']),
<Widget>[
actionKey(
_uppercase ? '切换为小写输入' : '切换为大写输入',
width: avail * 1 / 9.5,
emphasized: _uppercase,
content: Text(
_uppercase ? '⇧ 大' : '⇧ 小',
style: _uppercase ? whiteActionStyle : actionStyle,
),
onTap: () => setState(() => _uppercase = !_uppercase),
),
actionKey(
'切到数字面板',
width: avail * 1 / 9.5,
content: Text('123', style: actionStyle),
onTap: () => _switchMode(AppSoftKeyboardMode.digits),
),
actionKey(
'输入空格',
width: avail * 3.5 / 9.5,
content: Text('空格', style: actionStyle),
onTap: () => _insert(' '),
),
// 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。
_Key(
semanticLabel: '退格删除,长按连续删除',
onTap: _backspace,
onLongPressRepeat: _backspace,
width: avail * 2 / 9.5,
child: FIcon(
FAssets.icons.delete,
color: XfjDesignTokens.muted,
size: 22.sp,
),
),
if (widget.onCloseRequested != null)
actionKey(
'确认并收起键盘',
width: avail * 2 / 9.5,
emphasized: true,
content: Text('确认', style: whiteActionStyle),
onTap: widget.onCloseRequested!,
),
].toRowSpace(space: 8.w),
].toColumnSpace(space: 10.h);
}
/// 符号面板:常用符号五行五列 + 操作行(空格/退格/确认)。
Widget _buildSymbolsPanel(BuildContext context, double contentWidth) {
final theme = FTheme.of(context);
// 操作行按比例分配“内容宽 - 2 个间隙”,杜绝宽度求和溢出。
final avail = contentWidth - 2 * 8.w - 2;
const symbols = [
'-',
'.',
'_',
'/',
'*',
',',
':',
';',
'%',
'#',
'+',
'@',
'&',
'?',
'!',
'(',
')',
'[',
']',
'=',
];
final keyWidth = (contentWidth - 4 * 8.w) / 5 - 1;
Widget symbol(String value) => _Key(
semanticLabel: '输入符号$value',
onTap: () => _insert(value),
width: keyWidth,
child: Text(
value,
style: theme.typography.lg.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
);
final actionStyle = theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
);
final whiteActionStyle = theme.typography.sm.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
);
return <Widget>[
for (var index = 0; index < symbols.length; index += 5)
<Widget>[
for (final value in symbols.skip(index).take(5)) symbol(value),
].toRowSpace(space: 8.w),
<Widget>[
_Key(
semanticLabel: '输入空格',
onTap: () => _insert(' '),
width: avail * 2 / 5,
child: Text('空格', style: actionStyle),
),
// 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。
_Key(
semanticLabel: '退格删除,长按连续删除',
onTap: _backspace,
onLongPressRepeat: _backspace,
width: avail * 1.5 / 5,
child: FIcon(
FAssets.icons.delete,
color: XfjDesignTokens.muted,
size: 22.sp,
),
),
if (widget.onCloseRequested != null)
_Key(
semanticLabel: '确认并收起键盘',
onTap: widget.onCloseRequested!,
width: avail * 1.5 / 5,
emphasized: true,
child: Text('确认', style: whiteActionStyle),
),
].toRowSpace(space: 8.w),
].toColumnSpace(space: 10.h);
}
}
/// 自研可拖动软键盘弹出框。
///
/// 通过 [AppSoftKeyboard.show] 弹出:键盘浮在页面内容之上(透明遮罩),
/// 默认停靠屏幕底部水平居中,需要避开页面内容时可用 [AppSoftKeyboardDock]
/// 自定义停靠位(如登录页停靠左侧垂直居中);弹出后仍可按住顶部手柄行
/// 自由拖动,点击键盘外任意处或按“确认”收起。矮弹框内的输入场景应改用
/// [AppSoftKeyboardPanel] 内嵌。
abstract final class AppSoftKeyboard {
/// 浮层键盘的面板宽度(内容区,含左右内边距共 760)。
static final double floatingContentWidth = 736.w;
/// 弹出浮层键盘并绑定目标控制器;返回的 Future 在键盘关闭时完成。
///
/// 浮层以 [RawDialogRoute](requestFocus: false)实现:键盘是输入法而非
/// 模态任务,路由入栈不抢主焦点,被点击的输入框保持聚焦描边,实体键盘
/// 与扫码枪的输入继续进入该输入框。
static Future<void> show(
BuildContext context, {
required TextEditingController controller,
AppSoftKeyboardMode mode = AppSoftKeyboardMode.digits,
AppSoftKeyboardDock dock = AppSoftKeyboardDock.bottomCenter,
RegExp? allowPattern,
int? maxLength,
String semanticLabel = '终端软键盘',
}) {
return Navigator.of(context, rootNavigator: true).push<void>(
RawDialogRoute<void>(
// 不抢焦点:模态路由默认把主焦点移入弹层 FocusScope,会导致底层
// 输入框失去聚焦描边、实体输入无法进入目标框。
requestFocus: false,
pageBuilder: (dialogContext, _, __) => _SoftKeyboardDialog(
controller: controller,
mode: mode,
dock: dock,
allowPattern: allowPattern,
maxLength: maxLength,
semanticLabel: semanticLabel,
),
barrierDismissible: true,
// 透明遮罩:键盘期间页面内容可见,点击键盘外任意处即收起。
barrierColor: Colors.transparent,
// barrierDismissible 开启时框架要求提供遮罩语义标签。
barrierLabel: '收起软键盘',
// 键盘要求即时出现与消失,不做转场动画。
transitionDuration: Duration.zero,
),
);
}
}
/// 浮层键盘宿主:按停靠位对齐面板、承载拖动手柄行与内嵌面板。
class _SoftKeyboardDialog extends StatefulWidget {
const _SoftKeyboardDialog({
required this.controller,
required this.mode,
required this.dock,
required this.semanticLabel,
this.allowPattern,
this.maxLength,
});
final TextEditingController controller;
final AppSoftKeyboardMode mode;
/// 弹出时的默认停靠位;拖动偏移在此基础上叠加。
final AppSoftKeyboardDock dock;
final RegExp? allowPattern;
final int? maxLength;
final String semanticLabel;
@override
State<_SoftKeyboardDialog> createState() => _SoftKeyboardDialogState();
}
class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> {
/// 拖动偏移;相对当前停靠位的默认位置。
Offset _dragOffset = Offset.zero;
/// 拖动手势更新偏移;按屏幕半宽钳制,保证键盘不会被完全拖出屏幕。
void _onDragUpdate(DragUpdateDetails details) {
final halfScreen = MediaQuery.sizeOf(context) / 2;
setState(() {
_dragOffset += details.delta;
_dragOffset = Offset(
_dragOffset.dx.clamp(-halfScreen.width, halfScreen.width),
_dragOffset.dy.clamp(-halfScreen.height, halfScreen.height),
);
});
}
void _close() => Navigator.of(context).pop();
@override
Widget build(BuildContext context) {
// 弹层约束为全屏;按停靠位对齐面板,停靠侧留出安全边距。
final (alignment, margin) = switch (widget.dock) {
AppSoftKeyboardDock.bottomCenter => (
Alignment.bottomCenter,
EdgeInsets.only(bottom: 16.h),
),
AppSoftKeyboardDock.centerLeft => (
Alignment.centerLeft,
EdgeInsets.only(left: 16.w),
),
};
return SizedBox.expand(
child: Padding(
padding: margin,
child: Transform.translate(
offset: _dragOffset,
child: Align(
alignment: alignment,
child: Material(
color: Colors.transparent,
child: Container(
width: 760.w,
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: XfjDesignTokens.canvas,
borderRadius:
BorderRadius.circular(XfjDesignTokens.cardRadius),
border: Border.all(color: XfjDesignTokens.line),
boxShadow: XfjDesignTokens.surfaceShadow,
),
// Align 传入的是有界约束,列必须显式收缩,否则面板被拉满全屏高。
child: <Widget>[
_buildDragHandle(),
SizedBox(height: 10.h),
AppSoftKeyboardPanel(
controller: widget.controller,
mode: widget.mode,
allowPattern: widget.allowPattern,
maxLength: widget.maxLength,
semanticLabel: widget.semanticLabel,
onCloseRequested: _close,
contentWidth: AppSoftKeyboard.floatingContentWidth,
),
].toColumn(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
),
),
),
),
),
),
);
}
/// 拖动手柄行:右侧拖动指示,按住整行自由拖动键盘。
Widget _buildDragHandle() {
return GestureDetector(
onPanUpdate: _onDragUpdate,
child: <Widget>[
const Spacer(),
Icon(
Icons.drag_indicator_rounded,
color: XfjDesignTokens.muted,
size: 26.w,
semanticLabel: '拖动键盘',
),
].toRowSpace(space: 8.w).tight(height: 32.h).padding(horizontal: 4.w),
);
}
}
/// 单个键盘按键;固定宽度布局(弹层处于无界高度约束,禁用 Expanded),
/// 主操作键使用主题色底白字。
///
/// 按压反馈走“整键缩放 + 主题色渐变”而非涟漪:Listener 直接监听指针按下/
/// 抬起,只触发本键的一次 Transform 与 decoration 隐式动画,不参与手势
/// 竞技场、不引起布局重排,是触摸反馈里开销最低的实现;透明 Material 上
/// 涟漪本就不可见。
class _Key extends StatefulWidget {
const _Key({
required this.semanticLabel,
required this.onTap,
required this.width,
required this.child,
this.emphasized = false,
this.height,
this.onLongPressRepeat,
});
final String semanticLabel;
final VoidCallback onTap;
/// 按键宽度;由各面板按列数与间距计算。
final double width;
/// 按键内容(数字文字或图标)。
final Widget? child;
/// 是否主操作键(确认/大写锁定态),使用主题色底与白字。
final bool emphasized;
/// 按键高度;默认 62。
final double? height;
/// 长按连发回调;非空时按住超过系统长按阈值后先触发一次,随后周期连发。
/// 整个键盘目前只有退格键使用(长按持续删除)。
final VoidCallback? onLongPressRepeat;
@override
State<_Key> createState() => _KeyState();
}
class _KeyState extends State<_Key> {
/// 按压目标缩放:模拟实体键帽下沉,幅度保证一眼可辨。
static const double _pressedScale = 0.92;
/// 缩放与渐变共用同一时长曲线,让按压表现为一次同步手势。
static const Duration _pressDuration = Duration(milliseconds: 90);
/// 长按连发周期:10 次/秒,短按删一位、长按可控地连续清空。
static const Duration _longPressRepeatInterval = Duration(milliseconds: 100);
/// 普通键按压渐变:品牌浅蓝自上而下过渡到主题蓝浅调。
static final LinearGradient _normalPressedGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
XfjDesignTokens.primarySoft,
Color.alphaBlend(
XfjDesignTokens.primary.withValues(alpha: 0.18),
XfjDesignTokens.surface,
),
],
);
/// 主操作键按压渐变:主题蓝自上而下加深一档,替代抬起的纯色底。
static final LinearGradient _emphasizedPressedGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
XfjDesignTokens.primary,
Color.alphaBlend(
Colors.black.withValues(alpha: 0.14),
XfjDesignTokens.primary,
),
],
);
/// 当前是否有指针按在本键上;驱动按压缩放与主题色渐变。
bool _pressed = false;
/// 长按连发定时器;只在按住期间存在。
Timer? _repeatTimer;
void _setPressed(bool value) {
if (!mounted || _pressed == value) return;
setState(() => _pressed = value);
}
/// 长按阈值到达:先删一次,再以固定周期连发,直到指针抬起或取消。
void _startLongPressRepeat() {
widget.onLongPressRepeat?.call();
_repeatTimer?.cancel();
_repeatTimer = Timer.periodic(_longPressRepeatInterval, (_) {
if (!mounted) return;
widget.onLongPressRepeat?.call();
});
}
void _stopLongPressRepeat() {
_repeatTimer?.cancel();
_repeatTimer = null;
}
/// 指针结束的统一收尾:松开按压视觉并停掉连发。
void _pointerEnd() {
_setPressed(false);
_stopLongPressRepeat();
}
@override
void dispose() {
_stopLongPressRepeat();
super.dispose();
}
@override
Widget build(BuildContext context) {
final keyHeight = widget.height ?? 62.h;
final radius = BorderRadius.circular(XfjDesignTokens.controlRadius);
final BoxDecoration decoration;
if (_pressed) {
decoration = BoxDecoration(
gradient: widget.emphasized
? _emphasizedPressedGradient
: _normalPressedGradient,
border: Border.all(
color: widget.emphasized
? XfjDesignTokens.primary
: XfjDesignTokens.primary.withValues(alpha: 0.45),
),
borderRadius: radius,
);
} else {
decoration = BoxDecoration(
color: widget.emphasized
? XfjDesignTokens.primary
: XfjDesignTokens.surface,
border: Border.all(
color: widget.emphasized
? XfjDesignTokens.primary
: XfjDesignTokens.line,
),
borderRadius: radius,
);
}
return Semantics(
button: true,
label: widget.semanticLabel,
child: Listener(
onPointerDown: (_) => _setPressed(true),
onPointerUp: (_) => _pointerEnd(),
onPointerCancel: (_) => _pointerEnd(),
child: AnimatedScale(
scale: _pressed ? _pressedScale : 1,
duration: _pressDuration,
curve: Curves.easeOut,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
onLongPress: widget.onLongPressRepeat == null
? null
: _startLongPressRepeat,
borderRadius: radius,
// 渐变走 AnimatedContainer 隐式动画:只重绘本键 decoration,
// 不触发布局,与外层缩放共同构成零重排的按压反馈。
// 尺寸放在外层 SizedBox、容器内部用恒定 infinity 撑满:
// 面板切换会按位置复用 _Key 元素,键宽变化若参与隐式动画,
// 行布局会在过渡帧溢出。
child: SizedBox(
width: widget.width,
height: keyHeight,
child: AnimatedContainer(
duration: _pressDuration,
curve: Curves.easeOut,
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
decoration: decoration,
child: widget.child ?? const SizedBox.shrink(),
),
),
),
),
),
),
);
}
}