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:
cogwheel0
2025-10-10 18:49:35 +05:30
parent 570fa26011
commit fcb89e56c7
2 changed files with 187 additions and 412 deletions

View File

@@ -687,27 +687,29 @@ 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,
physics: const AlwaysScrollableScrollPhysics(),
cacheExtent: 300,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
Spacing.lg, Spacing.lg,
Spacing.md, Spacing.md,
Spacing.lg, Spacing.lg,
Spacing.lg, Spacing.lg,
), ),
physics: sliver: SliverList(
const AlwaysScrollableScrollPhysics(), // Allow pull-to-refresh while loading delegate: SliverChildBuilderDelegate((context, index) {
// Modest cache extent to avoid offscreen overwork but keep shimmer smooth
cacheExtent: 300,
itemCount: 6,
itemBuilder: (context, index) {
final isUser = index.isOdd; final isUser = index.isOdd;
return Align( return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, alignment: isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: Spacing.md), margin: const EdgeInsets.only(bottom: Spacing.md),
constraints: BoxConstraints( constraints: BoxConstraints(
@@ -716,7 +718,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
padding: const EdgeInsets.all(Spacing.md), padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isUser color: isUser
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.15) ? context.conduitTheme.buttonPrimary.withValues(
alpha: 0.15,
)
: context.conduitTheme.cardBackground, : context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppBorderRadius.messageBubble, AppBorderRadius.messageBubble,
@@ -735,7 +739,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: index % 3 == 0 ? 140 : 220, width: index % 3 == 0 ? 140 : 220,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.shimmerBase, color: context.conduitTheme.shimmerBase,
borderRadius: BorderRadius.circular(AppBorderRadius.xs), borderRadius: BorderRadius.circular(
AppBorderRadius.xs,
),
), ),
).animate().shimmer(duration: AnimationDuration.slow), ).animate().shimmer(duration: AnimationDuration.slow),
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
@@ -744,7 +750,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.shimmerBase, color: context.conduitTheme.shimmerBase,
borderRadius: BorderRadius.circular(AppBorderRadius.xs), borderRadius: BorderRadius.circular(
AppBorderRadius.xs,
),
), ),
).animate().shimmer(duration: AnimationDuration.slow), ).animate().shimmer(duration: AnimationDuration.slow),
if (index % 3 != 0) ...[ if (index % 3 != 0) ...[
@@ -754,7 +762,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: index % 2 == 0 ? 180 : 120, width: index % 2 == 0 ? 180 : 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.shimmerBase, color: context.conduitTheme.shimmerBase,
borderRadius: BorderRadius.circular(AppBorderRadius.xs), borderRadius: BorderRadius.circular(
AppBorderRadius.xs,
),
), ),
).animate().shimmer(duration: AnimationDuration.slow), ).animate().shimmer(duration: AnimationDuration.slow),
], ],
@@ -762,7 +772,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
), ),
); );
}, }, childCount: 6),
),
),
],
); );
} }
@@ -805,17 +818,21 @@ 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,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
Spacing.lg, Spacing.lg,
Spacing.md, Spacing.md,
Spacing.lg, Spacing.lg,
Spacing.lg, Spacing.lg,
), ),
sliver: OptimizedSliverList<ChatMessage>(
items: messages,
itemBuilder: (context, message, index) { itemBuilder: (context, message, index) {
final isUser = message.role == 'user'; final isUser = message.role == 'user';
final isStreaming = message.isStreaming; final isStreaming = message.isStreaming;
@@ -901,6 +918,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
); );
} }
}, },
),
),
],
); );
} }

View File

@@ -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);
}, },
); );