Image fixes

This commit is contained in:
2026-01-24 03:18:08 +00:00
parent ed89b6494d
commit 433787ff80
7 changed files with 383 additions and 21 deletions

View File

@@ -461,6 +461,23 @@ public class SkiaImage : SkiaView
try
{
// First try to load from embedded resources (MAUI standard pattern)
// MAUI converts SVG to PNG at build time, referenced as .png in XAML
var (stream, actualExtension) = TryLoadFromEmbeddedResource(filePath);
if (stream != null)
{
_isSvg = actualExtension.Equals(".svg", StringComparison.OrdinalIgnoreCase);
_currentFilePath = filePath;
_cacheKey = $"embedded:{filePath}";
using (stream)
{
await LoadFromStreamWithCacheAsync(stream, _cacheKey);
}
return;
}
// Fall back to file system
List<string> searchPaths = new List<string>
{
filePath,
@@ -469,7 +486,8 @@ public class SkiaImage : SkiaView
Path.Combine(AppContext.BaseDirectory, "Resources", filePath)
};
// Also try SVG if looking for PNG
// Also try SVG if looking for PNG (MAUI converts SVG to PNG at build time,
// but on Linux we load SVG directly)
if (filePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
{
string svgPath = Path.ChangeExtension(filePath, ".svg");
@@ -495,6 +513,7 @@ public class SkiaImage : SkiaView
_isSvg = false;
_currentFilePath = null;
_cacheKey = null;
Console.WriteLine($"[SkiaImage] File not found: {filePath}");
ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(new FileNotFoundException(filePath)));
return;
}
@@ -1153,6 +1172,174 @@ public class SkiaImage : SkiaView
}
base.Dispose(disposing);
}
/// <summary>
/// Tries to load an image from embedded resources.
/// Follows MAUI convention: XAML references .png, but source can be .svg
/// </summary>
private static (Stream? stream, string extension) TryLoadFromEmbeddedResource(string filePath)
{
// Get the file name without path
string fileName = Path.GetFileName(filePath);
string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
string requestedExt = Path.GetExtension(fileName);
// Search all loaded assemblies for the resource
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.ToList();
// Also include entry assembly
var entryAssembly = System.Reflection.Assembly.GetEntryAssembly();
if (entryAssembly != null && !assemblies.Contains(entryAssembly))
{
assemblies.Insert(0, entryAssembly);
}
foreach (var assembly in assemblies)
{
try
{
var resourceNames = assembly.GetManifestResourceNames();
// Try exact match first
foreach (var resourceName in resourceNames)
{
if (resourceName.EndsWith(fileName, StringComparison.OrdinalIgnoreCase))
{
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
Console.WriteLine($"[SkiaImage] Loaded embedded resource: {resourceName}");
return (stream, requestedExt);
}
}
}
// If looking for .png, also try .svg (MAUI pattern)
if (requestedExt.Equals(".png", StringComparison.OrdinalIgnoreCase))
{
string svgFileName = fileNameWithoutExt + ".svg";
foreach (var resourceName in resourceNames)
{
if (resourceName.EndsWith(svgFileName, StringComparison.OrdinalIgnoreCase))
{
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
Console.WriteLine($"[SkiaImage] Loaded SVG as PNG substitute: {resourceName}");
return (stream, ".svg");
}
}
}
}
}
catch
{
// Skip assemblies that can't be inspected
}
}
return (null, string.Empty);
}
/// <summary>
/// Loads image from stream with caching support.
/// </summary>
private async Task LoadFromStreamWithCacheAsync(Stream stream, string cacheKey)
{
// Check cache first
if (_imageCache.TryGetValue(cacheKey, out var cached))
{
cached.LastAccessed = DateTime.UtcNow;
if (cached.IsAnimated && cached.Frames != null)
{
_isAnimatedImage = true;
_animationFrames = cached.Frames;
_currentFrameIndex = 0;
if (cached.Frames.Count > 0 && cached.Frames[0].Bitmap != null)
{
_image?.Dispose();
_image = SKImage.FromBitmap(cached.Frames[0].Bitmap);
}
if (IsAnimationPlaying)
{
StartAnimation();
}
}
else if (cached.Bitmap != null)
{
_isAnimatedImage = false;
_bitmap = cached.Bitmap;
_image?.Dispose();
_image = SKImage.FromBitmap(cached.Bitmap);
}
_isLoading = false;
ImageLoaded?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
// Load from stream
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
if (_isSvg)
{
// Load SVG using Svg.Skia
await Task.Run(() =>
{
using var svg = new SKSvg();
svg.Load(memoryStream);
if (svg.Picture != null)
{
var cullRect = svg.Picture.CullRect;
int width = (int)(WidthRequest > 0 ? WidthRequest : cullRect.Width);
int height = (int)(HeightRequest > 0 ? HeightRequest : cullRect.Height);
if (width <= 0) width = 64;
if (height <= 0) height = 64;
float scale = Math.Min(width / cullRect.Width, height / cullRect.Height);
int bitmapWidth = Math.Max(1, (int)(cullRect.Width * scale));
int bitmapHeight = Math.Max(1, (int)(cullRect.Height * scale));
var bitmap = new SKBitmap(bitmapWidth, bitmapHeight, false);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Transparent);
canvas.Scale(scale);
// Translate to handle negative viewBox coordinates
canvas.Translate(-cullRect.Left, -cullRect.Top);
canvas.DrawPicture(svg.Picture, null);
CacheAndSetBitmap(cacheKey, bitmap, false);
}
});
}
else
{
// Load raster image
memoryStream.Position = 0;
await Task.Run(() =>
{
using var codec = SKCodec.Create(memoryStream);
if (codec != null)
{
var bitmap = SKBitmap.Decode(codec, codec.Info);
if (bitmap != null)
{
CacheAndSetBitmap(cacheKey, bitmap, false);
}
}
});
}
_isLoading = false;
ImageLoaded?.Invoke(this, EventArgs.Empty);
Invalidate();
}
}
/// <summary>

View File

@@ -443,9 +443,30 @@ public class SkiaStackLayout : SkiaLayoutView
? remainingHeight
: Math.Min(childHeight, remainingHeight > 0 ? remainingHeight : childHeight);
childBoundsLeft = content.Left;
// Respect child's HorizontalOptions for vertical layouts
var useWidth = Math.Min(childWidth, contentWidth);
float childLeft = content.Left;
var horizontalOptions = child.HorizontalOptions;
var alignmentValue = (int)horizontalOptions.Alignment;
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
if (alignmentValue == 1) // Center
{
childLeft = content.Left + (contentWidth - useWidth) / 2;
}
else if (alignmentValue == 2) // End
{
childLeft = content.Left + contentWidth - useWidth;
}
else if (alignmentValue == 3) // Fill
{
useWidth = contentWidth;
}
childBoundsLeft = childLeft;
childBoundsTop = content.Top + offset;
childBoundsWidth = contentWidth;
childBoundsWidth = useWidth;
childBoundsHeight = useHeight;
offset += useHeight + (float)Spacing;
}

View File

@@ -298,10 +298,15 @@ public class SkiaShell : SkiaLayoutView
public float FlyoutHeaderHeight { get; set; } = 140f;
/// <summary>
/// Optional footer text in the flyout.
/// Optional footer text in the flyout (fallback if no FlyoutFooterView).
/// </summary>
public string? FlyoutFooterText { get; set; }
/// <summary>
/// Optional footer view in the flyout.
/// </summary>
public SkiaView? FlyoutFooterView { get; set; }
/// <summary>
/// Height of the flyout footer.
/// </summary>
@@ -972,9 +977,31 @@ public class SkiaShell : SkiaLayoutView
};
canvas.DrawRect(flyoutBounds, flyoutPaint);
// Draw flyout items
float itemY = flyoutBounds.Top + 80;
// Calculate header and footer heights
float headerHeight = FlyoutHeaderView != null ? FlyoutHeaderHeight : 0f;
float footerHeight = FlyoutFooterView != null ? FlyoutFooterHeight :
(!string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f);
// Draw flyout header if present
if (FlyoutHeaderView != null)
{
var headerBounds = new SKRect(flyoutBounds.Left, flyoutBounds.Top, flyoutBounds.Right, flyoutBounds.Top + headerHeight);
FlyoutHeaderView.Measure(new Size(headerBounds.Width, headerBounds.Height));
FlyoutHeaderView.Arrange(new Rect(headerBounds.Left, headerBounds.Top, headerBounds.Width, headerBounds.Height));
FlyoutHeaderView.Draw(canvas);
}
// Draw flyout items with scrolling support
float itemHeight = 48f;
float itemsAreaTop = flyoutBounds.Top + headerHeight;
float itemsAreaBottom = flyoutBounds.Bottom - footerHeight;
// Clip to items area (between header and footer)
canvas.Save();
canvas.ClipRect(new SKRect(flyoutBounds.Left, itemsAreaTop, flyoutBounds.Right, itemsAreaBottom));
// Apply scroll offset
float itemY = itemsAreaTop - _flyoutScrollOffset;
using var itemTextPaint = new SKPaint
{
@@ -987,6 +1014,17 @@ public class SkiaShell : SkiaLayoutView
var section = _sections[i];
bool isSelected = i == _selectedSectionIndex;
// Skip items that are scrolled above the visible area
if (itemY + itemHeight < itemsAreaTop)
{
itemY += itemHeight;
continue;
}
// Stop if we're below the visible area
if (itemY > itemsAreaBottom)
break;
// Draw selection background
if (isSelected)
{
@@ -1004,6 +1042,29 @@ public class SkiaShell : SkiaLayoutView
itemY += itemHeight;
}
canvas.Restore();
// Draw flyout footer
if (FlyoutFooterView != null)
{
var footerBounds = new SKRect(flyoutBounds.Left, flyoutBounds.Bottom - footerHeight, flyoutBounds.Right, flyoutBounds.Bottom);
FlyoutFooterView.Measure(new Size(footerBounds.Width, footerBounds.Height));
FlyoutFooterView.Arrange(new Rect(footerBounds.Left, footerBounds.Top, footerBounds.Width, footerBounds.Height));
FlyoutFooterView.Draw(canvas);
}
else if (!string.IsNullOrEmpty(FlyoutFooterText))
{
// Fallback: draw simple text footer
using var footerPaint = new SKPaint
{
TextSize = 12f,
Color = _flyoutTextColorSK.WithAlpha(180),
IsAntialias = true
};
var footerY = flyoutBounds.Bottom - footerHeight / 2 + 4;
canvas.DrawText(FlyoutFooterText, flyoutBounds.Left + 16, footerY, footerPaint);
}
}
public override SkiaView? HitTest(float x, float y)
@@ -1062,20 +1123,33 @@ public class SkiaShell : SkiaLayoutView
if (flyoutBounds.Contains(e.X, e.Y))
{
// Check which section was tapped
float itemY = flyoutBounds.Top + 80;
float itemHeight = 48f;
// Calculate header and footer heights
float headerHeight = FlyoutHeaderView != null ? FlyoutHeaderHeight : 0f;
float footerHeight = FlyoutFooterView != null ? FlyoutFooterHeight :
(!string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f);
for (int i = 0; i < _sections.Count; i++)
float itemsAreaTop = flyoutBounds.Top + headerHeight;
float itemsAreaBottom = flyoutBounds.Bottom - footerHeight;
// Only check items if tap is in items area
if (e.Y >= itemsAreaTop && e.Y < itemsAreaBottom)
{
if (e.Y >= itemY && e.Y < itemY + itemHeight)
// Apply scroll offset to find which item was tapped
float itemY = itemsAreaTop - _flyoutScrollOffset;
float itemHeight = 48f;
for (int i = 0; i < _sections.Count; i++)
{
NavigateToSection(i, 0);
FlyoutIsPresented = false;
e.Handled = true;
return;
if (e.Y >= itemY && e.Y < itemY + itemHeight)
{
NavigateToSection(i, 0);
FlyoutIsPresented = false;
_flyoutScrollOffset = 0; // Reset scroll when closing
e.Handled = true;
return;
}
itemY += itemHeight;
}
itemY += itemHeight;
}
}
else if (FlyoutIsPresented)
@@ -1138,13 +1212,14 @@ public class SkiaShell : SkiaLayoutView
if (flyoutBounds.Contains(e.X, e.Y))
{
float headerHeight = FlyoutHeaderView != null ? FlyoutHeaderHeight : 0f;
float footerHeight = !string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f;
float footerHeight = FlyoutFooterView != null ? FlyoutFooterHeight :
(!string.IsNullOrEmpty(FlyoutFooterText) ? FlyoutFooterHeight : 0f);
float itemHeight = 48f;
float totalItemsHeight = _sections.Count * itemHeight;
float viewableHeight = flyoutBounds.Height - headerHeight - footerHeight;
float maxScroll = Math.Max(0f, totalItemsHeight - viewableHeight);
_flyoutScrollOffset -= e.DeltaY * 30f;
_flyoutScrollOffset += e.DeltaY * 30f;
_flyoutScrollOffset = Math.Max(0f, Math.Min(_flyoutScrollOffset, maxScroll));
Invalidate();
e.Handled = true;