From 0d8b18ffaecd95a629c16f3feb9c437da6953f07 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 23 Sep 2025 13:03:05 +0300 Subject: [PATCH 1/7] Added pausing to Marquee and improved state transition logic --- components/Marquee/src/Marquee.Events.cs | 30 ++- components/Marquee/src/Marquee.Properties.cs | 46 ++-- components/Marquee/src/Marquee.cs | 223 ++++++++++++++----- components/Marquee/src/Marquee.xaml | 10 +- 4 files changed, 226 insertions(+), 83 deletions(-) diff --git a/components/Marquee/src/Marquee.Events.cs b/components/Marquee/src/Marquee.Events.cs index 53502d7e2..03c11a558 100644 --- a/components/Marquee/src/Marquee.Events.cs +++ b/components/Marquee/src/Marquee.Events.cs @@ -12,13 +12,26 @@ public partial class Marquee /// /// Event raised when the Marquee begins scrolling. /// - public event EventHandler? MarqueeBegan; + /// + /// Could be started. Could be resumed. + /// + 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. /// @@ -72,8 +85,15 @@ 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) @@ -85,12 +105,12 @@ private void Segment_SizeChanged(object sender, SizeChangedEventArgs e) // 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..0f054c308 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,10 @@ 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; /// /// Initializes a new instance of the class. @@ -104,19 +110,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(); + + // Apply state transitions + VisualStateManager.GoToState(this, MarqueeActiveState, false); + MarqueeResumed?.Invoke(this, EventArgs.Empty); + } - // Invoke MarqueeBegan if Marquee is now playing and was not before - if (playing && !initial) + /// + /// 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,49 +167,107 @@ 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; - // 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); } } - /// - /// Updates the animation to match the current control state. - /// - /// True if animation should resume from its current position, false if it should restart. - /// Thrown when template parts are not supplied. - /// True if the Animation is now playing. - private bool UpdateAnimation(bool resume = true) + private void PlayMarquee(bool fromStart = false) { - // 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. - if (_marqueeContainer is null || - _marqueeTransform is null || - _segment1 is null || - _segment2 is null) + // Resume if paused and not playing from start + if (!fromStart && _isPaused && _isActive) { - return false; + ResumeMarquee(); + return; } - // The marquee is stopped. - // Update the animation to the stopped position. - if (!_isActive) + // 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) { - VisualStateManager.GoToState(this, MarqueeStoppedState, false); + _marqueeStoryboard.Stop(); + } - return false; + // 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(!HasTemplateParts()) + 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. + /// + /// + /// 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. + /// Returns whether or not an animation is neccesary. + [MemberNotNullWhen(true, nameof(_marqueeStoryboard))] + private bool UpdateAnimation(out TimeSpan seekPoint) + { + seekPoint = TimeSpan.Zero; + + // Check for crucial template parts + if (!HasTemplateParts()) + return false; // Get the size of the container and segment, based on the orientation. // Also track the property to adjust, also based on the orientation. @@ -204,18 +303,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 @@ -255,17 +353,6 @@ _segment1 is null || // 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 +362,17 @@ _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); + } + return true; } @@ -325,4 +423,21 @@ private Storyboard CreateMarqueeStoryboardAnimation(double start, double end, Ti return marqueeStoryboard; } + + [MemberNotNullWhen(true, nameof(_marqueeContainer), nameof(_marqueeTransform), nameof(_segment1), nameof(_segment2))] + private bool HasTemplateParts() + { + 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; + } + + 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 @@ - + - - - - - - + + From 6052375f54b6feb747f70916937ba1a5696e97f3 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 23 Sep 2025 13:11:41 +0300 Subject: [PATCH 2/7] Updated Marquee samples --- components/Marquee/samples/Marquee.md | 6 ++ .../samples/MarqueeBehaviorSample.xaml | 56 +++++++++++++++++++ .../samples/MarqueeBehaviorSample.xaml.cs | 14 +++++ components/Marquee/samples/MarqueeSample.xaml | 5 +- .../samples/MarqueeText.Samples.csproj | 12 +++- .../Marquee/samples/MarqueeTextSample.xaml | 18 +++++- .../Marquee/samples/MarqueeTextSample.xaml.cs | 9 +++ 7 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 components/Marquee/samples/MarqueeBehaviorSample.xaml create mode 100644 components/Marquee/samples/MarqueeBehaviorSample.xaml.cs 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..bfcf6071d --- /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 @@ - + - + + + + + + MarqueeBehaviorSample.xaml + + + MarqueeBehaviorSample.xaml + 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 @@ - + - - + + +