refactor: replace ListView with CustomScrollView and SliverList in chat page for improved performance
- Updated the loading messages list to use CustomScrollView and SliverList, enhancing the layout and performance during message loading. - Refactored the actual messages display to utilize OptimizedSliverList, allowing for better lazy loading and smoother scrolling. - Adjusted padding and cache extent settings to optimize the user experience while navigating through messages. - Streamlined the message rendering logic to improve maintainability and responsiveness of the chat interface.
This commit is contained in:
@@ -687,82 +687,95 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLoadingMessagesList() {
|
Widget _buildLoadingMessagesList() {
|
||||||
return ListView.builder(
|
// Use slivers to align with the actual messages view.
|
||||||
|
// Do not attach the primary scroll controller here to avoid
|
||||||
|
// AnimatedSwitcher attaching the same controller twice.
|
||||||
|
return CustomScrollView(
|
||||||
key: const ValueKey('loading_messages'),
|
key: const ValueKey('loading_messages'),
|
||||||
// Do not reuse the primary scroll controller here to avoid
|
|
||||||
// attaching the same controller to multiple lists during
|
|
||||||
// AnimatedSwitcher transitions.
|
|
||||||
controller: null,
|
controller: null,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
Spacing.lg,
|
|
||||||
Spacing.md,
|
|
||||||
Spacing.lg,
|
|
||||||
Spacing.lg,
|
|
||||||
),
|
|
||||||
physics:
|
|
||||||
const AlwaysScrollableScrollPhysics(), // Allow pull-to-refresh while loading
|
|
||||||
// Modest cache extent to avoid offscreen overwork but keep shimmer smooth
|
|
||||||
cacheExtent: 300,
|
cacheExtent: 300,
|
||||||
itemCount: 6,
|
slivers: [
|
||||||
itemBuilder: (context, index) {
|
SliverPadding(
|
||||||
final isUser = index.isOdd;
|
padding: const EdgeInsets.fromLTRB(
|
||||||
return Align(
|
Spacing.lg,
|
||||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
Spacing.md,
|
||||||
child: Container(
|
Spacing.lg,
|
||||||
margin: const EdgeInsets.only(bottom: Spacing.md),
|
Spacing.lg,
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isUser
|
|
||||||
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.15)
|
|
||||||
: context.conduitTheme.cardBackground,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppBorderRadius.messageBubble,
|
|
||||||
),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.cardBorder,
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.messageBubble(context),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 14,
|
|
||||||
width: index % 3 == 0 ? 140 : 220,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.shimmerBase,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
||||||
),
|
|
||||||
).animate().shimmer(duration: AnimationDuration.slow),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Container(
|
|
||||||
height: 14,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.shimmerBase,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
||||||
),
|
|
||||||
).animate().shimmer(duration: AnimationDuration.slow),
|
|
||||||
if (index % 3 != 0) ...[
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Container(
|
|
||||||
height: 14,
|
|
||||||
width: index % 2 == 0 ? 180 : 120,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.shimmerBase,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
||||||
),
|
|
||||||
).animate().shimmer(duration: AnimationDuration.slow),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
sliver: SliverList(
|
||||||
},
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final isUser = index.isOdd;
|
||||||
|
return Align(
|
||||||
|
alignment: isUser
|
||||||
|
? Alignment.centerRight
|
||||||
|
: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: Spacing.md),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(Spacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUser
|
||||||
|
? context.conduitTheme.buttonPrimary.withValues(
|
||||||
|
alpha: 0.15,
|
||||||
|
)
|
||||||
|
: context.conduitTheme.cardBackground,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppBorderRadius.messageBubble,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.cardBorder,
|
||||||
|
width: BorderWidth.regular,
|
||||||
|
),
|
||||||
|
boxShadow: ConduitShadows.messageBubble(context),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: index % 3 == 0 ? 140 : 220,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.shimmerBase,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppBorderRadius.xs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).animate().shimmer(duration: AnimationDuration.slow),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.shimmerBase,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppBorderRadius.xs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).animate().shimmer(duration: AnimationDuration.slow),
|
||||||
|
if (index % 3 != 0) ...[
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: index % 2 == 0 ? 180 : 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.shimmerBase,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppBorderRadius.xs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).animate().shimmer(duration: AnimationDuration.slow),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, childCount: 6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,102 +818,109 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return OptimizedList<ChatMessage>(
|
return CustomScrollView(
|
||||||
key: const ValueKey('actual_messages'),
|
key: const ValueKey('actual_messages'),
|
||||||
scrollController: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
items: messages,
|
cacheExtent: 600,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
slivers: [
|
||||||
Spacing.lg,
|
SliverPadding(
|
||||||
Spacing.md,
|
padding: const EdgeInsets.fromLTRB(
|
||||||
Spacing.lg,
|
Spacing.lg,
|
||||||
Spacing.lg,
|
Spacing.md,
|
||||||
),
|
Spacing.lg,
|
||||||
itemBuilder: (context, message, index) {
|
Spacing.lg,
|
||||||
final isUser = message.role == 'user';
|
),
|
||||||
final isStreaming = message.isStreaming;
|
sliver: OptimizedSliverList<ChatMessage>(
|
||||||
|
items: messages,
|
||||||
|
itemBuilder: (context, message, index) {
|
||||||
|
final isUser = message.role == 'user';
|
||||||
|
final isStreaming = message.isStreaming;
|
||||||
|
|
||||||
final isSelected = _selectedMessageIds.contains(message.id);
|
final isSelected = _selectedMessageIds.contains(message.id);
|
||||||
|
|
||||||
// Resolve a friendly model display name for message headers
|
// Resolve a friendly model display name for message headers
|
||||||
String? displayModelName;
|
String? displayModelName;
|
||||||
Model? matchedModel;
|
Model? matchedModel;
|
||||||
final rawModel = message.model;
|
final rawModel = message.model;
|
||||||
if (rawModel != null && rawModel.isNotEmpty) {
|
if (rawModel != null && rawModel.isNotEmpty) {
|
||||||
final modelsAsync = ref.watch(modelsProvider);
|
final modelsAsync = ref.watch(modelsProvider);
|
||||||
if (modelsAsync.hasValue) {
|
if (modelsAsync.hasValue) {
|
||||||
final models = modelsAsync.value!;
|
final models = modelsAsync.value!;
|
||||||
try {
|
try {
|
||||||
// Prefer exact ID match; fall back to exact name match
|
// Prefer exact ID match; fall back to exact name match
|
||||||
final match = models.firstWhere(
|
final match = models.firstWhere(
|
||||||
(m) => m.id == rawModel || m.name == rawModel,
|
(m) => m.id == rawModel || m.name == rawModel,
|
||||||
|
);
|
||||||
|
matchedModel = match;
|
||||||
|
displayModelName = _formatModelDisplayName(match.name);
|
||||||
|
} catch (_) {
|
||||||
|
// As a fallback, format the raw value to be more readable
|
||||||
|
displayModelName = _formatModelDisplayName(rawModel);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Models not loaded yet; format raw value for readability
|
||||||
|
displayModelName = _formatModelDisplayName(rawModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final modelIconUrl = resolveModelIconUrlForModel(
|
||||||
|
apiService,
|
||||||
|
matchedModel,
|
||||||
);
|
);
|
||||||
matchedModel = match;
|
|
||||||
displayModelName = _formatModelDisplayName(match.name);
|
|
||||||
} catch (_) {
|
|
||||||
// As a fallback, format the raw value to be more readable
|
|
||||||
displayModelName = _formatModelDisplayName(rawModel);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Models not loaded yet; format raw value for readability
|
|
||||||
displayModelName = _formatModelDisplayName(rawModel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final modelIconUrl = resolveModelIconUrlForModel(
|
// Wrap message in selection container if in selection mode
|
||||||
apiService,
|
Widget messageWidget;
|
||||||
matchedModel,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wrap message in selection container if in selection mode
|
// Use documentation style for assistant messages, bubble for user messages
|
||||||
Widget messageWidget;
|
if (isUser) {
|
||||||
|
messageWidget = UserMessageBubble(
|
||||||
|
key: ValueKey('user-${message.id}'),
|
||||||
|
message: message,
|
||||||
|
isUser: isUser,
|
||||||
|
isStreaming: isStreaming,
|
||||||
|
modelName: displayModelName,
|
||||||
|
onCopy: () => _copyMessage(message.content),
|
||||||
|
onRegenerate: () => _regenerateMessage(message),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
messageWidget = assistant.AssistantMessageWidget(
|
||||||
|
key: ValueKey('assistant-${message.id}'),
|
||||||
|
message: message,
|
||||||
|
isStreaming: isStreaming,
|
||||||
|
modelName: displayModelName,
|
||||||
|
modelIconUrl: modelIconUrl,
|
||||||
|
onCopy: () => _copyMessage(message.content),
|
||||||
|
onRegenerate: () => _regenerateMessage(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Use documentation style for assistant messages, bubble for user messages
|
// Add selection functionality if in selection mode
|
||||||
if (isUser) {
|
if (_isSelectionMode) {
|
||||||
messageWidget = UserMessageBubble(
|
return _SelectableMessageWrapper(
|
||||||
key: ValueKey('user-${message.id}'),
|
isSelected: isSelected,
|
||||||
message: message,
|
onTap: () => _toggleMessageSelection(message.id),
|
||||||
isUser: isUser,
|
onLongPress: () {
|
||||||
isStreaming: isStreaming,
|
if (!_isSelectionMode) {
|
||||||
modelName: displayModelName,
|
_toggleSelectionMode();
|
||||||
onCopy: () => _copyMessage(message.content),
|
_toggleMessageSelection(message.id);
|
||||||
onRegenerate: () => _regenerateMessage(message),
|
}
|
||||||
);
|
},
|
||||||
} else {
|
child: messageWidget,
|
||||||
messageWidget = assistant.AssistantMessageWidget(
|
);
|
||||||
key: ValueKey('assistant-${message.id}'),
|
} else {
|
||||||
message: message,
|
return GestureDetector(
|
||||||
isStreaming: isStreaming,
|
onLongPress: () {
|
||||||
modelName: displayModelName,
|
_toggleSelectionMode();
|
||||||
modelIconUrl: modelIconUrl,
|
_toggleMessageSelection(message.id);
|
||||||
onCopy: () => _copyMessage(message.content),
|
},
|
||||||
onRegenerate: () => _regenerateMessage(message),
|
child: messageWidget,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Add selection functionality if in selection mode
|
|
||||||
if (_isSelectionMode) {
|
|
||||||
return _SelectableMessageWrapper(
|
|
||||||
isSelected: isSelected,
|
|
||||||
onTap: () => _toggleMessageSelection(message.id),
|
|
||||||
onLongPress: () {
|
|
||||||
if (!_isSelectionMode) {
|
|
||||||
_toggleSelectionMode();
|
|
||||||
_toggleMessageSelection(message.id);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: messageWidget,
|
),
|
||||||
);
|
),
|
||||||
} else {
|
],
|
||||||
return GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
_toggleSelectionMode();
|
|
||||||
_toggleMessageSelection(message.id);
|
|
||||||
},
|
|
||||||
child: messageWidget,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,249 +4,7 @@ import 'skeleton_loader.dart';
|
|||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import 'improved_loading_states.dart';
|
import 'improved_loading_states.dart';
|
||||||
|
|
||||||
/// Optimized list widget with virtualization and performance enhancements
|
/// Sliver version of an optimized list for use in CustomScrollView.
|
||||||
class OptimizedList<T> extends ConsumerStatefulWidget {
|
|
||||||
final List<T> items;
|
|
||||||
final Widget Function(BuildContext context, T item, int index) itemBuilder;
|
|
||||||
final Widget? separatorBuilder;
|
|
||||||
final Widget? loadingWidget;
|
|
||||||
final Widget? emptyWidget;
|
|
||||||
final String? emptyMessage;
|
|
||||||
final Future<void> Function()? onRefresh;
|
|
||||||
final VoidCallback? onLoadMore;
|
|
||||||
final bool hasMore;
|
|
||||||
final bool isLoading;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
final ScrollController? scrollController;
|
|
||||||
final ScrollPhysics? physics;
|
|
||||||
final bool shrinkWrap;
|
|
||||||
final Axis scrollDirection;
|
|
||||||
final bool reverse;
|
|
||||||
final double? cacheExtent;
|
|
||||||
final int? itemExtent;
|
|
||||||
final bool addAutomaticKeepAlives;
|
|
||||||
final bool addRepaintBoundaries;
|
|
||||||
final bool enablePagination;
|
|
||||||
final double paginationThreshold;
|
|
||||||
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
|
|
||||||
|
|
||||||
const OptimizedList({
|
|
||||||
super.key,
|
|
||||||
required this.items,
|
|
||||||
required this.itemBuilder,
|
|
||||||
this.separatorBuilder,
|
|
||||||
this.loadingWidget,
|
|
||||||
this.emptyWidget,
|
|
||||||
this.emptyMessage,
|
|
||||||
this.onRefresh,
|
|
||||||
this.onLoadMore,
|
|
||||||
this.hasMore = false,
|
|
||||||
this.isLoading = false,
|
|
||||||
this.padding,
|
|
||||||
this.scrollController,
|
|
||||||
this.physics,
|
|
||||||
this.shrinkWrap = false,
|
|
||||||
this.scrollDirection = Axis.vertical,
|
|
||||||
this.reverse = false,
|
|
||||||
this.cacheExtent,
|
|
||||||
this.itemExtent,
|
|
||||||
this.addAutomaticKeepAlives = true,
|
|
||||||
this.addRepaintBoundaries = true,
|
|
||||||
this.enablePagination = false,
|
|
||||||
this.paginationThreshold = 0.8,
|
|
||||||
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<OptimizedList<T>> createState() => _OptimizedListState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
|
|
||||||
late ScrollController _scrollController;
|
|
||||||
bool _isLoadingMore = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_scrollController = widget.scrollController ?? ScrollController();
|
|
||||||
|
|
||||||
if (widget.enablePagination) {
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
if (widget.scrollController == null) {
|
|
||||||
_scrollController.dispose();
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
if (!widget.enablePagination ||
|
|
||||||
_isLoadingMore ||
|
|
||||||
!widget.hasMore ||
|
|
||||||
widget.onLoadMore == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
||||||
final currentScroll = _scrollController.position.pixels;
|
|
||||||
final threshold = maxScroll * widget.paginationThreshold;
|
|
||||||
|
|
||||||
if (currentScroll >= threshold) {
|
|
||||||
_loadMore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMore() async {
|
|
||||||
if (_isLoadingMore) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoadingMore = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
widget.onLoadMore?.call();
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingMore = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Show loading state
|
|
||||||
if (widget.isLoading && widget.items.isEmpty) {
|
|
||||||
return widget.loadingWidget ?? _buildDefaultLoadingWidget();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show empty state
|
|
||||||
if (widget.items.isEmpty) {
|
|
||||||
return widget.emptyWidget ??
|
|
||||||
ImprovedEmptyState(
|
|
||||||
title: AppLocalizations.of(context)!.noItems,
|
|
||||||
subtitle:
|
|
||||||
widget.emptyMessage ??
|
|
||||||
AppLocalizations.of(context)!.noItemsToDisplay,
|
|
||||||
icon: Icons.inbox_outlined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the list
|
|
||||||
Widget listWidget;
|
|
||||||
|
|
||||||
final ScrollPhysics effectivePhysics =
|
|
||||||
widget.physics ??
|
|
||||||
(widget.onRefresh != null
|
|
||||||
? const AlwaysScrollableScrollPhysics()
|
|
||||||
: const ClampingScrollPhysics());
|
|
||||||
|
|
||||||
final reverse = widget.reverse;
|
|
||||||
|
|
||||||
if (widget.separatorBuilder != null) {
|
|
||||||
listWidget = ListView.separated(
|
|
||||||
controller: _scrollController,
|
|
||||||
padding: widget.padding,
|
|
||||||
physics: effectivePhysics,
|
|
||||||
keyboardDismissBehavior: widget.keyboardDismissBehavior,
|
|
||||||
shrinkWrap: widget.shrinkWrap,
|
|
||||||
scrollDirection: widget.scrollDirection,
|
|
||||||
reverse: reverse,
|
|
||||||
cacheExtent: widget.cacheExtent ?? 250.0,
|
|
||||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
|
||||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
|
||||||
itemCount: widget.items.length + (widget.hasMore ? 1 : 0),
|
|
||||||
separatorBuilder: (context, index) => widget.separatorBuilder!,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= widget.items.length) {
|
|
||||||
return _buildLoadMoreIndicator();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildOptimizedItem(context, index, reverse: reverse);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
listWidget = ListView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
padding: widget.padding,
|
|
||||||
physics: effectivePhysics,
|
|
||||||
keyboardDismissBehavior: widget.keyboardDismissBehavior,
|
|
||||||
shrinkWrap: widget.shrinkWrap,
|
|
||||||
scrollDirection: widget.scrollDirection,
|
|
||||||
reverse: reverse,
|
|
||||||
cacheExtent: widget.cacheExtent ?? 250.0,
|
|
||||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
|
||||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
|
||||||
itemCount: widget.items.length + (widget.hasMore ? 1 : 0),
|
|
||||||
itemExtent: widget.itemExtent?.toDouble(),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index >= widget.items.length) {
|
|
||||||
return _buildLoadMoreIndicator();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildOptimizedItem(context, index, reverse: reverse);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add refresh indicator if enabled
|
|
||||||
if (widget.onRefresh != null) {
|
|
||||||
return RefreshIndicator(onRefresh: widget.onRefresh!, child: listWidget);
|
|
||||||
}
|
|
||||||
|
|
||||||
return listWidget;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildOptimizedItem(
|
|
||||||
BuildContext context,
|
|
||||||
int index, {
|
|
||||||
required bool reverse,
|
|
||||||
}) {
|
|
||||||
final effectiveIndex = reverse ? widget.items.length - index - 1 : index;
|
|
||||||
final item = widget.items[effectiveIndex];
|
|
||||||
|
|
||||||
if (widget.addRepaintBoundaries) {
|
|
||||||
return RepaintBoundary(
|
|
||||||
child: widget.itemBuilder(context, item, effectiveIndex),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return widget.itemBuilder(context, item, effectiveIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLoadMoreIndicator() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: _isLoadingMore
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: TextButton(
|
|
||||||
onPressed: _loadMore,
|
|
||||||
child: Text(AppLocalizations.of(context)!.loadMore),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultLoadingWidget() {
|
|
||||||
return ListView.builder(
|
|
||||||
padding: widget.padding,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: 5,
|
|
||||||
itemBuilder: (context, index) => const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: SkeletonLoader(height: 80),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sliver version of OptimizedList for use in CustomScrollView
|
|
||||||
class OptimizedSliverList<T> extends ConsumerWidget {
|
class OptimizedSliverList<T> extends ConsumerWidget {
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
final Widget Function(BuildContext context, T item, int index) itemBuilder;
|
final Widget Function(BuildContext context, T item, int index) itemBuilder;
|
||||||
@@ -275,14 +33,14 @@ class OptimizedSliverList<T> extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// Show loading state
|
// Loading state
|
||||||
if (isLoading && items.isEmpty) {
|
if (isLoading && items.isEmpty) {
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: loadingWidget ?? _buildDefaultLoadingWidget(),
|
child: loadingWidget ?? _buildDefaultLoadingWidget(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show empty state
|
// Empty state
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child:
|
child:
|
||||||
@@ -300,17 +58,16 @@ class OptimizedSliverList<T> extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the list
|
// List content
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
if (index >= items.length) {
|
if (index >= items.length) {
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
// Trigger load more
|
// Trigger pagination once this placeholder is built
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
onLoadMore?.call();
|
onLoadMore?.call();
|
||||||
});
|
});
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
@@ -323,11 +80,10 @@ class OptimizedSliverList<T> extends ConsumerWidget {
|
|||||||
final item = items[index];
|
final item = items[index];
|
||||||
final widget = itemBuilder(context, item, index);
|
final widget = itemBuilder(context, item, index);
|
||||||
|
|
||||||
// Wrap in repaint boundary for performance
|
// Wrap in repaint boundary for perf
|
||||||
if (addRepaintBoundaries) {
|
if (addRepaintBoundaries) {
|
||||||
return RepaintBoundary(child: widget);
|
return RepaintBoundary(child: widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
return widget;
|
return widget;
|
||||||
},
|
},
|
||||||
childCount: items.length + (hasMore ? 1 : 0),
|
childCount: items.length + (hasMore ? 1 : 0),
|
||||||
@@ -350,7 +106,7 @@ class OptimizedSliverList<T> extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Animated list with optimizations
|
/// Animated list with lightweight add/remove animations.
|
||||||
class OptimizedAnimatedList<T> extends ConsumerStatefulWidget {
|
class OptimizedAnimatedList<T> extends ConsumerStatefulWidget {
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
final Widget Function(
|
final Widget Function(
|
||||||
@@ -397,7 +153,7 @@ class _OptimizedAnimatedListState<T>
|
|||||||
void didUpdateWidget(OptimizedAnimatedList<T> oldWidget) {
|
void didUpdateWidget(OptimizedAnimatedList<T> oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
// Handle item additions
|
// Additions
|
||||||
for (int i = 0; i < widget.items.length; i++) {
|
for (int i = 0; i < widget.items.length; i++) {
|
||||||
if (i >= _items.length || widget.items[i] != _items[i]) {
|
if (i >= _items.length || widget.items[i] != _items[i]) {
|
||||||
_items.insert(i, widget.items[i]);
|
_items.insert(i, widget.items[i]);
|
||||||
@@ -408,7 +164,7 @@ class _OptimizedAnimatedListState<T>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle item removals
|
// Removals
|
||||||
for (int i = _items.length - 1; i >= widget.items.length; i--) {
|
for (int i = _items.length - 1; i >= widget.items.length; i--) {
|
||||||
final removedItem = _items[i];
|
final removedItem = _items[i];
|
||||||
_items.removeAt(i);
|
_items.removeAt(i);
|
||||||
@@ -431,7 +187,6 @@ class _OptimizedAnimatedListState<T>
|
|||||||
initialItemCount: _items.length,
|
initialItemCount: _items.length,
|
||||||
itemBuilder: (context, index, animation) {
|
itemBuilder: (context, index, animation) {
|
||||||
if (index >= _items.length) return const SizedBox.shrink();
|
if (index >= _items.length) return const SizedBox.shrink();
|
||||||
|
|
||||||
return widget.itemBuilder(context, _items[index], index, animation);
|
return widget.itemBuilder(context, _items[index], index, animation);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user