Files
maspotlighttour/MarketAlly.MASpotlightTour/OnboardingHost.cs

2293 lines
79 KiB
C#

using MarketAlly.SpotlightTour.Maui.Animations;
namespace MarketAlly.SpotlightTour.Maui;
/// <summary>
/// The main host control that orchestrates the onboarding tour experience.
/// Add this as an overlay on your page to enable spotlight tours.
/// </summary>
public class OnboardingHost : Grid, IDisposable
{
private readonly SpotlightOverlay _spotlightOverlay;
private readonly CalloutCard _calloutCard;
private readonly CornerNavigator _cornerNavigator;
private readonly ArrowIndicator _arrowIndicator;
private readonly InlineLabel _inlineLabel;
private readonly Grid _introContainer;
private IReadOnlyList<OnboardingStep> _steps = Array.Empty<OnboardingStep>();
private int _currentIndex = -1;
private VisualElement? _root;
private volatile bool _isRunning;
private volatile bool _showingIntro;
private CancellationTokenSource? _autoAdvanceCts;
private int _loopCount;
private string? _currentGroup;
private TaskCompletionSource<TourResult>? _tourCompletionSource;
private CalloutCorner? _currentCalloutCorner;
private readonly SemaphoreSlim _tourLock = new(1, 1);
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
/// <summary>
/// The display mode for the tour (SpotlightWithCallout, CalloutOnly, SpotlightWithInlineLabel).
/// </summary>
public static readonly BindableProperty DisplayModeProperty =
BindableProperty.Create(nameof(DisplayMode), typeof(TourDisplayMode), typeof(OnboardingHost),
TourDisplayMode.SpotlightWithCallout);
public TourDisplayMode DisplayMode
{
get => (TourDisplayMode)GetValue(DisplayModeProperty);
set => SetValue(DisplayModeProperty, value);
}
public static readonly BindableProperty ShowCornerNavigatorProperty =
BindableProperty.Create(nameof(ShowCornerNavigator), typeof(bool), typeof(OnboardingHost), true,
propertyChanged: (b, _, n) => ((OnboardingHost)b)._cornerNavigator.IsVisible = (bool)n && ((OnboardingHost)b)._isRunning);
public bool ShowCornerNavigator
{
get => (bool)GetValue(ShowCornerNavigatorProperty);
set => SetValue(ShowCornerNavigatorProperty, value);
}
public static readonly BindableProperty CornerNavigatorPlacementProperty =
BindableProperty.Create(nameof(CornerNavigatorPlacement), typeof(CornerNavigatorPlacement), typeof(OnboardingHost),
CornerNavigatorPlacement.BottomRight,
propertyChanged: (b, _, n) => ((OnboardingHost)b)._cornerNavigator.Placement = (CornerNavigatorPlacement)n);
public CornerNavigatorPlacement CornerNavigatorPlacement
{
get => (CornerNavigatorPlacement)GetValue(CornerNavigatorPlacementProperty);
set => SetValue(CornerNavigatorPlacementProperty, value);
}
public static readonly BindableProperty ShowSkipButtonProperty =
BindableProperty.Create(nameof(ShowSkipButton), typeof(bool), typeof(OnboardingHost), true,
propertyChanged: (b, _, n) => ((OnboardingHost)b)._cornerNavigator.ShowSkipButton = (bool)n);
public bool ShowSkipButton
{
get => (bool)GetValue(ShowSkipButtonProperty);
set => SetValue(ShowSkipButtonProperty, value);
}
public static readonly BindableProperty ShowArrowProperty =
BindableProperty.Create(nameof(ShowArrow), typeof(bool), typeof(OnboardingHost), true,
propertyChanged: (b, _, n) => ((OnboardingHost)b)._arrowIndicator.IsVisible = (bool)n && ((OnboardingHost)b)._isRunning);
public bool ShowArrow
{
get => (bool)GetValue(ShowArrowProperty);
set => SetValue(ShowArrowProperty, value);
}
public static readonly BindableProperty DimOpacityProperty =
BindableProperty.Create(nameof(DimOpacity), typeof(double), typeof(OnboardingHost), 0.6,
propertyChanged: (b, _, n) => ((OnboardingHost)b)._spotlightOverlay.DimOpacity = (double)n);
public double DimOpacity
{
get => (double)GetValue(DimOpacityProperty);
set => SetValue(DimOpacityProperty, value);
}
public static readonly BindableProperty DimColorProperty =
BindableProperty.Create(nameof(DimColor), typeof(Color), typeof(OnboardingHost), Colors.Black,
propertyChanged: (b, _, n) => ((OnboardingHost)b)._spotlightOverlay.DimColor = (Color)n);
public Color DimColor
{
get => (Color)GetValue(DimColorProperty);
set => SetValue(DimColorProperty, value);
}
public static readonly BindableProperty AnimationDurationProperty =
BindableProperty.Create(nameof(AnimationDuration), typeof(uint), typeof(OnboardingHost), (uint)250);
public uint AnimationDuration
{
get => (uint)GetValue(AnimationDurationProperty);
set => SetValue(AnimationDurationProperty, value);
}
/// <summary>
/// The positioning strategy for the callout card (Following, FixedCorner, AutoCorner).
/// </summary>
public static readonly BindableProperty CalloutPositionModeProperty =
BindableProperty.Create(nameof(CalloutPositionMode), typeof(CalloutPositionMode), typeof(OnboardingHost),
CalloutPositionMode.Following);
public CalloutPositionMode CalloutPositionMode
{
get => (CalloutPositionMode)GetValue(CalloutPositionModeProperty);
set => SetValue(CalloutPositionModeProperty, value);
}
/// <summary>
/// The corner to use when CalloutPositionMode is FixedCorner.
/// </summary>
public static readonly BindableProperty CalloutCornerProperty =
BindableProperty.Create(nameof(CalloutCorner), typeof(CalloutCorner), typeof(OnboardingHost),
CalloutCorner.BottomLeft);
public CalloutCorner CalloutCorner
{
get => (CalloutCorner)GetValue(CalloutCornerProperty);
set => SetValue(CalloutCornerProperty, value);
}
/// <summary>
/// Margin from screen edge when using corner positioning (in device-independent units).
/// </summary>
public static readonly BindableProperty CalloutCornerMarginProperty =
BindableProperty.Create(nameof(CalloutCornerMargin), typeof(double), typeof(OnboardingHost), 16.0);
public double CalloutCornerMargin
{
get => (double)GetValue(CalloutCornerMarginProperty);
set => SetValue(CalloutCornerMarginProperty, value);
}
/// <summary>
/// The visual theme for the tour (Light, Dark, or System).
/// </summary>
public static readonly BindableProperty ThemeProperty =
BindableProperty.Create(nameof(Theme), typeof(TourTheme), typeof(OnboardingHost),
TourTheme.Light,
propertyChanged: (b, _, _) => ((OnboardingHost)b).ApplyTheme());
public TourTheme Theme
{
get => (TourTheme)GetValue(ThemeProperty);
set => SetValue(ThemeProperty, value);
}
public static readonly BindableProperty AnimationsEnabledProperty =
BindableProperty.Create(nameof(AnimationsEnabled), typeof(bool), typeof(OnboardingHost), true);
public bool AnimationsEnabled
{
get => (bool)GetValue(AnimationsEnabledProperty);
set => SetValue(AnimationsEnabledProperty, value);
}
/// <summary>
/// Optional delay in milliseconds before auto-starting the tour.
/// Set to 0 or negative to disable auto-start (default).
/// </summary>
public static readonly BindableProperty AutoStartDelayProperty =
BindableProperty.Create(nameof(AutoStartDelay), typeof(int), typeof(OnboardingHost), 0);
public int AutoStartDelay
{
get => (int)GetValue(AutoStartDelayProperty);
set => SetValue(AutoStartDelayProperty, value);
}
/// <summary>
/// The group to use for auto-start. If null, all steps are included.
/// </summary>
public static readonly BindableProperty AutoStartGroupProperty =
BindableProperty.Create(nameof(AutoStartGroup), typeof(string), typeof(OnboardingHost), null);
public string? AutoStartGroup
{
get => (string?)GetValue(AutoStartGroupProperty);
set => SetValue(AutoStartGroupProperty, value);
}
/// <summary>
/// Optional delay in milliseconds before auto-advancing to the next step.
/// Set to 0 or negative to disable auto-advance (default).
/// When enabled, the tour automatically moves to the next step after this delay.
/// </summary>
public static readonly BindableProperty AutoAdvanceDelayProperty =
BindableProperty.Create(nameof(AutoAdvanceDelay), typeof(int), typeof(OnboardingHost), 0);
public int AutoAdvanceDelay
{
get => (int)GetValue(AutoAdvanceDelayProperty);
set => SetValue(AutoAdvanceDelayProperty, value);
}
/// <summary>
/// Number of times to automatically repeat the tour after completion.
/// 0 = disabled (default), positive number = repeat that many times.
/// </summary>
public static readonly BindableProperty AutoLoopProperty =
BindableProperty.Create(nameof(AutoLoop), typeof(int), typeof(OnboardingHost), 0);
public int AutoLoop
{
get => (int)GetValue(AutoLoopProperty);
set => SetValue(AutoLoopProperty, value);
}
/// <summary>
/// Optional intro/welcome view to display before the tour starts.
/// Set this to a custom view with your branding, welcome message, or start button.
/// Call DismissIntro() or tap the view to start the tour.
/// </summary>
public static readonly BindableProperty IntroViewProperty =
BindableProperty.Create(nameof(IntroView), typeof(View), typeof(OnboardingHost), null,
propertyChanged: OnIntroViewChanged);
public View? IntroView
{
get => (View?)GetValue(IntroViewProperty);
set => SetValue(IntroViewProperty, value);
}
private static void OnIntroViewChanged(BindableObject bindable, object oldValue, object newValue)
{
var host = (OnboardingHost)bindable;
host.SetupIntroView(oldValue as View, newValue as View);
}
/// <summary>
/// Controls whether the intro view is displayed when starting the tour.
/// Set to false to skip the intro (useful for returning users who have seen it before).
/// Default: true.
/// </summary>
public static readonly BindableProperty ShowIntroProperty =
BindableProperty.Create(nameof(ShowIntro), typeof(bool), typeof(OnboardingHost), true);
public bool ShowIntro
{
get => (bool)GetValue(ShowIntroProperty);
set => SetValue(ShowIntroProperty, value);
}
/// <summary>
/// When true, tapping anywhere on the dimmed overlay while the intro is shown will skip the tour.
/// Default: false (requires tapping the intro view's dismiss button/element).
/// </summary>
public static readonly BindableProperty AllowTapAnywhereToDismissIntroProperty =
BindableProperty.Create(nameof(AllowTapAnywhereToDismissIntro), typeof(bool), typeof(OnboardingHost), false);
public bool AllowTapAnywhereToDismissIntro
{
get => (bool)GetValue(AllowTapAnywhereToDismissIntroProperty);
set => SetValue(AllowTapAnywhereToDismissIntroProperty, value);
}
/// <summary>
/// Animation configuration for customizing entrance, exit, and effect animations.
/// </summary>
public static readonly BindableProperty AnimationConfigurationProperty =
BindableProperty.Create(nameof(AnimationConfiguration), typeof(AnimationConfiguration), typeof(OnboardingHost), null);
public AnimationConfiguration? AnimationConfiguration
{
get => (AnimationConfiguration?)GetValue(AnimationConfigurationProperty);
set => SetValue(AnimationConfigurationProperty, value);
}
/// <summary>
/// Gets the effective animation configuration (custom or default).
/// </summary>
private AnimationConfiguration EffectiveAnimationConfig =>
AnimationConfiguration ?? Animations.AnimationConfiguration.Default;
/// <summary>
/// The flow direction for RTL support. Use MatchParent to inherit from parent,
/// or explicitly set LeftToRight or RightToLeft.
/// </summary>
public static readonly BindableProperty TourFlowDirectionProperty =
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);
set => SetValue(TourFlowDirectionProperty, value);
}
private static void OnTourFlowDirectionChanged(BindableObject bindable, object oldValue, object newValue)
{
var host = (OnboardingHost)bindable;
host.ApplyFlowDirection();
}
#endregion
#region Events
/// <summary>
/// Raised when the tour starts.
/// </summary>
public event EventHandler? TourStarted;
/// <summary>
/// Raised when the tour completes (user reaches the end).
/// </summary>
public event EventHandler? TourCompleted;
/// <summary>
/// Raised when the tour is skipped.
/// </summary>
public event EventHandler? TourSkipped;
/// <summary>
/// Raised when moving to a new step.
/// </summary>
public event EventHandler<OnboardingStepEventArgs>? StepChanged;
/// <summary>
/// Raised when the tour ends (either completed or skipped).
/// </summary>
public event EventHandler? TourEnded;
/// <summary>
/// Raised when the intro view is shown.
/// </summary>
public event EventHandler? IntroShown;
/// <summary>
/// Raised when the intro view is dismissed.
/// </summary>
public event EventHandler? IntroDismissed;
/// <summary>
/// Raised before entering a step, allowing async preparation or skipping.
/// Actions execute before the spotlight animates to the new position.
/// Set e.Skip = true to skip this step.
/// </summary>
public event Func<object?, StepActionEventArgs, Task>? StepEntering;
/// <summary>
/// Raised after a step is fully visible (after animations complete).
/// </summary>
public event EventHandler<StepActionEventArgs>? StepEntered;
/// <summary>
/// Raised before leaving the current step.
/// Actions execute before navigating to the next/previous step.
/// </summary>
public event Func<object?, StepActionEventArgs, Task>? StepLeaving;
#endregion
#region Read-only Properties
/// <summary>
/// Gets whether a tour is currently running.
/// </summary>
public bool IsRunning => _isRunning;
/// <summary>
/// Gets the current step index (0-based).
/// </summary>
public int CurrentStepIndex => _currentIndex;
/// <summary>
/// Gets the total number of steps.
/// </summary>
public int TotalSteps => _steps.Count;
/// <summary>
/// Gets the current step, or null if no tour is running.
/// </summary>
public OnboardingStep? CurrentStep => _currentIndex >= 0 && _currentIndex < _steps.Count
? _steps[_currentIndex]
: null;
/// <summary>
/// Gets whether the intro view is currently being shown.
/// </summary>
public bool IsShowingIntro => _showingIntro;
#endregion
#region Step Action Registration
/// <summary>
/// Registers an action to execute when entering a specific step (by StepKey).
/// The action runs before the spotlight animates to the step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <param name="action">The async action to execute. Receives the step and cancellation token.</param>
public void RegisterStepEnteringAction(string stepKey, Func<OnboardingStep, CancellationToken, Task> action)
{
ArgumentNullException.ThrowIfNull(stepKey);
ArgumentNullException.ThrowIfNull(action);
_enteringActions[stepKey] = action;
}
/// <summary>
/// Registers an action to execute when leaving a specific step (by StepKey).
/// The action runs before navigating away from the step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <param name="action">The async action to execute. Receives the step and cancellation token.</param>
public void RegisterStepLeavingAction(string stepKey, Func<OnboardingStep, CancellationToken, Task> action)
{
ArgumentNullException.ThrowIfNull(stepKey);
ArgumentNullException.ThrowIfNull(action);
_leavingActions[stepKey] = action;
}
/// <summary>
/// Removes a registered entering action for a step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <returns>True if the action was removed, false if no action was registered.</returns>
public bool UnregisterStepEnteringAction(string stepKey)
{
return _enteringActions.Remove(stepKey);
}
/// <summary>
/// Removes a registered leaving action for a step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <returns>True if the action was removed, false if no action was registered.</returns>
public bool UnregisterStepLeavingAction(string stepKey)
{
return _leavingActions.Remove(stepKey);
}
/// <summary>
/// Clears all registered step actions.
/// </summary>
public void ClearStepActions()
{
_enteringActions.Clear();
_leavingActions.Clear();
}
#endregion
public OnboardingHost()
{
IsVisible = false;
InputTransparent = false;
BackgroundColor = Colors.Transparent;
HorizontalOptions = LayoutOptions.Fill;
VerticalOptions = LayoutOptions.Fill;
_spotlightOverlay = new SpotlightOverlay
{
IsVisible = false,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill
};
_calloutCard = new CalloutCard
{
IsVisible = false,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start
};
_arrowIndicator = new ArrowIndicator
{
IsVisible = false,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start
};
_inlineLabel = new InlineLabel
{
IsVisible = false,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start
};
_cornerNavigator = new CornerNavigator { IsVisible = false };
// Intro container for custom welcome content
_introContainer = new Grid
{
IsVisible = false,
BackgroundColor = Colors.Transparent,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill
};
// Add tap gesture for "tap anywhere to dismiss intro" feature
var introTapGesture = new TapGestureRecognizer();
introTapGesture.Tapped += OnIntroContainerTapped;
_introContainer.GestureRecognizers.Add(introTapGesture);
// Wire up events (using named handlers for proper unsubscription in Dispose)
_calloutCard.PreviousClicked += OnCalloutPreviousClicked;
_calloutCard.NextClicked += OnCalloutNextClicked;
_calloutCard.CloseClicked += OnCalloutCloseClicked;
_cornerNavigator.PreviousClicked += OnNavigatorPreviousClicked;
_cornerNavigator.NextClicked += OnNavigatorNextClicked;
_cornerNavigator.SkipClicked += OnNavigatorSkipClicked;
_spotlightOverlay.SpotlightTapped += OnSpotlightTapped;
_spotlightOverlay.DimmedAreaTapped += OnDimmedAreaTapped;
// Layer order: spotlight overlay (bottom), arrow, inline label, callout card, corner navigator, intro (top)
Children.Add(_spotlightOverlay);
Children.Add(_arrowIndicator);
Children.Add(_inlineLabel);
Children.Add(_calloutCard);
Children.Add(_cornerNavigator);
Children.Add(_introContainer);
// 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);
await AutoStartAsync();
}
}
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)
return;
// Find the root - walk up to find Page or use parent
var root = FindAutoStartRoot();
if (root == null)
return;
// Use StartTourWithIntroAsync if an intro view is configured and ShowIntro is true
if (IntroView != null && ShowIntro)
{
await StartTourWithIntroAsync(root, AutoStartGroup);
}
else
{
await StartTourAsync(root, AutoStartGroup);
}
}
private VisualElement? FindAutoStartRoot()
{
Element? current = Parent;
VisualElement? lastVisual = null;
while (current != null)
{
if (current is ContentPage contentPage)
return contentPage.Content as VisualElement ?? contentPage;
if (current is VisualElement ve)
lastVisual = ve;
current = current.Parent;
}
return lastVisual;
}
/// <summary>
/// Stops any currently running tour.
/// </summary>
public void StopTour()
{
if (_isRunning)
EndTour(TourResult.Cancelled);
}
/// <summary>
/// Starts the onboarding tour by scanning for tagged elements.
/// Returns a task that completes when the tour ends, allowing chaining of actions.
/// </summary>
/// <param name="root">The root element to scan (typically the Page or a layout).</param>
/// <param name="group">Optional group filter.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <returns>A task that completes with the tour result when the tour ends.</returns>
/// <example>
/// // Chain actions after tour completion:
/// var result = await OnboardingHost.StartTourAsync(this.Content);
/// if (result == TourResult.Completed)
/// await DisplayAlert("Done!", "Tour complete", "OK");
/// </example>
public async Task<TourResult> StartTourAsync(VisualElement root, string? group = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
await _tourLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Stop any existing tour first
if (_isRunning)
EndTourInternal(TourResult.Cancelled);
cancellationToken.ThrowIfCancellationRequested();
_root = root ?? throw new ArgumentNullException(nameof(root));
_currentGroup = group;
_loopCount = 0;
_steps = OnboardingScanner.FindSteps(root, group);
if (_steps.Count == 0)
return TourResult.NoSteps;
// Create completion source for awaiting tour end
_tourCompletionSource = new TaskCompletionSource<TourResult>();
_isRunning = true;
_currentIndex = -1;
_cornerNavigator.TotalSteps = _steps.Count;
// Apply current theme to all components
ApplyTheme();
ApplyFlowDirection();
// Ensure layout is complete before positioning
await WaitForLayoutAsync(cancellationToken).ConfigureAwait(false);
TourStarted?.Invoke(this, EventArgs.Empty);
// Position first step before showing anything
await GoToStepInternalAsync(0, cancellationToken).ConfigureAwait(false);
// Now show everything based on display mode (ensure on main thread)
if (MainThread.IsMainThread)
{
ShowElementsForDisplayMode();
IsVisible = true;
}
else
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
ShowElementsForDisplayMode();
IsVisible = true;
});
}
// 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);
}
finally
{
_tourLock.Release();
}
}
private void ShowElementsForDisplayMode()
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(ShowElementsForDisplayMode);
return;
}
// Hide callout card buttons when corner navigator is visible (avoid duplicate nav)
_calloutCard.ShowNavigationButtons = !ShowCornerNavigator;
switch (DisplayMode)
{
case TourDisplayMode.SpotlightWithCallout:
_spotlightOverlay.IsVisible = true;
_calloutCard.IsVisible = true;
_inlineLabel.IsVisible = false;
_cornerNavigator.IsVisible = ShowCornerNavigator;
InputTransparent = false;
break;
case TourDisplayMode.CalloutOnly:
_spotlightOverlay.IsVisible = false;
_calloutCard.IsVisible = true;
_inlineLabel.IsVisible = false;
_cornerNavigator.IsVisible = ShowCornerNavigator;
// Allow interaction with underlying elements in callout-only mode
InputTransparent = true;
_calloutCard.InputTransparent = false;
_cornerNavigator.InputTransparent = false;
break;
case TourDisplayMode.SpotlightWithInlineLabel:
_spotlightOverlay.IsVisible = true;
_calloutCard.IsVisible = false;
_inlineLabel.IsVisible = true;
_cornerNavigator.IsVisible = true; // Always show nav in this mode
InputTransparent = false;
break;
}
// Play entrance animations if enabled
if (AnimationsEnabled)
{
_ = PlayEntranceAnimationsAsync();
}
}
private async Task PlayEntranceAnimationsAsync()
{
var config = EffectiveAnimationConfig;
var tasks = new List<Task>();
if (_spotlightOverlay.IsVisible)
{
tasks.Add(_spotlightOverlay.ShowWithAnimationAsync(config));
// Start spotlight effect if configured
if (config.SpotlightEffect != SpotlightEffect.None)
{
_spotlightOverlay.StartSpotlightEffect(config.SpotlightEffect, config);
}
}
if (_calloutCard.IsVisible)
{
tasks.Add(_calloutCard.ShowWithAnimationAsync(config));
}
if (_inlineLabel.IsVisible)
{
tasks.Add(_inlineLabel.ShowWithAnimationAsync(config));
}
if (_cornerNavigator.IsVisible)
{
tasks.Add(_cornerNavigator.ShowWithAnimationAsync(config));
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
private async Task WaitForLayoutAsync(CancellationToken cancellationToken = default)
{
var config = TourConfiguration.Default;
// If already laid out, skip waiting
if (Width > 0 && Height > 0)
return;
// Make host temporarily visible to trigger layout (must be on UI thread)
if (!MainThread.IsMainThread)
{
await MainThread.InvokeOnMainThreadAsync(() => IsVisible = true);
}
else
{
IsVisible = true;
}
// Wait for layout pass
var tcs = new TaskCompletionSource<bool>();
void OnSizeChanged(object? sender, EventArgs e)
{
if (Width > 0 && Height > 0)
{
SizeChanged -= OnSizeChanged;
tcs.TrySetResult(true);
}
}
SizeChanged += OnSizeChanged;
try
{
// Timeout after configured delay
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(config.LayoutWaitTimeoutMs);
try
{
await tcs.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timeout occurred, not actual cancellation - continue
}
}
finally
{
SizeChanged -= OnSizeChanged;
// Must set visibility on UI thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => IsVisible = false);
}
else
{
IsVisible = false;
}
}
}
/// <summary>
/// Starts the tour from a specific step key.
/// Returns a task that completes when the tour ends.
/// </summary>
public async Task<TourResult> StartTourFromStepAsync(VisualElement root, string stepKey, string? group = null)
{
// Stop any existing tour first
if (_isRunning)
EndTour(TourResult.Cancelled);
_root = root;
_steps = OnboardingScanner.FindSteps(root, group);
if (_steps.Count == 0)
return TourResult.NoSteps;
// Create completion source for awaiting tour end
_tourCompletionSource = new TaskCompletionSource<TourResult>();
var startIndex = _steps
.Select((s, i) => new { Step = s, Index = i })
.FirstOrDefault(x => string.Equals(x.Step.StepKey, stepKey, StringComparison.OrdinalIgnoreCase))
?.Index ?? 0;
_isRunning = true;
_currentIndex = -1;
_cornerNavigator.TotalSteps = _steps.Count;
// Apply current theme to all components
ApplyTheme();
ApplyFlowDirection();
// Ensure layout is complete before positioning
await WaitForLayoutAsync();
TourStarted?.Invoke(this, EventArgs.Empty);
// Position first step before showing anything
await GoToStepAsync(startIndex);
// Now show everything based on display mode (ensure on main thread)
if (MainThread.IsMainThread)
{
ShowElementsForDisplayMode();
IsVisible = true;
}
else
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
ShowElementsForDisplayMode();
IsVisible = true;
});
}
// 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;
}
/// <summary>
/// Executes all entering actions for a step (event, attached property, registered action).
/// Returns a StepActionEventArgs that may have Skip set to true.
/// </summary>
private async Task<StepActionEventArgs> ExecuteStepEnteringActionsAsync(
OnboardingStep step,
int stepIndex,
int previousIndex,
CancellationToken cancellationToken = default)
{
var isForward = previousIndex < stepIndex || previousIndex < 0;
var eventArgs = new StepActionEventArgs(
step, stepIndex, _steps.Count, previousIndex, isForward, cancellationToken);
// 1. Fire StepEntering event (allows Skip)
if (StepEntering != null)
{
try
{
await StepEntering.Invoke(this, eventArgs).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] StepEntering event handler failed: {ex.Message}");
}
}
// If skipped via event, don't run other actions
if (eventArgs.Skip)
return eventArgs;
// 2. Execute attached property action (OnEntering on the element)
if (step.OnEntering != null)
{
try
{
await step.OnEntering(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] OnEntering attached action failed: {ex.Message}");
}
}
// 3. Execute registered action (by StepKey)
if (!string.IsNullOrEmpty(step.StepKey) && _enteringActions.TryGetValue(step.StepKey, out var registeredAction))
{
try
{
await registeredAction(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Registered entering action failed: {ex.Message}");
}
}
return eventArgs;
}
/// <summary>
/// Executes all leaving actions for a step (event, attached property, registered action).
/// </summary>
private async Task ExecuteStepLeavingActionsAsync(
OnboardingStep step,
int stepIndex,
int nextIndex,
CancellationToken cancellationToken = default)
{
var isForward = nextIndex > stepIndex;
var eventArgs = new StepActionEventArgs(
step, stepIndex, _steps.Count, stepIndex, isForward, cancellationToken);
// 1. Fire StepLeaving event
if (StepLeaving != null)
{
try
{
await StepLeaving.Invoke(this, eventArgs).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] StepLeaving event handler failed: {ex.Message}");
}
}
// 2. Execute attached property action (OnLeaving on the element)
if (step.OnLeaving != null)
{
try
{
await step.OnLeaving(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] OnLeaving attached action failed: {ex.Message}");
}
}
// 3. Execute registered action (by StepKey)
if (!string.IsNullOrEmpty(step.StepKey) && _leavingActions.TryGetValue(step.StepKey, out var registeredAction))
{
try
{
await registeredAction(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Registered leaving action failed: {ex.Message}");
}
}
}
/// <summary>
/// Fires the StepEntered event after animations complete.
/// </summary>
private void RaiseStepEntered(OnboardingStep step, int stepIndex, int previousIndex)
{
var isForward = previousIndex < stepIndex || previousIndex < 0;
var eventArgs = new StepActionEventArgs(
step, stepIndex, _steps.Count, previousIndex, isForward);
StepEntered?.Invoke(this, eventArgs);
}
/// <summary>
/// Navigates to a specific step by index.
/// </summary>
public async Task GoToStepAsync(int index)
{
if (!_isRunning || index < 0 || index >= _steps.Count || _root == null)
return;
var previousIndex = _currentIndex;
var step = _steps[index];
// Execute leaving actions for current step (if any)
if (previousIndex >= 0 && previousIndex < _steps.Count)
{
var previousStep = _steps[previousIndex];
await ExecuteStepLeavingActionsAsync(previousStep, previousIndex, index).ConfigureAwait(false);
}
// Execute entering actions for new step (before UI updates)
var enteringArgs = await ExecuteStepEnteringActionsAsync(step, index, previousIndex).ConfigureAwait(false);
// Handle skip - move to next or previous step
if (enteringArgs.Skip)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Step {index} skipped via StepEntering event");
var isForward = previousIndex < index || previousIndex < 0;
var nextIndex = isForward ? index + 1 : index - 1;
if (nextIndex >= 0 && nextIndex < _steps.Count)
{
await GoToStepAsync(nextIndex).ConfigureAwait(false);
}
else if (isForward)
{
// No more steps forward, complete tour
await CompleteTourAsync().ConfigureAwait(false);
}
// If going backward with no more steps, stay at current
return;
}
_currentIndex = index;
// Ensure element is visible (scroll if needed) - with timeout to prevent hanging
try
{
var scrollTask = step.Target.EnsureVisibleAsync(AnimationsEnabled);
var timeoutTask = Task.Delay(1000); // 1 second timeout
await Task.WhenAny(scrollTask, timeoutTask);
}
catch
{
// Ignore scroll errors - continue with tour
}
// Brief delay to allow scroll and layout to settle
await Task.Delay(50);
// Calculate bounds - retry a few times if we get invalid bounds
var bounds = step.Target.GetAbsoluteBoundsWithPadding(_root, step.SpotlightPadding);
// If bounds are invalid, wait and retry
for (int retry = 0; retry < 3 && (bounds.Width <= 0 || bounds.Height <= 0); retry++)
{
await Task.Delay(100);
bounds = step.Target.GetAbsoluteBoundsWithPadding(_root, step.SpotlightPadding);
}
// Fallback: if bounds are still invalid, use a default size
if (bounds.Width <= 0 || bounds.Height <= 0)
{
bounds = new Rect(bounds.X, bounds.Y,
bounds.Width <= 0 ? 100 : bounds.Width,
bounds.Height <= 0 ? 50 : bounds.Height);
}
// Determine effective placement
var placement = step.Placement;
if (placement == CalloutPlacement.Auto)
{
placement = DetermineAutoPlacement(bounds);
}
var isFirst = index == 0;
var isLast = index == _steps.Count - 1;
// Handle different display modes (wrap in try-catch to ensure navigation continues even if animations fail)
try
{
switch (DisplayMode)
{
case TourDisplayMode.SpotlightWithCallout:
await UpdateSpotlightWithCallout(step, bounds, placement, isFirst, isLast, previousIndex);
break;
case TourDisplayMode.CalloutOnly:
await UpdateCalloutOnly(step, bounds, placement, isFirst, isLast);
break;
case TourDisplayMode.SpotlightWithInlineLabel:
await UpdateSpotlightWithInlineLabel(step, bounds, placement, previousIndex);
break;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Display mode update failed: {ex.Message}");
// Ensure basic UI state is updated even if animations fail
_calloutCard.UpdateForStep(step.Title, step.Description, isFirst, isLast);
_spotlightOverlay.SetSpotlight(bounds, step.SpotlightShape, step.SpotlightCornerRadius);
}
// Update corner navigator
_cornerNavigator.UpdateForStep(index, _steps.Count);
// Get callout bounds for navigator auto-placement (so they don't overlap)
Rect? calloutBounds = null;
if (_calloutCard.IsVisible && DisplayMode != TourDisplayMode.SpotlightWithInlineLabel)
{
var calloutWidth = _calloutCard.Width > 0 ? _calloutCard.Width : 300;
var calloutHeight = _calloutCard.Height > 0 ? _calloutCard.Height : 150;
calloutBounds = new Rect(_calloutCard.TranslationX, _calloutCard.TranslationY, calloutWidth, calloutHeight);
}
_cornerNavigator.UpdateAutoPlacement(bounds, new Size(Width, Height), calloutBounds, _currentCalloutCorner);
// Raise StepEntered event (after animations complete)
RaiseStepEntered(step, index, previousIndex);
// Raise legacy StepChanged event
StepChanged?.Invoke(this, new OnboardingStepEventArgs(step, index, _steps.Count));
// Start auto-advance timer if enabled
StartAutoAdvanceTimer();
}
private void StartAutoAdvanceTimer()
{
// Cancel any existing timer
CancelAutoAdvanceTimer();
if (AutoAdvanceDelay <= 0 || !_isRunning)
return;
_autoAdvanceCts = new CancellationTokenSource();
var token = _autoAdvanceCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(AutoAdvanceDelay, token);
if (!token.IsCancellationRequested && _isRunning)
{
// Must dispatch to UI thread
MainThread.BeginInvokeOnMainThread(() =>
{
if (_isRunning)
GoToNextStep();
});
}
}
catch (TaskCanceledException)
{
// Timer was cancelled, ignore
}
}, token);
}
private void CancelAutoAdvanceTimer()
{
_autoAdvanceCts?.Cancel();
_autoAdvanceCts?.Dispose();
_autoAdvanceCts = null;
}
private async Task UpdateSpotlightWithCallout(OnboardingStep step, Rect bounds, CalloutPlacement placement, bool isFirst, bool isLast, int previousIndex)
{
var isForward = previousIndex < _currentIndex;
// Update spotlight
if (AnimationsEnabled && previousIndex >= 0)
{
await _spotlightOverlay.AnimateToAsync(bounds, step.SpotlightShape, step.SpotlightCornerRadius, AnimationDuration);
}
else
{
_spotlightOverlay.SetSpotlight(bounds, step.SpotlightShape, step.SpotlightCornerRadius);
}
// Animate step transition if this isn't the first step
if (AnimationsEnabled && previousIndex >= 0)
{
await _calloutCard.AnimateStepTransitionAsync(EffectiveAnimationConfig, isForward);
}
// Update callout card content
_calloutCard.UpdateForStep(step.Title, step.Description, isFirst, isLast);
// Position callout based on CalloutPositionMode
PositionCalloutCard(bounds, placement);
// Position arrow (only useful in Following mode) - ensure on main thread for UI updates
await MainThread.InvokeOnMainThreadAsync(() =>
{
if (ShowArrow && CalloutPositionMode == CalloutPositionMode.Following)
{
_arrowIndicator.IsVisible = true;
_arrowIndicator.ArrowColor = _calloutCard.CardBackgroundColor;
var calloutBounds = new Rect(
_calloutCard.TranslationX,
_calloutCard.TranslationY,
_calloutCard.Width > 0 ? _calloutCard.Width : _calloutCard.WidthRequest,
_calloutCard.Height > 0 ? _calloutCard.Height : 100);
_arrowIndicator.PositionBetween(calloutBounds, bounds, placement);
}
else
{
// Hide arrow in corner modes
_arrowIndicator.IsVisible = false;
}
// Hide inline label in this mode
_inlineLabel.IsVisible = false;
});
}
private void PositionCalloutCard(Rect spotlightBounds, CalloutPlacement placement)
{
var containerSize = new Size(Width, Height);
_currentCalloutCorner = null; // Reset
switch (CalloutPositionMode)
{
case CalloutPositionMode.Following:
_calloutCard.PositionRelativeToSpotlight(spotlightBounds, containerSize, placement);
break;
case CalloutPositionMode.FixedCorner:
_calloutCard.PositionInCorner(CalloutCorner, containerSize, CalloutCornerMargin);
_currentCalloutCorner = CalloutCorner;
break;
case CalloutPositionMode.AutoCorner:
var cardWidth = Math.Min(containerSize.Width * 0.85, 400);
var measured = _calloutCard.Measure(cardWidth, double.PositiveInfinity);
var bestCorner = CalloutCard.DetermineBestCorner(
spotlightBounds, containerSize, cardWidth, measured.Height, CalloutCornerMargin);
_calloutCard.PositionInCorner(bestCorner, containerSize, CalloutCornerMargin);
_currentCalloutCorner = bestCorner;
break;
}
}
private async Task UpdateCalloutOnly(OnboardingStep step, Rect bounds, CalloutPlacement placement, bool isFirst, bool isLast)
{
// No spotlight/dimming in this mode
_spotlightOverlay.ClearSpotlight();
// Update callout card content
_calloutCard.UpdateForStep(step.Title, step.Description, isFirst, isLast);
// Position callout based on CalloutPositionMode
PositionCalloutCard(bounds, placement);
// Position arrow pointing to target (only in Following mode) - ensure on main thread for UI updates
await MainThread.InvokeOnMainThreadAsync(() =>
{
if (ShowArrow && CalloutPositionMode == CalloutPositionMode.Following)
{
_arrowIndicator.IsVisible = true;
_arrowIndicator.ArrowColor = _calloutCard.CardBackgroundColor;
var calloutBounds = new Rect(
_calloutCard.TranslationX,
_calloutCard.TranslationY,
_calloutCard.Width > 0 ? _calloutCard.Width : _calloutCard.WidthRequest,
_calloutCard.Height > 0 ? _calloutCard.Height : 100);
_arrowIndicator.PositionBetween(calloutBounds, bounds, placement);
}
else
{
// Hide arrow in corner modes
_arrowIndicator.IsVisible = false;
}
// Hide inline label in this mode
_inlineLabel.IsVisible = false;
});
}
private async Task UpdateSpotlightWithInlineLabel(OnboardingStep step, Rect bounds, CalloutPlacement placement, int previousIndex)
{
// Update spotlight
if (AnimationsEnabled && previousIndex >= 0)
{
await _spotlightOverlay.AnimateToAsync(bounds, step.SpotlightShape, step.SpotlightCornerRadius, AnimationDuration);
}
else
{
_spotlightOverlay.SetSpotlight(bounds, step.SpotlightShape, step.SpotlightCornerRadius);
}
// Update inline label content
_inlineLabel.SetContent(step.Title, step.Description);
// Position inline label based on CalloutPositionMode
PositionInlineLabel(bounds, placement);
// Update visibility on main thread
await MainThread.InvokeOnMainThreadAsync(() =>
{
// Hide callout card, show inline label
_calloutCard.IsVisible = false;
_arrowIndicator.IsVisible = false;
_inlineLabel.IsVisible = true;
});
}
private void PositionInlineLabel(Rect spotlightBounds, CalloutPlacement placement)
{
var containerSize = new Size(Width, Height);
switch (CalloutPositionMode)
{
case CalloutPositionMode.Following:
_inlineLabel.PositionRelativeToSpotlight(spotlightBounds, containerSize, placement);
break;
case CalloutPositionMode.FixedCorner:
_inlineLabel.PositionInCorner(CalloutCorner, containerSize, CalloutCornerMargin);
break;
case CalloutPositionMode.AutoCorner:
var measured = _inlineLabel.Measure(_inlineLabel.MaximumWidthRequest, double.PositiveInfinity);
var bestCorner = InlineLabel.DetermineBestCorner(
spotlightBounds, containerSize, measured.Width, measured.Height, CalloutCornerMargin);
_inlineLabel.PositionInCorner(bestCorner, containerSize, CalloutCornerMargin);
break;
}
}
/// <summary>
/// Advances to the next step.
/// </summary>
public async Task GoToNextStepAsync()
{
if (_currentIndex < _steps.Count - 1)
{
await GoToStepAsync(_currentIndex + 1).ConfigureAwait(false);
}
else
{
await CompleteTourAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Advances to the next step (fire-and-forget version for event handlers).
/// </summary>
[Obsolete("Use GoToNextStepAsync() for proper async handling. This method exists for backward compatibility.")]
public void GoToNextStep()
{
_ = GoToNextStepAsync();
}
/// <summary>
/// Returns to the previous step.
/// </summary>
public async Task GoToPreviousStepAsync()
{
if (_currentIndex > 0)
{
await GoToStepAsync(_currentIndex - 1).ConfigureAwait(false);
}
}
/// <summary>
/// Returns to the previous step (fire-and-forget version for event handlers).
/// </summary>
[Obsolete("Use GoToPreviousStepAsync() for proper async handling. This method exists for backward compatibility.")]
public void GoToPreviousStep()
{
_ = GoToPreviousStepAsync();
}
/// <summary>
/// Completes the tour (user finished all steps).
/// </summary>
public async Task CompleteTourAsync()
{
if (!_isRunning)
return;
// Check if we should loop
if (AutoLoop > 0 && _loopCount < AutoLoop && _root != null)
{
_loopCount++;
await GoToStepAsync(0).ConfigureAwait(false); // Restart from first step
return;
}
EndTour(TourResult.Completed);
TourCompleted?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Completes the tour (fire-and-forget version for event handlers).
/// </summary>
[Obsolete("Use CompleteTourAsync() for proper async handling. This method exists for backward compatibility.")]
public void CompleteTour()
{
_ = CompleteTourAsync();
}
/// <summary>
/// Skips/cancels the tour.
/// </summary>
public void SkipTour()
{
if (!_isRunning)
return;
EndTour(TourResult.Skipped);
TourSkipped?.Invoke(this, EventArgs.Empty);
}
private void EndTour(TourResult result)
{
CancelAutoAdvanceTimer();
// Play exit animations if enabled
if (AnimationsEnabled && _isRunning)
{
_ = PlayExitAnimationsAndHideAsync(result);
}
else
{
HideTourElements(result);
}
}
private async Task PlayExitAnimationsAndHideAsync(TourResult result)
{
var config = EffectiveAnimationConfig;
var tasks = new List<Task>();
// Stop any spotlight effects
_spotlightOverlay.StopSpotlightEffect();
if (_spotlightOverlay.IsVisible)
{
tasks.Add(_spotlightOverlay.HideWithAnimationAsync(config));
}
if (_calloutCard.IsVisible)
{
tasks.Add(_calloutCard.HideWithAnimationAsync(config));
}
if (_inlineLabel.IsVisible)
{
tasks.Add(_inlineLabel.HideWithAnimationAsync(config));
}
if (_cornerNavigator.IsVisible)
{
tasks.Add(_cornerNavigator.HideWithAnimationAsync(config));
}
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch
{
// Ignore animation errors during exit
}
// Now hide everything
MainThread.BeginInvokeOnMainThread(() => HideTourElements(result));
}
private void HideTourElements(TourResult result)
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => HideTourElements(result));
return;
}
_isRunning = false;
_currentIndex = -1;
_steps = Array.Empty<OnboardingStep>();
_root = null;
IsVisible = false;
InputTransparent = false;
_spotlightOverlay.IsVisible = false;
_calloutCard.IsVisible = false;
_inlineLabel.IsVisible = false;
_cornerNavigator.IsVisible = false;
_arrowIndicator.IsVisible = false;
_introContainer.IsVisible = false;
_showingIntro = false;
_spotlightOverlay.ClearSpotlight();
// Reset animation states
_spotlightOverlay.ResetAnimationState();
_calloutCard.ResetAnimationState();
_inlineLabel.ResetAnimationState();
_cornerNavigator.ResetAnimationState();
// Complete the awaitable task
_tourCompletionSource?.TrySetResult(result);
_tourCompletionSource = null;
TourEnded?.Invoke(this, EventArgs.Empty);
}
private void OnSpotlightTapped(object? sender, EventArgs e)
{
var step = CurrentStep;
if (step == null)
return;
switch (step.TapBehavior)
{
case SpotlightTapBehavior.Advance:
GoToNextStep();
break;
case SpotlightTapBehavior.Close:
SkipTour();
break;
case SpotlightTapBehavior.AllowInteraction:
// TODO: Pass through the tap to the underlying element
break;
case SpotlightTapBehavior.None:
default:
// Do nothing
break;
}
}
private void OnDimmedAreaTapped(object? sender, EventArgs e)
{
// Optionally advance or do nothing when tapping outside
// Currently does nothing - could be made configurable
}
private CalloutPlacement DetermineAutoPlacement(Rect spotlightRect)
{
var config = TourConfiguration.Default;
return SpotlightGeometry.DetermineAutoPlacement(
spotlightRect,
new Size(Width, Height),
config.CalloutEstimatedHeight,
config.DefaultMargin,
config);
}
private void EndTourInternal(TourResult result)
{
CancelAutoAdvanceTimer();
// For internal use, skip animations and hide immediately
_spotlightOverlay.StopSpotlightEffect();
HideTourElements(result);
}
private async Task GoToStepInternalAsync(int index, CancellationToken cancellationToken = default)
{
if (!_isRunning || index < 0 || index >= _steps.Count || _root == null)
return;
var config = TourConfiguration.Default;
var previousIndex = _currentIndex;
var step = _steps[index];
// Execute leaving actions for current step (if any)
if (previousIndex >= 0 && previousIndex < _steps.Count)
{
var previousStep = _steps[previousIndex];
await ExecuteStepLeavingActionsAsync(previousStep, previousIndex, index, cancellationToken).ConfigureAwait(false);
}
// Execute entering actions for new step (before UI updates)
var enteringArgs = await ExecuteStepEnteringActionsAsync(step, index, previousIndex, cancellationToken).ConfigureAwait(false);
// Handle skip - move to next or previous step
if (enteringArgs.Skip)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Step {index} skipped via StepEntering event (internal)");
var isForward = previousIndex < index || previousIndex < 0;
var nextIndex = isForward ? index + 1 : index - 1;
if (nextIndex >= 0 && nextIndex < _steps.Count)
{
await GoToStepInternalAsync(nextIndex, cancellationToken).ConfigureAwait(false);
}
else if (isForward)
{
// No more steps forward, complete tour
await CompleteTourAsync().ConfigureAwait(false);
}
return;
}
_currentIndex = index;
// Ensure element is visible (scroll if needed) - with timeout to prevent hanging
try
{
using var scrollCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
scrollCts.CancelAfter(config.ScrollTimeoutMs);
await step.Target.EnsureVisibleAsync(AnimationsEnabled).WaitAsync(scrollCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Scroll timeout, continue with tour
}
catch
{
// Ignore other scroll errors - continue with tour
}
// Brief delay to allow scroll and layout to settle
await Task.Delay(config.LayoutSettleDelayMs, cancellationToken).ConfigureAwait(false);
// Calculate bounds - retry a few times if we get invalid bounds
var bounds = step.Target.GetAbsoluteBoundsWithPadding(_root, step.SpotlightPadding);
// If bounds are invalid, wait and retry
for (int retry = 0; retry < config.BoundsRetryCount && (bounds.Width <= 0 || bounds.Height <= 0); retry++)
{
await Task.Delay(config.BoundsRetryDelayMs, cancellationToken).ConfigureAwait(false);
bounds = step.Target.GetAbsoluteBoundsWithPadding(_root, step.SpotlightPadding);
}
// Fallback: if bounds are still invalid, use default size from config
if (bounds.Width <= 0 || bounds.Height <= 0)
{
bounds = new Rect(bounds.X, bounds.Y,
bounds.Width <= 0 ? config.FallbackElementWidth : bounds.Width,
bounds.Height <= 0 ? config.FallbackElementHeight : bounds.Height);
}
// Determine effective placement
var placement = step.Placement;
if (placement == CalloutPlacement.Auto)
{
placement = DetermineAutoPlacement(bounds);
}
var isFirst = index == 0;
var isLast = index == _steps.Count - 1;
// Handle different display modes (wrap in try-catch to ensure navigation continues even if animations fail)
try
{
switch (DisplayMode)
{
case TourDisplayMode.SpotlightWithCallout:
await UpdateSpotlightWithCallout(step, bounds, placement, isFirst, isLast, previousIndex);
break;
case TourDisplayMode.CalloutOnly:
await UpdateCalloutOnly(step, bounds, placement, isFirst, isLast);
break;
case TourDisplayMode.SpotlightWithInlineLabel:
await UpdateSpotlightWithInlineLabel(step, bounds, placement, previousIndex);
break;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Display mode update failed: {ex.Message}");
// Ensure basic UI state is updated even if animations fail
_calloutCard.UpdateForStep(step.Title, step.Description, isFirst, isLast);
_spotlightOverlay.SetSpotlight(bounds, step.SpotlightShape, step.SpotlightCornerRadius);
}
// Update corner navigator
_cornerNavigator.UpdateForStep(index, _steps.Count);
// Get callout bounds for navigator auto-placement (so they don't overlap)
Rect? calloutBounds = null;
if (_calloutCard.IsVisible && DisplayMode != TourDisplayMode.SpotlightWithInlineLabel)
{
var calloutWidth = _calloutCard.Width > 0 ? _calloutCard.Width : 300;
var calloutHeight = _calloutCard.Height > 0 ? _calloutCard.Height : config.CalloutEstimatedHeight;
calloutBounds = new Rect(_calloutCard.TranslationX, _calloutCard.TranslationY, calloutWidth, calloutHeight);
}
_cornerNavigator.UpdateAutoPlacement(bounds, new Size(Width, Height), calloutBounds, _currentCalloutCorner);
// Raise StepEntered event (after animations complete)
RaiseStepEntered(step, index, previousIndex);
// Raise legacy StepChanged event
StepChanged?.Invoke(this, new OnboardingStepEventArgs(step, index, _steps.Count));
// Start auto-advance timer if enabled
StartAutoAdvanceTimer();
}
private void ThrowIfDisposed()
{
if (_isDisposed)
throw new ObjectDisposedException(nameof(OnboardingHost));
}
private void ApplyTheme()
{
var isDark = GetEffectiveTheme() == TourTheme.Dark;
// Apply theme to callout card
_calloutCard.ApplyTheme(isDark);
// Apply theme to corner navigator
_cornerNavigator.ApplyTheme(isDark);
// Apply theme to inline label
_inlineLabel.ApplyTheme(isDark);
}
private void ApplyFlowDirection()
{
var effectiveDirection = GetEffectiveFlowDirection();
// Apply flow direction to all child components
_calloutCard.FlowDirection = effectiveDirection;
_cornerNavigator.FlowDirection = effectiveDirection;
_inlineLabel.FlowDirection = effectiveDirection;
_introContainer.FlowDirection = effectiveDirection;
// Notify components of RTL change for layout adjustments
var isRtl = effectiveDirection == FlowDirection.RightToLeft;
_calloutCard.ApplyFlowDirection(isRtl);
_cornerNavigator.ApplyFlowDirection(isRtl);
_inlineLabel.ApplyFlowDirection(isRtl);
}
/// <summary>
/// Gets the effective flow direction, resolving MatchParent to an actual direction.
/// </summary>
public FlowDirection GetEffectiveFlowDirection()
{
if (TourFlowDirection != FlowDirection.MatchParent)
return TourFlowDirection;
// Try to get from parent hierarchy
Element? current = Parent;
while (current != null)
{
if (current is VisualElement ve && ve.FlowDirection != FlowDirection.MatchParent)
return ve.FlowDirection;
current = current.Parent;
}
// Fall back to system culture
return System.Globalization.CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft
? FlowDirection.RightToLeft
: FlowDirection.LeftToRight;
}
/// <summary>
/// Gets whether the current effective flow direction is RTL.
/// </summary>
public bool IsRightToLeft => GetEffectiveFlowDirection() == FlowDirection.RightToLeft;
private TourTheme GetEffectiveTheme()
{
if (Theme == TourTheme.System)
{
return Application.Current?.RequestedTheme == AppTheme.Dark
? TourTheme.Dark
: TourTheme.Light;
}
return Theme;
}
#region Intro View
private void SetupIntroView(View? oldValue, View? newValue)
{
// Remove old intro view
if (oldValue != null)
{
_introContainer.Children.Remove(oldValue);
}
// Add new intro view
if (newValue != null)
{
_introContainer.Children.Clear();
_introContainer.Children.Add(newValue);
// User controls how to dismiss (via button or their own gesture)
}
}
/// <summary>
/// Displays the intro view. This is called automatically by StartTourWithIntroAsync.
/// </summary>
public void DisplayIntroView()
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(DisplayIntroView);
return;
}
if (IntroView == null)
return;
_showingIntro = true;
_introContainer.IsVisible = true;
// Show the dim overlay behind the intro
_spotlightOverlay.ClearSpotlight();
_spotlightOverlay.IsVisible = true;
IsVisible = true;
InputTransparent = false;
IntroShown?.Invoke(this, EventArgs.Empty);
}
private void OnIntroContainerTapped(object? sender, TappedEventArgs e)
{
if (!_showingIntro || !AllowTapAnywhereToDismissIntro)
return;
// Skip the entire tour, don't continue to first step
SkipTour();
}
/// <summary>
/// Dismisses the intro view and starts the tour.
/// </summary>
public async Task DismissIntroAsync()
{
if (!_showingIntro)
return;
_showingIntro = false;
// Ensure UI updates happen on the main thread
if (MainThread.IsMainThread)
{
_introContainer.IsVisible = false;
}
else
{
await MainThread.InvokeOnMainThreadAsync(() => _introContainer.IsVisible = false);
}
IntroDismissed?.Invoke(this, EventArgs.Empty);
// If steps are queued, continue with the tour
if (_steps.Count > 0 && _isRunning && _root != null)
{
try
{
await ContinueTourAfterIntro().ConfigureAwait(false);
}
catch
{
EndTour(TourResult.Cancelled);
}
}
else
{
// No tour queued, just hide everything
EndTour(TourResult.NoSteps);
}
}
/// <summary>
/// Dismisses the intro view (fire-and-forget version for event handlers).
/// </summary>
[Obsolete("Use DismissIntroAsync() for proper async handling. This method exists for backward compatibility.")]
public void DismissIntro()
{
_ = DismissIntroAsync();
}
private async Task ContinueTourAfterIntro()
{
// Verify we can continue
if (_steps.Count == 0 || !_isRunning || _root == null)
{
EndTour(TourResult.NoSteps);
return;
}
// Brief delay to let UI settle after intro dismissal
await Task.Delay(100);
// Ensure first step element bounds can be calculated
var firstStep = _steps[0];
if (firstStep?.Target == null)
{
EndTour(TourResult.NoSteps);
return;
}
// Position first step before showing anything
await GoToStepAsync(0);
// Check if step was actually set up (GoToStepAsync sets _currentIndex)
if (_currentIndex < 0)
{
// Failed to set up first step - end tour
EndTour(TourResult.NoSteps);
return;
}
// Now show everything based on display mode (ensure on main thread)
if (MainThread.IsMainThread)
{
ShowElementsForDisplayMode();
IsVisible = true; // Ensure host is visible
}
else
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
ShowElementsForDisplayMode();
IsVisible = true;
});
}
// Re-position after becoming visible (for corner modes that need valid dimensions)
await ReapplyCornerPositionIfNeededAsync();
}
/// <summary>
/// Starts the onboarding tour, showing the intro view first if one is configured.
/// Returns a task that completes when the tour ends, allowing chaining of actions.
/// </summary>
/// <param name="root">The root element to scan (typically the Page or a layout).</param>
/// <param name="group">Optional group filter.</param>
/// <param name="showIntro">Whether to show the intro view first (if configured). Default is true.</param>
/// <returns>A task that completes with the tour result when the tour ends.</returns>
/// <example>
/// // Chain actions after tour completion:
/// var result = await OnboardingHost.StartTourWithIntroAsync(this.Content);
/// if (result == TourResult.Completed)
/// await DisplayAlert("Welcome!", "You're all set!", "OK");
/// </example>
public async Task<TourResult> StartTourWithIntroAsync(VisualElement root, string? group = null, bool showIntro = true)
{
// Stop any existing tour first
if (_isRunning)
EndTour(TourResult.Cancelled);
_root = root;
_currentGroup = group;
_loopCount = 0;
_steps = OnboardingScanner.FindSteps(root, group);
if (_steps.Count == 0)
return TourResult.NoSteps;
// Create completion source for awaiting tour end
_tourCompletionSource = new TaskCompletionSource<TourResult>();
_isRunning = true;
_currentIndex = -1;
_cornerNavigator.TotalSteps = _steps.Count;
// Apply current theme to all components
ApplyTheme();
ApplyFlowDirection();
// Ensure layout is complete before positioning
await WaitForLayoutAsync();
TourStarted?.Invoke(this, EventArgs.Empty);
// Show intro if configured, requested, and ShowIntro property is true
if (showIntro && ShowIntro && IntroView != null)
{
DisplayIntroView();
// Tour will continue when DismissIntro is called
// Return the task that completes when tour ends
return await _tourCompletionSource.Task;
}
// No intro, proceed directly
await GoToStepAsync(0);
// Show elements on main thread
if (MainThread.IsMainThread)
{
ShowElementsForDisplayMode();
IsVisible = true;
}
else
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
ShowElementsForDisplayMode();
IsVisible = true;
});
}
// 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;
}
#endregion
#region IDisposable
/// <summary>
/// Releases resources used by the OnboardingHost.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases resources used by the OnboardingHost.
/// </summary>
/// <param name="disposing">True if called from Dispose(), false if from finalizer.</param>
protected virtual void Dispose(bool disposing)
{
if (_isDisposed)
return;
if (disposing)
{
// Stop any running tour
if (_isRunning)
EndTourInternal(TourResult.Cancelled);
// Cancel and dispose timer
CancelAutoAdvanceTimer();
// Dispose semaphore
_tourLock.Dispose();
// Unsubscribe events
_calloutCard.PreviousClicked -= OnCalloutPreviousClicked;
_calloutCard.NextClicked -= OnCalloutNextClicked;
_calloutCard.CloseClicked -= OnCalloutCloseClicked;
_cornerNavigator.PreviousClicked -= OnNavigatorPreviousClicked;
_cornerNavigator.NextClicked -= OnNavigatorNextClicked;
_cornerNavigator.SkipClicked -= OnNavigatorSkipClicked;
_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;
}
// Event handler methods for proper unsubscription
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) => _ = GoToPreviousStepAsync();
private void OnNavigatorNextClicked(object? sender, EventArgs e) => _ = GoToNextStepAsync();
private void OnNavigatorSkipClicked(object? sender, EventArgs e) => SkipTour();
#endregion
}
/// <summary>
/// Event args for step change events.
/// </summary>
public class OnboardingStepEventArgs : EventArgs
{
public OnboardingStep Step { get; }
public int StepIndex { get; }
public int TotalSteps { get; }
public OnboardingStepEventArgs(OnboardingStep step, int stepIndex, int totalSteps)
{
Step = step;
StepIndex = stepIndex;
TotalSteps = totalSteps;
}
}
/// <summary>
/// Event args for step action events (StepEntering, StepLeaving).
/// Allows actions to skip steps or perform async preparation.
/// </summary>
public class StepActionEventArgs : EventArgs
{
/// <summary>
/// The step being entered or left.
/// </summary>
public OnboardingStep Step { get; }
/// <summary>
/// The index of the step (0-based).
/// </summary>
public int StepIndex { get; }
/// <summary>
/// Total number of steps in the tour.
/// </summary>
public int TotalSteps { get; }
/// <summary>
/// The index of the previous step (-1 if this is the first step).
/// </summary>
public int PreviousStepIndex { get; }
/// <summary>
/// Whether navigation is moving forward (true) or backward (false).
/// </summary>
public bool IsForward { get; }
/// <summary>
/// Set to true to skip this step and move to the next one.
/// Only applies to StepEntering event.
/// </summary>
public bool Skip { get; set; }
/// <summary>
/// Cancellation token for the current operation.
/// </summary>
public CancellationToken CancellationToken { get; }
public StepActionEventArgs(
OnboardingStep step,
int stepIndex,
int totalSteps,
int previousStepIndex,
bool isForward,
CancellationToken cancellationToken = default)
{
Step = step;
StepIndex = stepIndex;
TotalSteps = totalSteps;
PreviousStepIndex = previousStepIndex;
IsForward = isForward;
CancellationToken = cancellationToken;
}
}