fix(flutter): 优化分类弧线与自动滚动
This commit is contained in:
1 parent
3041849344
commit
5af70c345a
2 files changed
+177
-63
No files matched your search
@@ -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<DiscoverPage> {
|
||||
late final DiscoverLogic logic;
|
||||
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;
|
||||
|
||||
@@ -99,6 +107,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
logic.scroll.removeListener(_onScroll);
|
||||
_categoryScroll.dispose();
|
||||
_headerPinned.dispose();
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
@@ -277,78 +286,126 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
}
|
||||
|
||||
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<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) {
|
||||
return Padding(
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user