// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Hosting; /// /// Renders MAUI views to Skia platform views. /// Handles the conversion of the view hierarchy. /// public class LinuxViewRenderer { private readonly IMauiContext _mauiContext; /// /// Static reference to the current MAUI Shell for navigation support. /// Used when Shell.Current is not available through normal lifecycle. /// public static Shell? CurrentMauiShell { get; private set; } /// /// Static reference to the current SkiaShell for navigation updates. /// public static SkiaShell? CurrentSkiaShell { get; private set; } /// /// Navigate to a route using the SkiaShell directly. /// Use this instead of Shell.Current.GoToAsync on Linux. /// /// The route to navigate to (e.g., "Buttons" or "//Buttons") /// True if navigation succeeded public static bool NavigateToRoute(string route) { if (CurrentSkiaShell == null) { Console.WriteLine($"[NavigateToRoute] CurrentSkiaShell is null"); return false; } // Clean up the route - remove leading // or / var cleanRoute = route.TrimStart('/'); Console.WriteLine($"[NavigateToRoute] Navigating to: {cleanRoute}"); for (int i = 0; i < CurrentSkiaShell.Sections.Count; i++) { var section = CurrentSkiaShell.Sections[i]; if (section.Route.Equals(cleanRoute, StringComparison.OrdinalIgnoreCase) || section.Title.Equals(cleanRoute, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"[NavigateToRoute] Found section {i}: {section.Title}"); CurrentSkiaShell.NavigateToSection(i); return true; } } Console.WriteLine($"[NavigateToRoute] Route not found: {cleanRoute}"); return false; } /// /// Current renderer instance for page rendering. /// public static LinuxViewRenderer? CurrentRenderer { get; set; } /// /// Pushes a page onto the navigation stack. /// /// The page to push /// True if successful public static bool PushPage(Page page) { Console.WriteLine($"[PushPage] Pushing page: {page.GetType().Name}"); if (CurrentSkiaShell == null) { Console.WriteLine($"[PushPage] CurrentSkiaShell is null"); return false; } if (CurrentRenderer == null) { Console.WriteLine($"[PushPage] CurrentRenderer is null"); return false; } try { // Render the page content SkiaView? pageContent = null; if (page is ContentPage contentPage && contentPage.Content != null) { pageContent = CurrentRenderer.RenderView(contentPage.Content); } if (pageContent == null) { Console.WriteLine($"[PushPage] Failed to render page content"); return false; } // Wrap in ScrollView if needed if (pageContent is not SkiaScrollView) { var scrollView = new SkiaScrollView { Content = pageContent }; pageContent = scrollView; } // Push onto SkiaShell's navigation stack CurrentSkiaShell.PushAsync(pageContent, page.Title ?? "Detail"); Console.WriteLine($"[PushPage] Successfully pushed page"); return true; } catch (Exception ex) { Console.WriteLine($"[PushPage] Error: {ex.Message}"); return false; } } /// /// Pops the current page from the navigation stack. /// /// True if successful public static bool PopPage() { Console.WriteLine($"[PopPage] Popping page"); if (CurrentSkiaShell == null) { Console.WriteLine($"[PopPage] CurrentSkiaShell is null"); return false; } return CurrentSkiaShell.PopAsync(); } public LinuxViewRenderer(IMauiContext mauiContext) { _mauiContext = mauiContext ?? throw new ArgumentNullException(nameof(mauiContext)); // Store reference for push/pop navigation CurrentRenderer = this; } /// /// Renders a MAUI page and returns the corresponding SkiaView. /// public SkiaView? RenderPage(Page page) { if (page == null) return null; // Special handling for Shell - Shell is our navigation container if (page is Shell shell) { return RenderShell(shell); } // Set handler context page.Handler?.DisconnectHandler(); var handler = page.ToHandler(_mauiContext); if (handler.PlatformView is SkiaView skiaPage) { // For ContentPage, render the content if (page is ContentPage contentPage && contentPage.Content != null) { var contentView = RenderView(contentPage.Content); if (skiaPage is SkiaPage sp && contentView != null) { sp.Content = contentView; } } return skiaPage; } return null; } /// /// Renders a MAUI Shell with all its navigation structure. /// private SkiaShell RenderShell(Shell shell) { // Store reference for navigation - Shell.Current is computed from Application.Current.Windows // Our platform handles navigation through SkiaShell directly CurrentMauiShell = shell; var skiaShell = new SkiaShell { Title = shell.Title ?? "App", FlyoutBehavior = shell.FlyoutBehavior switch { FlyoutBehavior.Flyout => ShellFlyoutBehavior.Flyout, FlyoutBehavior.Locked => ShellFlyoutBehavior.Locked, FlyoutBehavior.Disabled => ShellFlyoutBehavior.Disabled, _ => ShellFlyoutBehavior.Flyout }, MauiShell = shell }; // Apply shell colors based on theme ApplyShellColors(skiaShell, shell); // Render flyout header if present if (shell.FlyoutHeader is View headerView) { var skiaHeader = RenderView(headerView); if (skiaHeader != null) { skiaShell.FlyoutHeaderView = skiaHeader; skiaShell.FlyoutHeaderHeight = (float)(headerView.HeightRequest > 0 ? headerView.HeightRequest : 140.0); } } // Set flyout footer with version info 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) { ProcessShellItem(skiaShell, item); } // Store reference to SkiaShell for navigation CurrentSkiaShell = skiaShell; // Set up content renderer and color refresher delegates skiaShell.ContentRenderer = CreateShellContentPage; skiaShell.ColorRefresher = ApplyShellColors; // Subscribe to MAUI Shell navigation events to update SkiaShell shell.Navigated += OnShellNavigated; shell.Navigating += (s, e) => Console.WriteLine($"[Navigation] Navigating: {e.Target}"); Console.WriteLine($"[Navigation] Shell navigation events subscribed. Sections: {skiaShell.Sections.Count}"); for (int i = 0; i < skiaShell.Sections.Count; i++) { Console.WriteLine($"[Navigation] Section {i}: Route='{skiaShell.Sections[i].Route}', Title='{skiaShell.Sections[i].Title}'"); } return skiaShell; } /// /// Applies shell colors based on the current theme (dark/light mode). /// private static void ApplyShellColors(SkiaShell skiaShell, Shell shell) { bool isDark = Application.Current?.UserAppTheme == AppTheme.Dark; Console.WriteLine($"[ApplyShellColors] Theme is: {(isDark ? "Dark" : "Light")}"); // Flyout background color if (shell.FlyoutBackgroundColor != null && shell.FlyoutBackgroundColor != Colors.Transparent) { skiaShell.FlyoutBackgroundColor = shell.FlyoutBackgroundColor; Console.WriteLine($"[ApplyShellColors] FlyoutBackgroundColor from MAUI: {skiaShell.FlyoutBackgroundColor}"); } else { skiaShell.FlyoutBackgroundColor = isDark ? Color.FromRgb(30, 30, 30) : Color.FromRgb(255, 255, 255); Console.WriteLine($"[ApplyShellColors] Using default FlyoutBackgroundColor: {skiaShell.FlyoutBackgroundColor}"); } // Flyout text color skiaShell.FlyoutTextColor = isDark ? Color.FromRgb(224, 224, 224) : Color.FromRgb(33, 33, 33); Console.WriteLine($"[ApplyShellColors] FlyoutTextColor: {skiaShell.FlyoutTextColor}"); // Content background color skiaShell.ContentBackgroundColor = isDark ? Color.FromRgb(18, 18, 18) : Color.FromRgb(250, 250, 250); Console.WriteLine($"[ApplyShellColors] ContentBackgroundColor: {skiaShell.ContentBackgroundColor}"); // NavBar background color if (shell.BackgroundColor != null && shell.BackgroundColor != Colors.Transparent) { skiaShell.NavBarBackgroundColor = shell.BackgroundColor; } else { skiaShell.NavBarBackgroundColor = Color.FromRgb(33, 150, 243); // Material blue } } /// /// Handles MAUI Shell navigation events and updates SkiaShell accordingly. /// private static void OnShellNavigated(object? sender, ShellNavigatedEventArgs e) { Console.WriteLine($"[Navigation] OnShellNavigated called - Source: {e.Source}, Current: {e.Current?.Location}, Previous: {e.Previous?.Location}"); if (CurrentSkiaShell == null || CurrentMauiShell == null) { Console.WriteLine($"[Navigation] CurrentSkiaShell or CurrentMauiShell is null"); return; } // Get the current route from the Shell var currentState = CurrentMauiShell.CurrentState; var location = currentState?.Location?.OriginalString ?? ""; Console.WriteLine($"[Navigation] Location: {location}, Sections: {CurrentSkiaShell.Sections.Count}"); // Find the matching section in SkiaShell by route for (int i = 0; i < CurrentSkiaShell.Sections.Count; i++) { var section = CurrentSkiaShell.Sections[i]; Console.WriteLine($"[Navigation] Checking section {i}: Route='{section.Route}', Title='{section.Title}'"); if (!string.IsNullOrEmpty(section.Route) && location.Contains(section.Route, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"[Navigation] Match found by route! Navigating to section {i}"); if (i != CurrentSkiaShell.CurrentSectionIndex) { CurrentSkiaShell.NavigateToSection(i); } return; } if (!string.IsNullOrEmpty(section.Title) && location.Contains(section.Title, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"[Navigation] Match found by title! Navigating to section {i}"); if (i != CurrentSkiaShell.CurrentSectionIndex) { CurrentSkiaShell.NavigateToSection(i); } return; } } Console.WriteLine($"[Navigation] No matching section found for location: {location}"); } /// /// Process a ShellItem (FlyoutItem, TabBar, etc.) into SkiaShell sections. /// private void ProcessShellItem(SkiaShell skiaShell, ShellItem item) { if (item is FlyoutItem flyoutItem) { // Each FlyoutItem becomes a section var section = new ShellSection { Title = flyoutItem.Title ?? "", Route = flyoutItem.Route ?? flyoutItem.Title ?? "" }; // Process the items within the FlyoutItem foreach (var shellSection in flyoutItem.Items) { foreach (var content in shellSection.Items) { var shellContent = new ShellContent { Title = content.Title ?? shellSection.Title ?? flyoutItem.Title ?? "", Route = content.Route ?? "", MauiShellContent = content }; // Create the page content var pageContent = CreateShellContentPage(content); if (pageContent != null) { shellContent.Content = pageContent; } section.Items.Add(shellContent); } } // If there's only one item, use it as the main section content if (section.Items.Count == 1) { section.Title = section.Items[0].Title; } skiaShell.AddSection(section); } else if (item is TabBar tabBar) { // TabBar items get their own sections foreach (var tab in tabBar.Items) { var section = new ShellSection { Title = tab.Title ?? "", Route = tab.Route ?? "" }; foreach (var content in tab.Items) { var shellContent = new ShellContent { Title = content.Title ?? tab.Title ?? "", Route = content.Route ?? "", MauiShellContent = content }; var pageContent = CreateShellContentPage(content); if (pageContent != null) { shellContent.Content = pageContent; } section.Items.Add(shellContent); } skiaShell.AddSection(section); } } else { // Generic ShellItem var section = new ShellSection { Title = item.Title ?? "", Route = item.Route ?? "" }; foreach (var shellSection in item.Items) { foreach (var content in shellSection.Items) { var shellContent = new ShellContent { Title = content.Title ?? "", Route = content.Route ?? "", MauiShellContent = content }; var pageContent = CreateShellContentPage(content); if (pageContent != null) { shellContent.Content = pageContent; } section.Items.Add(shellContent); } } skiaShell.AddSection(section); } } /// /// Creates the page content for a ShellContent. /// private SkiaView? CreateShellContentPage(Controls.ShellContent content) { try { // Try to create the page from the content template Page? page = null; if (content.ContentTemplate != null) { page = content.ContentTemplate.CreateContent() as Page; } if (page == null && content.Content is Page contentPage) { page = contentPage; } if (page is ContentPage cp && cp.Content != null) { // Wrap in a scroll view if not already scrollable var contentView = RenderView(cp.Content); if (contentView != null) { // Get page background color if set Color? bgColor = null; if (cp.BackgroundColor != null && cp.BackgroundColor != Colors.Transparent) { bgColor = cp.BackgroundColor; Console.WriteLine($"[CreateShellContentPage] Page BackgroundColor: {bgColor}"); } if (contentView is SkiaScrollView scrollView) { if (bgColor != null) { scrollView.BackgroundColor = bgColor; } return scrollView; } else { var newScrollView = new SkiaScrollView { Content = contentView }; if (bgColor != null) { newScrollView.BackgroundColor = bgColor; } return newScrollView; } } } } catch (Exception) { // Silently handle template creation errors } return null; } /// /// Renders a MAUI view and returns the corresponding SkiaView. /// public SkiaView? RenderView(IView view) { if (view == null) return null; try { // Disconnect any existing handler if (view is Element element && element.Handler != null) { element.Handler.DisconnectHandler(); } // Create handler for the view // The handler's ConnectHandler and property mappers handle child views automatically var handler = view.ToHandler(_mauiContext); if (handler?.PlatformView is not SkiaView skiaView) { // If no Skia handler, create a fallback return CreateFallbackView(view); } // Handlers manage their own children via ConnectHandler and property mappers // No manual child rendering needed here - that caused "View already has a parent" errors return skiaView; } catch (Exception) { return CreateFallbackView(view); } } /// /// Creates a fallback view for unsupported view types. /// private SkiaView CreateFallbackView(IView view) { // For views without handlers, create a placeholder return new SkiaLabel { Text = $"[{view.GetType().Name}]", TextColor = Colors.Gray, FontSize = 12 }; } }