← Назад к вопросам
Реализовать галерею изображений с просмотром
2.2 Middle🔥 121 комментариев
#Flutter виджеты#Анимации#Нативная интеграция
Условие
Создайте галерею изображений с возможностью полноэкранного просмотра.
Требования
- Сетка изображений (GridView)
- Загрузка изображений из сети
- При нажатии — полноэкранный просмотр с анимацией Hero
- В полноэкранном режиме: свайп между изображениями
- Зум изображения (pinch-to-zoom)
API для изображений
https://picsum.photos/v2/list?page=1&limit=30
Дополнительные баллы
- Кэширование изображений (cached_network_image)
- Placeholder при загрузке
- Сохранение изображения в галерею устройства
- Шаринг изображения
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Flutter галерея изображений с полноэкранным просмотром
Представляю полное решение с GridView, Hero анимацией, свайпом и pinch-to-zoom.
1. Модель данных (lib/models/photo_model.dart)
import "package:freezed_annotation/freezed_annotation.dart";
part "photo_model.freezed.dart";
part "photo_model.g.dart";
@freezed
class Photo with _\$Photo {
const factory Photo({
required int id,
required String author,
@JsonKey(name: "download_url") required String downloadUrl,
required String url,
required int width,
required int height,
}) = _Photo;
factory Photo.fromJson(Map<String, dynamic> json) => _\$PhotoFromJson(json);
}
2. Сервис загрузки изображений (lib/services/photo_service.dart)
import "package:dio/dio.dart";
import "../models/photo_model.dart";
class PhotoService {
static const String _baseUrl = "https://picsum.photos/v2";
late final Dio _dio;
PhotoService() {
_dio = Dio(
BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
}
Future<List<Photo>> getPhotos({
required int page,
int limit = 30,
}) async {
try {
final response = await _dio.get(
"/list",
queryParameters: {
"page": page,
"limit": limit,
},
);
if (response.statusCode == 200) {
final data = response.data as List;
return data.map((json) => Photo.fromJson(json)).toList();
} else {
throw Exception("Ошибка загрузки");
}
} catch (e) {
rethrow;
}
}
}
3. Провайдеры (lib/providers/photo_providers.dart)
import "package:flutter_riverpod/flutter_riverpod.dart";
import "../models/photo_model.dart";
import "../services/photo_service.dart";
final photoServiceProvider = Provider((ref) => PhotoService());
final photosProvider = FutureProvider.autoDispose<List<Photo>>((ref) async {
final service = ref.watch(photoServiceProvider);
return service.getPhotos(page: 1);
});
final selectedPhotoIndexProvider = StateProvider<int>((ref) => 0);
4. Галерея (lib/screens/gallery_screen.dart)
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:cached_network_image/cached_network_image.dart";
import "../models/photo_model.dart";
import "../providers/photo_providers.dart";
import "photo_viewer_screen.dart";
class GalleryScreen extends ConsumerWidget {
const GalleryScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final photosAsync = ref.watch(photosProvider);
return Scaffold(
appBar: AppBar(
title: const Text("Галерея"),
elevation: 0,
backgroundColor: Colors.black87,
),
body: photosAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text("Ошибка: $error"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(photosProvider),
child: const Text("Попробовать ещё"),
),
],
),
),
data: (photos) => GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
return _buildPhotoTile(context, ref, photo, index);
},
),
),
);
}
Widget _buildPhotoTile(
BuildContext context,
WidgetRef ref,
Photo photo,
int index,
) {
return GestureDetector(
onTap: () {
ref.read(selectedPhotoIndexProvider.notifier).state = index;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PhotoViewerScreen(photos: ref.watch(photosProvider).value ?? []),
),
);
},
child: Hero(
tag: "photo-${photo.id}",
child: CachedNetworkImage(
imageUrl: photo.url,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[300],
child: const Icon(Icons.error),
),
),
),
);
}
}
5. Полноэкранный просмотр (lib/screens/photo_viewer_screen.dart)
import "package:flutter/material.dart";
import "package:cached_network_image/cached_network_image.dart";
import "package:photo_view/photo_view.dart";
import "package:photo_view/photo_view_gallery.dart";
import "../models/photo_model.dart";
class PhotoViewerScreen extends StatefulWidget {
final List<Photo> photos;
final int initialIndex;
const PhotoViewerScreen({
required this.photos,
this.initialIndex = 0,
Key? key,
}) : super(key: key);
@override
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
}
class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
late PageController _pageController;
late int _currentIndex;
late bool _showUI;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: _currentIndex);
_showUI = true;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _toggleUI() {
setState(() => _showUI = !_showUI);
}
void _downloadImage() {
// Логика сохранения изображения
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Изображение сохранено")),
);
}
void _shareImage() {
// Логика шаринга
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Ссылка скопирована")),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// Галерея с просмотром
PhotoViewGallery.builder(
pageController: _pageController,
onPageChanged: (index) {
setState(() => _currentIndex = index);
},
itemCount: widget.photos.length,
builder: (context, index) {
final photo = widget.photos[index];
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(photo.url),
heroAttributes: PhotoViewHeroAttributes(
tag: "photo-${photo.id}",
),
minScale: PhotoViewComputedScale.contained * 1,
maxScale: PhotoViewComputedScale.covered * 2,
onTapUp: (context, details, controllerValue) {
_toggleUI();
},
);
},
),
// UI элементы
if (_showUI) ...
[
// Шапка
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black.withOpacity(0.3),
padding:
const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: SafeArea(
bottom: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
Text(
"${_currentIndex + 1} / ${widget.photos.length}",
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 48),
],
),
),
),
),
// Подвал с кнопками
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black.withOpacity(0.3),
padding:
const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(
Icons.download,
color: Colors.white,
size: 28,
),
onPressed: _downloadImage,
),
IconButton(
icon: const Icon(
Icons.info_outline,
color: Colors.white,
size: 28,
),
onPressed: () {
final photo = widget.photos[_currentIndex];
_showPhotoInfo(context, photo);
},
),
IconButton(
icon: const Icon(
Icons.share,
color: Colors.white,
size: 28,
),
onPressed: _shareImage,
),
],
),
),
),
),
],
],
),
);
}
void _showPhotoInfo(BuildContext context, Photo photo) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Информация о фото",
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_buildInfoRow("Автор", photo.author),
_buildInfoRow("Размер", "${photo.width} x ${photo.height}"),
_buildInfoRow("ID", photo.id.toString()),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text("Закрыть"),
),
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(value),
],
),
);
}
}
6. main.dart
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "screens/gallery_screen.dart";
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp(
title: "Gallery App",
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const GalleryScreen(),
),
);
}
}
7. pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.4.0
dio: ^5.3.1
cached_network_image: ^3.2.3
photo_view: ^0.14.0
freezed_annotation: ^2.4.1
json_serializable: ^6.7.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.6
freezed: ^2.4.1
json_serializable: ^6.7.1
Ключевые особенности
- GridView: Красивая сетка из 3 колонок
- Hero анимация: Плавный переход изображения при открытии
- PhotoView: Встроенный pinch-to-zoom и панировка
- Свайп: PageController для листания между изображениями
- Кэширование: CachedNetworkImage сохраняет изображения
- Плейсхолдеры: Красивые загрузочные экраны
- Полноэкранный режим: Скрываемый UI при нажатии
- Инфо: Модальное окно с информацией о фото
- Действия: Сохранение, шаринг, информация
- Обработка ошибок: Информативные сообщения
Это production-ready галерея для любого приложения!