fix(ui): 重构堆叠卡片组件为可折叠列表

- 改用 CollapsibleCardList 替代 Stack 布局
- 设置最大高度限制 (280px),避免穿透下方区域
- 超出高度时启用内部滚动
- 点击卡片展开/折叠,带动画效果

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-16 23:17:35 -08:00
parent e3300a1163
commit 1d3407d157
1 changed files with 206 additions and 249 deletions

View File

@ -1,115 +1,94 @@
import 'package:flutter/material.dart';
///
///
class StackedCardsWidget<T> extends StatefulWidget {
///
///
class CollapsibleCardList<T> extends StatefulWidget {
///
final List<T> items;
///
final Widget Function(T item, bool isSelected) itemBuilder;
///
final Widget Function(T item) expandedBuilder;
///
final double peekHeight;
///
final Widget Function(T item) collapsedBuilder;
///
final double expandedCardHeight;
///
final double expandedHeight;
///
final double? cardWidth;
///
final double collapsedHeight;
///
///
final double maxHeight;
///
final ValueChanged<int>? onSelectedIndexChanged;
///
final bool enableSound;
const StackedCardsWidget({
const CollapsibleCardList({
super.key,
required this.items,
required this.itemBuilder,
this.peekHeight = 28,
this.expandedCardHeight = 120,
this.cardWidth,
required this.expandedBuilder,
required this.collapsedBuilder,
this.expandedHeight = 110,
this.collapsedHeight = 44,
this.maxHeight = 280,
this.onSelectedIndexChanged,
this.enableSound = true,
});
@override
State<StackedCardsWidget<T>> createState() => _StackedCardsWidgetState<T>();
State<CollapsibleCardList<T>> createState() => _CollapsibleCardListState<T>();
}
class _StackedCardsWidgetState<T> extends State<StackedCardsWidget<T>>
with SingleTickerProviderStateMixin {
///
class _CollapsibleCardListState<T> extends State<CollapsibleCardList<T>> {
int _selectedIndex = 0;
///
late AnimationController _animationController;
///
late ScrollController _scrollController;
///
int _lastFeedbackIndex = 0;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_animationController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
///
void _onScroll() {
if (widget.items.isEmpty) return;
//
final scrollOffset = _scrollController.offset;
final newIndex = (scrollOffset / widget.peekHeight).round().clamp(0, widget.items.length - 1);
if (newIndex != _selectedIndex) {
setState(() {
_selectedIndex = newIndex;
});
//
if (widget.enableSound && newIndex != _lastFeedbackIndex) {
_lastFeedbackIndex = newIndex;
}
widget.onSelectedIndexChanged?.call(newIndex);
}
}
///
void _onCardTap(int index) {
void _selectCard(int index) {
if (index == _selectedIndex) return;
setState(() {
_selectedIndex = index;
});
//
_scrollController.animateTo(
index * widget.peekHeight,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
widget.onSelectedIndexChanged?.call(index);
//
_scrollToSelected(index);
}
void _scrollToSelected(int index) {
if (!_scrollController.hasClients) return;
//
double offset = 0;
for (int i = 0; i < index; i++) {
offset += i == _selectedIndex ? widget.expandedHeight : widget.collapsedHeight;
offset += 6; //
}
//
final viewportHeight = _scrollController.position.viewportDimension;
final maxScroll = _scrollController.position.maxScrollExtent;
if (offset < _scrollController.offset) {
_scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else if (offset + widget.expandedHeight > _scrollController.offset + viewportHeight) {
_scrollController.animateTo(
(offset + widget.expandedHeight - viewportHeight).clamp(0, maxScroll),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
}
}
@override
@ -118,69 +97,145 @@ class _StackedCardsWidgetState<T> extends State<StackedCardsWidget<T>>
return const SizedBox.shrink();
}
// + (-1) *
final totalHeight = widget.expandedCardHeight +
(widget.items.length - 1) * widget.peekHeight;
//
final contentHeight = widget.expandedHeight +
(widget.items.length - 1) * widget.collapsedHeight +
(widget.items.length - 1) * 6; //
return SizedBox(
height: totalHeight.clamp(0, 300), //
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
//
if (notification is ScrollEndNotification) {
_snapToNearestCard();
}
return false;
},
child: ListView.builder(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
itemCount: widget.items.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedIndex;
final item = widget.items[index];
// 使 ConstrainedBox
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: contentHeight.clamp(0, widget.maxHeight),
),
child: ListView.separated(
controller: _scrollController,
shrinkWrap: true,
physics: contentHeight > widget.maxHeight
? const ClampingScrollPhysics()
: const NeverScrollableScrollPhysics(),
itemCount: widget.items.length,
separatorBuilder: (context, index) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final isSelected = index == _selectedIndex;
final item = widget.items[index];
return GestureDetector(
onTap: () => _onCardTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: isSelected ? widget.expandedCardHeight : widget.peekHeight,
margin: EdgeInsets.only(
bottom: index == widget.items.length - 1 ? 0 : 0,
),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isSelected ? 1.0 : 0.85,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.itemBuilder(item, isSelected),
),
return GestureDetector(
onTap: () => _selectCard(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: isSelected ? widget.expandedHeight : widget.collapsedHeight,
decoration: BoxDecoration(
color: isSelected ? Colors.white : const Color(0xFFFFFDF8),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? const Color(0x44D4AF37) : const Color(0x22D4AF37),
width: isSelected ? 1.5 : 1,
),
),
);
},
),
clipBehavior: Clip.antiAlias,
child: isSelected
? widget.expandedBuilder(item)
: widget.collapsedBuilder(item),
),
);
},
),
);
}
}
///
void _snapToNearestCard() {
if (!_scrollController.hasClients) return;
///
class IndexedCollapsibleCardList<T> extends StatefulWidget {
///
final List<T> items;
final targetOffset = _selectedIndex * widget.peekHeight;
if ((_scrollController.offset - targetOffset).abs() > 1) {
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
);
///
final Widget Function(T item) expandedBuilder;
///
final Widget Function(T item) collapsedBuilder;
///
final double expandedHeight;
///
final double collapsedHeight;
///
final double maxHeight;
const IndexedCollapsibleCardList({
super.key,
required this.items,
required this.expandedBuilder,
required this.collapsedBuilder,
this.expandedHeight = 110,
this.collapsedHeight = 44,
this.maxHeight = 280,
});
@override
State<IndexedCollapsibleCardList<T>> createState() =>
_IndexedCollapsibleCardListState<T>();
}
class _IndexedCollapsibleCardListState<T>
extends State<IndexedCollapsibleCardList<T>> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
//
CollapsibleCardList<T>(
items: widget.items,
expandedBuilder: widget.expandedBuilder,
collapsedBuilder: widget.collapsedBuilder,
expandedHeight: widget.expandedHeight,
collapsedHeight: widget.collapsedHeight,
maxHeight: widget.maxHeight,
onSelectedIndexChanged: (index) {
setState(() {
_selectedIndex = index;
});
},
),
//
if (widget.items.length > 1) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
widget.items.length,
(index) => Container(
width: index == _selectedIndex ? 16 : 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: index == _selectedIndex
? const Color(0xFFD4AF37)
: const Color(0x33D4AF37),
borderRadius: BorderRadius.circular(3),
),
),
),
),
],
],
);
}
}
// StackedCardsView
/// 使 Stack
/// 使 CollapsibleCardList
class StackedCardsView<T> extends StatefulWidget {
///
final List<T> items;
@ -215,130 +270,8 @@ class StackedCardsView<T> extends StatefulWidget {
}
class _StackedCardsViewState<T> extends State<StackedCardsView<T>> {
///
int _selectedIndex = 0;
///
double _dragStartY = 0;
///
double _dragOffset = 0;
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
//
final totalHeight = widget.expandedCardHeight +
(widget.items.length - 1) * widget.peekHeight;
return SizedBox(
height: totalHeight.clamp(0.0, 320.0),
child: GestureDetector(
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: Stack(
clipBehavior: Clip.none,
children: List.generate(widget.items.length, (index) {
final isSelected = index == _selectedIndex;
final item = widget.items[index];
//
double topPosition;
if (index <= _selectedIndex) {
//
topPosition = index * widget.peekHeight;
} else {
//
topPosition = widget.expandedCardHeight +
(index - 1) * widget.peekHeight;
}
//
topPosition += _dragOffset * (index == _selectedIndex ? 0.5 : 0.2);
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
top: topPosition,
left: 0,
right: 0,
child: GestureDetector(
onTap: () => _selectCard(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: isSelected
? widget.expandedCardHeight
: widget.peekHeight + 20, //
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.itemBuilder(item, isSelected, index),
),
),
),
);
}),
),
),
);
}
void _onDragStart(DragStartDetails details) {
_dragStartY = details.globalPosition.dy;
}
void _onDragUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset = details.globalPosition.dy - _dragStartY;
});
}
void _onDragEnd(DragEndDetails details) {
//
final velocity = details.primaryVelocity ?? 0;
final threshold = widget.peekHeight;
int newIndex = _selectedIndex;
if (_dragOffset < -threshold || velocity < -500) {
//
newIndex = (_selectedIndex + 1).clamp(0, widget.items.length - 1);
} else if (_dragOffset > threshold || velocity > 500) {
//
newIndex = (_selectedIndex - 1).clamp(0, widget.items.length - 1);
}
setState(() {
_dragOffset = 0;
if (newIndex != _selectedIndex) {
_selectedIndex = newIndex;
widget.onSelectedIndexChanged?.call(newIndex);
}
});
}
void _selectCard(int index) {
if (index == _selectedIndex) return;
@ -348,4 +281,28 @@ class _StackedCardsViewState<T> extends State<StackedCardsView<T>> {
widget.onSelectedIndexChanged?.call(index);
}
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
// 使 CollapsibleCardList Stack
return CollapsibleCardList<T>(
items: widget.items,
expandedHeight: widget.expandedCardHeight,
collapsedHeight: widget.peekHeight + 20,
maxHeight: 280,
expandedBuilder: (item) {
final index = widget.items.indexOf(item);
return widget.itemBuilder(item, true, index);
},
collapsedBuilder: (item) {
final index = widget.items.indexOf(item);
return widget.itemBuilder(item, false, index);
},
onSelectedIndexChanged: widget.onSelectedIndexChanged,
);
}
}