Files
ReelShort/lib/pages/recommend/widgets/recommend_channel_nav.dart

223 lines
7.4 KiB
Dart

// 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<ContentCategory> categories;
final String? selectedId;
final ValueChanged<String> onSelected;
final VoidCallback onSearch;
/// 右上角金色礼物盒(钱包入口);为空时不显示。
final VoidCallback? onGift;
@override
State<RecommendChannelNav> createState() => _RecommendChannelNavState();
}
class _RecommendChannelNavState extends State<RecommendChannelNav> {
static const double _revealLeftInset = 20;
final ScrollController _scrollController = ScrollController();
final GlobalKey _scrollViewKey = GlobalKey();
final Map<String, GlobalKey> _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),
),
),
],
),
);
}
}