Files
ReelShort/lib/pages/player/widgets/interaction_rail.dart

262 lines
6.9 KiB
Dart

// Author: fruit.com.cn
// 职责:播放互动栏的图标、计数与不影响触控区域的点击反馈。
import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import 'interaction_rail_burst.dart';
const double _defaultButtonSize = 44;
const double _defaultIconSize = 32;
const double _defaultCountFont = 12;
const double _defaultIconCountGap = 2;
const double _defaultItemGap = 24;
const _tapDuration = Duration(milliseconds: 220);
const double _popScale = 1.14;
const double _cancelScale = 0.86;
const double _popWeight = 40;
const double _settleWeight = 60;
final _growTween = _tapTween(_popScale);
final _shrinkTween = _tapTween(_cancelScale);
TweenSequence<double> _tapTween(double target) => TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 1.0,
end: target,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: _popWeight,
),
TweenSequenceItem(
tween: Tween(
begin: target,
end: 1.0,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: _settleWeight,
),
]);
/// 首页与完整播放器互动项之间的紧凑间距。
const double interactionRailCompactGap = 6;
/// 互动栏单项:图标、计数与点击。
class InteractionRailItem {
const InteractionRailItem({
required this.key,
required this.label,
required this.onTap,
this.asset,
this.icon,
this.count,
this.countKey,
this.selected = false,
this.selectedColor = AppColors.blue,
this.mirrorIcon = false,
this.animateTap = false,
}) : assert((asset == null) != (icon == null));
final Key key;
final String? asset;
final IconData? icon;
final String label;
final VoidCallback onTap;
final int? count;
final Key? countKey;
final bool selected;
final Color selectedColor;
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
final bool mirrorIcon;
/// 选中放大、取消缩小并显示周围纹路,计数和触控范围不参与缩放。
final bool animateTap;
}
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
class InteractionRail extends StatelessWidget {
const InteractionRail({
super.key,
required this.items,
this.itemGap = _defaultItemGap,
this.buttonSize = _defaultButtonSize,
this.iconSize = _defaultIconSize,
this.countFontSize = _defaultCountFont,
this.iconCountGap = _defaultIconCountGap,
});
final List<InteractionRailItem> items;
final double itemGap;
final double buttonSize;
final double iconSize;
final double countFontSize;
final double iconCountGap;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < items.length; index++) ...[
if (index > 0) SizedBox(height: itemGap),
_RailButton(
item: items[index],
buttonSize: buttonSize,
iconSize: iconSize,
countFontSize: countFontSize,
iconCountGap: iconCountGap,
),
],
],
);
}
}
class _RailButton extends StatefulWidget {
const _RailButton({
required this.item,
required this.buttonSize,
required this.iconSize,
required this.countFontSize,
required this.iconCountGap,
});
final InteractionRailItem item;
final double buttonSize;
final double iconSize;
final double countFontSize;
final double iconCountGap;
@override
State<_RailButton> createState() => _RailButtonState();
}
class _RailButtonState extends State<_RailButton>
with SingleTickerProviderStateMixin {
late final AnimationController _motion;
Color _burstColor = AppColors.onPrimary;
bool _contracting = false;
@override
void initState() {
super.initState();
_motion = AnimationController(vsync: this, duration: _tapDuration);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (MediaQuery.disableAnimationsOf(context)) _motion.reset();
}
@override
void dispose() {
_motion.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
key: widget.item.key,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: widget.buttonSize,
child: widget.item.animateTap ? _animatedButton() : _button(_icon()),
),
SizedBox(height: widget.iconCountGap),
_count(),
],
);
}
void _onTap() {
if (widget.item.animateTap && !MediaQuery.disableAnimationsOf(context)) {
_contracting = widget.item.selected;
_burstColor = _contracting
? AppColors.onPrimary
: widget.item.selectedColor;
_motion.forward(from: 0);
}
widget.item.onTap();
}
Widget _button(Widget icon) => IconButton(
onPressed: _onTap,
iconSize: widget.iconSize,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
overlayColor: AppColors.transparent,
shadowColor: AppColors.transparent,
elevation: 0,
splashFactory: NoSplash.splashFactory,
),
icon: icon,
);
Widget _animatedButton() => AnimatedBuilder(
animation: _motion,
child: _icon(),
builder: (context, child) => Stack(
fit: StackFit.expand,
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
_button(
Transform.scale(
scale: (_contracting ? _shrinkTween : _growTween).transform(
_motion.value,
),
child: child,
),
),
if (_motion.isAnimating)
Positioned.fill(
child: RailTapBurst(
key: const Key('rail-tap-burst'),
progress: _motion.value,
color: _burstColor,
contracting: _contracting,
),
),
],
),
);
Widget _icon() {
final item = widget.item;
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
return item.asset != null
? Image.asset(
item.asset!,
width: widget.iconSize,
height: widget.iconSize,
fit: BoxFit.contain,
color: tint,
colorBlendMode: BlendMode.srcIn,
)
: Transform.flip(
flipX: item.mirrorIcon,
child: Icon(item.icon, size: widget.iconSize, color: tint),
);
}
Widget _count() {
final item = widget.item;
return Text(
item.count == null ? item.label : formatInteractionCount(item.count!),
key: item.countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onPrimary,
height: 1,
fontSize: widget.countFontSize,
fontWeight: FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
),
);
}
}