2293 lines
79 KiB
C#
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;
|
|
}
|
|
}
|