// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/animated_nav_label.dart'; import '../../../l10n/app_localizations.dart'; import 'search_gift_glyphs.dart'; const double _navHeight = 56; const double _tabGap = 20; const double _tabFont = 17; const double _searchHitSize = AppDimens.tapTarget; const double _searchIconSize = 26; const double _giftIconSize = 26; const double _underlineWidth = 26; const double _underlineHeight = 3; /// 推荐页顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。 class RecommendChannelNav extends StatefulWidget { const RecommendChannelNav({ super.key, required this.categories, required this.selectedId, required this.onSelected, required this.onSearch, this.onGift, }); static const double height = _navHeight; /// 接口返回的内容分类(首项即「精选」)。 final List categories; final String? selectedId; final ValueChanged onSelected; final VoidCallback onSearch; /// 右上角金色礼物盒(钱包入口);为空时不显示。 final VoidCallback? onGift; @override State createState() => _RecommendChannelNavState(); } class _RecommendChannelNavState extends State { static const double _revealLeftInset = 20; final ScrollController _scrollController = ScrollController(); final GlobalKey _scrollViewKey = GlobalKey(); final Map _tabKeys = {}; GlobalKey _keyFor(String id) => _tabKeys.putIfAbsent(id, () => GlobalKey()); @override void didUpdateWidget(covariant RecommendChannelNav oldWidget) { super.didUpdateWidget(oldWidget); if (widget.selectedId != null && (widget.selectedId != oldWidget.selectedId || widget.categories.length != oldWidget.categories.length)) { WidgetsBinding.instance.addPostFrameCallback((_) => _revealSelected()); } } @override void dispose() { _scrollController.dispose(); super.dispose(); } /// 把选中频道平滑滚动到可视区域:对齐到滚动区左缘 20pt。 void _revealSelected() { final id = widget.selectedId; if (id == null || !_scrollController.hasClients) return; final tabCtx = _tabKeys[id]?.currentContext; final tabBox = tabCtx?.findRenderObject() as RenderBox?; final scrollBox = _scrollViewKey.currentContext?.findRenderObject() as RenderBox?; if (tabBox == null || scrollBox == null) return; final viewportLeft = scrollBox.localToGlobal(Offset.zero).dx; final tabLeft = tabBox.localToGlobal(Offset.zero).dx; final relative = tabLeft - viewportLeft; final position = _scrollController.position; final target = (position.pixels + relative - _revealLeftInset).clamp( 0.0, position.maxScrollExtent, ); if ((target - position.pixels).abs() < 0.5) return; _scrollController.animateTo( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final lang = Localizations.localeOf(context).languageCode; final categories = widget.categories; final tabs = <(String, String, String)>[ for (final category in categories) (category.id, category.localizedName(lang), category.id), ]; return SizedBox( key: const Key('recommend-channel-nav'), height: RecommendChannelNav.height, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: SingleChildScrollView( key: _scrollViewKey, controller: _scrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.only(left: 20, right: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ for (var index = 0; index < tabs.length; index++) ...[ if (index > 0) const SizedBox(width: _tabGap), KeyedSubtree( key: _keyFor(tabs[index].$3), child: _tab( key: Key('recommend-tab-${tabs[index].$3}'), code: tabs[index].$3, label: tabs[index].$2, selected: widget.selectedId == tabs[index].$1, onTap: () => widget.onSelected(tabs[index].$1), ), ), ], ], ), ), ), SizedBox.square( dimension: _searchHitSize, child: IconButton( key: const Key('recommend-search'), tooltip: l10n.search, onPressed: widget.onSearch, padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( width: _searchHitSize, height: _searchHitSize, ), iconSize: _searchIconSize, color: AppColors.textPrimary, icon: SearchGlyph( size: _searchIconSize, color: AppColors.textPrimary, ), ), ), if (widget.onGift != null) SizedBox.square( dimension: _searchHitSize, child: IconButton( key: const Key('recommend-gift'), tooltip: l10n.wallet, onPressed: widget.onGift, padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( width: _searchHitSize, height: _searchHitSize, ), iconSize: _giftIconSize, icon: GiftGlyph(size: _giftIconSize), ), ), const SizedBox(width: 8), ], ), ); } Widget _tab({ required Key key, required String code, required String label, required bool selected, required VoidCallback onTap, }) { return GestureDetector( key: key, behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。 const SizedBox(height: 4 + _underlineHeight), AnimatedNavLabel( label: label, selected: selected, style: const TextStyle( color: AppColors.textPrimary, fontSize: _tabFont, fontWeight: FontWeight.w400, letterSpacing: 0, height: 1.0, shadows: [Shadow(color: AppColors.mediaBlack, blurRadius: 6)], ), ), const SizedBox(height: 4), Container( key: Key('recommend-tab-$code-underline'), width: _underlineWidth, height: _underlineHeight, decoration: BoxDecoration( color: selected ? AppColors.brandRed : AppColors.transparent, borderRadius: BorderRadius.circular(_underlineHeight / 2), ), ), ], ), ); } }