Solving Flutter Web Image Rendering Issues: A Cross-Platform Approach
If you've ever tried to display images in a Flutter app that needs to work seamlessly across web and mobile platforms, you've probably run into some frustrating limitations. Recently, I tackled this exact problem when our markdown image renderer started choking on web deployments due to CORS restrictions and lack of proper zoom functionality.
The Problem
[FACT] Our original implementation was painfully simple - just Image.network(src)
wrapped in a GestureDetector
. This worked fine on mobile, or on Web with --web-renderer html
. However, web renderer is obsolete and will be removed shortly. We have to find a more reliable solution.
The Solution: Platform-Specific Implementations
[FACT] The key insight was to leverage Flutter's conditional imports to create platform-specific implementations while maintaining a clean, unified API.
Setting Up Conditional Imports
// Conditional imports for web
import 'web_image_stub.dart'
if (dart.library.html) 'web_image_impl.dart';
This pattern lets you have different implementations for web vs mobile while keeping your main code clean. The stub file handles non-web platforms, while the implementation file contains the web-specific logic.
Web Implementation: HTML Elements to the Rescue
[FACT] For web, I ditched Flutter's built-in image widgets entirely and went straight to HTML elements using HtmlElementView
. This bypasses CORS issues since the browser handles the image loading directly.
final imgElement = html.ImageElement()
..src = src
..style.width = '100%'
..style.objectFit = 'contain'
..style.cursor = 'pointer';
[CREATIVE] The magic happens when you register this as a platform view. Flutter treats it like any other widget, but under the hood, it's pure HTML - which means it plays nicely with browser security policies.
Adding Zoom Functionality
[FACT] The fullscreen implementation includes both mouse wheel and touch gesture zoom:
// Mouse wheel zoom
imgElement.onWheel.listen((event) {
event.preventDefault();
scale += event.deltaY > 0 ? -0.1 : 0.1;
scale = scale.clamp(0.5, 3.0);
imgElement.style.transform = 'scale($scale)';
});
[CREATIVE] For touch devices, I implemented pinch-to-zoom by tracking multiple touch points and calculating the distance between them. It's more complex than the mouse wheel version, but it gives web users the same intuitive zoom experience they expect.
Mobile Implementation: Keep It Simple
[FACT] For mobile platforms, I stuck with the tried-and-true approach but improved the UX:
Dialog.fullscreen(
backgroundColor: Colors.black,
child: Stack(
children: [
Center(
child: PhotoView(
imageProvider: NetworkImage(url),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4,
),
),
// Close button positioned in top-right
],
),
)
[FACT] The key improvements were switching to Dialog.fullscreen
instead of a regular dialog and adding a proper close button with consistent styling.
Key Takeaways
[FACT] 1. Conditional imports are your friend - They let you maintain clean separation between platform-specific code without cluttering your main logic.
[FACT] 2. HTML elements can solve web-specific problems - When Flutter widgets don't cut it on web, dropping down to HTML often provides better browser compatibility.
[FACT] 3. Consistent UX matters - Users expect zoom functionality on images, especially in fullscreen mode. Don't skimp on these details.
[CREATIVE] 4. Don't fight the platform - Web and mobile have different strengths. Embrace them instead of trying to force a one-size-fits-all solution.
The Result
[FACT] After implementing these changes, our image handling works consistently across platforms. Web users get smooth zoom functionality without CORS headaches, mobile users get the native experience they expect, and the codebase remains maintainable with clear separation of concerns.
[CREATIVE] Sometimes the best solution isn't the most elegant one - it's the one that actually works for your users across all the platforms they're using. This approach might seem like more code, but the improved user experience makes it worth every line.