Created
May 23, 2025 04:00
-
-
Save tech-andgar/c24a0091dd8bf7f588315c7db75211a4 to your computer and use it in GitHub Desktop.
Fix ResponsiveImage Flutter Options
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/foundation.dart' show kIsWeb; | |
| import 'dart:math' as math; | |
| void main() => runApp(MyApp()); | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| title: 'Responsive Image Solutions', | |
| debugShowCheckedModeBanner: false, | |
| theme: ThemeData(colorSchemeSeed: Colors.blue), | |
| navigatorKey: navigatorKey, // Add the navigator key here | |
| home: const MyHomePage(title: 'Responsive Image Solutions'), | |
| ); | |
| } | |
| } | |
| // Global navigator key for context access | |
| final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); | |
| class MyHomePage extends StatefulWidget { | |
| final String title; | |
| const MyHomePage({super.key, required this.title}); | |
| @override | |
| State<MyHomePage> createState() => _MyHomePageState(); | |
| } | |
| class _MyHomePageState extends State<MyHomePage> { | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: Text(widget.title), | |
| backgroundColor: Theme.of(context).colorScheme.inversePrimary, | |
| ), | |
| body: SingleChildScrollView( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| const Text( | |
| 'Solution 1: Tiered Cache Sizes with cacheWidth/cacheHeight', | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| const SizedBox(height: 8), | |
| const Text( | |
| 'Uses predefined cache sizes to prevent flickering while maintaining optimization.', | |
| ), | |
| const SizedBox(height: 10), | |
| SizedBox( | |
| height: 200, | |
| child: TieredResponsiveImage( | |
| imagePath: 'https://picsum.photos/id/237/2000/1500', | |
| source: ImageSourceType.network, | |
| fit: BoxFit.cover, | |
| ), | |
| ), | |
| const SizedBox(height: 30), | |
| const Text( | |
| 'Solution 2: Debounced Responsive Image', | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| const SizedBox(height: 8), | |
| const Text( | |
| 'Delays cache recalculation until window resizing stops.', | |
| ), | |
| const SizedBox(height: 10), | |
| SizedBox( | |
| height: 200, | |
| child: DebouncedResponsiveImage( | |
| imagePath: 'https://picsum.photos/id/238/2000/1500', | |
| source: ImageSourceType.network, | |
| fit: BoxFit.cover, | |
| ), | |
| ), | |
| const SizedBox(height: 30), | |
| const Text( | |
| 'Solution 3: Smart Caching (Desktop vs Mobile)', | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| const SizedBox(height: 8), | |
| const Text( | |
| 'Different strategies for desktop and mobile platforms.', | |
| ), | |
| const SizedBox(height: 10), | |
| SizedBox( | |
| height: 200, | |
| child: SmartResponsiveImage( | |
| imagePath: 'https://picsum.photos/id/239/2000/1500', | |
| source: ImageSourceType.network, | |
| fit: BoxFit.cover, | |
| ), | |
| ), | |
| const SizedBox(height: 30), | |
| const Text( | |
| 'Solution 4: Progressive Loading with ResizeImage', | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| const SizedBox(height: 8), | |
| const Text( | |
| 'Uses ResizeImage for proper low-res/high-res transition.', | |
| ), | |
| const SizedBox(height: 10), | |
| SizedBox( | |
| height: 200, | |
| child: ProgressiveResponsiveImage( | |
| imagePath: 'https://picsum.photos/id/240/2000/1500', | |
| source: ImageSourceType.network, | |
| fit: BoxFit.cover, | |
| // lowResUrl: 'optional-low-res-url-here', // If you have a separate low-res URL | |
| ), | |
| ), | |
| const SizedBox(height: 30), | |
| const Text( | |
| 'Solution 5: ResizeImage with Tiered Caching (Recommended)', | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| const SizedBox(height: 8), | |
| const Text( | |
| 'Combines ResizeImage benefits with tier-based caching to prevent flickering.', | |
| ), | |
| const SizedBox(height: 10), | |
| SizedBox( | |
| height: 200, | |
| child: ResizeImageWithTiers( | |
| imagePath: 'https://picsum.photos/id/241/2000/1500', | |
| source: ImageSourceType.network, | |
| fit: BoxFit.cover, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| enum ImageSourceType { network, asset } | |
| // SOLUTION 1: Tiered Cache Sizes (Most Recommended) | |
| class TieredResponsiveImage extends StatelessWidget { | |
| const TieredResponsiveImage({ | |
| required this.imagePath, | |
| required this.source, | |
| super.key, | |
| this.width, | |
| this.height, | |
| this.fit, | |
| }); | |
| final BoxFit? fit; | |
| final double? height; | |
| final String imagePath; | |
| final ImageSourceType source; | |
| final double? width; | |
| // Define cache tiers to prevent constant recalculation | |
| static const List<int> _cacheTiers = [200, 400, 600, 800, 1200, 1600, 2400]; | |
| int _getNearestCacheTier(double size) { | |
| final targetSize = (size * 2).round(); // Account for high DPI displays | |
| for (final tier in _cacheTiers) { | |
| if (targetSize <= tier) return tier; | |
| } | |
| return _cacheTiers.last; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return LayoutBuilder( | |
| builder: (context, constraints) { | |
| final displayWidth = width ?? constraints.maxWidth; | |
| final displayHeight = height ?? constraints.maxHeight; | |
| if (!displayWidth.isFinite || !displayHeight.isFinite) { | |
| return const Center(child: Icon(Icons.broken_image_outlined)); | |
| } | |
| // Use tiered cache sizes instead of exact pixel calculations | |
| final cacheWidth = _getNearestCacheTier(displayWidth); | |
| final cacheHeight = _getNearestCacheTier(displayHeight); | |
| return _buildImage(displayWidth, displayHeight, cacheWidth, cacheHeight); | |
| }, | |
| ); | |
| } | |
| Widget _buildImage(double displayWidth, double displayHeight, int cacheWidth, int cacheHeight) { | |
| if (source == ImageSourceType.network) { | |
| return Image.network( | |
| imagePath, | |
| width: displayWidth, | |
| height: displayHeight, | |
| cacheWidth: cacheWidth, | |
| cacheHeight: cacheHeight, | |
| fit: fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| loadingBuilder: _loadingBuilder, | |
| errorBuilder: _errorBuilder, | |
| ); | |
| } else { | |
| return Image.asset( | |
| imagePath, | |
| width: displayWidth, | |
| height: displayHeight, | |
| cacheWidth: cacheWidth, | |
| cacheHeight: cacheHeight, | |
| fit: fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| errorBuilder: _errorBuilder, | |
| ); | |
| } | |
| } | |
| Widget _loadingBuilder(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { | |
| if (loadingProgress == null) return child; | |
| return Center( | |
| child: CircularProgressIndicator( | |
| value: loadingProgress.expectedTotalBytes != null | |
| ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! | |
| : null, | |
| ), | |
| ); | |
| } | |
| Widget _errorBuilder(BuildContext context, Object error, StackTrace? stackTrace) { | |
| return const Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| Icon(Icons.error_outline, color: Colors.red, size: 40), | |
| SizedBox(height: 8), | |
| Text('Failed to load image', style: TextStyle(color: Colors.grey)), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| // SOLUTION 2: Debounced Responsive Image | |
| class DebouncedResponsiveImage extends StatefulWidget { | |
| const DebouncedResponsiveImage({ | |
| required this.imagePath, | |
| required this.source, | |
| super.key, | |
| this.width, | |
| this.height, | |
| this.fit, | |
| this.debounceMs = 300, | |
| }); | |
| final BoxFit? fit; | |
| final double? height; | |
| final String imagePath; | |
| final ImageSourceType source; | |
| final double? width; | |
| final int debounceMs; | |
| @override | |
| State<DebouncedResponsiveImage> createState() => _DebouncedResponsiveImageState(); | |
| } | |
| class _DebouncedResponsiveImageState extends State<DebouncedResponsiveImage> { | |
| int? _stableCacheWidth; | |
| int? _stableCacheHeight; | |
| DateTime _lastResizeTime = DateTime.now(); | |
| void _updateCacheSize(int newWidth, int newHeight) { | |
| _lastResizeTime = DateTime.now(); | |
| Future.delayed(Duration(milliseconds: widget.debounceMs), () { | |
| if (DateTime.now().difference(_lastResizeTime).inMilliseconds >= widget.debounceMs) { | |
| if (mounted && (_stableCacheWidth != newWidth || _stableCacheHeight != newHeight)) { | |
| setState(() { | |
| _stableCacheWidth = newWidth; | |
| _stableCacheHeight = newHeight; | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return LayoutBuilder( | |
| builder: (context, constraints) { | |
| final displayWidth = widget.width ?? constraints.maxWidth; | |
| final displayHeight = widget.height ?? constraints.maxHeight; | |
| if (!displayWidth.isFinite || !displayHeight.isFinite) { | |
| return const Center(child: Icon(Icons.broken_image_outlined)); | |
| } | |
| final pixelRatio = MediaQuery.of(context).devicePixelRatio; | |
| final newCacheWidth = (displayWidth * pixelRatio).round(); | |
| final newCacheHeight = (displayHeight * pixelRatio).round(); | |
| // Initialize cache size on first build | |
| _stableCacheWidth ??= newCacheWidth; | |
| _stableCacheHeight ??= newCacheHeight; | |
| // Update cache size with debouncing | |
| _updateCacheSize(newCacheWidth, newCacheHeight); | |
| return _buildImage(displayWidth, displayHeight, _stableCacheWidth!, _stableCacheHeight!); | |
| }, | |
| ); | |
| } | |
| Widget _buildImage(double displayWidth, double displayHeight, int cacheWidth, int cacheHeight) { | |
| if (widget.source == ImageSourceType.network) { | |
| return Image.network( | |
| widget.imagePath, | |
| width: displayWidth, | |
| height: displayHeight, | |
| cacheWidth: cacheWidth, | |
| cacheHeight: cacheHeight, | |
| fit: widget.fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| loadingBuilder: (context, child, loadingProgress) { | |
| if (loadingProgress == null) return child; | |
| return Center( | |
| child: CircularProgressIndicator( | |
| value: loadingProgress.expectedTotalBytes != null | |
| ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! | |
| : null, | |
| ), | |
| ); | |
| }, | |
| errorBuilder: (context, error, stackTrace) => const Center( | |
| child: Icon(Icons.error_outline, color: Colors.red), | |
| ), | |
| ); | |
| } else { | |
| return Image.asset( | |
| widget.imagePath, | |
| width: displayWidth, | |
| height: displayHeight, | |
| cacheWidth: cacheWidth, | |
| cacheHeight: cacheHeight, | |
| fit: widget.fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| errorBuilder: (context, error, stackTrace) => const Center( | |
| child: Icon(Icons.broken_image_outlined, color: Colors.grey), | |
| ), | |
| ); | |
| } | |
| } | |
| } | |
| // SOLUTION 3: Smart Caching (Different strategies for desktop vs mobile) | |
| class SmartResponsiveImage extends StatelessWidget { | |
| const SmartResponsiveImage({ | |
| required this.imagePath, | |
| required this.source, | |
| super.key, | |
| this.width, | |
| this.height, | |
| this.fit, | |
| }); | |
| final BoxFit? fit; | |
| final double? height; | |
| final String imagePath; | |
| final ImageSourceType source; | |
| final double? width; | |
| bool _isDesktop(BuildContext context) { | |
| return MediaQuery.of(context).size.shortestSide > 600; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return LayoutBuilder( | |
| builder: (context, constraints) { | |
| final displayWidth = width ?? constraints.maxWidth; | |
| final displayHeight = height ?? constraints.maxHeight; | |
| if (!displayWidth.isFinite || !displayHeight.isFinite) { | |
| return const Center(child: Icon(Icons.broken_image_outlined)); | |
| } | |
| int? cacheWidth; | |
| int? cacheHeight; | |
| if (_isDesktop(context)) { | |
| // On desktop, use a higher base resolution but cap it to prevent excessive memory usage | |
| final maxCacheSize = 1200; | |
| cacheWidth = math.min((displayWidth * 1.5).round(), maxCacheSize); | |
| cacheHeight = math.min((displayHeight * 1.5).round(), maxCacheSize); | |
| } else { | |
| // On mobile, use precise pixel ratio calculation for memory efficiency | |
| final pixelRatio = MediaQuery.of(context).devicePixelRatio; | |
| cacheWidth = (displayWidth * pixelRatio).round(); | |
| cacheHeight = (displayHeight * pixelRatio).round(); | |
| } | |
| return _buildImage(displayWidth, displayHeight, cacheWidth, cacheHeight); | |
| }, | |
| ); | |
| } | |
| Widget _buildImage(double displayWidth, double displayHeight, int cacheWidth, int cacheHeight) { | |
| if (source == ImageSourceType.network) { | |
| return Image.network( | |
| imagePath, | |
| width: displayWidth, | |
| height: displayHeight, | |
| cacheWidth: cacheWidth, | |
| cacheHeight: cacheHeight, | |
| fit: fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| loadingBuilder: (context, child, loadingProgress) { | |
| if (loadingProgress == null) return child; | |
| return Center( | |
| child: CircularProgressIndicator( | |
| value: loadingProgress.expectedTotalBytes != null | |
| ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! | |
| : null, | |
| ), | |
| ); | |
| }, | |
| errorBuilder: (context, error, stackTrace) => const Center( | |
| child: Icon(Icons.error_outline, color: Colors.red), | |
| ), | |
| ); | |
| } else { | |
| return Image.asset( | |
| imagePath, | |
| width: displayWidth, | |
| height: displayHeight, | |
| cacheWidth: cacheWidth, | |
| cacheHeight: cacheHeight, | |
| fit: fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| errorBuilder: (context, error, stackTrace) => const Center( | |
| child: Icon(Icons.broken_image_outlined, color: Colors.grey), | |
| ), | |
| ); | |
| } | |
| } | |
| } | |
| // SOLUTION 4: Progressive Loading with ResizeImage | |
| class ProgressiveResponsiveImage extends StatefulWidget { | |
| const ProgressiveResponsiveImage({ | |
| required this.imagePath, | |
| required this.source, | |
| super.key, | |
| this.width, | |
| this.height, | |
| this.fit, | |
| this.lowResUrl, // Optional separate low-res URL | |
| }); | |
| final BoxFit? fit; | |
| final double? height; | |
| final String imagePath; | |
| final ImageSourceType source; | |
| final double? width; | |
| final String? lowResUrl; // Proper way to handle low-res images | |
| @override | |
| State<ProgressiveResponsiveImage> createState() => _ProgressiveResponsiveImageState(); | |
| } | |
| class _ProgressiveResponsiveImageState extends State<ProgressiveResponsiveImage> { | |
| bool _showHighRes = false; | |
| @override | |
| Widget build(BuildContext context) { | |
| return LayoutBuilder( | |
| builder: (context, constraints) { | |
| final displayWidth = widget.width ?? constraints.maxWidth; | |
| final displayHeight = widget.height ?? constraints.maxHeight; | |
| if (!displayWidth.isFinite || !displayHeight.isFinite) { | |
| return const Center(child: Icon(Icons.broken_image_outlined)); | |
| } | |
| final pixelRatio = MediaQuery.of(context).devicePixelRatio; | |
| return Stack( | |
| fit: StackFit.expand, | |
| children: [ | |
| // Low resolution image using ResizeImage (loads first and fast) | |
| _buildLowResImage(displayWidth, displayHeight, pixelRatio), | |
| // High resolution image (loads after) | |
| AnimatedOpacity( | |
| opacity: _showHighRes ? 1.0 : 0.0, | |
| duration: const Duration(milliseconds: 300), | |
| child: _buildHighResImage(displayWidth, displayHeight, pixelRatio), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ); | |
| } | |
| Widget _buildLowResImage(double displayWidth, double displayHeight, double pixelRatio) { | |
| // Use very small cache size for quick initial load | |
| final lowResCacheWidth = math.min(200, (displayWidth * 0.5).round()); | |
| final lowResCacheHeight = math.min(200, (displayHeight * 0.5).round()); | |
| ImageProvider imageProvider; | |
| if (widget.source == ImageSourceType.network) { | |
| // Use provided low-res URL if available, otherwise same URL with small cache | |
| final imageUrl = widget.lowResUrl ?? widget.imagePath; | |
| imageProvider = ResizeImage( | |
| NetworkImage(imageUrl), | |
| width: lowResCacheWidth, | |
| height: lowResCacheHeight, | |
| ); | |
| } else { | |
| imageProvider = ResizeImage( | |
| AssetImage(widget.imagePath), | |
| width: lowResCacheWidth, | |
| height: lowResCacheHeight, | |
| ); | |
| } | |
| return Image( | |
| image: imageProvider, | |
| width: displayWidth, | |
| height: displayHeight, | |
| fit: widget.fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| ); | |
| } | |
| Widget _buildHighResImage(double displayWidth, double displayHeight, double pixelRatio) { | |
| // Use tiered cache size for high-res image | |
| final cacheWidth = _getNearestCacheTier(displayWidth * pixelRatio); | |
| final cacheHeight = _getNearestCacheTier(displayHeight * pixelRatio); | |
| ImageProvider imageProvider; | |
| if (widget.source == ImageSourceType.network) { | |
| imageProvider = ResizeImage( | |
| NetworkImage(widget.imagePath), | |
| width: cacheWidth, | |
| height: cacheHeight, | |
| ); | |
| } else { | |
| imageProvider = ResizeImage( | |
| AssetImage(widget.imagePath), | |
| width: cacheWidth, | |
| height: cacheHeight, | |
| ); | |
| } | |
| return Image( | |
| image: imageProvider, | |
| width: displayWidth, | |
| height: displayHeight, | |
| fit: widget.fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| loadingBuilder: (context, child, loadingProgress) { | |
| if (loadingProgress == null) { | |
| // Image loaded, show it | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| if (mounted) setState(() => _showHighRes = true); | |
| }); | |
| return child; | |
| } | |
| return const SizedBox.shrink(); // Hide while loading | |
| }, | |
| errorBuilder: (context, error, stackTrace) => const SizedBox.shrink(), | |
| ); | |
| } | |
| // Helper method for cache tiers | |
| static const List<int> _cacheTiers = [200, 400, 600, 800, 1200, 1600, 2400]; | |
| int _getNearestCacheTier(double size) { | |
| final targetSize = size.round(); | |
| for (final tier in _cacheTiers) { | |
| if (targetSize <= tier) return tier; | |
| } | |
| return _cacheTiers.last; | |
| } | |
| } | |
| // SOLUTION 5: ResizeImage with Tiered Caching (Best of both worlds) | |
| class ResizeImageWithTiers extends StatelessWidget { | |
| const ResizeImageWithTiers({ | |
| required this.imagePath, | |
| required this.source, | |
| super.key, | |
| this.width, | |
| this.height, | |
| this.fit, | |
| }); | |
| final BoxFit? fit; | |
| final double? height; | |
| final String imagePath; | |
| final ImageSourceType source; | |
| final double? width; | |
| // Cache tiers to prevent flickering | |
| static const List<int> _cacheTiers = [200, 400, 600, 800, 1200, 1600, 2400]; | |
| int _getNearestCacheTier(double size) { | |
| final targetSize = (size * MediaQuery.of(navigatorKey.currentContext!).devicePixelRatio).round(); | |
| for (final tier in _cacheTiers) { | |
| if (targetSize <= tier) return tier; | |
| } | |
| return _cacheTiers.last; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return LayoutBuilder( | |
| builder: (context, constraints) { | |
| final displayWidth = width ?? constraints.maxWidth; | |
| final displayHeight = height ?? constraints.maxHeight; | |
| if (!displayWidth.isFinite || !displayHeight.isFinite) { | |
| return const Center(child: Icon(Icons.broken_image_outlined)); | |
| } | |
| // Use tiered cache sizes to prevent flickering | |
| final cacheWidth = _getNearestCacheTier(displayWidth); | |
| final cacheHeight = _getNearestCacheTier(displayHeight); | |
| ImageProvider imageProvider; | |
| if (source == ImageSourceType.network) { | |
| imageProvider = ResizeImage( | |
| NetworkImage(imagePath), | |
| width: cacheWidth, | |
| height: cacheHeight, | |
| ); | |
| } else { | |
| imageProvider = ResizeImage( | |
| AssetImage(imagePath), | |
| width: cacheWidth, | |
| height: cacheHeight, | |
| ); | |
| } | |
| return Image( | |
| image: imageProvider, | |
| width: displayWidth, | |
| height: displayHeight, | |
| fit: fit ?? BoxFit.cover, | |
| gaplessPlayback: true, | |
| loadingBuilder: (context, child, loadingProgress) { | |
| if (loadingProgress == null) return child; | |
| return Center( | |
| child: CircularProgressIndicator( | |
| value: loadingProgress.expectedTotalBytes != null | |
| ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! | |
| : null, | |
| ), | |
| ); | |
| }, | |
| errorBuilder: (context, error, stackTrace) => const Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| Icon(Icons.error_outline, color: Colors.red, size: 40), | |
| SizedBox(height: 8), | |
| Text('Failed to load image', style: TextStyle(color: Colors.grey)), | |
| ], | |
| ), | |
| ), | |
| ); | |
| }, | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment