Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 64 additions & 20 deletions packages/flet/lib/src/controls/image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import 'package:flutter/material.dart';

import '../extensions/control.dart';
import '../models/control.dart';
import '../utils/animations.dart';
import '../utils/borders.dart';
import '../utils/box.dart';
import '../utils/colors.dart';
import '../utils/images.dart';
import '../utils/numbers.dart';
Expand All @@ -15,10 +15,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) {
Expand All @@ -29,25 +26,72 @@ 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,
fadeInAnimation: control.getAnimation("fade_in_animation"),
placeholderFadeOutAnimation:
control.getAnimation("placeholder_fade_out_animation"));

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,
Expand Down
168 changes: 130 additions & 38 deletions packages/flet/lib/src/utils/images.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:flutter_svg/svg.dart';

import '../flet_backend.dart';
import '../models/control.dart';
import '../utils/animations.dart';
import '../utils/strings.dart';
import '../utils/uri.dart';
import '../widgets/error.dart';
Expand Down Expand Up @@ -129,6 +130,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\"";

Expand All @@ -143,47 +145,62 @@ 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()}");
}
} 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) {
Expand Down Expand Up @@ -214,10 +231,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,
);
}
Expand All @@ -234,6 +255,9 @@ Widget buildImage({
? ColorFilter.mode(color, colorBlendMode ?? BlendMode.srcIn)
: null,
semanticsLabel: semanticsLabel,
errorBuilder: errorCtrl != null
? (context, error, stackTrace) => errorCtrl
: null,
);
} else {
// other image URL
Expand All @@ -252,10 +276,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,
);
}
Expand All @@ -266,6 +294,70 @@ Widget buildImage({
return const ErrorControl("A valid src value must be specified.");
}

class ImageFadeConfig {
const ImageFadeConfig(
{this.placeholder,
this.fadeInAnimation,
this.placeholderFadeOutAnimation});

final Widget? placeholder;
final ImplicitAnimationDetails? fadeInAnimation;
final ImplicitAnimationDetails? placeholderFadeOutAnimation;

/// Returns true if any fade-related option is set.
bool get enabled =>
placeholder != null ||
fadeInAnimation != null ||
placeholderFadeOutAnimation != null;

/// 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 effectiveFadeInCurve = fadeInAnimation?.curve ?? Curves.easeInOut;
final effectiveFadeInDuration =
fadeInAnimation?.duration ?? const Duration(milliseconds: 250);
final effectiveFadeOutCurve =
placeholderFadeOutAnimation?.curve ?? Curves.easeOut;
final effectiveFadeOutDuration = placeholderFadeOutAnimation?.duration ??
const Duration(milliseconds: 150);
final placeholderWidget = placeholder != null
? SizedBox(width: width, height: height, child: placeholder)
// invisible version to preserve layout
: SizedBox(
width: width,
height: height,
child: Opacity(opacity: 0, child: image));

return AnimatedSwitcher(
duration: isLoaded ? effectiveFadeInDuration : effectiveFadeOutDuration,
switchInCurve: effectiveFadeInCurve,
switchOutCurve: effectiveFadeOutCurve,
layoutBuilder: (Widget? current, List<Widget> 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});

Expand Down
Loading
Loading