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:
parent
e3300a1163
commit
1d3407d157
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue