diff --git a/demo/Ursa.Demo/Pages/MarqueeDemo.axaml b/demo/Ursa.Demo/Pages/MarqueeDemo.axaml new file mode 100644 index 0000000..eb79d11 --- /dev/null +++ b/demo/Ursa.Demo/Pages/MarqueeDemo.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/demo/Ursa.Demo/Pages/MarqueeDemo.axaml.cs b/demo/Ursa.Demo/Pages/MarqueeDemo.axaml.cs new file mode 100644 index 0000000..0f58a44 --- /dev/null +++ b/demo/Ursa.Demo/Pages/MarqueeDemo.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Ursa.Demo.Pages; + +public partial class MarqueeDemo : UserControl +{ + public MarqueeDemo() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs index 3fc9e46..b093413 100644 --- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs @@ -54,6 +54,7 @@ public partial class MainViewViewModel : ViewModelBase MenuKeys.MenuKeyIpBox => new IPv4BoxDemoViewModel(), MenuKeys.MenuKeyKeyGestureInput => new KeyGestureInputDemoViewModel(), MenuKeys.MenuKeyLoading => new LoadingDemoViewModel(), + MenuKeys.MenuKeyMarquee => new MarqueeDemoViewModel(), MenuKeys.MenuKeyMessageBox => new MessageBoxDemoViewModel(), MenuKeys.MenuKeyMultiComboBox => new MultiComboBoxDemoViewModel(), MenuKeys.MenuKeyNavMenu => new NavMenuDemoViewModel(), diff --git a/demo/Ursa.Demo/ViewModels/MarqueeDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/MarqueeDemoViewModel.cs new file mode 100644 index 0000000..cea4d3b --- /dev/null +++ b/demo/Ursa.Demo/ViewModels/MarqueeDemoViewModel.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Ursa.Demo.ViewModels; + +public class MarqueeDemoViewModel: ViewModelBase +{ + +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs index 7559eb7..9b90094 100644 --- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs @@ -35,6 +35,7 @@ public class MenuViewModel : ViewModelBase new() { MenuHeader = "IPv4Box", Key = MenuKeys.MenuKeyIpBox }, new() { MenuHeader = "KeyGestureInput", Key = MenuKeys.MenuKeyKeyGestureInput }, new() { MenuHeader = "Loading", Key = MenuKeys.MenuKeyLoading }, + new() { MenuHeader = "Marquee", Key = MenuKeys.MenuKeyMarquee }, new() { MenuHeader = "Message Box", Key = MenuKeys.MenuKeyMessageBox }, new() { MenuHeader = "MultiComboBox", Key = MenuKeys.MenuKeyMultiComboBox, Status = "Updated" }, new() { MenuHeader = "Nav Menu", Key = MenuKeys.MenuKeyNavMenu }, @@ -89,6 +90,7 @@ public static class MenuKeys public const string MenuKeyIpBox = "IPv4Box"; public const string MenuKeyKeyGestureInput = "KeyGestureInput"; public const string MenuKeyLoading = "Loading"; + public const string MenuKeyMarquee = "Marquee"; public const string MenuKeyMessageBox = "MessageBox"; public const string MenuKeyMultiComboBox = "MultiComboBox"; public const string MenuKeyNavMenu = "NavMenu"; diff --git a/src/Ursa.Themes.Semi/Controls/Marquee.axaml b/src/Ursa.Themes.Semi/Controls/Marquee.axaml new file mode 100644 index 0000000..1cf94a2 --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/Marquee.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index 4129232..0c8421c 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -27,6 +27,7 @@ + diff --git a/src/Ursa/Controls/Marquee/Direction.cs b/src/Ursa/Controls/Marquee/Direction.cs new file mode 100644 index 0000000..bd00e24 --- /dev/null +++ b/src/Ursa/Controls/Marquee/Direction.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Ursa.Controls; + +public enum Direction +{ + Left, + Right, + Up, + Down, +} \ No newline at end of file diff --git a/src/Ursa/Controls/Marquee/Marquee.cs b/src/Ursa/Controls/Marquee/Marquee.cs new file mode 100644 index 0000000..4208db0 --- /dev/null +++ b/src/Ursa/Controls/Marquee/Marquee.cs @@ -0,0 +1,278 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Threading; +using Irihi.Avalonia.Shared.Helpers; +using Timer = System.Timers.Timer; + +namespace Ursa.Controls; + +public class Marquee : ContentControl +{ + /// + /// Defines the property. + /// + public static readonly StyledProperty IsRunningProperty = AvaloniaProperty.Register( + nameof(IsRunning), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty DirectionProperty = AvaloniaProperty.Register( + nameof(Direction)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SpeedProperty = AvaloniaProperty.Register( + nameof(Speed), 60.0, coerce: OnCoerceSpeed); + + private static double OnCoerceSpeed(AvaloniaObject arg1, double arg2) + { + if (arg2 < 0) return 0; + return arg2; + } + + private Timer? _timer; + + static Marquee() + { + ClipToBoundsProperty.OverrideDefaultValue(true); + HorizontalContentAlignmentProperty.OverrideDefaultValue(HorizontalAlignment.Center); + VerticalContentAlignmentProperty.OverrideDefaultValue(VerticalAlignment.Center); + HorizontalContentAlignmentProperty.Changed.AddClassHandler((o,_)=>o.InvalidatePresenterPosition()); + VerticalContentAlignmentProperty.Changed.AddClassHandler((o,_)=>o.InvalidatePresenterPosition()); + IsRunningProperty.Changed.AddClassHandler((o, args) => o.OnIsRunningChanged(args)); + } + + private void OnIsRunningChanged(AvaloniaPropertyChangedEventArgs args) + { + if (args.NewValue.Value) + { + _timer?.Start(); + } + else + { + _timer?.Stop(); + } + } + + public Marquee() + { + _timer = new Timer(); + _timer.Interval = 1000 / 60.0; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (Presenter is not null) + { + Presenter.SizeChanged+= OnPresenterSizeChanged; + } + _timer?.Stop(); + _timer?.Dispose(); + _timer = new Timer(); + _timer.Interval = 1000 / 60.0; + _timer.Elapsed += TimerOnTick; + if (IsRunning) + { + _timer.Start(); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + if (_timer is not null) + { + _timer.Elapsed -= TimerOnTick; + _timer.Stop(); + _timer.Dispose(); + } + if (Presenter is not null) + { + Presenter.SizeChanged -= OnPresenterSizeChanged; + } + } + + /// + /// Gets or sets a value indicating whether the marquee is running. + /// + public bool IsRunning + { + get => GetValue(IsRunningProperty); + set => SetValue(IsRunningProperty, value); + } + + /// + /// Gets or sets the direction of the marquee. + /// + public Direction Direction + { + get => GetValue(DirectionProperty); + set => SetValue(DirectionProperty, value); + } + + /// + /// Gets or sets the speed of the marquee. Point per second. + /// + public double Speed + { + get => GetValue(SpeedProperty); + set => SetValue(SpeedProperty, value); + } + + private void OnPresenterSizeChanged(object sender, SizeChangedEventArgs e) + { + InvalidatePresenterPosition(); + } + + + + private void TimerOnTick(object sender, System.EventArgs e) + { + if (Presenter is null) return; + var layoutValues = Dispatcher.UIThread.Invoke(GetLayoutValues); + var location = UpdateLocation(layoutValues); + if (location is null) return; + Dispatcher.UIThread.Post(() => + { + Canvas.SetTop(Presenter, location.Value.top); + Canvas.SetLeft(Presenter, location.Value.left); + }, DispatcherPriority.Render); + } + + private void InvalidatePresenterPosition() + { + if (Presenter is null) return; + var layoutValues = GetLayoutValues(); + var location = UpdateLocation(layoutValues); + if (location is null) return; + Canvas.SetTop(Presenter, location.Value.top); + Canvas.SetLeft(Presenter, location.Value.left); + } + + protected override Size MeasureOverride(Size availableSize) + { + var result = base.MeasureOverride(availableSize); + var presenter = Presenter; + if (presenter is null) return result; + var size = presenter.DesiredSize; + if (double.IsInfinity(result.Width) || result.Width == 0) + { + result = result.WithWidth(size.Width); + } + if (double.IsInfinity(result.Height) || result.Height == 0) + { + result = result.WithHeight(size.Height); + } + return result; + } + + private (double top, double left)? UpdateLocation(LayoutValues values) + { + var horizontalOffset = values.Direction switch + { + Direction.Up or Direction.Down => GetHorizontalOffset(values.Bounds, values.PresenterSize, values.HorizontalAlignment), + Direction.Left or Direction.Right => values.Left, + }; + var verticalOffset = values.Direction switch + { + Direction.Up or Direction.Down => values.Top, + Direction.Left or Direction.Right => GetVerticalOffset(values.Bounds, values.PresenterSize, values.VerticalAlignment), + }; + if (horizontalOffset is double.NaN) horizontalOffset = 0.0; + if (verticalOffset is double.NaN) verticalOffset = 0.0; + var speed = values.Diff; + var diff = values.Direction switch + { + Direction.Up => -speed, + Direction.Down => speed, + Direction.Left => -speed, + Direction.Right => speed, + _ => 0 + }; + switch (values.Direction) + { + case Direction.Up: + case Direction.Down: + verticalOffset += diff; + break; + case Direction.Left: + case Direction.Right: + horizontalOffset += diff; + break; + } + switch (values.Direction) + { + case Direction.Down: + if (verticalOffset > values.Bounds.Height) verticalOffset = -values.PresenterSize.Height; + break; + case Direction.Up: + if (verticalOffset < -values.PresenterSize.Height) verticalOffset = values.Bounds.Height; + break; + case Direction.Right: + if (horizontalOffset > values.Bounds.Width) horizontalOffset = -values.PresenterSize.Width; + break; + case Direction.Left: + if (horizontalOffset < -values.PresenterSize.Width) horizontalOffset = values.Bounds.Width; + break; + } + verticalOffset = MathHelpers.SafeClamp(verticalOffset, -values.PresenterSize.Height, values.Bounds.Height); + horizontalOffset = MathHelpers.SafeClamp(horizontalOffset, -values.PresenterSize.Width, values.Bounds.Width); + return (verticalOffset, horizontalOffset); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetHorizontalOffset(Size bounds, Size presenterBounds, HorizontalAlignment horizontalAlignment) + { + return horizontalAlignment switch + { + HorizontalAlignment.Left => 0, + HorizontalAlignment.Center => (bounds.Width - presenterBounds.Width) / 2, + HorizontalAlignment.Right => bounds.Width - presenterBounds.Width, + _ => 0 + }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetVerticalOffset(Size bounds, Size presenterBounds, VerticalAlignment verticalAlignment) + { + return verticalAlignment switch + { + VerticalAlignment.Top => 0, + VerticalAlignment.Center => (bounds.Height - presenterBounds.Height) / 2, + VerticalAlignment.Bottom => bounds.Height - presenterBounds.Height, + _ => 0 + }; + } + + private LayoutValues GetLayoutValues() + { + return new LayoutValues + { + Bounds = Bounds.Size, + PresenterSize = Presenter?.Bounds.Size ?? new Size(), + Left = Presenter is null? 0 : Canvas.GetLeft(Presenter), + Top = Presenter is null? 0 : Canvas.GetTop(Presenter), + Diff = IsRunning ? Speed / 60.0 : 0, + HorizontalAlignment = HorizontalContentAlignment, + VerticalAlignment = VerticalContentAlignment, + Direction = Direction + }; + } +} + +struct LayoutValues +{ + public Size Bounds { get; set; } + public Size PresenterSize { get; set; } + public double Left { get; set; } + public double Top { get; set; } + public double Diff { get; set; } + public Direction Direction { get; set; } + public HorizontalAlignment HorizontalAlignment { get; set; } + public VerticalAlignment VerticalAlignment { get; set; } +} \ No newline at end of file