refactor: 미사용 파일 삭제
- 이전 버전의 화면 파일들 제거 (add_subscription_screen_old, detail_screen_old) - 사용하지 않는 위젯 파일들 제거 (lazy_loading_list, cached_network_image_widget) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,315 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import 'skeleton_loading.dart';
|
|
||||||
|
|
||||||
/// 최적화된 캐시 네트워크 이미지 위젯
|
|
||||||
class OptimizedCachedNetworkImage extends StatelessWidget {
|
|
||||||
final String imageUrl;
|
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final BoxFit fit;
|
|
||||||
final BorderRadius? borderRadius;
|
|
||||||
final Duration fadeInDuration;
|
|
||||||
final Duration fadeOutDuration;
|
|
||||||
final Widget? placeholder;
|
|
||||||
final Widget? errorWidget;
|
|
||||||
final Map<String, String>? httpHeaders;
|
|
||||||
final bool enableMemoryCache;
|
|
||||||
final bool enableDiskCache;
|
|
||||||
final int? maxWidth;
|
|
||||||
final int? maxHeight;
|
|
||||||
|
|
||||||
const OptimizedCachedNetworkImage({
|
|
||||||
super.key,
|
|
||||||
required this.imageUrl,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
this.borderRadius,
|
|
||||||
this.fadeInDuration = const Duration(milliseconds: 300),
|
|
||||||
this.fadeOutDuration = const Duration(milliseconds: 300),
|
|
||||||
this.placeholder,
|
|
||||||
this.errorWidget,
|
|
||||||
this.httpHeaders,
|
|
||||||
this.enableMemoryCache = true,
|
|
||||||
this.enableDiskCache = true,
|
|
||||||
this.maxWidth,
|
|
||||||
this.maxHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// 성능 최적화를 위한 이미지 크기 계산
|
|
||||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
|
||||||
final optimalWidth = maxWidth ??
|
|
||||||
(width != null ? (width! * devicePixelRatio).round() : null);
|
|
||||||
final optimalHeight = maxHeight ??
|
|
||||||
(height != null ? (height! * devicePixelRatio).round() : null);
|
|
||||||
|
|
||||||
Widget image = CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
fadeInDuration: fadeInDuration,
|
|
||||||
fadeOutDuration: fadeOutDuration,
|
|
||||||
httpHeaders: httpHeaders,
|
|
||||||
memCacheWidth: optimalWidth,
|
|
||||||
memCacheHeight: optimalHeight,
|
|
||||||
maxWidthDiskCache: optimalWidth,
|
|
||||||
maxHeightDiskCache: optimalHeight,
|
|
||||||
placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(),
|
|
||||||
errorWidget: (context, url, error) =>
|
|
||||||
errorWidget ?? _buildDefaultErrorWidget(),
|
|
||||||
imageBuilder: (context, imageProvider) {
|
|
||||||
return Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: borderRadius,
|
|
||||||
image: DecorationImage(
|
|
||||||
image: imageProvider,
|
|
||||||
fit: fit,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (borderRadius != null) {
|
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: borderRadius!,
|
|
||||||
child: image,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultPlaceholder() {
|
|
||||||
return SkeletonLoading(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
borderRadius: borderRadius?.topLeft.x ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultErrorWidget() {
|
|
||||||
return Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surfaceColorAlt,
|
|
||||||
borderRadius: borderRadius,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.broken_image_outlined,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 프로그레시브 이미지 로더 (저화질 → 고화질)
|
|
||||||
class ProgressiveNetworkImage extends StatelessWidget {
|
|
||||||
final String thumbnailUrl;
|
|
||||||
final String imageUrl;
|
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final BoxFit fit;
|
|
||||||
final BorderRadius? borderRadius;
|
|
||||||
|
|
||||||
const ProgressiveNetworkImage({
|
|
||||||
super.key,
|
|
||||||
required this.thumbnailUrl,
|
|
||||||
required this.imageUrl,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
this.borderRadius,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.passthrough,
|
|
||||||
children: [
|
|
||||||
// 썸네일 (저화질)
|
|
||||||
OptimizedCachedNetworkImage(
|
|
||||||
imageUrl: thumbnailUrl,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
borderRadius: borderRadius,
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
),
|
|
||||||
// 원본 이미지 (고화질)
|
|
||||||
OptimizedCachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
borderRadius: borderRadius,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 이미지 갤러리 위젯 (메모리 효율적)
|
|
||||||
class OptimizedImageGallery extends StatefulWidget {
|
|
||||||
final List<String> imageUrls;
|
|
||||||
final double itemHeight;
|
|
||||||
final double spacing;
|
|
||||||
final int crossAxisCount;
|
|
||||||
final void Function(int)? onImageTap;
|
|
||||||
|
|
||||||
const OptimizedImageGallery({
|
|
||||||
super.key,
|
|
||||||
required this.imageUrls,
|
|
||||||
this.itemHeight = 120,
|
|
||||||
this.spacing = 8,
|
|
||||||
this.crossAxisCount = 3,
|
|
||||||
this.onImageTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<OptimizedImageGallery> createState() => _OptimizedImageGalleryState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OptimizedImageGalleryState extends State<OptimizedImageGallery> {
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
final Set<int> _visibleIndices = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
// 초기 보이는 아이템 계산
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_calculateVisibleIndices();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
_calculateVisibleIndices();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _calculateVisibleIndices() {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final viewportHeight = context.size?.height ?? 0;
|
|
||||||
final scrollOffset = _scrollController.offset;
|
|
||||||
final itemHeight = widget.itemHeight + widget.spacing;
|
|
||||||
final itemsPerRow = widget.crossAxisCount;
|
|
||||||
|
|
||||||
final firstVisibleRow = (scrollOffset / itemHeight).floor();
|
|
||||||
final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil();
|
|
||||||
|
|
||||||
final newVisibleIndices = <int>{};
|
|
||||||
for (int row = firstVisibleRow; row <= lastVisibleRow; row++) {
|
|
||||||
for (int col = 0; col < itemsPerRow; col++) {
|
|
||||||
final index = row * itemsPerRow + col;
|
|
||||||
if (index < widget.imageUrls.length) {
|
|
||||||
newVisibleIndices.add(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!setEquals(_visibleIndices, newVisibleIndices)) {
|
|
||||||
setState(() {
|
|
||||||
_visibleIndices.clear();
|
|
||||||
_visibleIndices.addAll(newVisibleIndices);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GridView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: widget.crossAxisCount,
|
|
||||||
childAspectRatio: 1.0,
|
|
||||||
crossAxisSpacing: widget.spacing,
|
|
||||||
mainAxisSpacing: widget.spacing,
|
|
||||||
),
|
|
||||||
itemCount: widget.imageUrls.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
// 보이는 영역의 이미지만 로드
|
|
||||||
if (_visibleIndices.contains(index) ||
|
|
||||||
(index >= _visibleIndices.first - widget.crossAxisCount &&
|
|
||||||
index <= _visibleIndices.last + widget.crossAxisCount)) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => widget.onImageTap?.call(index),
|
|
||||||
child: OptimizedCachedNetworkImage(
|
|
||||||
imageUrl: widget.imageUrls[index],
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 보이지 않는 영역은 플레이스홀더
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surfaceColorAlt,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool setEquals(Set<int> a, Set<int> b) {
|
|
||||||
if (a.length != b.length) return false;
|
|
||||||
for (final item in a) {
|
|
||||||
if (!b.contains(item)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 히어로 애니메이션이 적용된 이미지
|
|
||||||
class HeroNetworkImage extends StatelessWidget {
|
|
||||||
final String imageUrl;
|
|
||||||
final String heroTag;
|
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final BoxFit fit;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const HeroNetworkImage({
|
|
||||||
super.key,
|
|
||||||
required this.imageUrl,
|
|
||||||
required this.heroTag,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Hero(
|
|
||||||
tag: heroTag,
|
|
||||||
child: OptimizedCachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'dart:async';
|
|
||||||
import '../utils/performance_optimizer.dart';
|
|
||||||
import '../widgets/skeleton_loading.dart';
|
|
||||||
|
|
||||||
/// 레이지 로딩이 적용된 리스트 위젯
|
|
||||||
class LazyLoadingList<T> extends StatefulWidget {
|
|
||||||
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
|
||||||
final Widget Function(BuildContext, T, int) itemBuilder;
|
|
||||||
final int pageSize;
|
|
||||||
final double scrollThreshold;
|
|
||||||
final Widget? loadingWidget;
|
|
||||||
final Widget? emptyWidget;
|
|
||||||
final Widget? errorWidget;
|
|
||||||
final bool enableRefresh;
|
|
||||||
final ScrollPhysics? physics;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
|
|
||||||
const LazyLoadingList({
|
|
||||||
super.key,
|
|
||||||
required this.loadMore,
|
|
||||||
required this.itemBuilder,
|
|
||||||
this.pageSize = 20,
|
|
||||||
this.scrollThreshold = 0.8,
|
|
||||||
this.loadingWidget,
|
|
||||||
this.emptyWidget,
|
|
||||||
this.errorWidget,
|
|
||||||
this.enableRefresh = true,
|
|
||||||
this.physics,
|
|
||||||
this.padding,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LazyLoadingList<T>> createState() => _LazyLoadingListState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LazyLoadingListState<T> extends State<LazyLoadingList<T>> {
|
|
||||||
final List<T> _items = [];
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
|
|
||||||
int _currentPage = 0;
|
|
||||||
bool _isLoading = false;
|
|
||||||
bool _hasMore = true;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadInitialData();
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
if (_isLoading || !_hasMore) return;
|
|
||||||
|
|
||||||
final position = _scrollController.position;
|
|
||||||
final maxScroll = position.maxScrollExtent;
|
|
||||||
final currentScroll = position.pixels;
|
|
||||||
|
|
||||||
if (currentScroll >= maxScroll * widget.scrollThreshold) {
|
|
||||||
_loadMoreData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadInitialData() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final newItems = await PerformanceMeasure.measure(
|
|
||||||
name: 'Initial data load',
|
|
||||||
operation: () => widget.loadMore(0, widget.pageSize),
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_items.clear();
|
|
||||||
_items.addAll(newItems);
|
|
||||||
_currentPage = 0;
|
|
||||||
_hasMore = newItems.length >= widget.pageSize;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_error = e.toString();
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMoreData() async {
|
|
||||||
if (_isLoading || !_hasMore) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final nextPage = _currentPage + 1;
|
|
||||||
final newItems = await PerformanceMeasure.measure(
|
|
||||||
name: 'Load more data (page $nextPage)',
|
|
||||||
operation: () => widget.loadMore(nextPage, widget.pageSize),
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_items.addAll(newItems);
|
|
||||||
_currentPage = nextPage;
|
|
||||||
_hasMore = newItems.length >= widget.pageSize;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_error = e.toString();
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
|
||||||
await _loadInitialData();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (_error != null && _items.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: widget.errorWidget ?? _buildDefaultErrorWidget(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_isLoading && _items.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: widget.emptyWidget ?? _buildDefaultEmptyWidget(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget listView = ListView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
physics: widget.physics ?? PerformanceOptimizer.getOptimizedScrollPhysics(),
|
|
||||||
padding: widget.padding,
|
|
||||||
itemCount: _items.length + (_isLoading || _hasMore ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index < _items.length) {
|
|
||||||
return widget.itemBuilder(context, _items[index], index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로딩 인디케이터
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Center(
|
|
||||||
child: widget.loadingWidget ?? _buildDefaultLoadingWidget(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (widget.enableRefresh) {
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _refresh,
|
|
||||||
child: listView,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return listView;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultLoadingWidget() {
|
|
||||||
return const CircularProgressIndicator();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultEmptyWidget() {
|
|
||||||
return Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.inbox_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'데이터가 없습니다',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultErrorWidget() {
|
|
||||||
return Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.red[400],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'오류가 발생했습니다',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_error ?? '',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _loadInitialData,
|
|
||||||
child: const Text('다시 시도'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 캐시가 적용된 레이지 로딩 리스트
|
|
||||||
class CachedLazyLoadingList<T> extends StatefulWidget {
|
|
||||||
final String cacheKey;
|
|
||||||
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
|
||||||
final Widget Function(BuildContext, T, int) itemBuilder;
|
|
||||||
final int pageSize;
|
|
||||||
final Duration cacheDuration;
|
|
||||||
final Widget? loadingWidget;
|
|
||||||
final Widget? emptyWidget;
|
|
||||||
|
|
||||||
const CachedLazyLoadingList({
|
|
||||||
super.key,
|
|
||||||
required this.cacheKey,
|
|
||||||
required this.loadMore,
|
|
||||||
required this.itemBuilder,
|
|
||||||
this.pageSize = 20,
|
|
||||||
this.cacheDuration = const Duration(minutes: 5),
|
|
||||||
this.loadingWidget,
|
|
||||||
this.emptyWidget,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CachedLazyLoadingList<T>> createState() => _CachedLazyLoadingListState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CachedLazyLoadingListState<T> extends State<CachedLazyLoadingList<T>> {
|
|
||||||
final Map<int, List<T>> _pageCache = {};
|
|
||||||
|
|
||||||
Future<List<T>> _loadWithCache(int page, int pageSize) async {
|
|
||||||
// 캐시 확인
|
|
||||||
if (_pageCache.containsKey(page)) {
|
|
||||||
return _pageCache[page]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
final items = await widget.loadMore(page, pageSize);
|
|
||||||
|
|
||||||
// 캐시 저장
|
|
||||||
_pageCache[page] = items;
|
|
||||||
|
|
||||||
// 일정 시간 후 캐시 제거
|
|
||||||
Timer(widget.cacheDuration, () {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_pageCache.remove(page);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return LazyLoadingList<T>(
|
|
||||||
loadMore: _loadWithCache,
|
|
||||||
itemBuilder: widget.itemBuilder,
|
|
||||||
pageSize: widget.pageSize,
|
|
||||||
loadingWidget: widget.loadingWidget,
|
|
||||||
emptyWidget: widget.emptyWidget,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 무한 스크롤 그리드 뷰
|
|
||||||
class LazyLoadingGrid<T> extends StatefulWidget {
|
|
||||||
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
|
||||||
final Widget Function(BuildContext, T, int) itemBuilder;
|
|
||||||
final int crossAxisCount;
|
|
||||||
final int pageSize;
|
|
||||||
final double scrollThreshold;
|
|
||||||
final double childAspectRatio;
|
|
||||||
final double crossAxisSpacing;
|
|
||||||
final double mainAxisSpacing;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
|
|
||||||
const LazyLoadingGrid({
|
|
||||||
super.key,
|
|
||||||
required this.loadMore,
|
|
||||||
required this.itemBuilder,
|
|
||||||
required this.crossAxisCount,
|
|
||||||
this.pageSize = 20,
|
|
||||||
this.scrollThreshold = 0.8,
|
|
||||||
this.childAspectRatio = 1.0,
|
|
||||||
this.crossAxisSpacing = 8.0,
|
|
||||||
this.mainAxisSpacing = 8.0,
|
|
||||||
this.padding,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LazyLoadingGrid<T>> createState() => _LazyLoadingGridState<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LazyLoadingGridState<T> extends State<LazyLoadingGrid<T>> {
|
|
||||||
final List<T> _items = [];
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
|
|
||||||
int _currentPage = 0;
|
|
||||||
bool _isLoading = false;
|
|
||||||
bool _hasMore = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadInitialData();
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
if (_isLoading || !_hasMore) return;
|
|
||||||
|
|
||||||
final position = _scrollController.position;
|
|
||||||
final maxScroll = position.maxScrollExtent;
|
|
||||||
final currentScroll = position.pixels;
|
|
||||||
|
|
||||||
if (currentScroll >= maxScroll * widget.scrollThreshold) {
|
|
||||||
_loadMoreData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadInitialData() async {
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
|
|
||||||
final newItems = await widget.loadMore(0, widget.pageSize);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_items.clear();
|
|
||||||
_items.addAll(newItems);
|
|
||||||
_currentPage = 0;
|
|
||||||
_hasMore = newItems.length >= widget.pageSize;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMoreData() async {
|
|
||||||
if (_isLoading || !_hasMore) return;
|
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
|
|
||||||
final nextPage = _currentPage + 1;
|
|
||||||
final newItems = await widget.loadMore(nextPage, widget.pageSize);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_items.addAll(newItems);
|
|
||||||
_currentPage = nextPage;
|
|
||||||
_hasMore = newItems.length >= widget.pageSize;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GridView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
padding: widget.padding,
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: widget.crossAxisCount,
|
|
||||||
childAspectRatio: widget.childAspectRatio,
|
|
||||||
crossAxisSpacing: widget.crossAxisSpacing,
|
|
||||||
mainAxisSpacing: widget.mainAxisSpacing,
|
|
||||||
),
|
|
||||||
itemCount: _items.length + (_isLoading ? widget.crossAxisCount : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index < _items.length) {
|
|
||||||
return widget.itemBuilder(context, _items[index], index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로딩 스켈레톤
|
|
||||||
return const SkeletonLoading(
|
|
||||||
height: 100,
|
|
||||||
borderRadius: 12,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user