diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 4b1fa46..5245c5e 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -38,8 +38,12 @@ const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; +const double _categoryUnderlineWidth = 20; +const double _categoryUnderlineHeight = 3; +const double _categoryUnderlineStrokeWidth = 2; const double _stickyHeaderHeight = 44; const Duration _stickyHeaderTransition = Duration(milliseconds: 160); +const Duration _categoryScrollDuration = Duration(milliseconds: 200); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { @@ -63,6 +67,10 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; final ValueNotifier _headerPinned = ValueNotifier(false); + final ScrollController _categoryScroll = ScrollController(); + final Map _categoryAnchors = {}; + String? _ensuredCategoryId; + bool _categoryRevealScheduled = false; bool get embedded => widget.callbacks.embedded; @@ -99,6 +107,7 @@ class _DiscoverPageState extends State { @override void dispose() { logic.scroll.removeListener(_onScroll); + _categoryScroll.dispose(); _headerPinned.dispose(); logic.dispose(); super.dispose(); @@ -277,78 +286,126 @@ class _DiscoverPageState extends State { } Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { + _scheduleSelectedCategoryReveal(); return SizedBox( key: const Key('theater-category-chips'), height: _categoryTabHeight, - child: ListView.separated( + child: SingleChildScrollView( + key: const Key('theater-category-scroll'), + controller: _categoryScroll, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: categories.categories.length + 1, - separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap), - itemBuilder: (context, index) { - if (index == 0) { - return _categoryTab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _categoryTab( key: const Key('theater-category-all'), + anchorKey: _anchorFor('all'), id: 'all', label: l10n.all, selected: logic.selectedCategoryId == null, - onTap: () => logic.selectCategory(null), - ); - } - final category = categories.categories[index - 1]; - return _categoryTab( - key: Key('theater-category-${category.id}'), - id: category.id, - label: category.name, - selected: logic.selectedCategoryId == category.id, - onTap: () => logic.selectCategory(category.id), - ); - }, + onTap: () => _selectCategory(null), + ), + for (final category in categories.categories) ...[ + const SizedBox(width: _categoryTabGap), + _categoryTab( + key: Key('theater-category-${category.id}'), + anchorKey: _anchorFor(category.id), + id: category.id, + label: category.name, + selected: logic.selectedCategoryId == category.id, + onTap: () => _selectCategory(category.id), + ), + ], + ], + ), ), ); } Widget _categoryTab({ required Key key, + required GlobalKey anchorKey, required String id, required String label, required bool selected, required VoidCallback onTap, }) { - return GestureDetector( - key: key, - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - maxLines: 1, - style: TextStyle( - color: selected ? AppColors.onSurface : AppColors.muted, - fontSize: selected - ? _categorySelectedFontSize - : _categoryFontSize, - fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + return KeyedSubtree( + key: anchorKey, + child: GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: selected + ? _categorySelectedFontSize + : _categoryFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), ), - ), - const SizedBox(height: 4), - Container( - key: Key('theater-category-underline-$id'), - width: 18, - height: 2, - decoration: BoxDecoration( - color: selected ? AppColors.blue : Colors.transparent, - borderRadius: BorderRadius.circular(1), + const SizedBox(height: 4), + CustomPaint( + key: Key('theater-category-underline-$id'), + size: const Size( + _categoryUnderlineWidth, + _categoryUnderlineHeight, + ), + painter: CategoryUnderlinePainter( + color: selected ? AppColors.blue : Colors.transparent, + ), ), - ), - ], + ], + ), ), ); } + GlobalKey _anchorFor(String id) { + return _categoryAnchors.putIfAbsent(id, GlobalKey.new); + } + + void _selectCategory(String? categoryId) { + logic.selectCategory(categoryId); + final id = categoryId ?? 'all'; + _ensuredCategoryId = id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_revealCategory(id)); + }); + } + + void _scheduleSelectedCategoryReveal() { + final selectedId = logic.selectedCategoryId ?? 'all'; + if (_categoryRevealScheduled || _ensuredCategoryId == selectedId) return; + _categoryRevealScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + _categoryRevealScheduled = false; + if (!mounted || (logic.selectedCategoryId ?? 'all') != selectedId) return; + final revealed = await _revealCategory(selectedId); + if (revealed) _ensuredCategoryId = selectedId; + }); + } + + Future _revealCategory(String id) async { + final target = _categoryAnchors[id]?.currentContext; + if (target == null) return false; + await Scrollable.ensureVisible( + target, + alignment: 0.5, + duration: _categoryScrollDuration, + curve: Curves.easeOutCubic, + ); + return true; + } + Widget _toolbar(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), @@ -501,3 +558,27 @@ class _DiscoverPageState extends State { ], ); } + +/// 分类 tab 的弧形选中线。 +class CategoryUnderlinePainter extends CustomPainter { + const CategoryUnderlinePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _categoryUnderlineStrokeWidth + ..strokeCap = StrokeCap.round; + final path = Path() + ..moveTo(1, size.height - 1) + ..quadraticBezierTo(size.width / 2, 0, size.width - 1, size.height - 1); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CategoryUnderlinePainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 0103065..16bb0bf 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/pages/discover/discover_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -274,23 +275,54 @@ void main() { testWidgets('should_show_category_underline_for_selection', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - Color underline(String id) => - (tester - .widget( - find.byKey(Key('theater-category-underline-$id')), - ) - .decoration! - as BoxDecoration) - .color!; + CategoryUnderlinePainter underline(String id) => + tester + .widget( + find.byKey(Key('theater-category-underline-$id')), + ) + .painter! + as CategoryUnderlinePainter; - expect(underline('all'), AppColors.blue); - expect(underline('7'), Colors.transparent); + expect( + tester + .widget( + find.byKey(const Key('theater-category-underline-all')), + ) + .size, + const Size(20, 3), + ); + expect(underline('all').color, AppColors.blue); + expect(underline('7').color, Colors.transparent); await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); - expect(underline('all'), Colors.transparent); - expect(underline('7'), AppColors.blue); + expect(underline('all').color, Colors.transparent); + expect(underline('7').color, AppColors.blue); + }); + + testWidgets('should_scroll_offscreen_category_into_view_when_tapped', ( + tester, + ) async { + final categories = [ + for (var index = 0; index < 12; index++) + ContentCategory(id: '$index', name: 'Long category $index'), + ]; + await _pumpTheater( + tester, + categoryRepository: _GridCategoryRepository(categories: categories), + ); + + final target = find.byKey(const Key('theater-category-11')); + expect(tester.getRect(target).right, greaterThan(390)); + + tester.widget(target).onTap!(); + await tester.pump(); + await tester.pumpAndSettle(); + + final rect = tester.getRect(target); + expect(rect.left, greaterThanOrEqualTo(0)); + expect(rect.right, lessThanOrEqualTo(390)); }); testWidgets('should_use_text_pull_to_refresh', (tester) async { @@ -344,15 +376,16 @@ Future _pumpTheater( } class _GridCategoryRepository implements CategoryRepository { - _GridCategoryRepository({HomeFeed? discoverFeed}) - : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; + _GridCategoryRepository({ + HomeFeed? discoverFeed, + this.categories = const [ContentCategory(id: '7', name: 'Romance')], + }) : discoverFeed = discoverFeed ?? _defaultDiscoverFeed; final HomeFeed discoverFeed; + final List categories; @override - Future> fetchCategories() async => const [ - ContentCategory(id: '7', name: 'Romance'), - ]; + Future> fetchCategories() async => categories; @override Future fetchDiscoverFeed() async => discoverFeed;