Compare commits

...

5 commits

Author SHA1 Message Date
ClemensFischer b44018ac8f TileImageLoader 2025-11-13 19:25:11 +01:00
ClemensFischer 76f920a053 Fixed MBTileSource 2025-11-13 17:40:29 +01:00
ClemensFischer 4912fa1e40 TileSource/TileImageLoader 2025-11-13 17:06:33 +01:00
ClemensFischer cb8fff0dd1 Abstract classes Tile, TileSource 2025-11-13 15:32:01 +01:00
ClemensFischer 20e4fcce75 ITile, ITileSource interfaces 2025-11-13 13:36:28 +01:00
25 changed files with 155 additions and 138 deletions

View file

@ -32,14 +32,13 @@ namespace MapControl.MBTiles
await connection.OpenAsync();
using (var command = new SQLiteCommand("select * from metadata", connection))
{
var reader = await command.ExecuteReaderAsync();
using var command = new SQLiteCommand("select * from metadata", connection);
while (await reader.ReadAsync())
{
Metadata[(string)reader["name"]] = (string)reader["value"];
}
var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
Metadata[(string)reader["name"]] = (string)reader["value"];
}
}
@ -58,24 +57,23 @@ namespace MapControl.MBTiles
Close();
}
public override async Task<ImageSource> LoadImageAsync(int x, int y, int zoomLevel)
public override async Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row)
{
ImageSource image = null;
try
{
using (var command = new SQLiteCommand("select tile_data from tiles where zoom_level=@z and tile_column=@x and tile_row=@y", connection))
using var command = new SQLiteCommand("select tile_data from tiles where zoom_level=@z and tile_column=@x and tile_row=@y", connection);
command.Parameters.AddWithValue("@z", zoomLevel);
command.Parameters.AddWithValue("@x", column);
command.Parameters.AddWithValue("@y", (1 << zoomLevel) - row - 1);
var buffer = (byte[])await command.ExecuteScalarAsync();
if (buffer?.Length > 0)
{
command.Parameters.AddWithValue("@z", zoomLevel);
command.Parameters.AddWithValue("@x", x);
command.Parameters.AddWithValue("@y", (1 << zoomLevel) - y - 1);
var buffer = (byte[])await command.ExecuteScalarAsync();
if (buffer?.Length > 0)
{
image = await LoadImageAsync(buffer);
}
image = await ImageLoader.LoadImageAsync(buffer);
}
}
catch (Exception ex)

View file

@ -1,4 +1,5 @@
using Avalonia;
global using DependencyProperty = Avalonia.AvaloniaProperty;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using System;

View file

@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
@ -8,9 +9,12 @@ using System.Threading.Tasks;
namespace MapControl
{
public partial class Tile
public class ImageTile(int zoomLevel, int x, int y, int columnCount)
: Tile(zoomLevel, x, y, columnCount)
{
public async Task LoadImageAsync(Func<Task<IImage>> loadImageFunc)
public Image Image { get; } = new Image { Stretch = Stretch.Fill };
public override async Task LoadImageAsync(Func<Task<IImage>> loadImageFunc)
{
var image = await loadImageFunc().ConfigureAwait(false);

View file

@ -1,9 +1,4 @@
global using DependencyProperty = Avalonia.AvaloniaProperty;
global using FrameworkElement = Avalonia.Controls.Control;
global using Brush = Avalonia.Media.IBrush;
global using ImageSource = Avalonia.Media.IImage;
global using PropertyPath = System.String;
using Avalonia;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
@ -13,6 +8,7 @@ using Avalonia.Media;
using Avalonia.Styling;
using System.Threading;
using System.Threading.Tasks;
using Brush = Avalonia.Media.IBrush;
namespace MapControl
{

View file

@ -1,5 +1,5 @@
using Avalonia;
using Avalonia.Controls;
global using FrameworkElement = Avalonia.Controls.Control;
using Avalonia;
using Avalonia.Media;
namespace MapControl
@ -20,18 +20,18 @@ namespace MapControl
AffectsParentArrange<MapPanel>(LocationProperty, BoundingBoxProperty);
}
public static MapBase GetParentMap(Control element)
public static MapBase GetParentMap(FrameworkElement element)
{
return (MapBase)element.GetValue(ParentMapProperty);
}
public static void SetRenderTransform(Control element, Transform transform, double originX = 0d, double originY = 0d)
public static void SetRenderTransform(FrameworkElement element, Transform transform, double originX = 0d, double originY = 0d)
{
element.RenderTransform = transform;
element.RenderTransformOrigin = new RelativePoint(originX, originY, RelativeUnit.Relative);
}
private static void SetVisible(Control element, bool visible)
private static void SetVisible(FrameworkElement element, bool visible)
{
element.IsVisible = visible;
}

View file

@ -4,7 +4,7 @@ using System.Text;
namespace MapControl
{
public class BoundingBoxTileSource : TileSource
public class BoundingBoxTileSource : UriTileSource
{
public override Uri GetUri(int zoomLevel, int column, int row)
{

View file

@ -9,6 +9,8 @@ using System.Windows.Media;
using Windows.UI.Xaml.Media;
#elif WINUI
using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using ImageSource = Avalonia.Media.IImage;
#endif
namespace MapControl

View file

@ -3,12 +3,12 @@ using System.Linq;
namespace MapControl
{
public partial class TileCollection : List<Tile>
public partial class ImageTileList : List<ImageTile>
{
/// <summary>
/// Adds existing Tiles from the source collection or newly created Tiles to fill the specified tile matrix.
/// Adds existing ImageTile from the source collection or newly created ImageTile to fill the specified tile matrix.
/// </summary>
public void FillMatrix(TileCollection source, int zoomLevel, int xMin, int yMin, int xMax, int yMax, int columnCount)
public void FillMatrix(ImageTileList source, int zoomLevel, int xMin, int yMin, int xMax, int yMax, int columnCount)
{
for (var y = yMin; y <= yMax; y++)
{
@ -18,7 +18,7 @@ namespace MapControl
if (tile == null)
{
tile = new Tile(zoomLevel, x, y, columnCount);
tile = new ImageTile(zoomLevel, x, y, columnCount);
var equivalentTile = source.FirstOrDefault(
t => t.Image.Source != null && t.ZoomLevel == tile.ZoomLevel && t.Column == tile.Column && t.Row == tile.Row);

View file

@ -14,6 +14,7 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using Avalonia.Controls;
using Brush = Avalonia.Media.IBrush;
#endif
namespace MapControl

View file

@ -10,6 +10,7 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using Avalonia;
using Brush = Avalonia.Media.IBrush;
#endif
namespace MapControl

View file

@ -18,6 +18,8 @@ using Microsoft.UI.Xaml.Media;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Brush = Avalonia.Media.IBrush;
using ImageSource = Avalonia.Media.IImage;
#endif
namespace MapControl

View file

@ -15,6 +15,7 @@ using Microsoft.UI.Xaml.Data;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using PropertyPath = System.String;
#endif
namespace MapControl

View file

@ -27,6 +27,7 @@ using Avalonia.Controls.Shapes;
using Avalonia.Data;
using Avalonia.Layout;
using PointCollection = System.Collections.Generic.List<Avalonia.Point>;
using PropertyPath = System.String;
#endif
namespace MapControl

View file

@ -22,7 +22,7 @@ namespace MapControl
/// <summary>
/// Displays a standard Web Mercator map tile pyramid, e.g. a OpenStreetMap tiles.
/// </summary>
public partial class MapTileLayer : MapTilePyramidLayer
public partial class MapTileLayer : TilePyramidLayer
{
public static readonly DependencyProperty MinZoomLevelProperty =
DependencyPropertyHelper.Register<MapTileLayer, int>(nameof(MinZoomLevel), 0);
@ -43,7 +43,7 @@ namespace MapControl
/// </summary>
public static MapTileLayer OpenStreetMapTileLayer => new()
{
TileSource = new TileSource { UriTemplate = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" },
TileSource = TileSource.Parse("https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
SourceName = "OpenStreetMap",
Description = "© [OpenStreetMap Contributors](http://www.openstreetmap.org/copyright)"
};
@ -52,7 +52,7 @@ namespace MapControl
public TileMatrix TileMatrix { get; private set; }
public TileCollection Tiles { get; private set; } = [];
public ImageTileList Tiles { get; private set; } = [];
/// <summary>
/// Minimum zoom level supported by the MapTileLayer. Default value is 0.
@ -178,7 +178,7 @@ namespace MapControl
private void UpdateTiles(bool reset)
{
var tiles = new TileCollection();
var tiles = new ImageTileList();
if (TileSource != null && TileMatrix != null)
{

View file

@ -1,20 +1,18 @@
#if WPF
using System.Windows.Controls;
using System;
using System.Threading.Tasks;
#if WPF
using System.Windows.Media;
#elif UWP
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;
#elif WINUI
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using Avalonia.Controls;
using Avalonia.Media;
using ImageSource = Avalonia.Media.IImage;
#endif
namespace MapControl
{
public partial class Tile(int zoomLevel, int x, int y, int columnCount)
public abstract class Tile(int zoomLevel, int x, int y, int columnCount)
{
public int ZoomLevel { get; } = zoomLevel;
public int X { get; } = x;
@ -22,7 +20,11 @@ namespace MapControl
public int Column { get; } = ((x % columnCount) + columnCount) % columnCount;
public int Row => Y;
public Image Image { get; } = new Image { Stretch = Stretch.Fill };
public bool IsPending { get; set; } = true;
/// <summary>
/// Runs a tile image download Task and passes the result to the UI thread.
/// </summary>
public abstract Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc);
}
}

View file

@ -11,12 +11,19 @@ using System.Threading.Tasks;
namespace MapControl
{
/// <summary>
/// Loads and optionally caches map tile images for a MapTileLayer.
/// Loads and optionally caches map tile images for a MapTilePyramidLayer.
/// </summary>
public interface ITileImageLoader
{
/// <summary>
/// Starts asynchronous loading of all pending tiles from the tiles collection.
/// Caching is enabled when cacheName is a non-empty string and tiles are loaded from http or https Uris.
/// </summary>
void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName, IProgress<double> progress);
/// <summary>
/// Terminates all running tile loading tasks.
/// </summary>
void CancelLoadTiles();
}
@ -67,13 +74,9 @@ namespace MapControl
private int tileCount;
private int taskCount;
/// <summary>
/// Loads all pending tiles from the tiles collection. Tile image caching is enabled when the Cache
/// property is not null and tileSource.UriFormat starts with "http" and cacheName is a non-empty string.
/// </summary>
public void BeginLoadTiles(IEnumerable<Tile> tiles, TileSource tileSource, string cacheName, IProgress<double> progress)
{
if (Cache == null || tileSource.UriTemplate == null || !tileSource.UriTemplate.StartsWith("http"))
if (Cache == null)
{
cacheName = null; // disable caching
}
@ -119,6 +122,7 @@ namespace MapControl
if (tileQueue.Count > 0)
{
tile = tileQueue.Dequeue();
tile.IsPending = false;
return true;
}
@ -132,34 +136,12 @@ namespace MapControl
while (TryDequeueTile(out Tile tile))
{
tile.IsPending = false;
Logger?.LogDebug("Thread {thread,2}: Loading tile ({zoom}/{column}/{row})",
Environment.CurrentManagedThreadId, tile.ZoomLevel, tile.Column, tile.Row);
try
{
// Pass tileSource.LoadImageAsync calls to platform-specific method
// tile.LoadImageAsync(Func<Task<ImageSource>>) for completion in the UI thread.
Logger?.LogDebug("Thread {thread,2}: Loading tile ({zoom}/{column}/{row})",
Environment.CurrentManagedThreadId, tile.ZoomLevel, tile.Column, tile.Row);
if (string.IsNullOrEmpty(cacheName))
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(tile.ZoomLevel, tile.Column, tile.Row)).ConfigureAwait(false);
}
else
{
var uri = tileSource.GetUri(tile.ZoomLevel, tile.Column, tile.Row);
if (uri != null)
{
var buffer = await LoadCachedBuffer(tile, uri, cacheName).ConfigureAwait(false);
if (buffer?.Length > 0)
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(buffer)).ConfigureAwait(false);
}
}
}
await LoadTileImage(tile, tileSource, cacheName);
}
catch (Exception ex)
{
@ -170,6 +152,32 @@ namespace MapControl
}
}
private static async Task LoadTileImage(Tile tile, TileSource tileSource, string cacheName)
{
// Pass image loading callbacks to platform-specific method
// tile.LoadImageAsync(Func<Task<ImageSource>>) for completion in the UI thread.
var uri = tileSource.GetUri(tile.ZoomLevel, tile.Column, tile.Row);
if (uri == null)
{
await tile.LoadImageAsync(() => tileSource.LoadImageAsync(tile.ZoomLevel, tile.Column, tile.Row)).ConfigureAwait(false);
}
else if (uri.Scheme != "http" && uri.Scheme != "https" || string.IsNullOrEmpty(cacheName))
{
await tile.LoadImageAsync(() => ImageLoader.LoadImageAsync(uri)).ConfigureAwait(false);
}
else
{
var buffer = await LoadCachedBuffer(tile, uri, cacheName).ConfigureAwait(false);
if (buffer != null)
{
await tile.LoadImageAsync(() => ImageLoader.LoadImageAsync(buffer)).ConfigureAwait(false);
}
}
}
private static async Task<byte[]> LoadCachedBuffer(Tile tile, Uri uri, string cacheName)
{
var extension = Path.GetExtension(uri.LocalPath).ToLower();

View file

@ -20,47 +20,48 @@ using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using Avalonia.Controls;
using Avalonia.Media;
using Brush = Avalonia.Media.IBrush;
#endif
namespace MapControl
{
public abstract class MapTilePyramidLayer : Panel, IMapLayer
public abstract class TilePyramidLayer : Panel, IMapLayer
{
public static readonly DependencyProperty TileSourceProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, TileSource>(nameof(TileSource), null,
DependencyPropertyHelper.Register<TilePyramidLayer, TileSource>(nameof(TileSource), null,
(layer, oldValue, newValue) => layer.UpdateTiles(true));
public static readonly DependencyProperty SourceNameProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, string>(nameof(SourceName));
DependencyPropertyHelper.Register<TilePyramidLayer, string>(nameof(SourceName));
public static readonly DependencyProperty DescriptionProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, string>(nameof(Description));
DependencyPropertyHelper.Register<TilePyramidLayer, string>(nameof(Description));
public static readonly DependencyProperty MaxBackgroundLevelsProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, int>(nameof(MaxBackgroundLevels), 5);
DependencyPropertyHelper.Register<TilePyramidLayer, int>(nameof(MaxBackgroundLevels), 5);
public static readonly DependencyProperty UpdateIntervalProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, TimeSpan>(nameof(UpdateInterval), TimeSpan.FromSeconds(0.2),
DependencyPropertyHelper.Register<TilePyramidLayer, TimeSpan>(nameof(UpdateInterval), TimeSpan.FromSeconds(0.2),
(layer, oldValue, newValue) => layer.updateTimer.Interval = newValue);
public static readonly DependencyProperty UpdateWhileViewportChangingProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, bool>(nameof(UpdateWhileViewportChanging));
DependencyPropertyHelper.Register<TilePyramidLayer, bool>(nameof(UpdateWhileViewportChanging));
public static readonly DependencyProperty MapBackgroundProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, Brush>(nameof(MapBackground));
DependencyPropertyHelper.Register<TilePyramidLayer, Brush>(nameof(MapBackground));
public static readonly DependencyProperty MapForegroundProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, Brush>(nameof(MapForeground));
DependencyPropertyHelper.Register<TilePyramidLayer, Brush>(nameof(MapForeground));
public static readonly DependencyProperty LoadingProgressProperty =
DependencyPropertyHelper.Register<MapTilePyramidLayer, double>(nameof(LoadingProgress), 1d);
DependencyPropertyHelper.Register<TilePyramidLayer, double>(nameof(LoadingProgress), 1d);
private readonly Progress<double> loadingProgress;
private readonly UpdateTimer updateTimer;
private ITileImageLoader tileImageLoader;
private MapBase parentMap;
protected MapTilePyramidLayer()
protected TilePyramidLayer()
{
IsHitTestVisible = false;

View file

@ -7,6 +7,8 @@ using System.Windows.Media;
using Windows.UI.Xaml.Media;
#elif WINUI
using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using ImageSource = Avalonia.Media.IImage;
#endif
namespace MapControl
@ -20,6 +22,28 @@ namespace MapControl
[System.ComponentModel.TypeConverter(typeof(TileSourceConverter))]
#endif
public class TileSource
{
/// <summary>
/// Gets the image request Uri for the specified zoom level and tile indices.
/// May return null when the image shall be loaded by the LoadImageAsync method.
/// </summary>
public virtual Uri GetUri(int zoomLevel, int column, int row) => null;
/// <summary>
/// Loads a tile image without an Uri.
/// </summary>
public virtual Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row) => null;
/// <summary>
/// Creates a TileSource instance from an Uri template string.
/// </summary>
public static TileSource Parse(string uriTemplate)
{
return new UriTileSource { UriTemplate = uriTemplate };
}
}
public class UriTileSource : TileSource
{
private string uriTemplate;
@ -35,20 +59,14 @@ namespace MapControl
if (uriTemplate != null && uriTemplate.Contains("{s}") && Subdomains == null)
{
Subdomains = new string[] { "a", "b", "c" }; // default OpenStreetMap subdomains
Subdomains = ["a", "b", "c"]; // default OpenStreetMap subdomains
}
}
}
/// <summary>
/// Gets or sets an array of request subdomain names that are replaced for the {s} format specifier.
/// </summary>
public string[] Subdomains { get; set; }
/// <summary>
/// Gets the image Uri for the specified tile indices and zoom level.
/// </summary>
public virtual Uri GetUri(int zoomLevel, int column, int row)
public override Uri GetUri(int zoomLevel, int column, int row)
{
Uri uri = null;
@ -71,41 +89,13 @@ namespace MapControl
return uri;
}
/// <summary>
/// Loads a tile ImageSource asynchronously from GetUri(zoomLevel, column, row).
/// This method is called by TileImageLoader when caching is disabled.
/// </summary>
public virtual Task<ImageSource> LoadImageAsync(int zoomLevel, int column, int row)
{
var uri = GetUri(zoomLevel, column, row);
return uri != null ? ImageLoader.LoadImageAsync(uri) : Task.FromResult((ImageSource)null);
}
/// <summary>
/// Loads a tile ImageSource asynchronously from an encoded frame buffer in a byte array.
/// This method is called by TileImageLoader when caching is enabled.
/// </summary>
public virtual Task<ImageSource> LoadImageAsync(byte[] buffer)
{
return ImageLoader.LoadImageAsync(buffer);
}
public override string ToString()
{
return UriTemplate;
}
/// <summary>
/// Creates a TileSource instance from an Uri template string.
/// </summary>
public static TileSource Parse(string uriTemplate)
{
return new TileSource { UriTemplate = uriTemplate };
}
}
public class TmsTileSource : TileSource
public class TmsTileSource : UriTileSource
{
public override Uri GetUri(int zoomLevel, int column, int row)
{

View file

@ -110,7 +110,7 @@ namespace MapControl
}
}
public class MapProjectionConverter : TypeConverter, IValueConverter
public partial class MapProjectionConverter : TypeConverter, IValueConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{

View file

@ -17,6 +17,7 @@ using Microsoft.UI.Xaml.Media;
#elif AVALONIA
using Avalonia;
using Avalonia.Interactivity;
using ImageSource = Avalonia.Media.IImage;
#endif
namespace MapControl

View file

@ -20,7 +20,7 @@ namespace MapControl
/// <summary>
/// Displays map tiles from a Web Map Tile Service (WMTS).
/// </summary>
public partial class WmtsTileLayer : MapTilePyramidLayer
public partial class WmtsTileLayer : TilePyramidLayer
{
private static ILogger logger;
private static ILogger Logger => logger ??= ImageLoader.LoggerFactory?.CreateLogger(typeof(WmtsTileLayer));

View file

@ -35,7 +35,7 @@ namespace MapControl
public TileMatrix TileMatrix { get; private set; }
public TileCollection Tiles { get; private set; } = [];
public ImageTileList Tiles { get; private set; } = [];
public void UpdateRenderTransform(ViewTransform viewTransform)
{
@ -85,7 +85,7 @@ namespace MapControl
TileMatrix = new TileMatrix(TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax);
var tiles = new TileCollection();
var tiles = new ImageTileList();
tiles.FillMatrix(Tiles, TileMatrix.ZoomLevel, xMin, yMin, xMax, yMax, WmtsTileMatrix.MatrixWidth);
Tiles = tiles;

View file

@ -3,7 +3,7 @@ using System.Text;
namespace MapControl
{
public class WmtsTileSource : TileSource
public class WmtsTileSource : UriTileSource
{
public WmtsTileMatrixSet TileMatrixSet { get; set; }

View file

@ -8,9 +8,12 @@ using System.Windows.Media.Imaging;
namespace MapControl
{
public partial class Tile
public class ImageTile(int zoomLevel, int x, int y, int columnCount)
: Tile(zoomLevel, x, y, columnCount)
{
public async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
public Image Image { get; } = new Image { Stretch = Stretch.Fill };
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{
var image = await loadImageFunc().ConfigureAwait(false);

View file

@ -3,12 +3,14 @@ using System.Threading.Tasks;
#if UWP
using Windows.UI.Core;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Animation;
using Windows.UI.Xaml.Media.Imaging;
#else
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Media.Imaging;
@ -16,9 +18,12 @@ using Microsoft.UI.Xaml.Media.Imaging;
namespace MapControl
{
public partial class Tile
public class ImageTile(int zoomLevel, int x, int y, int columnCount)
: Tile(zoomLevel, x, y, columnCount)
{
public async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
public Image Image { get; } = new Image { Stretch = Stretch.Fill };
public override async Task LoadImageAsync(Func<Task<ImageSource>> loadImageFunc)
{
var tcs = new TaskCompletionSource<object>();