Flutter 앱: 컨셉포토 라이트박스 추가 및 이미지 프리로드 개선

- 컨셉포토 갤러리에 라이트박스 기능 추가
  - PhotoViewGallery로 이미지 스와이프 및 줌 지원
  - 멤버/컨셉 정보 바텀시트 (info 버튼)
  - 이미지 다운로드 기능
  - 슬라이딩 인디케이터
- PhotoViewGallery에 allowImplicitScrolling 옵션 추가
  - 인접 페이지 미리 빌드로 스와이프 시 즉시 표시
- 불필요한 수동 프리로드 코드 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 13:54:59 +09:00
parent 300fe18a8d
commit 6b512f943e
2 changed files with 449 additions and 26 deletions

View file

@ -945,17 +945,12 @@ class _TeaserViewer extends StatefulWidget {
class _TeaserViewerState extends State<_TeaserViewer> { class _TeaserViewerState extends State<_TeaserViewer> {
late PageController _pageController; late PageController _pageController;
late int _currentIndex; late int _currentIndex;
final Set<int> _preloadedIndices = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentIndex = widget.initialIndex; _currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex); _pageController = PageController(initialPage: widget.initialIndex);
//
WidgetsBinding.instance.addPostFrameCallback((_) {
_preloadAdjacentImages(_currentIndex);
});
} }
@override @override
@ -964,25 +959,6 @@ class _TeaserViewerState extends State<_TeaserViewer> {
super.dispose(); super.dispose();
} }
/// ( 2, )
void _preloadAdjacentImages(int index) {
for (int i = index - 2; i <= index + 2; i++) {
if (i >= 0 && i < widget.teasers.length && !_preloadedIndices.contains(i)) {
final teaser = widget.teasers[i];
if (teaser.mediaType != 'video') {
final url = teaser.originalUrl;
if (url != null && url.isNotEmpty) {
_preloadedIndices.add(i);
precacheImage(
CachedNetworkImageProvider(url),
context,
);
}
}
}
}
}
/// ( ) /// ( )
Future<void> _downloadImage() async { Future<void> _downloadImage() async {
final teaser = widget.teasers[_currentIndex]; final teaser = widget.teasers[_currentIndex];
@ -1018,9 +994,9 @@ class _TeaserViewerState extends State<_TeaserViewer> {
PhotoViewGallery.builder( PhotoViewGallery.builder(
pageController: _pageController, pageController: _pageController,
itemCount: widget.teasers.length, itemCount: widget.teasers.length,
allowImplicitScrolling: true,
onPageChanged: (index) { onPageChanged: (index) {
setState(() => _currentIndex = index); setState(() => _currentIndex = index);
_preloadAdjacentImages(index);
}, },
backgroundDecoration: const BoxDecoration(color: Colors.black), backgroundDecoration: const BoxDecoration(color: Colors.black),
builder: (context, index) { builder: (context, index) {

View file

@ -2,12 +2,16 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../models/album.dart'; import '../../models/album.dart';
import '../../services/albums_service.dart'; import '../../services/albums_service.dart';
import '../../services/download_service.dart';
class AlbumGalleryView extends StatefulWidget { class AlbumGalleryView extends StatefulWidget {
final String albumName; final String albumName;
@ -283,6 +287,7 @@ class _MasonryGrid extends StatelessWidget {
child: _PhotoItem( child: _PhotoItem(
photo: item.photo, photo: item.photo,
index: item.originalIndex, index: item.originalIndex,
allPhotos: photos,
skipAnimation: skipAnimation, skipAnimation: skipAnimation,
), ),
); );
@ -299,6 +304,7 @@ class _MasonryGrid extends StatelessWidget {
child: _PhotoItem( child: _PhotoItem(
photo: item.photo, photo: item.photo,
index: item.originalIndex, index: item.originalIndex,
allPhotos: photos,
skipAnimation: skipAnimation, skipAnimation: skipAnimation,
), ),
); );
@ -348,10 +354,12 @@ class _PhotoItem extends StatefulWidget {
final ConceptPhoto photo; final ConceptPhoto photo;
final int index; final int index;
final bool skipAnimation; final bool skipAnimation;
final List<ConceptPhoto> allPhotos;
const _PhotoItem({ const _PhotoItem({
required this.photo, required this.photo,
required this.index, required this.index,
required this.allPhotos,
this.skipAnimation = false, this.skipAnimation = false,
}); });
@ -421,7 +429,22 @@ class _PhotoItemState extends State<_PhotoItem>
position: _slideAnimation, position: _slideAnimation,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
// TODO: Navigator.push(
context,
PageRouteBuilder(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) {
return _ConceptPhotoViewer(
photos: widget.allPhotos,
initialIndex: widget.index,
);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 200),
),
);
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -458,3 +481,427 @@ class _PhotoItemState extends State<_PhotoItem>
); );
} }
} }
/// ()
class _ConceptPhotoViewer extends StatefulWidget {
final List<ConceptPhoto> photos;
final int initialIndex;
const _ConceptPhotoViewer({
required this.photos,
required this.initialIndex,
});
@override
State<_ConceptPhotoViewer> createState() => _ConceptPhotoViewerState();
}
class _ConceptPhotoViewerState extends State<_ConceptPhotoViewer> {
late PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
///
Future<void> _downloadImage() async {
final photo = widget.photos[_currentIndex];
final imageUrl = photo.originalUrl;
if (imageUrl == null || imageUrl.isEmpty) return;
final taskId = await downloadImage(imageUrl);
if (taskId != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('다운로드를 시작합니다'),
duration: Duration(seconds: 2),
),
);
}
}
///
bool get _hasInfo {
final photo = widget.photos[_currentIndex];
return (photo.members != null && photo.members!.isNotEmpty) ||
(photo.concept != null && photo.concept!.isNotEmpty);
}
/// Info
void _showInfoSheet() {
final photo = widget.photos[_currentIndex];
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => _InfoBottomSheet(photo: photo),
);
}
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final topPadding = MediaQuery.of(context).padding.top;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
//
PhotoViewGallery.builder(
pageController: _pageController,
itemCount: widget.photos.length,
allowImplicitScrolling: true, //
onPageChanged: (index) {
setState(() => _currentIndex = index);
},
backgroundDecoration: const BoxDecoration(color: Colors.black),
builder: (context, index) {
final photo = widget.photos[index];
final imageUrl = photo.mediumUrl ?? photo.originalUrl;
if (imageUrl == null || imageUrl.isEmpty) {
return PhotoViewGalleryPageOptions.customChild(
child: const Center(
child: Icon(
LucideIcons.imageOff,
color: Colors.white54,
size: 64,
),
),
);
}
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(photo.originalUrl ?? imageUrl),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
initialScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(tag: 'concept_photo_$index'),
);
},
loadingBuilder: (context, event) => const Center(
child: CircularProgressIndicator(
color: Colors.white54,
strokeWidth: 2,
),
),
),
//
Positioned(
top: topPadding + 8,
left: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// :
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(LucideIcons.x, color: Colors.white70, size: 24),
),
),
),
),
// :
if (widget.photos.length > 1)
Text(
'${_currentIndex + 1} / ${widget.photos.length}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()],
),
),
// : +
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_hasInfo)
GestureDetector(
onTap: _showInfoSheet,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(
LucideIcons.info,
color: Colors.white70,
size: 22,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _downloadImage,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(
LucideIcons.download,
color: Colors.white70,
size: 22,
),
),
),
],
),
),
),
],
),
),
),
//
if (widget.photos.length > 1)
Positioned(
bottom: bottomPadding + 16,
left: 0,
right: 0,
child: _SlidingIndicator(
count: widget.photos.length,
currentIndex: _currentIndex,
onTap: (index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
},
),
),
],
),
),
);
}
}
///
class _InfoBottomSheet extends StatelessWidget {
final ConceptPhoto photo;
const _InfoBottomSheet({required this.photo});
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
return Container(
decoration: const BoxDecoration(
color: Color(0xFF18181B), // zinc-900
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFF52525B), // zinc-600
borderRadius: BorderRadius.circular(2),
),
),
//
Padding(
padding: EdgeInsets.fromLTRB(20, 8, 20, 32 + bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'사진 정보',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
//
if (photo.members != null && photo.members!.isNotEmpty)
_InfoRow(
icon: LucideIcons.users,
iconBackgroundColor: AppColors.primary.withValues(alpha: 0.2),
iconColor: AppColors.primary,
label: '멤버',
value: photo.members!,
),
//
if (photo.concept != null && photo.concept!.isNotEmpty) ...[
if (photo.members != null && photo.members!.isNotEmpty)
const SizedBox(height: 16),
_InfoRow(
icon: LucideIcons.tag,
iconBackgroundColor: Colors.white.withValues(alpha: 0.1),
iconColor: const Color(0xFFA1A1AA), // zinc-400
label: '컨셉',
value: photo.concept!,
),
],
],
),
),
],
),
);
}
}
///
class _InfoRow extends StatelessWidget {
final IconData icon;
final Color iconBackgroundColor;
final Color iconColor;
final String label;
final String value;
const _InfoRow({
required this.icon,
required this.iconBackgroundColor,
required this.iconColor,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: iconBackgroundColor,
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: iconColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: Color(0xFFA1A1AA), // zinc-400
fontSize: 12,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
),
),
],
),
),
],
);
}
}
///
class _SlidingIndicator extends StatelessWidget {
final int count;
final int currentIndex;
final Function(int) onTap;
const _SlidingIndicator({
required this.count,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
const double width = 120;
const double dotSpacing = 18;
const double activeDotSize = 12;
final double halfWidth = width / 2;
final double translateX = -(currentIndex * dotSpacing) + halfWidth - (activeDotSize / 2);
return Center(
child: SizedBox(
width: width,
height: 20,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
colors: [
Colors.transparent,
Colors.white,
Colors.white,
Colors.transparent,
],
stops: [0.0, 0.15, 0.85, 1.0],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: Stack(
children: [
//
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: translateX,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(count, (index) {
final isActive = index == currentIndex;
const inactiveDotSize = 10.0;
return GestureDetector(
onTap: () => onTap(index),
child: Container(
width: dotSpacing,
alignment: Alignment.center,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isActive ? activeDotSize : inactiveDotSize,
height: isActive ? activeDotSize : inactiveDotSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Colors.white
: Colors.white.withValues(alpha: 0.4),
),
),
),
);
}),
),
),
],
),
),
),
);
}
}