diff --git a/packages/flet/lib/src/controls/image.dart b/packages/flet/lib/src/controls/image.dart index cb591eec7..b7e66119d 100644 --- a/packages/flet/lib/src/controls/image.dart +++ b/packages/flet/lib/src/controls/image.dart @@ -3,10 +3,11 @@ import 'package:flutter/material.dart'; import '../extensions/control.dart'; import '../models/control.dart'; import '../utils/borders.dart'; -import '../utils/box.dart'; +import '../utils/animations.dart'; import '../utils/colors.dart'; import '../utils/images.dart'; import '../utils/numbers.dart'; +import '../utils/time.dart'; import '../widgets/error.dart'; import 'base_controls.dart'; @@ -15,10 +16,7 @@ class ImageControl extends StatelessWidget { static const String svgTag = " xmlns=\"http://www.w3.org/2000/svg\""; - const ImageControl({ - super.key, - required this.control, - }); + const ImageControl({super.key, required this.control}); @override Widget build(BuildContext context) { @@ -29,25 +27,73 @@ class ImageControl extends StatelessWidget { return const ErrorControl("Image must have \"src\" specified."); } + final width = control.getDouble("width"); + final height = control.getDouble("height"); + final fit = control.getBoxFit("fit"); + final repeat = control.getImageRepeat("repeat", ImageRepeat.noRepeat)!; + final color = control.getColor("color", context); + final colorBlendMode = control.getBlendMode("color_blend_mode"); + final semanticsLabel = control.getString("semantics_label"); + final gaplessPlayback = control.getBool("gapless_playback"); + final excludeFromSemantics = + control.getBool("exclude_from_semantics", false)!; + final filterQuality = + control.getFilterQuality("filter_quality", FilterQuality.medium)!; + final cacheWidth = control.getInt("cache_width"); + final cacheHeight = control.getInt("cache_height"); + final antiAlias = control.getBool("anti_alias", false)!; + final errorContent = control.buildWidget("error_content"); + + // Optional placeholder shown while the image is loading. + Widget? placeholder; + final placeholderSrc = control.get("placeholder_src"); + if (placeholderSrc != null) { + placeholder = buildImage( + context: context, + src: placeholderSrc, + width: width, + height: height, + fit: control.getBoxFit("placeholder_fit", fit), + repeat: repeat, + color: color, + colorBlendMode: colorBlendMode, + semanticsLabel: semanticsLabel, + gaplessPlayback: gaplessPlayback, + excludeFromSemantics: excludeFromSemantics, + filterQuality: filterQuality, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + antiAlias: antiAlias, + errorCtrl: errorContent, + ); + } + + final fadeConfig = ImageFadeConfig( + placeholder: placeholder, + fadeInDuration: control.getDuration("fade_in_duration"), + fadeOutDuration: control.getDuration("fade_out_duration"), + fadeInCurve: control.getCurve("fade_in_curve"), + fadeOutCurve: control.getCurve("fade_out_curve")); + Widget? image = buildImage( context: context, src: rawSrc, - width: control.getDouble("width"), - height: control.getDouble("height"), - cacheWidth: control.getInt("cache_width"), - cacheHeight: control.getInt("cache_height"), - antiAlias: control.getBool("anti_alias", false)!, - repeat: control.getImageRepeat("repeat", ImageRepeat.noRepeat)!, - fit: control.getBoxFit("fit"), - colorBlendMode: control.getBlendMode("color_blend_mode"), - color: control.getColor("color", context), - semanticsLabel: control.getString("semantics_label"), - gaplessPlayback: control.getBool("gapless_playback"), - excludeFromSemantics: control.getBool("exclude_from_semantics", false)!, - filterQuality: - control.getFilterQuality("filter_quality", FilterQuality.medium)!, + width: width, + height: height, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + antiAlias: antiAlias, + repeat: repeat, + fit: fit, + colorBlendMode: colorBlendMode, + color: color, + semanticsLabel: semanticsLabel, + gaplessPlayback: gaplessPlayback, + excludeFromSemantics: excludeFromSemantics, + filterQuality: filterQuality, disabled: control.disabled, - errorCtrl: control.buildWidget("error_content"), + errorCtrl: errorContent, + fadeConfig: fadeConfig.enabled ? fadeConfig : null, ); return LayoutControl( control: control, diff --git a/packages/flet/lib/src/utils/images.dart b/packages/flet/lib/src/utils/images.dart index 404e04b81..c7e8bd18b 100644 --- a/packages/flet/lib/src/utils/images.dart +++ b/packages/flet/lib/src/utils/images.dart @@ -129,6 +129,7 @@ Widget buildImage({ bool excludeFromSemantics = false, FilterQuality filterQuality = FilterQuality.low, bool disabled = false, + ImageFadeConfig? fadeConfig, }) { const String svgTag = " xmlns=\"http://www.w3.org/2000/svg\""; @@ -143,31 +144,41 @@ Widget buildImage({ try { // SVG bytes if (arrayIndexOf(bytes, Uint8List.fromList(utf8.encode(svgTag))) != -1) { - return SvgPicture.memory(bytes, - width: width, - height: height, - excludeFromSemantics: excludeFromSemantics, - fit: fit ?? BoxFit.contain, - colorFilter: color != null - ? ColorFilter.mode(color, colorBlendMode ?? BlendMode.srcIn) - : null, - semanticsLabel: semanticsLabel); + return SvgPicture.memory( + bytes, + width: width, + height: height, + excludeFromSemantics: excludeFromSemantics, + fit: fit ?? BoxFit.contain, + colorFilter: color != null + ? ColorFilter.mode(color, colorBlendMode ?? BlendMode.srcIn) + : null, + semanticsLabel: semanticsLabel, + ); } else { // other image bytes - return Image.memory(bytes, - width: width, - height: height, - repeat: repeat, - fit: fit, - color: color, - cacheHeight: cacheHeight, - cacheWidth: cacheWidth, - filterQuality: filterQuality, - isAntiAlias: antiAlias, - colorBlendMode: colorBlendMode, - gaplessPlayback: gaplessPlayback ?? false, - excludeFromSemantics: excludeFromSemantics, - semanticLabel: semanticsLabel); + return Image.memory( + bytes, + width: width, + height: height, + repeat: repeat, + fit: fit, + color: color, + cacheHeight: cacheHeight, + cacheWidth: cacheWidth, + filterQuality: filterQuality, + isAntiAlias: antiAlias, + colorBlendMode: colorBlendMode, + gaplessPlayback: gaplessPlayback ?? false, + excludeFromSemantics: excludeFromSemantics, + semanticLabel: semanticsLabel, + frameBuilder: (BuildContext context, Widget child, int? frame, + bool wasSyncLoaded) => + fadeConfig != null && fadeConfig.enabled + ? fadeConfig.wrapFrame(child, frame, wasSyncLoaded, + width: width, height: height) + : child, + ); } } catch (ex) { return ErrorControl("Error decoding base64: ${ex.toString()}"); @@ -175,15 +186,20 @@ Widget buildImage({ } else if (resolvedSrc.hasUri) { var stringSrc = resolvedSrc.uri!; if (stringSrc.contains(svgTag)) { - return SvgPicture.memory(Uint8List.fromList(utf8.encode(stringSrc)), - width: width, - height: height, - fit: fit ?? BoxFit.contain, - excludeFromSemantics: excludeFromSemantics, - colorFilter: color != null - ? ColorFilter.mode(color, colorBlendMode ?? BlendMode.srcIn) - : null, - semanticsLabel: semanticsLabel); + return SvgPicture.memory( + Uint8List.fromList(utf8.encode(stringSrc)), + width: width, + height: height, + fit: fit ?? BoxFit.contain, + excludeFromSemantics: excludeFromSemantics, + colorFilter: color != null + ? ColorFilter.mode(color, colorBlendMode ?? BlendMode.srcIn) + : null, + semanticsLabel: semanticsLabel, + errorBuilder: errorCtrl != null + ? (context, error, stackTrace) => errorCtrl + : null, + ); } else { var assetSrc = FletBackend.of(context).getAssetSource(stringSrc); if (assetSrc.isFile) { @@ -214,10 +230,14 @@ Widget buildImage({ gaplessPlayback: gaplessPlayback ?? false, colorBlendMode: colorBlendMode, semanticLabel: semanticsLabel, + frameBuilder: (BuildContext context, Widget child, int? frame, + bool wasSyncLoaded) => + fadeConfig != null && fadeConfig.enabled + ? fadeConfig.wrapFrame(child, frame, wasSyncLoaded, + width: width, height: height) + : child, errorBuilder: errorCtrl != null - ? (context, error, stackTrace) { - return errorCtrl; - } + ? (context, error, stackTrace) => errorCtrl : null, ); } @@ -234,6 +254,9 @@ Widget buildImage({ ? ColorFilter.mode(color, colorBlendMode ?? BlendMode.srcIn) : null, semanticsLabel: semanticsLabel, + errorBuilder: errorCtrl != null + ? (context, error, stackTrace) => errorCtrl + : null, ); } else { // other image URL @@ -252,10 +275,14 @@ Widget buildImage({ gaplessPlayback: gaplessPlayback ?? false, colorBlendMode: colorBlendMode, semanticLabel: semanticsLabel, + frameBuilder: (BuildContext context, Widget child, int? frame, + bool wasSyncLoaded) => + fadeConfig != null && fadeConfig.enabled + ? fadeConfig.wrapFrame(child, frame, wasSyncLoaded, + width: width, height: height) + : child, errorBuilder: errorCtrl != null - ? (context, error, stackTrace) { - return errorCtrl; - } + ? (context, error, stackTrace) => errorCtrl : null, ); } @@ -266,6 +293,79 @@ Widget buildImage({ return const ErrorControl("A valid src value must be specified."); } +class ImageFadeConfig { + const ImageFadeConfig( + {this.placeholder, + this.fadeInDuration, + this.fadeOutDuration, + this.fadeInCurve, + this.fadeOutCurve}); + + final Widget? placeholder; + final Duration? fadeInDuration; + final Duration? fadeOutDuration; + final Curve? fadeInCurve; + final Curve? fadeOutCurve; + + /// Returns true if any fade-related option is set. + bool get enabled => + placeholder != null || + fadeInDuration != null || + fadeOutDuration != null || + fadeInCurve != null || + fadeOutCurve != null; + + Duration get resolvedFadeInDuration => + fadeInDuration ?? const Duration(milliseconds: 250); + + Duration get resolvedFadeOutDuration => + fadeOutDuration ?? const Duration(milliseconds: 150); + + Curve get resolvedFadeInCurve => fadeInCurve ?? Curves.easeInOut; + + Curve get resolvedFadeOutCurve => fadeOutCurve ?? Curves.easeOut; + + /// Wraps an [Image] frame with a placeholder-to-image fade transition. + /// + /// - Shows [placeholder] (or a transparent placeholder that preserves layout) + /// until the first frame is available. + /// - Fades the loaded image in using [fadeInDuration]/[fadeInCurve]. + /// - Fades the placeholder out using [fadeOutDuration]/[fadeOutCurve]. + Widget wrapFrame(Widget image, int? frame, bool wasSyncLoaded, + {double? width, double? height}) { + if (!enabled) { + return image; + } + + final isLoaded = frame != null || wasSyncLoaded; + final placeholderWidget = placeholder != null + ? SizedBox(width: width, height: height, child: placeholder) + // Defaults to an invisible version to preserve layout. + : SizedBox( + width: width, + height: height, + child: Opacity(opacity: 0, child: image)); + + return AnimatedSwitcher( + duration: isLoaded ? resolvedFadeInDuration : resolvedFadeOutDuration, + switchInCurve: resolvedFadeInCurve, + switchOutCurve: resolvedFadeOutCurve, + layoutBuilder: (Widget? current, List previousChildren) => + Stack( + alignment: Alignment.center, + children: [ + ...previousChildren, + if (current != null) current, + ], + ), + child: isLoaded + ? KeyedSubtree(key: const ValueKey("image-loaded"), child: image) + : KeyedSubtree( + key: const ValueKey("image-placeholder"), + child: placeholderWidget)); + } +} + class ResolvedAssetSource { const ResolvedAssetSource({this.bytes, this.uri, this.error}); diff --git a/sdk/python/examples/controls/image/fade_in.py b/sdk/python/examples/controls/image/fade_in.py new file mode 100644 index 000000000..b17af37d1 --- /dev/null +++ b/sdk/python/examples/controls/image/fade_in.py @@ -0,0 +1,20 @@ +import flet as ft + + +def main(page: ft.Page): + page.add( + ft.Image( + src="https://picsum.photos/320/200?random=2", + width=360, + height=220, + fit=ft.BoxFit.COVER, + # blue image + placeholder_src="data:image/png;base64,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", # noqa: E501 + fade_in_duration=400, + fade_out_duration=200, + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/image.md b/sdk/python/packages/flet/docs/controls/image.md index 40b913231..86735d756 100644 --- a/sdk/python/packages/flet/docs/controls/image.md +++ b/sdk/python/packages/flet/docs/controls/image.md @@ -19,6 +19,12 @@ example_media: ../examples/controls/image/media {{ image(example_media + "/gallery.gif", width="80%") }} +### Fade-in images with a placeholder + +```python +--8<-- "{{ examples }}/fade_in.py" +``` + ### Displaying images from base64 strings and byte data ```python diff --git a/sdk/python/packages/flet/integration_tests/controls/core/golden/macos/image/placeholder_1.png b/sdk/python/packages/flet/integration_tests/controls/core/golden/macos/image/placeholder_1.png new file mode 100644 index 000000000..5dd519f7b Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/core/golden/macos/image/placeholder_1.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/core/golden/macos/image/placeholder_2.png b/sdk/python/packages/flet/integration_tests/controls/core/golden/macos/image/placeholder_2.png new file mode 100644 index 000000000..8c32183fd Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/core/golden/macos/image/placeholder_2.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/core/test_image.py b/sdk/python/packages/flet/integration_tests/controls/core/test_image.py index dfd8a6098..62d37cb85 100644 --- a/sdk/python/packages/flet/integration_tests/controls/core/test_image.py +++ b/sdk/python/packages/flet/integration_tests/controls/core/test_image.py @@ -99,3 +99,44 @@ async def test_src_bytes(flet_app: ftt.FletTestApp, request): pump_times=1, pump_duration=1000, ) + + +@pytest.mark.skip(reason="The test is flaky on CI") +@pytest.mark.asyncio(loop_scope="module") +async def test_placeholder_1(flet_app: ftt.FletTestApp, request): + await flet_app.assert_control_screenshot( + request.node.name, + ft.Image( + src="/minion.png", + width=100, + height=100, + fit=ft.BoxFit.CONTAIN, + placeholder_src=base64_image, + fade_in_duration=1000, + fade_out_duration=250, + fade_in_curve=ft.AnimationCurve.EASE_IN_OUT, + fade_out_curve=ft.AnimationCurve.EASE_OUT, + ), + pump_times=1, + pump_duration=50, + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_placeholder_2(flet_app: ftt.FletTestApp, request): + await flet_app.assert_control_screenshot( + request.node.name, + ft.Image( + src="/minion.png", + width=100, + height=100, + fit=ft.BoxFit.CONTAIN, + placeholder_src=base64_image, + fade_in_duration=1000, + fade_out_duration=250, + fade_in_curve=ft.AnimationCurve.EASE_IN_OUT, + fade_out_curve=ft.AnimationCurve.EASE_OUT, + ), + pump_times=3, + pump_duration=1000, + ) diff --git a/sdk/python/packages/flet/src/flet/controls/core/image.py b/sdk/python/packages/flet/src/flet/controls/core/image.py index a2f1206a5..941663740 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/image.py +++ b/sdk/python/packages/flet/src/flet/controls/core/image.py @@ -1,9 +1,11 @@ from typing import Optional, Union +from flet.controls.animation import AnimationCurve from flet.controls.base_control import control from flet.controls.border_radius import BorderRadiusValue from flet.controls.box import BoxFit, FilterQuality from flet.controls.control import Control +from flet.controls.duration import DurationValue from flet.controls.layout_control import LayoutControl from flet.controls.types import ( BlendMode, @@ -100,6 +102,56 @@ class Image(LayoutControl): The rendering quality of the image. """ + placeholder_src: Optional[Union[str, bytes]] = None + """ + A placeholder displayed while the image is loading. + + It can be one of the following: + - A URL or local [asset file](https://flet.dev/docs/cookbook/assets) path; + - A base64 string; + - Raw bytes. + + Note: + SVG sources are currently not supported as placeholders. If provided, + this property will be ignored and the [`src`][(c).] will be + displayed directly instead. + """ + + placeholder_fit: Optional[BoxFit] = None + """ + How to inscribe the placeholder into its space. + + Falls back to [`fit`][(c).] when omitted. + """ + + fade_in_duration: Optional[DurationValue] = None + """ + Duration of the fade-in animation when the image loads. + + Defaults to 250 milliseconds when any fade option is set. + """ + + fade_out_duration: Optional[DurationValue] = None + """ + Duration of the fade-out animation for the placeholder. + + Defaults to 150 milliseconds when any fade option is set. + """ + + fade_in_curve: Optional[AnimationCurve] = None + """ + Animation curve used for the fade-in transition. + + Defaults to `AnimationCurve.EASE_IN_OUT` when a fade is enabled. + """ + + fade_out_curve: Optional[AnimationCurve] = None + """ + Animation curve used for the fade-out transition. + + Defaults to `AnimationCurve.EASE_OUT` when a fade is enabled. + """ + cache_width: Optional[int] = None """ The size at which this image should be decoded.