// 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 _tapTween(double target) => TweenSequence([ 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 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)], ), ); } }