diff --git a/Handlers/ImageHandler.cs b/Handlers/ImageHandler.cs index 6a1cad7..a961e2b 100644 --- a/Handlers/ImageHandler.cs +++ b/Handlers/ImageHandler.cs @@ -24,6 +24,8 @@ public partial class ImageHandler : ViewHandler [nameof(IView.Background)] = MapBackground, ["Width"] = MapWidth, ["Height"] = MapHeight, + ["HorizontalOptions"] = MapHorizontalOptions, + ["VerticalOptions"] = MapVerticalOptions, }; public static CommandMapper CommandMapper = new(ViewHandler.ViewCommandMapper) @@ -148,6 +150,26 @@ public partial class ImageHandler : ViewHandler } } + public static void MapHorizontalOptions(ImageHandler handler, IImage image) + { + if (handler.PlatformView is null) return; + + if (image is Image img) + { + handler.PlatformView.HorizontalOptions = img.HorizontalOptions; + } + } + + public static void MapVerticalOptions(ImageHandler handler, IImage image) + { + if (handler.PlatformView is null) return; + + if (image is Image img) + { + handler.PlatformView.VerticalOptions = img.VerticalOptions; + } + } + // Image source loading helper private ImageSourceServiceResultManager _sourceLoader = null!; diff --git a/Hosting/LinuxViewRenderer.cs b/Hosting/LinuxViewRenderer.cs index b206fba..38f79dc 100644 --- a/Hosting/LinuxViewRenderer.cs +++ b/Hosting/LinuxViewRenderer.cs @@ -201,9 +201,22 @@ public class LinuxViewRenderer } } - // Set flyout footer with version info - var version = Assembly.GetEntryAssembly()?.GetName().Version; - skiaShell.FlyoutFooterText = $"Version {version?.Major ?? 1}.{version?.Minor ?? 0}.{version?.Build ?? 0}"; + // Render flyout footer if present, otherwise use version text + if (shell.FlyoutFooter is View footerView) + { + var skiaFooter = RenderView(footerView); + if (skiaFooter != null) + { + skiaShell.FlyoutFooterView = skiaFooter; + skiaShell.FlyoutFooterHeight = (float)(footerView.HeightRequest > 0 ? footerView.HeightRequest : 40.0); + } + } + else + { + // Fallback: use assembly version as footer text + var version = Assembly.GetEntryAssembly()?.GetName().Version; + skiaShell.FlyoutFooterText = $"Version {version?.Major ?? 1}.{version?.Minor ?? 0}.{version?.Build ?? 0}"; + } // Process shell items into sections foreach (var item in shell.Items) diff --git a/Views/SkiaImage.cs b/Views/SkiaImage.cs index 08ed712..4932b1d 100644 --- a/Views/SkiaImage.cs +++ b/Views/SkiaImage.cs @@ -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 searchPaths = new List { 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); } + + /// + /// Tries to load an image from embedded resources. + /// Follows MAUI convention: XAML references .png, but source can be .svg + /// + 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); + } + + /// + /// Loads image from stream with caching support. + /// + 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(); + } } /// diff --git a/Views/SkiaLayoutView.cs b/Views/SkiaLayoutView.cs index 6634eb0..6ea5574 100644 --- a/Views/SkiaLayoutView.cs +++ b/Views/SkiaLayoutView.cs @@ -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; } diff --git a/Views/SkiaShell.cs b/Views/SkiaShell.cs index 9f2729a..a9f5cf3 100644 --- a/Views/SkiaShell.cs +++ b/Views/SkiaShell.cs @@ -298,10 +298,15 @@ public class SkiaShell : SkiaLayoutView public float FlyoutHeaderHeight { get; set; } = 140f; /// - /// Optional footer text in the flyout. + /// Optional footer text in the flyout (fallback if no FlyoutFooterView). /// public string? FlyoutFooterText { get; set; } + /// + /// Optional footer view in the flyout. + /// + public SkiaView? FlyoutFooterView { get; set; } + /// /// Height of the flyout footer. /// @@ -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; diff --git a/assets/logo_only.png b/assets/logo_only.png new file mode 100644 index 0000000..fa1d567 Binary files /dev/null and b/assets/logo_only.png differ diff --git a/assets/logo_only.svg b/assets/logo_only.svg new file mode 100644 index 0000000..e91fde5 --- /dev/null +++ b/assets/logo_only.svg @@ -0,0 +1,44 @@ + + + + + + + + +