diff --git a/components/Marquee/samples/Dependencies.props b/components/Marquee/samples/Dependencies.props
index e622e1df4..b0471fc89 100644
--- a/components/Marquee/samples/Dependencies.props
+++ b/components/Marquee/samples/Dependencies.props
@@ -11,21 +11,21 @@
-
+
-
+
-
+
-
+
diff --git a/components/Marquee/samples/Marquee.md b/components/Marquee/samples/Marquee.md
index 69f8e9a96..ecb85181d 100644
--- a/components/Marquee/samples/Marquee.md
+++ b/components/Marquee/samples/Marquee.md
@@ -50,3 +50,9 @@ The default direction is left, meaning the content will move leftwards, but this
It is possible to use non-text content in the Marquee control. However templating must be used because the control will need to be duplicated for the looping animation.
> [!Sample MarqueeSample]
+
+# Level Up with Behaviors
+
+Use behaviors to triggers the Marquee on events
+
+> [!Sample MarqueeBehaviorSample]
diff --git a/components/Marquee/samples/MarqueeBehaviorSample.xaml b/components/Marquee/samples/MarqueeBehaviorSample.xaml
new file mode 100644
index 000000000..e9f3b53be
--- /dev/null
+++ b/components/Marquee/samples/MarqueeBehaviorSample.xaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/Marquee/samples/MarqueeBehaviorSample.xaml.cs b/components/Marquee/samples/MarqueeBehaviorSample.xaml.cs
new file mode 100644
index 000000000..e4bc7a8bf
--- /dev/null
+++ b/components/Marquee/samples/MarqueeBehaviorSample.xaml.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace MarqueeExperiment.Samples;
+
+[ToolkitSample(id: nameof(MarqueeBehaviorSample), "Marquee", description: "A control for scrolling content in a marquee fashion.")]
+public sealed partial class MarqueeBehaviorSample : Page
+{
+ public MarqueeBehaviorSample()
+ {
+ this.InitializeComponent();
+ }
+}
diff --git a/components/Marquee/samples/MarqueeSample.xaml b/components/Marquee/samples/MarqueeSample.xaml
index 5a37d7777..ad9325679 100644
--- a/components/Marquee/samples/MarqueeSample.xaml
+++ b/components/Marquee/samples/MarqueeSample.xaml
@@ -1,4 +1,4 @@
-
+
-
-
-
- MarqueeTextSample.xaml
-
-
-
-
- MarqueeSample.xaml
-
-
diff --git a/components/Marquee/samples/MarqueeTextSample.xaml b/components/Marquee/samples/MarqueeTextSample.xaml
index 3912726e9..7d5d25bd6 100644
--- a/components/Marquee/samples/MarqueeTextSample.xaml
+++ b/components/Marquee/samples/MarqueeTextSample.xaml
@@ -1,4 +1,4 @@
-
+
-
-
+
+
+
+
+
+
diff --git a/components/Marquee/samples/MarqueeTextSample.xaml.cs b/components/Marquee/samples/MarqueeTextSample.xaml.cs
index d42d73b9c..8ef7eccb7 100644
--- a/components/Marquee/samples/MarqueeTextSample.xaml.cs
+++ b/components/Marquee/samples/MarqueeTextSample.xaml.cs
@@ -16,6 +16,7 @@ namespace MarqueeExperiment.Samples;
#else
[ToolkitSampleMultiChoiceOption("MQBehavior", "Ticker", "Looping", Title = "Marquee Behavior")]
#endif
+[ToolkitSampleBoolOption("MQAuto", true, Title = "Auto Play")]
public sealed partial class MarqueeTextSample : Page
{
public MarqueeTextSample()
@@ -41,4 +42,12 @@ public MarqueeTextSample()
"Down" => MarqueeDirection.Down,
_ => throw new System.NotImplementedException(),
};
+
+ private void StartMarquee_Click(object sender, RoutedEventArgs e) => MarqueeControl.StartMarquee();
+
+ private void StopMarquee_Click(object sender, RoutedEventArgs e) => MarqueeControl.StopMarquee();
+
+ private void ResumeMarquee_Click(object sender, RoutedEventArgs e) => MarqueeControl.ResumeMarquee();
+
+ private void PauseMarquee_Click(object sender, RoutedEventArgs e) => MarqueeControl.PauseMarquee();
}
diff --git a/components/Marquee/src/Marquee.Events.cs b/components/Marquee/src/Marquee.Events.cs
index 53502d7e2..1343bd201 100644
--- a/components/Marquee/src/Marquee.Events.cs
+++ b/components/Marquee/src/Marquee.Events.cs
@@ -12,13 +12,23 @@ public partial class Marquee
///
/// Event raised when the Marquee begins scrolling.
///
- public event EventHandler? MarqueeBegan;
+ public event EventHandler? MarqueeStarted;
///
- /// Event raised when the Marquee stops scrolling for any reason.
+ /// Event raised when the Marquee is stopped manually or completed.
///
public event EventHandler? MarqueeStopped;
+ ///
+ /// Event raised when the Marquee is resumed from a pause.
+ ///
+ public event EventHandler? MarqueeResumed;
+
+ ///
+ /// Event raised when the Marquee is paused.
+ ///
+ public event EventHandler? MarqueePaused;
+
///
/// Event raised when the Marquee completes scrolling.
///
@@ -28,7 +38,7 @@ private void Marquee_Loaded(object sender, RoutedEventArgs e)
{
// While loaded, detach the loaded event and attach the unloaded event
this.Loaded -= this.Marquee_Loaded;
- this.Unloaded += Marquee_Unloaded;
+ this.Unloaded += this.Marquee_Unloaded;
// Attach other events
if (_marqueeContainer is not null)
@@ -36,10 +46,24 @@ private void Marquee_Loaded(object sender, RoutedEventArgs e)
_marqueeContainer.SizeChanged += Container_SizeChanged;
}
+ if (_segment1 is not null)
+ {
+ _segment1.SizeChanged += Segment_SizeChanged;
+ }
+
if (_marqueeStoryboard is not null)
{
_marqueeStoryboard.Completed += StoryBoard_Completed;
}
+
+ // Setup the animation
+ UpdateMarquee(false);
+
+ // The marquee should run when loaded if auto play is enabled
+ if (AutoPlay)
+ {
+ StartMarquee();
+ }
}
private void Marquee_Unloaded(object sender, RoutedEventArgs e)
@@ -53,6 +77,11 @@ private void Marquee_Unloaded(object sender, RoutedEventArgs e)
_marqueeContainer.SizeChanged -= Container_SizeChanged;
}
+ if (_segment1 is not null)
+ {
+ _segment1.SizeChanged -= Segment_SizeChanged;
+ }
+
if (_marqueeStoryboard is not null)
{
_marqueeStoryboard.Completed -= StoryBoard_Completed;
@@ -62,9 +91,7 @@ private void Marquee_Unloaded(object sender, RoutedEventArgs e)
private void Container_SizeChanged(object sender, SizeChangedEventArgs e)
{
if (_marqueeContainer is null)
- {
return;
- }
// Clip the marquee within its bounds
_marqueeContainer.Clip = new RectangleGeometry
@@ -72,25 +99,30 @@ private void Container_SizeChanged(object sender, SizeChangedEventArgs e)
Rect = new Rect(0, 0, e.NewSize.Width, e.NewSize.Height)
};
+ // Update animation on the fly
+ UpdateMarquee(true);
+
// The marquee should run when the size changes in case the text gets cutoff
- StartMarquee();
+ // and auto play is enabled.
+ if (AutoPlay)
+ {
+ StartMarquee();
+ }
}
private void Segment_SizeChanged(object sender, SizeChangedEventArgs e)
{
if (_segment1 is null)
- {
return;
- }
// If the segment size changes, we need to update the storyboard,
// and seek to the correct position to maintain a smooth animation.
- UpdateAnimation(true);
+ UpdateMarquee(true);
}
private void StoryBoard_Completed(object? sender, object e)
{
- StopMarquee(true);
+ StopMarquee();
MarqueeCompleted?.Invoke(this, EventArgs.Empty);
}
}
diff --git a/components/Marquee/src/Marquee.Properties.cs b/components/Marquee/src/Marquee.Properties.cs
index 000817633..107896ebf 100644
--- a/components/Marquee/src/Marquee.Properties.cs
+++ b/components/Marquee/src/Marquee.Properties.cs
@@ -11,6 +11,9 @@ namespace CommunityToolkit.WinUI.Controls;
///
public partial class Marquee
{
+ private static readonly DependencyProperty AutoPlayProperty =
+ DependencyProperty.Register(nameof(AutoPlay), typeof(bool), typeof(Marquee), new PropertyMetadata(false));
+
private static readonly DependencyProperty SpeedProperty =
DependencyProperty.Register(nameof(Speed), typeof(double), typeof(Marquee), new PropertyMetadata(32d, PropertyChanged));
@@ -18,11 +21,20 @@ public partial class Marquee
DependencyProperty.Register(nameof(RepeatBehavior), typeof(RepeatBehavior), typeof(Marquee), new PropertyMetadata(new RepeatBehavior(1), PropertyChanged));
private static readonly DependencyProperty BehaviorProperty =
- DependencyProperty.Register(nameof(Behavior), typeof(MarqueeBehavior), typeof(Marquee), new PropertyMetadata(0, BehaviorPropertyChanged));
+ DependencyProperty.Register(nameof(Behavior), typeof(MarqueeBehavior), typeof(Marquee), new PropertyMetadata(MarqueeBehavior.Ticker, BehaviorPropertyChanged));
private static readonly DependencyProperty DirectionProperty =
DependencyProperty.Register(nameof(Direction), typeof(MarqueeDirection), typeof(Marquee), new PropertyMetadata(MarqueeDirection.Left, DirectionPropertyChanged));
+ ///
+ /// Gets or sets whether or not the Marquee plays immediately upon loading or updating a property.
+ ///
+ public bool AutoPlay
+ {
+ get => (bool)GetValue(AutoPlayProperty);
+ set => SetValue(AutoPlayProperty, value);
+ }
+
///
/// Gets or sets the speed the text moves in the Marquee.
///
@@ -79,17 +91,16 @@ public MarqueeDirection Direction
private static void BehaviorPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not Marquee control)
- {
return;
- }
- bool active = control._isActive;
var newBehavior = (MarqueeBehavior)e.NewValue;
VisualStateManager.GoToState(control, GetVisualStateName(newBehavior), true);
- control.StopMarquee(false);
- if (active)
+ // It is always impossible to perform an on the fly behavior change.
+ control.UpdateMarquee(false);
+
+ if (control.AutoPlay)
{
control.StartMarquee();
}
@@ -98,11 +109,8 @@ private static void BehaviorPropertyChanged(DependencyObject d, DependencyProper
private static void DirectionPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not Marquee control)
- {
return;
- }
- bool active = control._isActive;
var oldDirection = (MarqueeDirection)e.OldValue;
var newDirection = (MarqueeDirection)e.NewValue;
bool oldAxisX = oldDirection is MarqueeDirection.Left or MarqueeDirection.Right;
@@ -110,12 +118,11 @@ private static void DirectionPropertyChanged(DependencyObject d, DependencyPrope
VisualStateManager.GoToState(control, GetVisualStateName(newDirection), true);
- if (oldAxisX != newAxisX)
- {
- control.StopMarquee(false);
- }
+ // If the axis changed we cannot update the animation on the fly.
+ // Otherwise, the animation can be updated and resumed seamlessly
+ control.UpdateMarquee(oldAxisX == newAxisX);
- if (active)
+ if (control.AutoPlay)
{
control.StartMarquee();
}
@@ -124,10 +131,15 @@ private static void DirectionPropertyChanged(DependencyObject d, DependencyPrope
private static void PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not Marquee control)
- {
return;
- }
- control.UpdateAnimation();
+ // It is always possible to update these properties on the fly.
+ // NOTE: The RepeatBehavior will reset its count though. Can this be fixed?
+ control.UpdateMarquee(true);
+
+ if (control.AutoPlay)
+ {
+ control.StartMarquee();
+ }
}
}
diff --git a/components/Marquee/src/Marquee.cs b/components/Marquee/src/Marquee.cs
index d29d2e04a..dcba7b1ee 100644
--- a/components/Marquee/src/Marquee.cs
+++ b/components/Marquee/src/Marquee.cs
@@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using System.Diagnostics.CodeAnalysis;
+
namespace CommunityToolkit.WinUI.Controls;
///
@@ -26,6 +28,7 @@ public partial class Marquee : ContentControl
private const string MarqueeTransformPartName = "MarqueeTransform";
private const string MarqueeActiveState = "MarqueeActive";
+ private const string MarqueePausedState = "MarqueePaused";
private const string MarqueeStoppedState = "MarqueeStopped";
private const string DirectionVisualStateGroupName = "DirectionStateGroup";
@@ -45,7 +48,16 @@ public partial class Marquee : ContentControl
private TranslateTransform? _marqueeTransform;
private Storyboard? _marqueeStoryboard;
- private bool _isActive;
+ // Used to track if the marquee is active or not.
+ // This signifies being mid animation. A paused marquee is still active!
+ private bool _isActive = false;
+ private bool _isPaused = false;
+
+ // This is used to track the position when stopped.
+ // If the animation update happens while running, this position
+ // is lost and must be set when the animation stops.
+ private double _stoppedPosition;
+ private DependencyProperty? _animationProperty;
///
/// Initializes a new instance of the class.
@@ -55,6 +67,14 @@ public Marquee()
DefaultStyleKey = typeof(Marquee);
}
+ ///
+ /// Unsubscribes from the loaded event when the control is being disposed.
+ ///
+ ~Marquee()
+ {
+ Loaded -= this.Marquee_Loaded;
+ }
+
///
protected override void OnApplyTemplate()
{
@@ -66,12 +86,10 @@ protected override void OnApplyTemplate()
_segment2 = (ContentPresenter)GetTemplateChild(Segment2PartName);
_marqueeTransform = (TranslateTransform)GetTemplateChild(MarqueeTransformPartName);
- _marqueeContainer.SizeChanged += Container_SizeChanged;
- _segment1.SizeChanged += Segment_SizeChanged;
-
// Swapping tabs in TabView caused errors where the control would unload and never reattach events.
- // Hotfix: Track the loaded event. This should be fine because the GC will handle detaching the Loaded
- // event on disposal. However, more research is required
+ // Fix: Track the loaded event. This should be fine because the GC will handle detaching the Loaded
+ // event on disposal. However, more research is required.
+ // As a result, all other events should be attached in the Loaded event handler.
Loaded += this.Marquee_Loaded;
VisualStateManager.GoToState(this, GetVisualStateName(Direction), false);
@@ -104,19 +122,54 @@ private static string GetVisualStateName(MarqueeBehavior behavior)
}
///
- /// Begins the Marquee animation if not running.
+ /// Begins the Marquee animation if not running or resumes if paused.
///
/// Thrown when template parts are not supplied.
- public void StartMarquee()
+ public void StartMarquee() => PlayMarquee(fromStart: false);
+
+ ///
+ /// Restarts the Marquee from the start of the animation regardless of current state.
+ ///
+ ///
+ /// will not be raised if the marquee was already active.
+ ///
+ public void RestartMarquee() => PlayMarquee(fromStart: true);
+
+ ///
+ /// Resumes the Marquee animation if paused.
+ ///
+ public void ResumeMarquee()
{
- bool initial = _isActive;
- _isActive = true;
- bool playing = UpdateAnimation(initial);
+ // If not paused or not active, do nothing
+ if (!_isPaused || !_isActive)
+ return;
+
+ // Resume the storyboard
+ _isPaused = false;
+ _marqueeStoryboard?.Resume();
- // Invoke MarqueeBegan if Marquee is now playing and was not before
- if (playing && !initial)
+ // Apply state transitions
+ VisualStateManager.GoToState(this, MarqueeActiveState, false);
+ MarqueeResumed?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Pauses the Marquee animation.
+ ///
+ public void PauseMarquee()
+ {
+ // Log initial paused status
+ bool wasPaused = _isPaused;
+
+ // Ensure paused status
+ _marqueeStoryboard?.Pause();
+ _isPaused = true;
+
+ if (!wasPaused)
{
- MarqueeBegan?.Invoke(this, EventArgs.Empty);
+ // Apply state transitions
+ VisualStateManager.GoToState(this, MarqueePausedState, false);
+ MarqueePaused?.Invoke(this, EventArgs.Empty);
}
}
@@ -126,48 +179,125 @@ public void StartMarquee()
/// Thrown when template parts are not supplied.
public void StopMarquee()
{
- StopMarquee(_isActive);
- }
+ bool wasStopped = !_isActive;
- private void StopMarquee(bool initialState)
- {
- // Set _isActive and update the animation to match
+ // Ensure stopped status
+ _marqueeStoryboard?.Stop();
_isActive = false;
- bool playing = UpdateAnimation(false);
+ _isPaused = false;
+
+ // Set the transform to the stopped position if provided.
+ if (_animationProperty is not null)
+ {
+ _marqueeTransform?.SetValue(_animationProperty, _stoppedPosition);
+ }
- // Invoke MarqueeStopped if Marquee is not playing and was before
- if (!playing && initialState)
+ if (!wasStopped)
{
+ // Apply state transitions
+ VisualStateManager.GoToState(this, MarqueeStoppedState, false);
MarqueeStopped?.Invoke(this, EventArgs.Empty);
}
}
+ private void PlayMarquee(bool fromStart = false)
+ {
+ // Resume if paused and not playing from start
+ if (!fromStart && _isPaused && _isActive)
+ {
+ ResumeMarquee();
+ return;
+ }
+
+ // Do nothing if storyboard is null or already playing and not from start.
+ if (_marqueeStoryboard is null || (_isActive && !fromStart))
+ return;
+
+ bool wasActive = _isActive;
+
+ // Stop the storboard if it is already active and playing from start
+ if (fromStart)
+ {
+ _marqueeStoryboard.Stop();
+ }
+
+ // Start the storyboard
+ _marqueeStoryboard.Begin();
+
+ // Update the status variables
+ _isActive = true;
+ _isPaused = false;
+
+ if (!wasActive)
+ {
+ // Apply state transitions
+ VisualStateManager.GoToState(this, MarqueeActiveState, false);
+ MarqueeStarted?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ private void UpdateMarquee(bool onTheFly)
+ {
+ // Check for crucial template parts
+ if (_marqueeTransform is null)
+ return;
+
+ // If the update cannot be made on the fly,
+ // stop the marquee and reset the transform
+ if (!onTheFly)
+ {
+ StopMarquee();
+ _marqueeTransform.X = 0;
+ _marqueeTransform.Y = 0;
+ }
+
+ // Apply the animation update
+ bool hasAnimation = UpdateAnimation(out var seek);
+
+ // If updating on the fly, and there is an animation,
+ // seek to the correct position
+ if (onTheFly && hasAnimation && _isActive)
+ {
+ _marqueeStoryboard?.Begin();
+ _marqueeStoryboard?.Seek(seek);
+
+ // Restore paused state if necessary
+ if (_isPaused)
+ {
+ PauseMarquee();
+ }
+ }
+ }
+
///
/// Updates the animation to match the current control state.
///
- /// True if animation should resume from its current position, false if it should restart.
+ ///
+ /// When in looping mode, it is possible that no animation is necessary.
+ ///
+ /// The seek point to resume the animation (if possible or appropriate.
/// Thrown when template parts are not supplied.
- /// True if the Animation is now playing.
- private bool UpdateAnimation(bool resume = true)
+ /// Returns whether or not an animation is neccesary.
+ private bool UpdateAnimation(out TimeSpan seekPoint)
{
- // Crucial template parts are missing!
- // This can happen during initialization of certain properties.
- // Gracefully return when this happens. Proper checks for these template parts happen in OnApplyTemplate.
+ seekPoint = TimeSpan.Zero;
+
+ // Check for crucial template parts
if (_marqueeContainer is null ||
_marqueeTransform is null ||
_segment1 is null ||
_segment2 is null)
{
+ // Crucial template parts are missing!
+ // This can happen during initialization of certain properties.
+ // Gracefully return when this happens. Proper checks for these template parts happen in OnApplyTemplate.
return false;
}
- // The marquee is stopped.
- // Update the animation to the stopped position.
- if (!_isActive)
+ // Unbind events from the old storyboard
+ if (_marqueeStoryboard is not null)
{
- VisualStateManager.GoToState(this, MarqueeStoppedState, false);
-
- return false;
+ _marqueeStoryboard.Completed -= StoryBoard_Completed;
}
// Get the size of the container and segment, based on the orientation.
@@ -204,18 +334,17 @@ _segment1 is null ||
// If the marquee is in looping mode and the segment is smaller
// than the container, then the animation does not not need to play.
- // NOTE: Use resume as initial because _isActive is updated before
- // calling update animation. If _isActive were passed, it would allow for
- // MarqueeStopped to be invoked when the marquee was already stopped.
- StopMarquee(resume);
+ // Reset the transform to 0 and hide the second segment
+ _marqueeContainer.SetValue(dp, 0);
_segment2.Visibility = Visibility.Collapsed;
-
+
+ _marqueeStoryboard = null;
return false;
}
// The start position is offset 100% if in ticker mode
// Otherwise it's 0
- double start = IsTicker ? containerSize : 0;
+ double start = IsTicker ? containerSize + 1 : 0;
// The end is when the end of the text reaches the border if in bouncing mode
// Otherwise it is when the first set of text is 100% out of view
@@ -228,6 +357,7 @@ _segment1 is null ||
// If the distance is zero, don't play an animation
if (distance is 0)
{
+ _marqueeStoryboard = null;
return false;
}
@@ -243,29 +373,12 @@ _segment1 is null ||
// Calculate the animation duration by dividing the distance by the speed
TimeSpan duration = TimeSpan.FromSeconds(distance / Speed);
- // Unbind events from the old storyboard
- if (_marqueeStoryboard is not null)
- {
- _marqueeStoryboard.Completed -= StoryBoard_Completed;
- }
-
// Create new storyboard and animation
_marqueeStoryboard = CreateMarqueeStoryboardAnimation(start, end, duration, targetProperty);
// Bind the storyboard completed event
_marqueeStoryboard.Completed += StoryBoard_Completed;
- // Set the visual state to active and begin the animation
- VisualStateManager.GoToState(this, MarqueeActiveState, true);
- _marqueeStoryboard.Begin();
-
- // If resuming, seek the animation so the text resumes from its current position.
- if (resume)
- {
- double progress = Math.Abs(start - value) / distance;
- _marqueeStoryboard.Seek(TimeSpan.FromTicks((long)(duration.Ticks * progress)));
- }
-
// NOTE: Can this be optimized to remove or reduce the need for this callback?
// Invalidate the segment measures when the transform changes.
// This forces virtualized panels to re-measure the segments
@@ -275,6 +388,22 @@ _segment1 is null ||
_segment2.InvalidateMeasure();
});
+ // Calculate the seek point for seamless animation updates
+ double progress = Math.Abs(start - value) / distance;
+ seekPoint = TimeSpan.FromTicks((long)(duration.Ticks * progress));
+
+ // Set the value of the transform to the start position if not active.
+ // This puts the content in the correct starting position without using the animation.
+ if (!_isActive)
+ {
+ _marqueeTransform.SetValue(dp, start);
+ }
+
+ // Set stopped position and animation property regardless of the active state.
+ // This will be used when the animation stops.
+ _stoppedPosition = start;
+ _animationProperty = dp;
+
return true;
}
diff --git a/components/Marquee/src/Marquee.xaml b/components/Marquee/src/Marquee.xaml
index 73f6ec52c..ab1367556 100644
--- a/components/Marquee/src/Marquee.xaml
+++ b/components/Marquee/src/Marquee.xaml
@@ -1,4 +1,4 @@
-
+
-
-
-
-
-
-
+
+