Shell Navigation & Animation Fixes

This commit is contained in:
David H. Friedel Jr. 2025-12-17 01:33:29 -05:00
parent ddf023db5f
commit 999a1b4626
6 changed files with 1007 additions and 54 deletions

View File

@ -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<Task>();
@ -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:

View File

@ -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;
/// <summary>
/// Applies any pending corner position when container size becomes available.
/// Call this from OnboardingHost when layout changes.
/// </summary>
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);
}
}
/// <summary>

View File

@ -1,3 +1,6 @@
using System.Collections;
using System.Reflection;
namespace MarketAlly.SpotlightTour.Maui;
/// <summary>
@ -10,25 +13,88 @@ public class DefaultStepScanner : IStepScanner
/// </summary>
public static DefaultStepScanner Instance { get; } = new();
/// <summary>
/// Common property names that contain child elements in custom controls.
/// </summary>
private static readonly HashSet<string> CommonItemsPropertyNames = new(StringComparer.OrdinalIgnoreCase)
{
"Items",
"Children",
"Content",
"MenuItems",
"ToolbarItems",
"PrimaryItems",
"SecondaryItems",
"Buttons",
"Controls",
"Elements",
"Views",
"Pages",
"Tabs",
"Panes",
"Panels",
"Sections"
};
/// <summary>
/// Whether to scan all IEnumerable properties for elements (more thorough but slower).
/// </summary>
public bool ScanAllCollectionProperties { get; set; } = true;
/// <summary>
/// Enable debug logging to diagnose scanning issues.
/// </summary>
public bool EnableDebugLogging { get; set; } = false;
/// <summary>
/// Cache mapping non-visual BindableObjects to their rendered VisualElement.
/// This is populated when scanning custom controls like MAToolbar.
/// </summary>
private readonly Dictionary<BindableObject, VisualElement> _modelToViewCache = new();
/// <inheritdoc />
public IReadOnlyList<OnboardingStep> FindSteps(Element root, string? group = null)
{
var steps = new List<(OnboardingStep Step, int TraversalOrder)>();
var traversalIndex = 0;
var visited = new HashSet<object>();
// 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();
}
/// <summary>
/// Builds a mapping from model objects (like ToolbarButton) to their rendered VisualElements.
/// This enables detecting onboarding steps on non-VisualElement items.
/// </summary>
private void BuildModelToViewMappings(Element root, HashSet<object> 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);
}
}
}
/// <summary>
/// Attempts to build model-to-view mappings for a custom control.
/// </summary>
private void TryBuildMappingsForCustomControl(VisualElement control, HashSet<object> 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<VisualElement>();
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<string>(bo, "Text") ?? GetPropertyValue<string>(bo, "Name") ?? "unnamed";
System.Diagnostics.Debug.WriteLine($"[Scanner] Mapped model '{modelText}' to view {matchedView.GetType().Name}");
}
}
else if (EnableDebugLogging)
{
var modelText = GetPropertyValue<string>(bo, "Text") ?? GetPropertyValue<string>(bo, "Name") ?? "unnamed";
System.Diagnostics.Debug.WriteLine($"[Scanner] Could not find rendered view for model '{modelText}'");
}
}
itemIndex++;
}
}
/// <summary>
/// Collects all visual descendants of an element.
/// </summary>
private static void CollectAllVisualDescendants(VisualElement parent, List<VisualElement> descendants, HashSet<object> 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);
}
}
}
/// <summary>
/// Tries to find the rendered VisualElement for a model object.
/// Uses various heuristics like matching Text, Name/AutomationId, or position.
/// </summary>
private VisualElement? TryFindRenderedViewForModel(BindableObject model, List<VisualElement> visualChildren, int itemIndex)
{
// Strategy 1: Match by AutomationId (if model has Name property)
var modelName = GetPropertyValue<string>(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<string>(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<string>(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;
}
/// <summary>
/// Scans for onboarding steps on non-VisualElement items (like ToolbarButton).
/// </summary>
private void ScanNonVisualElementSteps(
Element root,
List<(OnboardingStep Step, int TraversalOrder)> steps,
ref int traversalIndex,
string? group,
HashSet<object> 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<string>(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<VisualElement>();
CollectAllVisualDescendants(parentVe, descendants, new HashSet<object>());
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);
}
}
}
/// <summary>
/// Recursively scans a non-VisualElement BindableObject for nested items with onboarding properties.
/// </summary>
private void ScanNonVisualElementBindableObject(
BindableObject obj,
List<(OnboardingStep Step, int TraversalOrder)> steps,
ref int traversalIndex,
string? group,
HashSet<object> 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<string>(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<VisualElement>();
CollectAllVisualDescendants(parentControl, descendants, new HashSet<object>());
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
}
}
}
/// <summary>
/// Creates an OnboardingStep using properties from a model object but targeting a rendered VisualElement.
/// </summary>
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),
};
}
/// <summary>
/// Gets a property value from an object using reflection.
/// </summary>
private static T? GetPropertyValue<T>(object obj, string propertyName) where T : class
{
var property = obj.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
return property?.GetValue(obj) as T;
}
/// <inheritdoc />
public OnboardingStep? FindStepByKey(Element root, string stepKey)
{
@ -86,39 +532,350 @@ public class DefaultStepScanner : IStepScanner
/// <summary>
/// Traverses the visual tree but skips OnboardingHost and its descendants.
/// Also checks common collection properties for custom controls.
/// </summary>
private static void TraverseVisualTreeExcludingHost(Element root, Action<VisualElement> action)
{
TraverseVisualTreeExcludingHost(root, action, new HashSet<object>());
}
private static void TraverseVisualTreeExcludingHost(Element root, Action<VisualElement> action, HashSet<object> 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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Determines if a page is currently displayed (not a cached Shell page).
/// </summary>
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;
}
/// <summary>
/// Traverses the visual tree including all elements.
/// Also checks common collection properties for custom controls.
/// </summary>
private static void TraverseVisualTree(Element root, Action<VisualElement> action)
{
TraverseVisualTree(root, action, new HashSet<object>());
}
private static void TraverseVisualTree(Element root, Action<VisualElement> action, HashSet<object> 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);
}
/// <summary>
/// Checks collection properties for child elements.
/// This enables scanning of custom controls like toolbars that don't use standard visual tree.
/// </summary>
private static void TraverseCustomCollectionProperties(
Element root,
Action<VisualElement> action,
HashSet<object> 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}");
}
}
}
/// <summary>
/// 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.
/// </summary>
private static void TraverseVisualTree(Element root, Action<VisualElement> action)
private static void TraverseBindableObjectProperties(
BindableObject obj,
Action<VisualElement> action,
HashSet<object> 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
}
}
}

View File

@ -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;
/// <summary>
/// Positions the label in a specific screen corner.
/// </summary>
@ -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;
}
/// <summary>
/// Applies any pending corner position when container size becomes available.
/// </summary>
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);
}
}
/// <summary>
/// Determines the best corner that doesn't overlap with the spotlight area.
/// </summary>

View File

@ -17,7 +17,7 @@
<PackageId>MarketAlly.SpotlightTour.Maui</PackageId>
<Title>MASpotlightTour - Feature Tour &amp; Onboarding for .NET MAUI</Title>
<Version>1.1.0</Version>
<Version>1.2.0</Version>
<Authors>David H Friedel Jr</Authors>
<Company>MarketAlly</Company>
<Description>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.</Description>
@ -30,6 +30,19 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>
Version 1.2.0 - Shell Navigation &amp; 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

View File

@ -29,6 +29,7 @@ public class OnboardingHost : Grid, IDisposable
private bool _isDisposed;
private readonly Dictionary<string, Func<OnboardingStep, CancellationToken, Task>> _enteringActions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Func<OnboardingStep, CancellationToken, Task>> _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);
/// <summary>
/// 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).
/// </summary>
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));
}
}
/// <summary>
/// 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.
/// </summary>
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
/// </summary>
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
/// </summary>
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();
}
/// <summary>
@ -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