fix(flutter): 优化分类弧线与自动滚动

This commit is contained in:
Zhou Weidong committed 2026-10-20 17:07:25 +08:00
1 parent 3041849344
commit 5af70c345a
2 files changed
+177 -63

No files matched your search

+127 -46
View File
@@ -38,8 +38,12 @@ const double _categoryTabHeight = 44;
const double _categoryTabGap = 20; const double _categoryTabGap = 20;
const double _categorySelectedFontSize = 16; const double _categorySelectedFontSize = 16;
const double _categoryFontSize = 15; const double _categoryFontSize = 15;
const double _categoryUnderlineWidth = 20;
const double _categoryUnderlineHeight = 3;
const double _categoryUnderlineStrokeWidth = 2;
const double _stickyHeaderHeight = 44; const double _stickyHeaderHeight = 44;
const Duration _stickyHeaderTransition = Duration(milliseconds: 160); const Duration _stickyHeaderTransition = Duration(milliseconds: 160);
const Duration _categoryScrollDuration = Duration(milliseconds: 200);
/// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。
class DiscoverPage extends StatefulWidget { class DiscoverPage extends StatefulWidget {
@@ -63,6 +67,10 @@ class DiscoverPage extends StatefulWidget {
class _DiscoverPageState extends State<DiscoverPage> { class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic; late final DiscoverLogic logic;
final ValueNotifier<bool> _headerPinned = ValueNotifier<bool>(false); final ValueNotifier<bool> _headerPinned = ValueNotifier<bool>(false);
final ScrollController _categoryScroll = ScrollController();
final Map<String, GlobalKey> _categoryAnchors = {};
String? _ensuredCategoryId;
bool _categoryRevealScheduled = false;
bool get embedded => widget.callbacks.embedded; bool get embedded => widget.callbacks.embedded;
@@ -99,6 +107,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
@override @override
void dispose() { void dispose() {
logic.scroll.removeListener(_onScroll); logic.scroll.removeListener(_onScroll);
_categoryScroll.dispose();
_headerPinned.dispose(); _headerPinned.dispose();
logic.dispose(); logic.dispose();
super.dispose(); super.dispose();
@@ -277,78 +286,126 @@ class _DiscoverPageState extends State<DiscoverPage> {
} }
Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) {
_scheduleSelectedCategoryReveal();
return SizedBox( return SizedBox(
key: const Key('theater-category-chips'), key: const Key('theater-category-chips'),
height: _categoryTabHeight, height: _categoryTabHeight,
child: ListView.separated( child: SingleChildScrollView(
key: const Key('theater-category-scroll'),
controller: _categoryScroll,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
itemCount: categories.categories.length + 1, child: Row(
separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap), mainAxisSize: MainAxisSize.min,
itemBuilder: (context, index) { children: [
if (index == 0) { _categoryTab(
return _categoryTab(
key: const Key('theater-category-all'), key: const Key('theater-category-all'),
anchorKey: _anchorFor('all'),
id: 'all', id: 'all',
label: l10n.all, label: l10n.all,
selected: logic.selectedCategoryId == null, selected: logic.selectedCategoryId == null,
onTap: () => logic.selectCategory(null), onTap: () => _selectCategory(null),
); ),
} for (final category in categories.categories) ...[
final category = categories.categories[index - 1]; const SizedBox(width: _categoryTabGap),
return _categoryTab( _categoryTab(
key: Key('theater-category-${category.id}'), key: Key('theater-category-${category.id}'),
id: category.id, anchorKey: _anchorFor(category.id),
label: category.name, id: category.id,
selected: logic.selectedCategoryId == category.id, label: category.name,
onTap: () => logic.selectCategory(category.id), selected: logic.selectedCategoryId == category.id,
); onTap: () => _selectCategory(category.id),
}, ),
],
],
),
), ),
); );
} }
Widget _categoryTab({ Widget _categoryTab({
required Key key, required Key key,
required GlobalKey anchorKey,
required String id, required String id,
required String label, required String label,
required bool selected, required bool selected,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
return GestureDetector( return KeyedSubtree(
key: key, key: anchorKey,
behavior: HitTestBehavior.opaque, child: GestureDetector(
onTap: onTap, key: key,
child: Column( behavior: HitTestBehavior.opaque,
mainAxisSize: MainAxisSize.min, onTap: onTap,
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Text( mainAxisAlignment: MainAxisAlignment.center,
label, children: [
maxLines: 1, Text(
style: TextStyle( label,
color: selected ? AppColors.onSurface : AppColors.muted, maxLines: 1,
fontSize: selected style: TextStyle(
? _categorySelectedFontSize color: selected ? AppColors.onSurface : AppColors.muted,
: _categoryFontSize, fontSize: selected
fontWeight: selected ? FontWeight.w800 : FontWeight.w500, ? _categorySelectedFontSize
: _categoryFontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
),
), ),
), const SizedBox(height: 4),
const SizedBox(height: 4), CustomPaint(
Container( key: Key('theater-category-underline-$id'),
key: Key('theater-category-underline-$id'), size: const Size(
width: 18, _categoryUnderlineWidth,
height: 2, _categoryUnderlineHeight,
decoration: BoxDecoration( ),
color: selected ? AppColors.blue : Colors.transparent, painter: CategoryUnderlinePainter(
borderRadius: BorderRadius.circular(1), 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<bool> _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) { Widget _toolbar(AppLocalizations l10n) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 12), padding: const EdgeInsets.fromLTRB(14, 2, 14, 12),
@@ -501,3 +558,27 @@ class _DiscoverPageState extends State<DiscoverPage> {
], ],
); );
} }
/// 分类 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;
}
+50 -17
View File
@@ -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/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.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/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.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 { testWidgets('should_show_category_underline_for_selection', (tester) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
Color underline(String id) => CategoryUnderlinePainter underline(String id) =>
(tester tester
.widget<Container>( .widget<CustomPaint>(
find.byKey(Key('theater-category-underline-$id')), find.byKey(Key('theater-category-underline-$id')),
) )
.decoration! .painter!
as BoxDecoration) as CategoryUnderlinePainter;
.color!;
expect(underline('all'), AppColors.blue); expect(
expect(underline('7'), Colors.transparent); tester
.widget<CustomPaint>(
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.tap(find.byKey(const Key('theater-category-7')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(underline('all'), Colors.transparent); expect(underline('all').color, Colors.transparent);
expect(underline('7'), AppColors.blue); 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<GestureDetector>(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 { testWidgets('should_use_text_pull_to_refresh', (tester) async {
@@ -344,15 +376,16 @@ Future<void> _pumpTheater(
} }
class _GridCategoryRepository implements CategoryRepository { class _GridCategoryRepository implements CategoryRepository {
_GridCategoryRepository({HomeFeed? discoverFeed}) _GridCategoryRepository({
: discoverFeed = discoverFeed ?? _defaultDiscoverFeed; HomeFeed? discoverFeed,
this.categories = const [ContentCategory(id: '7', name: 'Romance')],
}) : discoverFeed = discoverFeed ?? _defaultDiscoverFeed;
final HomeFeed discoverFeed; final HomeFeed discoverFeed;
final List<ContentCategory> categories;
@override @override
Future<List<ContentCategory>> fetchCategories() async => const [ Future<List<ContentCategory>> fetchCategories() async => categories;
ContentCategory(id: '7', name: 'Romance'),
];
@override @override
Future<HomeFeed> fetchDiscoverFeed() async => discoverFeed; Future<HomeFeed> fetchDiscoverFeed() async => discoverFeed;