From 999a1b4626088125fdb48c493feba608d8c5f159 Mon Sep 17 00:00:00 2001 From: Dave Friedel Date: Wed, 17 Dec 2025 01:33:29 -0500 Subject: [PATCH] Shell Navigation & Animation Fixes --- .../Animations/AnimationHelper.cs | 45 +- MarketAlly.MASpotlightTour/CalloutCard.cs | 33 +- .../DefaultStepScanner.cs | 779 +++++++++++++++++- MarketAlly.MASpotlightTour/InlineLabel.cs | 28 +- .../MASpotlightTour.Maui.csproj | 15 +- MarketAlly.MASpotlightTour/OnboardingHost.cs | 161 +++- 6 files changed, 1007 insertions(+), 54 deletions(-) diff --git a/MarketAlly.MASpotlightTour/Animations/AnimationHelper.cs b/MarketAlly.MASpotlightTour/Animations/AnimationHelper.cs index 1c14a15..51e791c 100644 --- a/MarketAlly.MASpotlightTour/Animations/AnimationHelper.cs +++ b/MarketAlly.MASpotlightTour/Animations/AnimationHelper.cs @@ -60,12 +60,18 @@ public static class AnimationHelper return; } + // Capture the target position BEFORE any modifications + // This preserves positions set by corner positioning, etc. + var targetX = element.TranslationX; + var targetY = element.TranslationY; + if (animation == EntranceAnimation.None || config.EntranceDurationMs == 0) { element.Opacity = 1; element.Scale = 1; - element.TranslationX = 0; - element.TranslationY = 0; + // Preserve the target position instead of resetting to 0 + element.TranslationX = targetX; + element.TranslationY = targetY; element.IsVisible = true; return; } @@ -73,8 +79,8 @@ public static class AnimationHelper var duration = config.EntranceDurationMs; var easing = ToMauiEasing(config.EntranceEasing); - // Set initial state - SetEntranceInitialState(element, animation, config); + // Set initial state (with offset from target position) + SetEntranceInitialState(element, animation, config, targetX, targetY); element.IsVisible = true; // Apply delay if configured @@ -85,16 +91,17 @@ public static class AnimationHelper cancellationToken.ThrowIfCancellationRequested(); - // Animate to final state - await AnimateEntranceAsync(element, animation, duration, easing, config); + // Animate to final state (the target position) + await AnimateEntranceAsync(element, animation, duration, easing, config, targetX, targetY); } - private static void SetEntranceInitialState(VisualElement element, EntranceAnimation animation, AnimationConfiguration config) + private static void SetEntranceInitialState(VisualElement element, EntranceAnimation animation, AnimationConfiguration config, double targetX, double targetY) { element.Opacity = 0; element.Scale = 1; - element.TranslationX = 0; - element.TranslationY = 0; + // Start at the target position (preserves corner positioning, etc.) + element.TranslationX = targetX; + element.TranslationY = targetY; element.RotationY = 0; switch (animation) @@ -104,24 +111,24 @@ public static class AnimationHelper break; case EntranceAnimation.SlideFromLeft: - element.TranslationX = -config.SlideDistance; + element.TranslationX = targetX - config.SlideDistance; break; case EntranceAnimation.SlideFromRight: - element.TranslationX = config.SlideDistance; + element.TranslationX = targetX + config.SlideDistance; break; case EntranceAnimation.SlideFromTop: - element.TranslationY = -config.SlideDistance; + element.TranslationY = targetY - config.SlideDistance; break; case EntranceAnimation.SlideFromBottom: case EntranceAnimation.SlideUpFade: - element.TranslationY = config.SlideDistance; + element.TranslationY = targetY + config.SlideDistance; break; case EntranceAnimation.SlideDownFade: - element.TranslationY = -config.SlideDistance; + element.TranslationY = targetY - config.SlideDistance; break; case EntranceAnimation.ScaleUp: @@ -149,7 +156,9 @@ public static class AnimationHelper EntranceAnimation animation, uint duration, Easing easing, - AnimationConfiguration config) + AnimationConfiguration config, + double targetX, + double targetY) { var tasks = new List(); @@ -162,19 +171,19 @@ public static class AnimationHelper case EntranceAnimation.SlideFromLeft: case EntranceAnimation.SlideFromRight: tasks.Add(element.FadeTo(1, duration, easing)); - tasks.Add(element.TranslateTo(0, element.TranslationY, duration, easing)); + tasks.Add(element.TranslateTo(targetX, targetY, duration, easing)); break; case EntranceAnimation.SlideFromTop: case EntranceAnimation.SlideFromBottom: tasks.Add(element.FadeTo(1, duration, easing)); - tasks.Add(element.TranslateTo(element.TranslationX, 0, duration, easing)); + tasks.Add(element.TranslateTo(targetX, targetY, duration, easing)); break; case EntranceAnimation.SlideUpFade: case EntranceAnimation.SlideDownFade: tasks.Add(element.FadeTo(1, duration, easing)); - tasks.Add(element.TranslateTo(0, 0, duration, easing)); + tasks.Add(element.TranslateTo(targetX, targetY, duration, easing)); break; case EntranceAnimation.ScaleUp: diff --git a/MarketAlly.MASpotlightTour/CalloutCard.cs b/MarketAlly.MASpotlightTour/CalloutCard.cs index 381cef3..1259e59 100644 --- a/MarketAlly.MASpotlightTour/CalloutCard.cs +++ b/MarketAlly.MASpotlightTour/CalloutCard.cs @@ -355,11 +355,21 @@ public class CalloutCard : Border return; } + // Guard against invalid container size - defer positioning + if (containerSize.Width <= 0 || containerSize.Height <= 0) + { + System.Diagnostics.Debug.WriteLine($"[CalloutCard] PositionInCorner: Invalid container size {containerSize}, deferring"); + // Store the corner for later positioning when we have valid dimensions + _pendingCorner = corner; + _pendingMargin = margin; + return; + } + var cardWidth = Math.Min(containerSize.Width * 0.85, 400); WidthRequest = cardWidth; var measured = Measure(cardWidth, double.PositiveInfinity); - var cardHeight = measured.Height; + var cardHeight = Math.Max(measured.Height, 100); // Ensure minimum height double x, y; @@ -389,6 +399,27 @@ public class CalloutCard : Border TranslationX = x; TranslationY = y; + + System.Diagnostics.Debug.WriteLine($"[CalloutCard] PositionInCorner: {corner}, container={containerSize}, pos=({x},{y})"); + } + + // For deferred corner positioning + private CalloutCorner? _pendingCorner; + private double _pendingMargin = 16; + + /// + /// Applies any pending corner position when container size becomes available. + /// Call this from OnboardingHost when layout changes. + /// + public void ApplyPendingCornerPosition(Size containerSize) + { + if (_pendingCorner.HasValue && containerSize.Width > 0 && containerSize.Height > 0) + { + var corner = _pendingCorner.Value; + var margin = _pendingMargin; + _pendingCorner = null; + PositionInCorner(corner, containerSize, margin); + } } /// diff --git a/MarketAlly.MASpotlightTour/DefaultStepScanner.cs b/MarketAlly.MASpotlightTour/DefaultStepScanner.cs index 5a3b62b..5fdcf17 100644 --- a/MarketAlly.MASpotlightTour/DefaultStepScanner.cs +++ b/MarketAlly.MASpotlightTour/DefaultStepScanner.cs @@ -1,3 +1,6 @@ +using System.Collections; +using System.Reflection; + namespace MarketAlly.SpotlightTour.Maui; /// @@ -10,25 +13,88 @@ public class DefaultStepScanner : IStepScanner /// public static DefaultStepScanner Instance { get; } = new(); + /// + /// Common property names that contain child elements in custom controls. + /// + private static readonly HashSet CommonItemsPropertyNames = new(StringComparer.OrdinalIgnoreCase) + { + "Items", + "Children", + "Content", + "MenuItems", + "ToolbarItems", + "PrimaryItems", + "SecondaryItems", + "Buttons", + "Controls", + "Elements", + "Views", + "Pages", + "Tabs", + "Panes", + "Panels", + "Sections" + }; + + /// + /// Whether to scan all IEnumerable properties for elements (more thorough but slower). + /// + public bool ScanAllCollectionProperties { get; set; } = true; + + /// + /// Enable debug logging to diagnose scanning issues. + /// + public bool EnableDebugLogging { get; set; } = false; + + /// + /// Cache mapping non-visual BindableObjects to their rendered VisualElement. + /// This is populated when scanning custom controls like MAToolbar. + /// + private readonly Dictionary _modelToViewCache = new(); + /// public IReadOnlyList FindSteps(Element root, string? group = null) { var steps = new List<(OnboardingStep Step, int TraversalOrder)>(); var traversalIndex = 0; + var visited = new HashSet(); + // Clear the model-to-view cache for fresh scan + _modelToViewCache.Clear(); + + if (EnableDebugLogging) + System.Diagnostics.Debug.WriteLine($"[Scanner] Starting scan from root: {root?.GetType().Name}"); + + // First pass: Build model-to-view mappings for custom controls (like MAToolbar) + BuildModelToViewMappings(root, visited); + + // Reset visited for main traversal + visited.Clear(); + + // Main pass: Find all onboarding steps TraverseVisualTreeExcludingHost(root, ve => { + if (EnableDebugLogging) + System.Diagnostics.Debug.WriteLine($"[Scanner] Checking: {ve.GetType().Name} (IsStep: {Onboarding.IsOnboardingStep(ve)})"); + if (!Onboarding.IsOnboardingStep(ve)) return; var step = OnboardingStep.FromElement(ve); + if (EnableDebugLogging) + System.Diagnostics.Debug.WriteLine($"[Scanner] Found step: {step.StepKey ?? step.Title ?? "unnamed"}"); + // Filter by group if specified if (group != null && !string.Equals(step.Group, group, StringComparison.OrdinalIgnoreCase)) return; steps.Add((step, traversalIndex++)); - }); + }, visited); + + // Second pass: Find steps on non-VisualElement items (like toolbar buttons) + visited.Clear(); + ScanNonVisualElementSteps(root, steps, ref traversalIndex, group, visited); // Sort by Order, then by traversal order (captured during collection) // This avoids the O(n²) bug of using IndexOf in the sort predicate @@ -40,6 +106,386 @@ public class DefaultStepScanner : IStepScanner .AsReadOnly(); } + /// + /// Builds a mapping from model objects (like ToolbarButton) to their rendered VisualElements. + /// This enables detecting onboarding steps on non-VisualElement items. + /// + private void BuildModelToViewMappings(Element root, HashSet visited) + { + if (root == null || visited.Contains(root)) + return; + + visited.Add(root); + + // Skip elements from cached Shell pages + if (root is VisualElement ve && !IsElementEffectivelyVisible(ve)) + return; + + // Check if this is a custom control with an Items collection (like MAToolbar) + if (root is VisualElement parentControl) + { + TryBuildMappingsForCustomControl(parentControl, visited); + } + + // Recurse into visual children + if (root is IVisualTreeElement vte) + { + foreach (var child in vte.GetVisualChildren()) + { + if (child is Element element) + BuildModelToViewMappings(element, visited); + } + } + } + + /// + /// Attempts to build model-to-view mappings for a custom control. + /// + private void TryBuildMappingsForCustomControl(VisualElement control, HashSet visited) + { + var type = control.GetType(); + + // Look for an "Items" property that contains model objects + var itemsProperty = type.GetProperty("Items", BindingFlags.Public | BindingFlags.Instance); + if (itemsProperty == null) + return; + + var items = itemsProperty.GetValue(control) as IEnumerable; + if (items == null) + return; + + // Get the visual children of this control + var visualChildren = new List(); + CollectAllVisualDescendants(control, visualChildren, visited); + + if (EnableDebugLogging) + System.Diagnostics.Debug.WriteLine($"[Scanner] Custom control {type.Name} has {visualChildren.Count} visual descendants"); + + // Try to match model items to visual children + var itemIndex = 0; + foreach (var item in items) + { + if (item is BindableObject bo && !(item is VisualElement)) + { + // This is a model object (like ToolbarButton) + var matchedView = TryFindRenderedViewForModel(bo, visualChildren, itemIndex); + + if (matchedView != null) + { + _modelToViewCache[bo] = matchedView; + + if (EnableDebugLogging) + { + var modelText = GetPropertyValue(bo, "Text") ?? GetPropertyValue(bo, "Name") ?? "unnamed"; + System.Diagnostics.Debug.WriteLine($"[Scanner] Mapped model '{modelText}' to view {matchedView.GetType().Name}"); + } + } + else if (EnableDebugLogging) + { + var modelText = GetPropertyValue(bo, "Text") ?? GetPropertyValue(bo, "Name") ?? "unnamed"; + System.Diagnostics.Debug.WriteLine($"[Scanner] Could not find rendered view for model '{modelText}'"); + } + } + + itemIndex++; + } + } + + /// + /// Collects all visual descendants of an element. + /// + private static void CollectAllVisualDescendants(VisualElement parent, List descendants, HashSet visited) + { + if (parent is not IVisualTreeElement vte) + return; + + foreach (var child in vte.GetVisualChildren()) + { + if (child is VisualElement ve && !visited.Contains(ve)) + { + visited.Add(ve); + descendants.Add(ve); + CollectAllVisualDescendants(ve, descendants, visited); + } + } + } + + /// + /// Tries to find the rendered VisualElement for a model object. + /// Uses various heuristics like matching Text, Name/AutomationId, or position. + /// + private VisualElement? TryFindRenderedViewForModel(BindableObject model, List visualChildren, int itemIndex) + { + // Strategy 1: Match by AutomationId (if model has Name property) + var modelName = GetPropertyValue(model, "Name"); + if (!string.IsNullOrEmpty(modelName)) + { + var match = visualChildren.FirstOrDefault(v => v.AutomationId == modelName); + if (match != null) + return match; + } + + // Strategy 2: Match by Text content + var modelText = GetPropertyValue(model, "Text"); + if (!string.IsNullOrEmpty(modelText)) + { + // Look for a control that contains this text (Button, Label, etc.) + foreach (var ve in visualChildren) + { + var veText = GetPropertyValue(ve, "Text"); + if (string.Equals(veText, modelText, StringComparison.Ordinal)) + return ve; + + // Also check if it's a container with a Label child + if (ve is IVisualTreeElement vte) + { + foreach (var child in vte.GetVisualChildren()) + { + if (child is Label label && label.Text == modelText) + return ve; // Return the container, not the label + } + } + } + } + + // Strategy 3: For items without clear identifiers, use BindingContext matching + foreach (var ve in visualChildren) + { + if (ve.BindingContext == model) + return ve; + } + + return null; + } + + /// + /// Scans for onboarding steps on non-VisualElement items (like ToolbarButton). + /// + private void ScanNonVisualElementSteps( + Element root, + List<(OnboardingStep Step, int TraversalOrder)> steps, + ref int traversalIndex, + string? group, + HashSet visited) + { + if (root == null || visited.Contains(root)) + return; + + visited.Add(root); + + // Skip elements from cached Shell pages + if (root is VisualElement ve && !IsElementEffectivelyVisible(ve)) + return; + + // Check collection properties for non-VisualElement items with onboarding properties + var type = root.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + try + { + if (property.GetIndexParameters().Length > 0) + continue; + + if (!CommonItemsPropertyNames.Contains(property.Name) && !ScanAllCollectionProperties) + continue; + + var value = property.GetValue(root); + if (value == null || visited.Contains(value)) + continue; + + if (value is IEnumerable enumerable && value is not string) + { + foreach (var item in enumerable) + { + if (item is BindableObject bo && !(item is VisualElement) && !visited.Contains(bo)) + { + visited.Add(bo); + + // Check if this item has onboarding properties + if (Onboarding.IsOnboardingStep(bo)) + { + if (EnableDebugLogging) + { + var itemText = GetPropertyValue(bo, "Text") ?? "unnamed"; + System.Diagnostics.Debug.WriteLine($"[Scanner] Found onboarding step on non-visual item: {bo.GetType().Name} '{itemText}'"); + } + + // Try to find the rendered view for this item + VisualElement? renderedView = null; + + // First check our cache + if (_modelToViewCache.TryGetValue(bo, out var cachedView)) + { + renderedView = cachedView; + } + else if (root is VisualElement parentVe) + { + // Try to find it in the parent's visual tree + var descendants = new List(); + CollectAllVisualDescendants(parentVe, descendants, new HashSet()); + renderedView = TryFindRenderedViewForModel(bo, descendants, 0); + } + + if (renderedView != null) + { + // Create step using the model's properties but targeting the rendered view + var step = CreateStepFromModelAndView(bo, renderedView); + + if (EnableDebugLogging) + System.Diagnostics.Debug.WriteLine($"[Scanner] Created step '{step.StepKey ?? step.Title}' targeting {renderedView.GetType().Name}"); + + // Filter by group if specified + if (group == null || string.Equals(step.Group, group, StringComparison.OrdinalIgnoreCase)) + { + steps.Add((step, traversalIndex++)); + } + } + else if (EnableDebugLogging) + { + System.Diagnostics.Debug.WriteLine($"[Scanner] WARNING: Could not find rendered view for onboarding item"); + } + } + + // Recursively scan this item's properties + ScanNonVisualElementBindableObject(bo, steps, ref traversalIndex, group, visited, root as VisualElement); + } + } + } + } + catch (Exception ex) + { + if (EnableDebugLogging) + System.Diagnostics.Debug.WriteLine($"[Scanner] Error scanning property {property.Name}: {ex.Message}"); + } + } + + // Recurse into visual children + if (root is IVisualTreeElement vte) + { + foreach (var child in vte.GetVisualChildren()) + { + if (child is Element element) + ScanNonVisualElementSteps(element, steps, ref traversalIndex, group, visited); + } + } + } + + /// + /// Recursively scans a non-VisualElement BindableObject for nested items with onboarding properties. + /// + private void ScanNonVisualElementBindableObject( + BindableObject obj, + List<(OnboardingStep Step, int TraversalOrder)> steps, + ref int traversalIndex, + string? group, + HashSet visited, + VisualElement? parentControl) + { + var type = obj.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + try + { + if (property.GetIndexParameters().Length > 0) + continue; + + if (!CommonItemsPropertyNames.Contains(property.Name)) + continue; + + var value = property.GetValue(obj); + if (value == null || visited.Contains(value)) + continue; + + if (value is IEnumerable enumerable && value is not string) + { + foreach (var item in enumerable) + { + if (item is BindableObject bo && !(item is VisualElement) && !visited.Contains(bo)) + { + visited.Add(bo); + + if (Onboarding.IsOnboardingStep(bo)) + { + if (EnableDebugLogging) + { + var itemText = GetPropertyValue(bo, "Text") ?? "unnamed"; + System.Diagnostics.Debug.WriteLine($"[Scanner] Found nested onboarding step: {bo.GetType().Name} '{itemText}'"); + } + + VisualElement? renderedView = null; + + if (_modelToViewCache.TryGetValue(bo, out var cachedView)) + { + renderedView = cachedView; + } + else if (parentControl != null) + { + var descendants = new List(); + CollectAllVisualDescendants(parentControl, descendants, new HashSet()); + renderedView = TryFindRenderedViewForModel(bo, descendants, 0); + } + + if (renderedView != null) + { + var step = CreateStepFromModelAndView(bo, renderedView); + + if (group == null || string.Equals(step.Group, group, StringComparison.OrdinalIgnoreCase)) + { + steps.Add((step, traversalIndex++)); + } + } + } + + // Recurse + ScanNonVisualElementBindableObject(bo, steps, ref traversalIndex, group, visited, parentControl); + } + } + } + } + catch + { + // Ignore + } + } + } + + /// + /// Creates an OnboardingStep using properties from a model object but targeting a rendered VisualElement. + /// + private static OnboardingStep CreateStepFromModelAndView(BindableObject model, VisualElement renderedView) + { + return new OnboardingStep + { + Target = renderedView, + StepKey = Onboarding.GetStepKey(model) ?? Onboarding.GetEffectiveStepKey(model), + Title = Onboarding.GetTitle(model), + Description = Onboarding.GetDescription(model), + Order = Onboarding.GetOrder(model), + Group = Onboarding.GetGroup(model), + SpotlightEnabled = Onboarding.GetSpotlightEnabled(model), + Placement = Onboarding.GetPlacement(model), + SpotlightShape = Onboarding.GetSpotlightShape(model), + SpotlightPadding = Onboarding.GetSpotlightPadding(model), + SpotlightCornerRadius = Onboarding.GetSpotlightCornerRadius(model), + TapBehavior = Onboarding.GetTapBehavior(model), + OnEntering = Onboarding.GetOnEntering(model), + OnLeaving = Onboarding.GetOnLeaving(model), + }; + } + + /// + /// Gets a property value from an object using reflection. + /// + private static T? GetPropertyValue(object obj, string propertyName) where T : class + { + var property = obj.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + return property?.GetValue(obj) as T; + } + /// public OnboardingStep? FindStepByKey(Element root, string stepKey) { @@ -86,39 +532,350 @@ public class DefaultStepScanner : IStepScanner /// /// Traverses the visual tree but skips OnboardingHost and its descendants. + /// Also checks common collection properties for custom controls. /// private static void TraverseVisualTreeExcludingHost(Element root, Action action) { + TraverseVisualTreeExcludingHost(root, action, new HashSet()); + } + + private static void TraverseVisualTreeExcludingHost(Element root, Action action, HashSet visited) + { + if (root == null || visited.Contains(root)) + return; + + visited.Add(root); + if (root is OnboardingHost) return; // Skip the OnboardingHost and all its children + // Skip elements that are not visible or not connected to a visible page + // This prevents finding elements from cached Shell pages if (root is VisualElement ve) - action(ve); + { + // Skip if the element or any ancestor is not visible + if (!IsElementEffectivelyVisible(ve)) + return; + action(ve); + } + + // First, traverse standard visual children if (root is IVisualTreeElement vte) { foreach (var child in vte.GetVisualChildren()) { if (child is Element element) - TraverseVisualTreeExcludingHost(element, action); + TraverseVisualTreeExcludingHost(element, action, visited); + } + } + + // Then, check common collection properties for custom controls (toolbars, etc.) + TraverseCustomCollectionProperties(root, action, visited, excludeHost: true); + } + + /// + /// Checks if an element is effectively visible (itself and all ancestors are visible). + /// This helps filter out elements from cached Shell pages that aren't currently displayed. + /// + private static bool IsElementEffectivelyVisible(VisualElement element) + { + // Check the element itself + if (!element.IsVisible) + return false; + + // Walk up the tree to check ancestors + Element? current = element.Parent; + while (current != null) + { + if (current is VisualElement parentVe) + { + // If any ancestor is not visible, this element is effectively hidden + if (!parentVe.IsVisible) + return false; + + // Special handling for Shell: skip pages that aren't the current page + if (current is Page page) + { + // Check if this page is actually the currently displayed page + if (!IsPageCurrentlyDisplayed(page)) + return false; + } + } + + current = current.Parent; + } + + return true; + } + + /// + /// Determines if a page is currently displayed (not a cached Shell page). + /// + private static bool IsPageCurrentlyDisplayed(Page page) + { + // If using Shell navigation, check if this page is the current page + if (Shell.Current != null) + { + var currentPage = Shell.Current.CurrentPage; + if (currentPage != null) + { + // Direct match + if (currentPage == page) + return true; + + // Check if the page is an ancestor of the current page (for nested navigation) + Element? ancestor = currentPage.Parent; + while (ancestor != null) + { + if (ancestor == page) + return true; + ancestor = ancestor.Parent; + } + + // Check if the current page is an ancestor of this page + ancestor = page.Parent; + while (ancestor != null) + { + if (ancestor == currentPage) + return true; + ancestor = ancestor.Parent; + } + + // This page is not related to the current page - it's likely a cached page + return false; + } + } + + // For non-Shell apps or if we can't determine, assume the page is displayed + // if it's visible + return page.IsVisible; + } + + /// + /// Traverses the visual tree including all elements. + /// Also checks common collection properties for custom controls. + /// + private static void TraverseVisualTree(Element root, Action action) + { + TraverseVisualTree(root, action, new HashSet()); + } + + private static void TraverseVisualTree(Element root, Action action, HashSet visited) + { + if (root == null || visited.Contains(root)) + return; + + visited.Add(root); + + if (root is VisualElement ve) + action(ve); + + // First, traverse standard visual children + if (root is IVisualTreeElement vte) + { + foreach (var child in vte.GetVisualChildren()) + { + if (child is Element element) + TraverseVisualTree(element, action, visited); + } + } + + // Then, check common collection properties for custom controls (toolbars, etc.) + TraverseCustomCollectionProperties(root, action, visited, excludeHost: false); + } + + /// + /// Checks collection properties for child elements. + /// This enables scanning of custom controls like toolbars that don't use standard visual tree. + /// + private static void TraverseCustomCollectionProperties( + Element root, + Action action, + HashSet visited, + bool excludeHost) + { + var type = root.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + try + { + // Skip indexed properties + if (property.GetIndexParameters().Length > 0) + continue; + + // Check if it's a known property name OR if we're scanning all collections + var isKnownProperty = CommonItemsPropertyNames.Contains(property.Name); + var shouldCheck = isKnownProperty || Instance.ScanAllCollectionProperties; + + if (!shouldCheck) + continue; + + // Skip properties that are unlikely to contain child elements + if (property.PropertyType == typeof(string) || + property.PropertyType.IsPrimitive || + property.PropertyType.IsEnum || + property.PropertyType == typeof(Color) || + property.PropertyType == typeof(Thickness) || + property.PropertyType == typeof(Rect) || + property.PropertyType == typeof(Size) || + property.PropertyType == typeof(Point)) + continue; + + var value = property.GetValue(root); + if (value == null || visited.Contains(value)) + continue; + + if (Instance.EnableDebugLogging && (value is IEnumerable || value is BindableObject)) + System.Diagnostics.Debug.WriteLine($"[Scanner] Checking property '{property.Name}' on {root.GetType().Name}, value type: {value.GetType().Name}"); + + // Handle single BindableObject/Element (Content property, etc.) + if (value is BindableObject bindableObj) + { + // Check if this object itself has onboarding properties + if (value is VisualElement ve && !visited.Contains(ve)) + { + visited.Add(ve); + action(ve); + + // Also recurse into this element + if (excludeHost) + TraverseVisualTreeExcludingHost(ve, action, visited); + else + TraverseVisualTree(ve, action, visited); + } + else if (value is Element element && !visited.Contains(element)) + { + if (excludeHost) + TraverseVisualTreeExcludingHost(element, action, visited); + else + TraverseVisualTree(element, action, visited); + } + } + // Handle collections (Items, Children, MenuItems, etc.) + else if (value is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item == null || visited.Contains(item)) + continue; + + if (item is VisualElement ve && !visited.Contains(ve)) + { + visited.Add(ve); + action(ve); + + // Also recurse into this element + if (excludeHost) + TraverseVisualTreeExcludingHost(ve, action, visited); + else + TraverseVisualTree(ve, action, visited); + } + else if (item is Element element) + { + if (excludeHost) + TraverseVisualTreeExcludingHost(element, action, visited); + else + TraverseVisualTree(element, action, visited); + } + else if (item is BindableObject bo) + { + // For non-Element BindableObjects, check if they have the attached properties + // and recursively scan their properties too + if (Onboarding.IsOnboardingStep(bo)) + { + System.Diagnostics.Debug.WriteLine($"[Scanner] Found onboarding step on non-Element: {bo.GetType().Name}"); + } + TraverseBindableObjectProperties(bo, action, visited, excludeHost); + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Scanner] Error scanning property {property.Name}: {ex.Message}"); } } } /// - /// Traverses the visual tree including all elements. + /// Traverses properties of a BindableObject that isn't an Element. + /// Some custom controls use BindableObjects that aren't Elements but contain Elements. /// - private static void TraverseVisualTree(Element root, Action action) + private static void TraverseBindableObjectProperties( + BindableObject obj, + Action action, + HashSet visited, + bool excludeHost) { - if (root is VisualElement ve) - action(ve); + if (obj == null || visited.Contains(obj)) + return; - if (root is IVisualTreeElement vte) + visited.Add(obj); + + var type = obj.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) { - foreach (var child in vte.GetVisualChildren()) + try { - if (child is Element element) - TraverseVisualTree(element, action); + if (property.GetIndexParameters().Length > 0) + continue; + + if (property.PropertyType == typeof(string) || + property.PropertyType.IsPrimitive || + property.PropertyType.IsEnum) + continue; + + var value = property.GetValue(obj); + if (value == null || visited.Contains(value)) + continue; + + if (value is VisualElement ve) + { + visited.Add(ve); + action(ve); + if (excludeHost) + TraverseVisualTreeExcludingHost(ve, action, visited); + else + TraverseVisualTree(ve, action, visited); + } + else if (value is Element element) + { + if (excludeHost) + TraverseVisualTreeExcludingHost(element, action, visited); + else + TraverseVisualTree(element, action, visited); + } + else if (value is IEnumerable enumerable && value is not string) + { + foreach (var item in enumerable) + { + if (item is VisualElement itemVe && !visited.Contains(itemVe)) + { + visited.Add(itemVe); + action(itemVe); + if (excludeHost) + TraverseVisualTreeExcludingHost(itemVe, action, visited); + else + TraverseVisualTree(itemVe, action, visited); + } + else if (item is Element itemElement && !visited.Contains(itemElement)) + { + if (excludeHost) + TraverseVisualTreeExcludingHost(itemElement, action, visited); + else + TraverseVisualTree(itemElement, action, visited); + } + } + } + } + catch + { + // Ignore } } } diff --git a/MarketAlly.MASpotlightTour/InlineLabel.cs b/MarketAlly.MASpotlightTour/InlineLabel.cs index c19e27e..b06ac7a 100644 --- a/MarketAlly.MASpotlightTour/InlineLabel.cs +++ b/MarketAlly.MASpotlightTour/InlineLabel.cs @@ -196,6 +196,10 @@ public class InlineLabel : Border return SpotlightGeometry.DetermineInlineLabelPlacement(spotlightRect, containerSize, margin); } + // For deferred corner positioning + private CalloutCorner? _pendingCorner; + private double _pendingMargin = 16; + /// /// Positions the label in a specific screen corner. /// @@ -208,9 +212,17 @@ public class InlineLabel : Border return; } + // Guard against invalid container size - defer positioning + if (containerSize.Width <= 0 || containerSize.Height <= 0) + { + _pendingCorner = corner; + _pendingMargin = margin; + return; + } + var measured = Measure(MaximumWidthRequest, double.PositiveInfinity); var labelWidth = Math.Min(measured.Width, MaximumWidthRequest); - var labelHeight = measured.Height; + var labelHeight = Math.Max(measured.Height, 40); // Ensure minimum height double x, y; @@ -242,6 +254,20 @@ public class InlineLabel : Border TranslationY = y; } + /// + /// Applies any pending corner position when container size becomes available. + /// + public void ApplyPendingCornerPosition(Size containerSize) + { + if (_pendingCorner.HasValue && containerSize.Width > 0 && containerSize.Height > 0) + { + var corner = _pendingCorner.Value; + var margin = _pendingMargin; + _pendingCorner = null; + PositionInCorner(corner, containerSize, margin); + } + } + /// /// Determines the best corner that doesn't overlap with the spotlight area. /// diff --git a/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj b/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj index e57217e..96a1ed4 100644 --- a/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj +++ b/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj @@ -17,7 +17,7 @@ MarketAlly.SpotlightTour.Maui MASpotlightTour - Feature Tour & Onboarding for .NET MAUI - 1.1.0 + 1.2.0 David H Friedel Jr MarketAlly A powerful, declarative spotlight tour and onboarding library for .NET MAUI applications. Create beautiful feature tours with spotlight overlays, callout cards, and step-by-step guidance using simple XAML attached properties. Features include: multiple display modes (spotlight with callout, callout-only, inline labels), flexible callout positioning (following, fixed corner, auto-corner), customizable spotlight shapes (rectangle, rounded rectangle, circle), corner navigator with step indicators, intro/welcome views, auto-advance timers, tour looping, light/dark theme support, smooth animations, and awaitable tour completion for action chaining. Perfect for user onboarding, feature discovery, and interactive tutorials. Supports iOS, Android, Windows, and macOS. @@ -30,6 +30,19 @@ MIT README.md +Version 1.2.0 - Shell Navigation & Animation Fixes: +- FIXED: Corner positioning now works correctly on the first tour step +- FIXED: Tour steps no longer bleed between pages when using Shell.GoToAsync() navigation +- NEW: Tours automatically stop when navigating away from a page (Page.Disappearing) +- NEW: StopTourOnPageNavigate property to control auto-stop behavior (default: true) +- Animations now preserve target positions instead of resetting to origin +- Scanner filters out elements from cached Shell pages + +Version 1.1.1 - Custom Control Support: +- FIXED: Scanner now detects tour steps inside custom controls (toolbars, menus, etc.) +- Enhanced DefaultStepScanner to check common collection properties (Items, MenuItems, ToolbarItems, etc.) +- Supports nested items like MenuItems inside ToolbarSplitButton + Version 1.1.0 - Step Actions: - NEW: Step Actions - Execute custom async code when entering/leaving tour steps - NEW: OnEntering/OnLeaving attached properties for per-element actions diff --git a/MarketAlly.MASpotlightTour/OnboardingHost.cs b/MarketAlly.MASpotlightTour/OnboardingHost.cs index 0f2a453..274b838 100644 --- a/MarketAlly.MASpotlightTour/OnboardingHost.cs +++ b/MarketAlly.MASpotlightTour/OnboardingHost.cs @@ -29,6 +29,7 @@ public class OnboardingHost : Grid, IDisposable private bool _isDisposed; private readonly Dictionary> _enteringActions = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _leavingActions = new(StringComparer.OrdinalIgnoreCase); + private Page? _parentPage; #region Bindable Properties @@ -302,6 +303,20 @@ public class OnboardingHost : Grid, IDisposable BindableProperty.Create(nameof(TourFlowDirection), typeof(FlowDirection), typeof(OnboardingHost), FlowDirection.MatchParent, propertyChanged: OnTourFlowDirectionChanged); + /// + /// When true (default), automatically stops any running tour when the parent page is navigated away from. + /// This prevents tour state from persisting across page navigations. + /// Set to false if you need tours to persist during navigation (e.g., for tabbed scenarios). + /// + public static readonly BindableProperty StopTourOnPageNavigateProperty = + BindableProperty.Create(nameof(StopTourOnPageNavigate), typeof(bool), typeof(OnboardingHost), true); + + public bool StopTourOnPageNavigate + { + get => (bool)GetValue(StopTourOnPageNavigateProperty); + set => SetValue(StopTourOnPageNavigateProperty, value); + } + public FlowDirection TourFlowDirection { get => (FlowDirection)GetValue(TourFlowDirectionProperty); @@ -533,12 +548,92 @@ public class OnboardingHost : Grid, IDisposable // Handle auto-start when added to visual tree Loaded += OnLoaded; + + // Handle layout changes for deferred corner positioning + SizeChanged += OnHostSizeChanged; + } + + private void OnHostSizeChanged(object? sender, EventArgs e) + { + if (Width > 0 && Height > 0 && _isRunning) + { + // Apply any pending corner position for callout card + _calloutCard.ApplyPendingCornerPosition(new Size(Width, Height)); + _inlineLabel.ApplyPendingCornerPosition(new Size(Width, Height)); + } + } + + /// + /// Re-applies corner positioning after the host becomes visible and has valid dimensions. + /// This is a safety net for edge cases where initial positioning couldn't complete. + /// + private async Task ReapplyCornerPositionIfNeededAsync() + { + if (CalloutPositionMode == CalloutPositionMode.Following) + return; // Not needed for following mode + + // Wait for valid dimensions if not already available (up to 500ms) + for (int i = 0; i < 10 && (Width <= 0 || Height <= 0); i++) + { + await Task.Delay(50); + } + + // Brief delay to ensure layout is fully complete + await Task.Delay(50); + + // Apply any pending corner positions and verify current position is correct + await MainThread.InvokeOnMainThreadAsync(() => + { + _calloutCard.ApplyPendingCornerPosition(new Size(Width, Height)); + _inlineLabel.ApplyPendingCornerPosition(new Size(Width, Height)); + }); + } + + private void ReapplyCornerPositionInternal() + { + if (Width <= 0 || Height <= 0) + return; + + var containerSize = new Size(Width, Height); + + // Force measure the callout card to ensure it has valid dimensions + _calloutCard.Measure(Math.Min(containerSize.Width * 0.85, 400), double.PositiveInfinity); + + if (CalloutPositionMode == CalloutPositionMode.FixedCorner) + { + _calloutCard.PositionInCorner(CalloutCorner, containerSize, CalloutCornerMargin); + _inlineLabel.PositionInCorner(CalloutCorner, containerSize, CalloutCornerMargin); + } + else if (CalloutPositionMode == CalloutPositionMode.AutoCorner && _currentIndex >= 0 && _currentIndex < _steps.Count && _root != null) + { + var step = _steps[_currentIndex]; + var bounds = step.Target.GetAbsoluteBoundsRelativeTo(_root); + + var cardWidth = Math.Min(containerSize.Width * 0.85, 400); + var cardMeasured = _calloutCard.Measure(cardWidth, double.PositiveInfinity); + var bestCorner = CalloutCard.DetermineBestCorner(bounds, containerSize, cardWidth, cardMeasured.Height, CalloutCornerMargin); + + _calloutCard.PositionInCorner(bestCorner, containerSize, CalloutCornerMargin); + _currentCalloutCorner = bestCorner; + + var labelMeasured = _inlineLabel.Measure(_inlineLabel.MaximumWidthRequest, double.PositiveInfinity); + var labelBestCorner = InlineLabel.DetermineBestCorner(bounds, containerSize, labelMeasured.Width, labelMeasured.Height, CalloutCornerMargin); + _inlineLabel.PositionInCorner(labelBestCorner, containerSize, CalloutCornerMargin); + } } private async void OnLoaded(object? sender, EventArgs e) { Loaded -= OnLoaded; + // Find and subscribe to parent page's Disappearing event + // This ensures tours are stopped when navigating away from the page + _parentPage = FindParentPage(); + if (_parentPage != null) + { + _parentPage.Disappearing += OnParentPageDisappearing; + } + if (AutoStartDelay > 0) { await Task.Delay(AutoStartDelay); @@ -546,6 +641,28 @@ public class OnboardingHost : Grid, IDisposable } } + private Page? FindParentPage() + { + Element? current = Parent; + while (current != null) + { + if (current is Page page) + return page; + current = current.Parent; + } + return null; + } + + private void OnParentPageDisappearing(object? sender, EventArgs e) + { + // Stop any running tour when the page is navigated away from + // This prevents tour state from bleeding into other pages + if (_isRunning && StopTourOnPageNavigate) + { + StopTour(); + } + } + private async Task AutoStartAsync() { if (_isRunning) @@ -639,7 +756,6 @@ public class OnboardingHost : Grid, IDisposable // Apply current theme to all components ApplyTheme(); - ApplyFlowDirection(); ApplyFlowDirection(); // Ensure layout is complete before positioning @@ -665,6 +781,9 @@ public class OnboardingHost : Grid, IDisposable }); } + // Re-position after becoming visible (for corner modes that need valid dimensions) + await ReapplyCornerPositionIfNeededAsync(); + // Return the task that completes when tour ends return await _tourCompletionSource.Task.ConfigureAwait(false); } @@ -873,6 +992,9 @@ public class OnboardingHost : Grid, IDisposable }); } + // Re-position after becoming visible (for corner modes that need valid dimensions) + await ReapplyCornerPositionIfNeededAsync(); + // Return the task that completes when tour ends return await _tourCompletionSource.Task; } @@ -1007,13 +1129,8 @@ public class OnboardingHost : Grid, IDisposable /// public async Task GoToStepAsync(int index) { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToStepAsync entered: index={index}, _isRunning={_isRunning}, _steps.Count={_steps?.Count ?? -1}, _root null={_root == null}"); - if (!_isRunning || index < 0 || index >= _steps.Count || _root == null) - { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToStepAsync: early return due to guard condition"); return; - } var previousIndex = _currentIndex; var step = _steps[index]; @@ -1049,7 +1166,6 @@ public class OnboardingHost : Grid, IDisposable } _currentIndex = index; - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToStepAsync: previousIndex={previousIndex}, new _currentIndex={_currentIndex}"); // Ensure element is visible (scroll if needed) - with timeout to prevent hanging try @@ -1121,7 +1237,6 @@ public class OnboardingHost : Grid, IDisposable } // Update corner navigator - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Calling UpdateForStep: index={index}, totalSteps={_steps.Count}"); _cornerNavigator.UpdateForStep(index, _steps.Count); // Get callout bounds for navigator auto-placement (so they don't overlap) @@ -1359,15 +1474,12 @@ public class OnboardingHost : Grid, IDisposable /// public async Task GoToNextStepAsync() { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToNextStepAsync: _currentIndex={_currentIndex}, _steps.Count={_steps?.Count ?? -1}"); if (_currentIndex < _steps.Count - 1) { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToNextStepAsync: advancing to step {_currentIndex + 1}"); await GoToStepAsync(_currentIndex + 1).ConfigureAwait(false); } else { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToNextStepAsync: completing tour"); await CompleteTourAsync().ConfigureAwait(false); } } @@ -1703,7 +1815,6 @@ public class OnboardingHost : Grid, IDisposable } // Update corner navigator - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Calling UpdateForStep: index={index}, totalSteps={_steps.Count}"); _cornerNavigator.UpdateForStep(index, _steps.Count); // Get callout bounds for navigator auto-placement (so they don't overlap) @@ -1953,6 +2064,9 @@ public class OnboardingHost : Grid, IDisposable IsVisible = true; }); } + + // Re-position after becoming visible (for corner modes that need valid dimensions) + await ReapplyCornerPositionIfNeededAsync(); } /// @@ -2026,6 +2140,9 @@ public class OnboardingHost : Grid, IDisposable }); } + // Re-position after becoming visible (for corner modes that need valid dimensions) + await ReapplyCornerPositionIfNeededAsync(); + // Return the task that completes when tour ends return await _tourCompletionSource.Task; } @@ -2074,6 +2191,14 @@ public class OnboardingHost : Grid, IDisposable _spotlightOverlay.SpotlightTapped -= OnSpotlightTapped; _spotlightOverlay.DimmedAreaTapped -= OnDimmedAreaTapped; Loaded -= OnLoaded; + SizeChanged -= OnHostSizeChanged; + + // Unsubscribe from parent page events + if (_parentPage != null) + { + _parentPage.Disappearing -= OnParentPageDisappearing; + _parentPage = null; + } } _isDisposed = true; @@ -2083,16 +2208,8 @@ public class OnboardingHost : Grid, IDisposable private void OnCalloutPreviousClicked(object? sender, EventArgs e) => _ = GoToPreviousStepAsync(); private void OnCalloutNextClicked(object? sender, EventArgs e) => _ = GoToNextStepAsync(); private void OnCalloutCloseClicked(object? sender, EventArgs e) => _ = CompleteTourAsync(); - private void OnNavigatorPreviousClicked(object? sender, EventArgs e) - { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] OnNavigatorPreviousClicked, _currentIndex={_currentIndex}"); - _ = GoToPreviousStepAsync(); - } - private void OnNavigatorNextClicked(object? sender, EventArgs e) - { - System.Diagnostics.Debug.WriteLine($"[OnboardingHost] OnNavigatorNextClicked, _currentIndex={_currentIndex}, _steps.Count={_steps?.Count}"); - _ = GoToNextStepAsync(); - } + private void OnNavigatorPreviousClicked(object? sender, EventArgs e) => _ = GoToPreviousStepAsync(); + private void OnNavigatorNextClicked(object? sender, EventArgs e) => _ = GoToNextStepAsync(); private void OnNavigatorSkipClicked(object? sender, EventArgs e) => SkipTour(); #endregion