1using System.ComponentModel;
4using System.Threading.Tasks;
23 public static readonly BindableProperty SourceProperty =
24 BindableProperty.Create(
29 propertyChanged: OnSourceChanged);
32 public static readonly BindableProperty ErrorPlaceholderProperty =
33 BindableProperty.Create(
38 propertyChanged: OnErrorPlaceholderChanged);
41 public static readonly BindableProperty AspectProperty =
42 BindableProperty.Create(
47 propertyChanged: OnAspectChanged);
50 public static readonly BindableProperty ParentIdProperty =
51 BindableProperty.Create(
58 public static readonly BindableProperty PermanentProperty =
59 BindableProperty.Create(
66 public static readonly BindableProperty CacheDurationProperty =
67 BindableProperty.Create(
74 private static readonly BindablePropertyKey IsLoadingPropertyKey =
75 BindableProperty.CreateReadOnly(
81 public static readonly BindableProperty IsLoadingProperty = IsLoadingPropertyKey.BindableProperty;
88 get => (string)this.GetValue(SourceProperty);
89 set => this.SetValue(SourceProperty, value);
95 [TypeConverter(typeof(ImageSourceConverter))]
98 get => (ImageSource)this.GetValue(ErrorPlaceholderProperty);
99 set => this.SetValue(ErrorPlaceholderProperty, value);
107 get => (
Aspect)this.GetValue(AspectProperty);
108 set => this.SetValue(AspectProperty, value);
116 get => (string)this.GetValue(ParentIdProperty);
117 set => this.SetValue(ParentIdProperty, value);
125 get => (bool)this.GetValue(PermanentProperty);
126 set => this.SetValue(PermanentProperty, value);
134 get => (TimeSpan?)this.GetValue(CacheDurationProperty);
135 set => this.SetValue(CacheDurationProperty, value);
143 get => (bool)this.GetValue(IsLoadingProperty);
144 private set => this.SetValue(IsLoadingPropertyKey, value);
147 private readonly Image imageView;
148 private readonly ActivityIndicator spinner;
150 private bool isErrorDisplayed;
151 private bool isDisposed;
155 this.spinner =
new ActivityIndicator
159 HorizontalOptions = LayoutOptions.Center,
160 VerticalOptions = LayoutOptions.Center
163 this.imageView =
new Image
168 this.Content =
new Grid
170 Children = { this.imageView, this.spinner }
174 Builder.Named(
"UriImage.Load");
175 Builder.AutoStart(
false);
176 Builder.WithPolicy(
Policies.Retry(3, (
int Attempt, Exception Error) => TimeSpan.FromMilliseconds(Math.Min(2000, 200 * Attempt))));
177 Builder.Run(this.LoadImageCoreAsync);
179 this.loadImageTask = Builder.Build();
180 this.loadImageTask.StateChanged += this.OnLoadTaskStateChanged;
183 protected override void OnHandlerChanging(HandlerChangingEventArgs Args)
185 base.OnHandlerChanging(Args);
187 if (Args.NewHandler is
null && !
this.isDisposed)
189 this.loadImageTask.StateChanged -= this.OnLoadTaskStateChanged;
190 this.loadImageTask.Dispose();
191 this.isDisposed =
true;
195 private static void OnAspectChanged(BindableObject Bindable,
object OldValue,
object NewValue)
197 UriImage Control = (UriImage)Bindable;
198 Control.imageView.Aspect = (
Aspect)NewValue;
201 private static void OnSourceChanged(BindableObject Bindable,
object OldValue,
object NewValue)
203 UriImage Control = (UriImage)Bindable;
204 Control.isErrorDisplayed =
false;
205 if (!Control.isDisposed)
206 Control.loadImageTask.Run();
209 private static void OnErrorPlaceholderChanged(BindableObject Bindable,
object OldValue,
object NewValue)
211 UriImage Control = (UriImage)Bindable;
212 if (Control.isErrorDisplayed)
213 Control.imageView.Source = (ImageSource)NewValue;
219 MainThread.BeginInvokeOnMainThread(() =>
221 this.IsLoading = IsRunning;
222 this.spinner.IsRunning = IsRunning;
223 this.spinner.IsVisible = IsRunning;
227 _ = this.ShowErrorAsync();
230 private async Task LoadImageCoreAsync(TaskContext<int> Context)
234 string? CurrentSource = this.
Source;
235 if (
string.IsNullOrWhiteSpace(CurrentSource))
237 await this.ShowErrorAsync();
241 ImageSource? NewImage = await this.ResolveImageSourceAsync(CurrentSource, Context.CancellationToken);
242 if (NewImage is
null)
244 await this.ShowErrorAsync();
248 Context.CancellationToken.ThrowIfCancellationRequested();
250 await MainThread.InvokeOnMainThreadAsync(() =>
252 this.isErrorDisplayed =
false;
253 this.imageView.Source = NewImage;
256 catch (OperationCanceledException)
267 private async Task<ImageSource?> ResolveImageSourceAsync(
string CurrentSource, CancellationToken CancellationToken)
269 bool IsSvgUri = CurrentSource.EndsWith(
".svg", StringComparison.OrdinalIgnoreCase);
271 if (CurrentSource.StartsWith(
"resource://", StringComparison.Ordinal))
273 CancellationToken.ThrowIfCancellationRequested();
274 return ImageSource.FromResource(CurrentSource[11..]);
277 if (CurrentSource.StartsWith(
"file://", StringComparison.Ordinal))
279 CancellationToken.ThrowIfCancellationRequested();
281 return CurrentSource[7..^4];
282 return CurrentSource[7..];
285 if (!Uri.TryCreate(CurrentSource, UriKind.Absolute, out Uri? UriValue))
288 string Key =
string.IsNullOrEmpty(this.
ParentId) ? CurrentSource : this.
ParentId;
290 (
byte[]? ImageBytes,
string _) = await CacheService.
GetOrFetch(UriValue, Key,
this.Permanent);
292 if (ImageBytes is
null || ImageBytes.Length == 0)
298 byte[] ProcessedBytes = ImageBytes;
300 ProcessedBytes = this.TryConvertSvgToPng(ProcessedBytes, CancellationToken);
302 CancellationToken.ThrowIfCancellationRequested();
304 return this.CreateImageSourceFromBytes(ProcessedBytes);
307 private ImageSource CreateImageSourceFromBytes(
byte[] ImageBytes)
309 byte[] Buffer = ImageBytes;
310 return ImageSource.FromStream(() =>
new MemoryStream(Buffer));
313 private byte[] TryConvertSvgToPng(
byte[] ImageBytes, CancellationToken CancellationToken)
317 CancellationToken.ThrowIfCancellationRequested();
318 SKSvg Svg =
new SKSvg();
319 using (MemoryStream InputStream =
new MemoryStream(ImageBytes))
321 Svg.Load(InputStream);
324 CancellationToken.ThrowIfCancellationRequested();
326 if (Svg.Picture is
null)
329 using (MemoryStream OutputStream =
new MemoryStream())
331 bool Converted = Svg.Picture.ToImage(OutputStream, SKColor.Parse(
"#00FFFFFF"), SKEncodedImageFormat.Png, 100, 1, 1, SKColorType.Rgba8888, SKAlphaType.Premul, SKColorSpace.CreateSrgb());
335 return OutputStream.ToArray();
338 catch (OperationCanceledException)
349 private Task ShowErrorAsync()
351 return MainThread.InvokeOnMainThreadAsync(() =>
353 this.isErrorDisplayed =
true;
354 this.IsLoading =
false;
355 this.spinner.IsRunning =
false;
356 this.spinner.IsVisible =
false;
361 private static bool IsSvg(
byte[] imageBytes)
363 if (imageBytes is
null || imageBytes.Length < 5)
367 string Header =
System.Text.Encoding.UTF8.GetString(imageBytes, 0, Math.Min(imageBytes.Length, 256));
368 return Header.TrimStart().StartsWith(
"<svg", StringComparison.OrdinalIgnoreCase);
A set of never changing property constants and helpful values.
Base class that references services in the app.
static ILogService LogService
Log service.
A control that displays an image from a URI (string) with built-in caching, loading indicator,...
bool IsLoading
Indicates whether the image is currently loading.
Aspect Aspect
The Aspect for the inner Image.
bool Permanent
Whether to make this cache entry permanent (true) or temporary (false).
string Source
Source URI string of the image to load.
TimeSpan? CacheDuration
Custom cache duration for this entry; null uses default expiry.
ImageSource ErrorPlaceholder
Image shown if loading fails.
string ParentId
Optional custom parent ID for cache entries.
Provides a data-binding friendly mechanism to manage and report the status of asynchronous operations...
Defines operations for caching arbitrary internet-fetchable content.
Task<(byte[]? Data, string ContentType)> GetOrFetch(Uri Uri, string ParentId, bool Permanent)
Retrieves content for the specified URI from cache or fetches and caches it if missing.
ObservableTaskStatus
UI-friendly status for an observable async operation. (Renamed to avoid collision with System....
class Header(ISimulationNode Parent, Model Model)
Represents an identity property.