Shell Navigation & Animation Fixes
This commit is contained in:
@@ -60,12 +60,18 @@ public static class AnimationHelper
|
|||||||
return;
|
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)
|
if (animation == EntranceAnimation.None || config.EntranceDurationMs == 0)
|
||||||
{
|
{
|
||||||
element.Opacity = 1;
|
element.Opacity = 1;
|
||||||
element.Scale = 1;
|
element.Scale = 1;
|
||||||
element.TranslationX = 0;
|
// Preserve the target position instead of resetting to 0
|
||||||
element.TranslationY = 0;
|
element.TranslationX = targetX;
|
||||||
|
element.TranslationY = targetY;
|
||||||
element.IsVisible = true;
|
element.IsVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,8 +79,8 @@ public static class AnimationHelper
|
|||||||
var duration = config.EntranceDurationMs;
|
var duration = config.EntranceDurationMs;
|
||||||
var easing = ToMauiEasing(config.EntranceEasing);
|
var easing = ToMauiEasing(config.EntranceEasing);
|
||||||
|
|
||||||
// Set initial state
|
// Set initial state (with offset from target position)
|
||||||
SetEntranceInitialState(element, animation, config);
|
SetEntranceInitialState(element, animation, config, targetX, targetY);
|
||||||
element.IsVisible = true;
|
element.IsVisible = true;
|
||||||
|
|
||||||
// Apply delay if configured
|
// Apply delay if configured
|
||||||
@@ -85,16 +91,17 @@ public static class AnimationHelper
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
// Animate to final state
|
// Animate to final state (the target position)
|
||||||
await AnimateEntranceAsync(element, animation, duration, easing, config);
|
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.Opacity = 0;
|
||||||
element.Scale = 1;
|
element.Scale = 1;
|
||||||
element.TranslationX = 0;
|
// Start at the target position (preserves corner positioning, etc.)
|
||||||
element.TranslationY = 0;
|
element.TranslationX = targetX;
|
||||||
|
element.TranslationY = targetY;
|
||||||
element.RotationY = 0;
|
element.RotationY = 0;
|
||||||
|
|
||||||
switch (animation)
|
switch (animation)
|
||||||
@@ -104,24 +111,24 @@ public static class AnimationHelper
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideFromLeft:
|
case EntranceAnimation.SlideFromLeft:
|
||||||
element.TranslationX = -config.SlideDistance;
|
element.TranslationX = targetX - config.SlideDistance;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideFromRight:
|
case EntranceAnimation.SlideFromRight:
|
||||||
element.TranslationX = config.SlideDistance;
|
element.TranslationX = targetX + config.SlideDistance;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideFromTop:
|
case EntranceAnimation.SlideFromTop:
|
||||||
element.TranslationY = -config.SlideDistance;
|
element.TranslationY = targetY - config.SlideDistance;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideFromBottom:
|
case EntranceAnimation.SlideFromBottom:
|
||||||
case EntranceAnimation.SlideUpFade:
|
case EntranceAnimation.SlideUpFade:
|
||||||
element.TranslationY = config.SlideDistance;
|
element.TranslationY = targetY + config.SlideDistance;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideDownFade:
|
case EntranceAnimation.SlideDownFade:
|
||||||
element.TranslationY = -config.SlideDistance;
|
element.TranslationY = targetY - config.SlideDistance;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.ScaleUp:
|
case EntranceAnimation.ScaleUp:
|
||||||
@@ -149,7 +156,9 @@ public static class AnimationHelper
|
|||||||
EntranceAnimation animation,
|
EntranceAnimation animation,
|
||||||
uint duration,
|
uint duration,
|
||||||
Easing easing,
|
Easing easing,
|
||||||
AnimationConfiguration config)
|
AnimationConfiguration config,
|
||||||
|
double targetX,
|
||||||
|
double targetY)
|
||||||
{
|
{
|
||||||
var tasks = new List<Task>();
|
var tasks = new List<Task>();
|
||||||
|
|
||||||
@@ -162,19 +171,19 @@ public static class AnimationHelper
|
|||||||
case EntranceAnimation.SlideFromLeft:
|
case EntranceAnimation.SlideFromLeft:
|
||||||
case EntranceAnimation.SlideFromRight:
|
case EntranceAnimation.SlideFromRight:
|
||||||
tasks.Add(element.FadeTo(1, duration, easing));
|
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;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideFromTop:
|
case EntranceAnimation.SlideFromTop:
|
||||||
case EntranceAnimation.SlideFromBottom:
|
case EntranceAnimation.SlideFromBottom:
|
||||||
tasks.Add(element.FadeTo(1, duration, easing));
|
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;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.SlideUpFade:
|
case EntranceAnimation.SlideUpFade:
|
||||||
case EntranceAnimation.SlideDownFade:
|
case EntranceAnimation.SlideDownFade:
|
||||||
tasks.Add(element.FadeTo(1, duration, easing));
|
tasks.Add(element.FadeTo(1, duration, easing));
|
||||||
tasks.Add(element.TranslateTo(0, 0, duration, easing));
|
tasks.Add(element.TranslateTo(targetX, targetY, duration, easing));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EntranceAnimation.ScaleUp:
|
case EntranceAnimation.ScaleUp:
|
||||||
|
|||||||
@@ -355,11 +355,21 @@ public class CalloutCard : Border
|
|||||||
return;
|
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);
|
var cardWidth = Math.Min(containerSize.Width * 0.85, 400);
|
||||||
WidthRequest = cardWidth;
|
WidthRequest = cardWidth;
|
||||||
|
|
||||||
var measured = Measure(cardWidth, double.PositiveInfinity);
|
var measured = Measure(cardWidth, double.PositiveInfinity);
|
||||||
var cardHeight = measured.Height;
|
var cardHeight = Math.Max(measured.Height, 100); // Ensure minimum height
|
||||||
|
|
||||||
double x, y;
|
double x, y;
|
||||||
|
|
||||||
@@ -389,6 +399,27 @@ public class CalloutCard : Border
|
|||||||
|
|
||||||
TranslationX = x;
|
TranslationX = x;
|
||||||
TranslationY = y;
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace MarketAlly.SpotlightTour.Maui;
|
namespace MarketAlly.SpotlightTour.Maui;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -10,25 +13,88 @@ public class DefaultStepScanner : IStepScanner
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static DefaultStepScanner Instance { get; } = new();
|
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 />
|
/// <inheritdoc />
|
||||||
public IReadOnlyList<OnboardingStep> FindSteps(Element root, string? group = null)
|
public IReadOnlyList<OnboardingStep> FindSteps(Element root, string? group = null)
|
||||||
{
|
{
|
||||||
var steps = new List<(OnboardingStep Step, int TraversalOrder)>();
|
var steps = new List<(OnboardingStep Step, int TraversalOrder)>();
|
||||||
var traversalIndex = 0;
|
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 =>
|
TraverseVisualTreeExcludingHost(root, ve =>
|
||||||
{
|
{
|
||||||
|
if (EnableDebugLogging)
|
||||||
|
System.Diagnostics.Debug.WriteLine($"[Scanner] Checking: {ve.GetType().Name} (IsStep: {Onboarding.IsOnboardingStep(ve)})");
|
||||||
|
|
||||||
if (!Onboarding.IsOnboardingStep(ve))
|
if (!Onboarding.IsOnboardingStep(ve))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var step = OnboardingStep.FromElement(ve);
|
var step = OnboardingStep.FromElement(ve);
|
||||||
|
|
||||||
|
if (EnableDebugLogging)
|
||||||
|
System.Diagnostics.Debug.WriteLine($"[Scanner] Found step: {step.StepKey ?? step.Title ?? "unnamed"}");
|
||||||
|
|
||||||
// Filter by group if specified
|
// Filter by group if specified
|
||||||
if (group != null && !string.Equals(step.Group, group, StringComparison.OrdinalIgnoreCase))
|
if (group != null && !string.Equals(step.Group, group, StringComparison.OrdinalIgnoreCase))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
steps.Add((step, traversalIndex++));
|
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)
|
// Sort by Order, then by traversal order (captured during collection)
|
||||||
// This avoids the O(n²) bug of using IndexOf in the sort predicate
|
// This avoids the O(n²) bug of using IndexOf in the sort predicate
|
||||||
@@ -40,6 +106,386 @@ public class DefaultStepScanner : IStepScanner
|
|||||||
.AsReadOnly();
|
.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 />
|
/// <inheritdoc />
|
||||||
public OnboardingStep? FindStepByKey(Element root, string stepKey)
|
public OnboardingStep? FindStepByKey(Element root, string stepKey)
|
||||||
{
|
{
|
||||||
@@ -86,39 +532,350 @@ public class DefaultStepScanner : IStepScanner
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Traverses the visual tree but skips OnboardingHost and its descendants.
|
/// Traverses the visual tree but skips OnboardingHost and its descendants.
|
||||||
|
/// Also checks common collection properties for custom controls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void TraverseVisualTreeExcludingHost(Element root, Action<VisualElement> action)
|
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)
|
if (root is OnboardingHost)
|
||||||
return; // Skip the OnboardingHost and all its children
|
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)
|
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)
|
if (root is IVisualTreeElement vte)
|
||||||
{
|
{
|
||||||
foreach (var child in vte.GetVisualChildren())
|
foreach (var child in vte.GetVisualChildren())
|
||||||
{
|
{
|
||||||
if (child is Element element)
|
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>
|
/// <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>
|
/// </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)
|
if (obj == null || visited.Contains(obj))
|
||||||
action(ve);
|
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)
|
if (property.GetIndexParameters().Length > 0)
|
||||||
TraverseVisualTree(element, action);
|
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);
|
return SpotlightGeometry.DetermineInlineLabelPlacement(spotlightRect, containerSize, margin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For deferred corner positioning
|
||||||
|
private CalloutCorner? _pendingCorner;
|
||||||
|
private double _pendingMargin = 16;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Positions the label in a specific screen corner.
|
/// Positions the label in a specific screen corner.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -208,9 +212,17 @@ public class InlineLabel : Border
|
|||||||
return;
|
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 measured = Measure(MaximumWidthRequest, double.PositiveInfinity);
|
||||||
var labelWidth = Math.Min(measured.Width, MaximumWidthRequest);
|
var labelWidth = Math.Min(measured.Width, MaximumWidthRequest);
|
||||||
var labelHeight = measured.Height;
|
var labelHeight = Math.Max(measured.Height, 40); // Ensure minimum height
|
||||||
|
|
||||||
double x, y;
|
double x, y;
|
||||||
|
|
||||||
@@ -242,6 +254,20 @@ public class InlineLabel : Border
|
|||||||
TranslationY = y;
|
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>
|
/// <summary>
|
||||||
/// Determines the best corner that doesn't overlap with the spotlight area.
|
/// Determines the best corner that doesn't overlap with the spotlight area.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<PackageId>MarketAlly.SpotlightTour.Maui</PackageId>
|
<PackageId>MarketAlly.SpotlightTour.Maui</PackageId>
|
||||||
<Title>MASpotlightTour - Feature Tour & Onboarding for .NET MAUI</Title>
|
<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>
|
<Authors>David H Friedel Jr</Authors>
|
||||||
<Company>MarketAlly</Company>
|
<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>
|
<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>
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<PackageReleaseNotes>
|
<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:
|
Version 1.1.0 - Step Actions:
|
||||||
- NEW: Step Actions - Execute custom async code when entering/leaving tour steps
|
- NEW: Step Actions - Execute custom async code when entering/leaving tour steps
|
||||||
- NEW: OnEntering/OnLeaving attached properties for per-element actions
|
- NEW: OnEntering/OnLeaving attached properties for per-element actions
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
private bool _isDisposed;
|
private bool _isDisposed;
|
||||||
private readonly Dictionary<string, Func<OnboardingStep, CancellationToken, Task>> _enteringActions = new(StringComparer.OrdinalIgnoreCase);
|
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 readonly Dictionary<string, Func<OnboardingStep, CancellationToken, Task>> _leavingActions = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private Page? _parentPage;
|
||||||
|
|
||||||
#region Bindable Properties
|
#region Bindable Properties
|
||||||
|
|
||||||
@@ -302,6 +303,20 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
BindableProperty.Create(nameof(TourFlowDirection), typeof(FlowDirection), typeof(OnboardingHost), FlowDirection.MatchParent,
|
BindableProperty.Create(nameof(TourFlowDirection), typeof(FlowDirection), typeof(OnboardingHost), FlowDirection.MatchParent,
|
||||||
propertyChanged: OnTourFlowDirectionChanged);
|
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
|
public FlowDirection TourFlowDirection
|
||||||
{
|
{
|
||||||
get => (FlowDirection)GetValue(TourFlowDirectionProperty);
|
get => (FlowDirection)GetValue(TourFlowDirectionProperty);
|
||||||
@@ -533,12 +548,92 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
|
|
||||||
// Handle auto-start when added to visual tree
|
// Handle auto-start when added to visual tree
|
||||||
Loaded += OnLoaded;
|
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)
|
private async void OnLoaded(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
Loaded -= OnLoaded;
|
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)
|
if (AutoStartDelay > 0)
|
||||||
{
|
{
|
||||||
await Task.Delay(AutoStartDelay);
|
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()
|
private async Task AutoStartAsync()
|
||||||
{
|
{
|
||||||
if (_isRunning)
|
if (_isRunning)
|
||||||
@@ -639,7 +756,6 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
|
|
||||||
// Apply current theme to all components
|
// Apply current theme to all components
|
||||||
ApplyTheme();
|
ApplyTheme();
|
||||||
ApplyFlowDirection();
|
|
||||||
ApplyFlowDirection();
|
ApplyFlowDirection();
|
||||||
|
|
||||||
// Ensure layout is complete before positioning
|
// 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 the task that completes when tour ends
|
||||||
return await _tourCompletionSource.Task.ConfigureAwait(false);
|
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 the task that completes when tour ends
|
||||||
return await _tourCompletionSource.Task;
|
return await _tourCompletionSource.Task;
|
||||||
}
|
}
|
||||||
@@ -1007,13 +1129,8 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task GoToStepAsync(int index)
|
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)
|
if (!_isRunning || index < 0 || index >= _steps.Count || _root == null)
|
||||||
{
|
|
||||||
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToStepAsync: early return due to guard condition");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
var previousIndex = _currentIndex;
|
var previousIndex = _currentIndex;
|
||||||
var step = _steps[index];
|
var step = _steps[index];
|
||||||
@@ -1049,7 +1166,6 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_currentIndex = index;
|
_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
|
// Ensure element is visible (scroll if needed) - with timeout to prevent hanging
|
||||||
try
|
try
|
||||||
@@ -1121,7 +1237,6 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update corner navigator
|
// Update corner navigator
|
||||||
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Calling UpdateForStep: index={index}, totalSteps={_steps.Count}");
|
|
||||||
_cornerNavigator.UpdateForStep(index, _steps.Count);
|
_cornerNavigator.UpdateForStep(index, _steps.Count);
|
||||||
|
|
||||||
// Get callout bounds for navigator auto-placement (so they don't overlap)
|
// Get callout bounds for navigator auto-placement (so they don't overlap)
|
||||||
@@ -1359,15 +1474,12 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task GoToNextStepAsync()
|
public async Task GoToNextStepAsync()
|
||||||
{
|
{
|
||||||
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToNextStepAsync: _currentIndex={_currentIndex}, _steps.Count={_steps?.Count ?? -1}");
|
|
||||||
if (_currentIndex < _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);
|
await GoToStepAsync(_currentIndex + 1).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToNextStepAsync: completing tour");
|
|
||||||
await CompleteTourAsync().ConfigureAwait(false);
|
await CompleteTourAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1703,7 +1815,6 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update corner navigator
|
// Update corner navigator
|
||||||
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Calling UpdateForStep: index={index}, totalSteps={_steps.Count}");
|
|
||||||
_cornerNavigator.UpdateForStep(index, _steps.Count);
|
_cornerNavigator.UpdateForStep(index, _steps.Count);
|
||||||
|
|
||||||
// Get callout bounds for navigator auto-placement (so they don't overlap)
|
// Get callout bounds for navigator auto-placement (so they don't overlap)
|
||||||
@@ -1953,6 +2064,9 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
IsVisible = true;
|
IsVisible = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-position after becoming visible (for corner modes that need valid dimensions)
|
||||||
|
await ReapplyCornerPositionIfNeededAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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 the task that completes when tour ends
|
||||||
return await _tourCompletionSource.Task;
|
return await _tourCompletionSource.Task;
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2191,14 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
_spotlightOverlay.SpotlightTapped -= OnSpotlightTapped;
|
_spotlightOverlay.SpotlightTapped -= OnSpotlightTapped;
|
||||||
_spotlightOverlay.DimmedAreaTapped -= OnDimmedAreaTapped;
|
_spotlightOverlay.DimmedAreaTapped -= OnDimmedAreaTapped;
|
||||||
Loaded -= OnLoaded;
|
Loaded -= OnLoaded;
|
||||||
|
SizeChanged -= OnHostSizeChanged;
|
||||||
|
|
||||||
|
// Unsubscribe from parent page events
|
||||||
|
if (_parentPage != null)
|
||||||
|
{
|
||||||
|
_parentPage.Disappearing -= OnParentPageDisappearing;
|
||||||
|
_parentPage = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_isDisposed = true;
|
_isDisposed = true;
|
||||||
@@ -2083,16 +2208,8 @@ public class OnboardingHost : Grid, IDisposable
|
|||||||
private void OnCalloutPreviousClicked(object? sender, EventArgs e) => _ = GoToPreviousStepAsync();
|
private void OnCalloutPreviousClicked(object? sender, EventArgs e) => _ = GoToPreviousStepAsync();
|
||||||
private void OnCalloutNextClicked(object? sender, EventArgs e) => _ = GoToNextStepAsync();
|
private void OnCalloutNextClicked(object? sender, EventArgs e) => _ = GoToNextStepAsync();
|
||||||
private void OnCalloutCloseClicked(object? sender, EventArgs e) => _ = CompleteTourAsync();
|
private void OnCalloutCloseClicked(object? sender, EventArgs e) => _ = CompleteTourAsync();
|
||||||
private void OnNavigatorPreviousClicked(object? sender, EventArgs e)
|
private void OnNavigatorPreviousClicked(object? sender, EventArgs e) => _ = GoToPreviousStepAsync();
|
||||||
{
|
private void OnNavigatorNextClicked(object? sender, EventArgs e) => _ = GoToNextStepAsync();
|
||||||
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 OnNavigatorSkipClicked(object? sender, EventArgs e) => SkipTour();
|
private void OnNavigatorSkipClicked(object? sender, EventArgs e) => SkipTour();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
Reference in New Issue
Block a user