Shell Navigation & Animation Fixes
This commit is contained in:
parent
ddf023db5f
commit
999a1b4626
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
<PackageId>MarketAlly.SpotlightTour.Maui</PackageId>
|
||||
<Title>MASpotlightTour - Feature Tour & 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 & 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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user