WPF(MVVM) Хочу услишать ваши рекомендации

У меня есть код (логично) , WPF(MVVM) - написал свой UserControll и хочу узнать как можно было б улучшить код. Так как, он написан для MVVM, но вся логика UserControll написана во View. Пожалуйста дайте мне путь для розвития) Знаю что этот код плохой)

MainWindow.xaml

<Window x:Class="ClockUnit.View.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:ClockUnit.View"
        xmlns:Clocker="clr-namespace:ClockUnit.View.UserControls"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800"
        xmlns:vm="clr-namespace:ClockUnit.ViewModel"
        xmlns:convert ="clr-namespace:ClockUnit.View.UserControls.Convert"
        x:Name="MainWindowForm"
        >
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>




    <Grid>
        <StackPanel Margin="0 20 0 0">
            <Clocker:ClockUserControl Length="{Binding Path=DataContext.Length, ElementName=MainWindowForm}"/>
            <TextBox Text="{Binding Length , UpdateSourceTrigger=PropertyChanged}" Height="30" Margin="0 20" FontSize="22" FontWeight="Bold"/>
        </StackPanel>
        
        
            
    </Grid>
        
        
</Window>

MainWindow.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using Microsoft.Xaml.Behaviors;


using ClockUnit.ViewModel;

namespace ClockUnit.View
{
    /// <summary>
    /// Логика взаимодействия для MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            this.DataContext = new MainWindowViewModel();
            InitializeComponent();
            
        }
    }
}

MainWindowViewModel.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;

namespace ClockUnit.ViewModel
{
    public class MainWindowViewModel : ViewModelBase
    {

        public MainWindowViewModel()
        {
            SendMessage = new RelayCommand(Click);
        }
        public void Click()
        {
            Console.WriteLine("Click");
        }
        public ICommand SendMessage { get; set; }






        private double length = 30;
        public double Length
        {
            get => length; 
            set
            {
                length = value;
                OnProperyChanged(nameof(length));
            }
        }
        



    }
}

ViewModelBase.cs

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;

namespace ClockUnit.ViewModel
{
    public class ViewModelBase : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public void OnProperyChanged(string variable)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(variable));
        }
    }
}

RelayCommand.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;

namespace ClockUnit.ViewModel
{
    public class RelayCommand : ICommand
    {
        Action _Execute;
        public RelayCommand(Action func)
        {
            _Execute = func;
        }

        public event EventHandler CanExecuteChanged;

        public virtual bool CanExecute(object parameter)
        {
            return true;
        }

        public void Execute(object parameter)
        {
            _Execute?.Invoke();
        }
      
        protected void OnCanExecuteChanged()
        {
            CanExecuteChanged?.Invoke(this, EventArgs.Empty);
        }
    }
}

ClockUserControll.xaml

<UserControl x:Class="ClockUnit.View.UserControls.ClockUserControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:ClockUnit.View.UserControls"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800"
             xmlns:vm ="clr-namespace:ClockUnit.ViewModel"
             >

    <UserControl.DataContext>
        <vm:ClockViewModel/>
    </UserControl.DataContext>
    
    
    <Grid>
        <Canvas Width="200" Height="200" x:Name="ClockF" Background="WhiteSmoke" ></Canvas>
    </Grid>
    
    
</UserControl>

ClockUserControll.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Threading;
using ClockUnit.ViewModel;
using ClockUnit.View.UserControls;
using System.ComponentModel;


namespace ClockUnit.View.UserControls
{
    /// <summary>
    /// Логика взаимодействия для ClockUserControl.xaml
    /// </summary>
    public partial class ClockUserControl : UserControl 
    {

        public readonly static DependencyProperty LengthKey = DependencyProperty.Register("Length", typeof(double), typeof(ClockUserControl), new PropertyMetadata(90.0));
        public double Length
        {
            get => (double)GetValue(LengthKey);
            set => SetValue(LengthKey, value);
        }
 

        public ClockUserControl()
        {
            InitializeComponent();
            this.DataContext = new ClockViewModel();
            StartTick();    
        }

        public SolidColorBrush ColorLine { get; set; } = new SolidColorBrush(Colors.Black);
        public int dx = 100;
        public int time;


        public void InitClock()
        {
            Ellipse ellipse = new Ellipse();
            ellipse.Width = 200;
            ellipse.Height = 200;
            ellipse.Stretch = Stretch.Fill;
            ellipse.Fill = Brushes.White;
            ellipse.Stroke = Brushes.Black;
            ellipse.StrokeThickness = 2;

            Ellipse point = new Ellipse();
            point.Width = 10;
            point.Height = 10;
            point.Fill = Brushes.Black;
            point.Margin = new Thickness(95, 95, 95, 95);

            ClockF.Children.Add(ellipse);
            ClockF.Children.Add(point);


            ClockF.MouseDown += ReturnColor;

        }
        public async void SecondLine()
        {

            double second = DateTime.Now.Second;


            double angle = (second / 59) * 6.28;

            Line line = new Line();

            line.X1 = dx;
            line.Y1 = dx;


            line.X2 = dx + Length * Math.Sin(angle);
            line.Y2 = dx - Length * Math.Cos(angle);

            line.Stroke = ColorLine;
            line.StrokeThickness = 3;



            line.MouseDown += MouseD;



            ClockF.Children.Add(line);

        }
        public async void Tick(object sender, EventArgs e)
        {
            this.Dispatcher.Invoke(() =>
            {
                ClockF.Children.Clear();
                InitClock();
                SecondLine();

            }


            );

        }
        public void StartTick()
        {

            DispatcherTimer timer = new DispatcherTimer();
            timer.Interval = TimeSpan.FromMilliseconds(100);
            timer.Tick += Tick;
            timer.Start();

        }
        private void MouseD(object sender , MouseButtonEventArgs e)
        {
            ColorLine = Brushes.Red;
            time = e.Timestamp;
        }
        private void ReturnColor(object sender, MouseButtonEventArgs e)
        {
            if(e.Timestamp != time && ColorLine != Brushes.Black)
            {
                ColorLine = Brushes.Black;
            }
            
        }



    }
}

ClockViewModel.cs

using ClockUnit.ViewModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ClockUnit.ViewModel
{
    public class ClockViewModel: ViewModelBase
    {
    }
}

ТРИГГЕР

<RadioButton 
             Width="1"
             Height="48"
             Background="Black"
             Command="{Binding ArrowClickCommand, ElementName=uc}"
             CommandParameter="Second"
             GroupName="arrow"
             RenderTransformOrigin="0.5,1"
             Cursor="Hand"
             Style = "{StaticResource Button.Toggle}"
             >
                       <RadioButton.RenderTransform>
                            <TransformGroup>
                                <RotateTransform Angle="{Binding SecondAngle, ElementName=uc}" />
                                <TranslateTransform X="0" Y="-24" />
                            </TransformGroup>
                       </RadioButton.RenderTransform>







                <RadioButton.Triggers>
                    <EventTrigger RoutedEvent="Loaded">
                        <BeginStoryboard>
                            <Storyboard TargetProperty="(RenderTransform).(RotateTransform.Angle)">
                                <DoubleAnimation 
                                             To="{Binding SecondAngle, ElementName=uc}"
                                             Duration="0:0:1"  
                                             AutoReverse="False" 
                                             RepeatBehavior="Forever" />
                            </Storyboard>
                        </BeginStoryboard>
                    </EventTrigger>
                </RadioButton.Triggers>
            </RadioButton>

Ответы (1 шт):

Автор решения: EvgeniyZ

Ну, вы правы, код весьма странный.

Пройдемся по недостаткам

  1. Установка DataContext внутри View объекта плохо, ведь тем самым ваш View знает про другие слои, а также (что еще хуже), управляет созданием других слоев. То есть, вот этого у вас быть вообще не должно:

    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    

    как и этого

    this.DataContext = new MainWindowViewModel();
    

    На SO есть отличный ответ эту тему, советую почитать.

  2. ClockUserControl - С именами я думаю вы понимаете, что стоит поработать, к чему тут допустим UserControl приписка? Вы у стандартных контролов видите такое? TextBoxUserControl, BorderUserControl? Я - нет. Так почему вы так городите?

  3. У ClockUserControl установлен свой DataContext, когда он должен быть независимым от чего либо, у него должно быть DependencyProperty, которое принимает, к примеру время, все, на основе этих данных он дальше и работает, но внутри него не должно быть каких-либо данных, как и не должно быть указания каких-либо DataContext. Вот просто смотрите на стандартные контролы, вы видите у TextBox допустим установленного DataContext? Да вроде нет.

  4. Вы создаете в коде контролы, например это:

    Ellipse ellipse = new Ellipse();
    

    Зачем тогда вы используете WPF проект, в котором есть XAML разметка созданная специально для дизайна, не используя ее? Берите WinForms, пишите там по старинке, будет еще более-менее уместно, но в WPF, вы не должны вообще хотеть в коде использовать и создавать контролы, 90% задач спокойно берет на себя чистый XAML.

  5. Ваша логика часов весьма странная, ибо вы постоянно добавляете новый контрол (ClockF.Children.Add(line);), постоянно перерисовываете все часы целиком, ради того, чтобы просто поменять угол наклона стрелочки. В WPF есть механизмы, которые позволяют задавать угол любому объекту, делается это через RotateTransform, просто установите его, привяжите свойство Angle и меняйте просто привязанное свойство, чтобы подвинуть стрелку, все.

Теперь давайте попробуем сделать как положено:

Создадим контрол

Цель - сделать контрол, который на вход будет принимать объект времени (в C# это TimeSpan) и на его основе просто ставить стрелки в нужном положении.

  1. Создаем UserControl, назовем просто Clock.

  2. Теперь давайте создадим просто равномерный круг, который будет подстраиваться под размеры контейнера:

     <Viewbox
         MinHeight="100"
         Stretch="Uniform"
         StretchDirection="Both">
         <Grid>
             <Ellipse
                 Width="100"
                 Height="100"
                 Fill="WhiteSmoke"
                 Stroke="Silver"
                 StrokeThickness=".5" />
         </Grid>
     </Viewbox>
    

    Viewbox - автоматически подгоняет внутренние объекты под размеры контейнера, свойства у него заданы такие, чтобы при изменении размера менялись все стороны пропорционально, а само содержимое заполняло полностью весь контейнер. Внутри простая сетка с кругом, у которого лишь заданы цвета.

  3. Теперь давайте сделаем три стрелки (час, мин, сек), получим полностью примерно следующее:

     <Viewbox
         MinHeight="100"
         Stretch="Uniform"
         StretchDirection="Both">
         <Grid>
             <Ellipse
                 Width="100"
                 Height="100"
                 Fill="WhiteSmoke"
                 Stroke="Silver"
                 StrokeThickness=".5" />
             <Line
                 HorizontalAlignment="Center"
                 VerticalAlignment="Center"
                 RenderTransformOrigin="0,1"
                 Stroke="#FF4C4C4C"
                 StrokeThickness="2"
                 Y2="40">
                 <Line.RenderTransform>
                     <TransformGroup>
                         <RotateTransform Angle="30" />
                         <TranslateTransform X="0.5" Y="-20" />
                     </TransformGroup>
                 </Line.RenderTransform>
             </Line>
             <Line
                 HorizontalAlignment="Center"
                 VerticalAlignment="Center"
                 RenderTransformOrigin="0,1"
                 Stroke="#FF4C4C4C"
                 StrokeThickness="2"
                 Y2="30">
                 <Line.RenderTransform>
                     <TransformGroup>
                         <RotateTransform Angle="0" />
                         <TranslateTransform X="0.5" Y="-15" />
                     </TransformGroup>
                 </Line.RenderTransform>
             </Line>
             <Line
                 HorizontalAlignment="Center"
                 VerticalAlignment="Center"
                 RenderTransformOrigin="0,1"
                 Stroke="Black"
                 StrokeThickness=".5"
                 Y2="48">
                 <Line.RenderTransform>
                     <TransformGroup>
                         <RotateTransform Angle="60" />
                         <TranslateTransform X=".13" Y="-24" />
                     </TransformGroup>
                 </Line.RenderTransform>
             </Line>
             <Ellipse
                 Width="5"
                 Height="5"
                 Fill="Silver" />
         </Grid>
     </Viewbox>
    

    Тут у нас простые линии, определенной длины, определенного цвета. У каждой линии есть RenderTransform - это настройки трансформации, где RotateTransform - поворот на указанный угол относительно центральной точки, а TranslateTransform - сдвиг объекта по оси X/Y. Сама центральная точка задается свойством RenderTransformOrigin, конкретно в коде выше, это низ объекта, по центру.

На данном моменте у нас получается нечто такое:

ClockResult

  1. Теперь давайте автоматизировать это все. Как я писал ранее, на вход контрол будет принимать только TimeSpan (вы можете по аналогии вынести все цвета, ну и другие значения). Идем в Clock.xaml.cs, пишем после конструктора propdp и жмем два раза TAB, студия нам создаст код, в котором нам надо указать тип контрола, тип свойства, ну и значение по умолчанию. Получаем такое:

     public TimeSpan Time
     {
         get => (TimeSpan)GetValue(TimeProperty);
         set => SetValue(TimeProperty, value);
     }
    
     public static readonly DependencyProperty TimeProperty =
         DependencyProperty.Register("Time", typeof(TimeSpan), typeof(Clock), new PropertyMetadata(TimeSpan.Zero));
    
  2. Отлично, теперь мы знаем время, осталось немного, а именно, подсчитать градусы для стрелок и привязать их. Давайте по каждому изменению свойства времени, будем вызывать определенный метод. В DependencyProperty можно это сделать путем указания самого метода после значения по умолчанию (TimeSpan.Zero), ну а так, как свойство статично, нам надо проверить sender события, преобразовать его, а уже затем вызывать метод. Получим примерно следующее:

     public static readonly DependencyProperty TimeProperty =
         DependencyProperty.Register("Time", typeof(TimeSpan), typeof(Clock), new PropertyMetadata(TimeSpan.Zero, OntPropertyChanged));
    
    
     private static void OntPropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
     {
         if (sender is Clock clock)
         {
             clock.UpdateAngles();
         }
     }
    
     public void UpdateAngles()
     {
    
     }
    
  3. Сделаем теперь публичные свойства, которые будут содержать в себе угол каждой стрелки, ну и посчитаем их:

     public double HourAngle { get; private set; }
     public double MinuteAngle { get; private set; }
     public double SecondAngle { get; private set; }
    
     public void UpdateAngles()
     {
         // В помощь: https://www.omnicalculator.com/math/clock-angle
    
         var totalHours = 12;                                             // Сколько часов в одном круге
         var totalMinutes = 60;                                           // Сколько минут в одном круге
         var totalAngle = 360;                                            // Полная окружность
         double hourAnglePerHour = totalAngle / totalHours;               // На сколько градусов меняется угол часовой стрелки в час = 30
         double hourAnglePerMin = hourAnglePerHour / totalMinutes;        // На сколько градусов меняется угол часовой стрелки в мин = 0.5
         double minAndSecAnglePerMin = totalAngle / totalMinutes;         // На сколько градусов меняется угол мин. и сек. за минуту = 6
    
         HourAngle = hourAnglePerHour * Time.Hours + hourAnglePerMin * Time.Minutes;
    
         MinuteAngle = Time.Minutes * minAndSecAnglePerMin;
         SecondAngle = Time.Seconds * minAndSecAnglePerMin;
    
         OnProperyChanged(nameof(HourAngle));
         OnProperyChanged(nameof(MinuteAngle));
         OnProperyChanged(nameof(SecondAngle));
     }
    

    OnProperyChanged - это INPC, с которым вы уже знакомы, я поленился делать отдельный класс, запихнул это прям в контрол и вызвал в методе. Вам советую вынести это в сами свойства. Со всеми подсчетами думаю разберетесь, я оставил ссылку, где отлично объясняют как посчитать углы.

  4. Теперь открываем XAML контрола, задаем самому контролу имя x:Name="uc", далее привязываем угол каждой стрелки к своему свойству примерно следующим образом:

     <RotateTransform Angle="{Binding MinuteAngle, ElementName=uc}" />
    
  5. Контрол наш готов, использовать его так:

     <local:Clock Time="23:40:15" />
    

Результат:

Static Clock Result

Привязываем к реальному времени

Давайте запустим фоновую задачу, которая просто будет менять значение свойства, к которому мы привяжемся.

  1. Делаем класс, например MainViewModel, в нем пишем следующее:

     public class MainViewModel : INotifyPropertyChanged
     {
         public MainViewModel()
         {
             Start();
         }
    
         private TimeSpan _time;
    
         public TimeSpan Time
         {
             get => _time;
             set
             {
                 _time = value;
                 OnProperyChanged(nameof(Time));
             }
         }
    
    
         public async void Start()
         {
             while (true)
             {
                 Time = DateTime.Now.TimeOfDay;
                 await Task.Delay(1000);
             }
         }
    
         public event PropertyChangedEventHandler PropertyChanged;
         public void OnProperyChanged(string variable)
         {
             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(variable));
         }
     }
    

    INPC лучше вынести в отдельный класс (как у вас в вопросе), я просто ленюсь... По коду думаю ничего сложного нет, простая фоновая (async) задача, которая крутит бесконечный цикл, в котором раз секунду меняется свойство времени. Учтите, async void не лучшая затея, если вы будете там писать серьезный код, который надо ожидать и который может выдать ошибки, у меня как видите просто меняется свойство, без какой-либо логики, из-за чего такой подход тут вполне подойдет. Также не забывайте про остановку, если она нужна, то смотрите в сторону CancellationToken.

  2. Открываем App.xaml и убираем там StartupUri="MainWindow.xaml".

  3. Открываем App.xaml.cs и переопределяем там OnStartup на нечто такое:

     protected override void OnStartup(StartupEventArgs e)
     {
         base.OnStartup(e);
         new MainWindow() { DataContext = new MainViewModel() }.Show();
     }
    
  4. Открываем MainWindow.xaml и прописываем там наш контрол:

     <local:Clock Time="{Binding Time}" />
    

    Как видите, через Binding мы привязались к свойству, которое отдает нам текущее время.

Результат всей этой работы, следующий:

Total Result

Собственно, вот мы и сделали контрол, который на основе входного времени показывает его в виде аналоговых часов. Заметьте, контрол не знает ничего про ViewModel слои, он независим вообще от чего либо, а само время у нас в ViewModel слое (если время получается из вне, то должно быть в Model). Так что, старайтесь делить все на мало связанные друг с другом слои, это основы основ MVVM)


Дополнение №1: Кликабельные стрелки.

  1. Из за того, что нам надо убирать выделение с других стрелок при клике на определенную, нам надо использовать RadioButton, который позволяет задать имя группы, по которому он сам будет убирать выделение. Но стандартный RadioButton является просто кругом с точной, что не очень подходит под задачу, придется писать стиль. Сам стиль можем разместить где угодно, хоть в ресурсах приложения, хоть в ресурсах контрола, хоть в ресурсах самих стрелок, разметка будет примерно следующей:

     <UserControl.Resources>
         <Style
             x:Key="Button.Toggle"
             BasedOn="{StaticResource {x:Type ToggleButton}}"
             TargetType="ToggleButton">
             <Setter Property="BorderThickness" Value="0" />
             <Setter Property="Background" Value="Black" />
             <Setter Property="Cursor" Value="Hand" />
    
             <Setter Property="Template">
                 <Setter.Value>
                     <ControlTemplate TargetType="ToggleButton">
                         <Border
                             x:Name="border"
                             Width="{TemplateBinding Width}"
                             Height="{TemplateBinding Height}"
                             Background="{TemplateBinding Background}" />
    
                         <ControlTemplate.Triggers>
                             <Trigger Property="IsChecked" Value="True">
                                 <Setter TargetName="border" Property="Background" Value="Red" />
                             </Trigger>
                         </ControlTemplate.Triggers>
                     </ControlTemplate>
                 </Setter.Value>
             </Setter>
         </Style>
     </UserControl.Resources>
    

    Как видите, я поместил стиль в ресурсы самого контрола. Сам стиль наследуется от ToggleButton, чтобы по поведению был как простая кнопка, ну а внутри я меняю вид на простой Border. Также тут интересен триггер, который меняет цвет стрелки, если по ней нажать.

  2. Теперь нам нужно еще одно DependencyProperty, которое будет принимать ICommand - команду, по клику которой мы будем делать в VM нужные нам действия:

     public ICommand ArrowClickCommand
     {
         get => (ICommand)GetValue(ArrowClickCommandProperty);
         set => SetValue(ArrowClickCommandProperty, value);
     }
    
     public static readonly DependencyProperty ArrowClickCommandProperty =
         DependencyProperty.Register("ArrowClickCommand", typeof(ICommand), typeof(Clock), new PropertyMetadata(null));
    
  3. Имея команду и стиль, мы можем теперь переделать все стрелки на RadioButton, с указанием сразу команды и ее параметров:

     <RadioButton
         Width=".5"
         Height="48"
         Background="Black"
         Command="{Binding ArrowClickCommand, ElementName=uc}"
         CommandParameter="second"
         GroupName="arrow"
         RenderTransformOrigin="0.5,1"
         Style="{StaticResource Button.Toggle}">
         <RadioButton.RenderTransform>
             <TransformGroup>
                 <RotateTransform Angle="{Binding SecondAngle, ElementName=uc}" />
                 <TranslateTransform X="0" Y="-24" />
             </TransformGroup>
         </RadioButton.RenderTransform>
     </RadioButton>
    

    Я не стал приводить все стрелки, лишь секундную, отличия тут минимальные, лишь поменяли Line на RadioButton. Из нового тут GroupName - расказывал выше про это; Command - ранее созданная команда; CommandParameter - передаваемые в команду значения (в мое случае просто строковое обозначение кнопки).

  4. С контролом мы закончили, осталось сделать команду в VM слое. Ваш класс RelayCommand я заменю на такой:

     public class RelayCommand : ICommand
     {
         private Action<object> execute;
         private Func<object, bool> canExecute;
    
         public event EventHandler CanExecuteChanged
         {
             add => CommandManager.RequerySuggested += value;
             remove => CommandManager.RequerySuggested -= value;
         }
    
         public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
         {
             this.execute = execute;
             this.canExecute = canExecute;
         }
    
         public bool CanExecute(object parameter)
         {
             return canExecute == null || canExecute(parameter);
         }
    
         public void Execute(object parameter)
         {
             execute(parameter);
         }
     }
    
  5. Дальшее в MainViewModel делаем публичное свойство команды

     public ICommand ArrowClickCommand { get; set; }
    
  6. Инициализируем эту команду в конструкторе:

     public MainViewModel()
     {
         Start();
         ArrowClickCommand = new RelayCommand(param => OnArrowClick(param));
     }
    
  7. Ну и сам метод. Я его реализовывать не буду, оставлю это вам, я лишь сделаю заглушку, которая в отладочную консоль будет выводить тег стрелки:

     private void OnArrowClick(object arrow)
     {
         if (arrow is string arrowKey)
         {
             Debug.WriteLine(arrowKey);
         }
     }
    

Все, теперь при клике должна выделяться одна конкретная стрелка и вызываться команда, которая уже сделает все необходимые действия.

Дополнение №2: Анимация.

  1. Для начала давайте сделаем событие, которое будет оповещать нас о том, что у нас изменилось время, для этого в C# коде создаем само событие:

     public static readonly RoutedEvent ClockChangedEvent
     = EventManager.RegisterRoutedEvent("ClockChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Clock));
    
     public event RoutedEventHandler ClockChanged
     {
         add => AddHandler(ClockChangedEvent, value);
         remove => RemoveHandler(ClockChangedEvent, value);
     }
    
  2. Теперь идем в XAML и находим там нашу секундную стрелку, нам надо задать ей имя (x:Name="..."), а также прописать саму анимацию, получить должны примерно следующее:

     <RadioButton
         x:Name="SecondArrow"
         Width=".5"
         Height="48"
         Background="Black"
         Command="{Binding ArrowClickCommand, ElementName=uc}"
         CommandParameter="second"
         GroupName="arrow"
         RenderTransformOrigin="0.5,1"
         Style="{StaticResource Button.Toggle}">
         <RadioButton.RenderTransform>
             <TransformGroup>
                 <RotateTransform Angle="0" />
                 <TranslateTransform X="0" Y="-24" />
             </TransformGroup>
         </RadioButton.RenderTransform>
         <RadioButton.Triggers>
             <EventTrigger RoutedEvent="local:Clock.ClockChanged">
                 <BeginStoryboard>
                     <Storyboard>
                         <DoubleAnimation
                             AutoReverse="False"
                             Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(RotateTransform.Angle)"
                             From="{Binding OldSecondAngle, ElementName=uc}"
                             To="{Binding SecondAngle, ElementName=uc}"
                             Duration="0:0:1" />
                     </Storyboard>
                 </BeginStoryboard>
             </EventTrigger>
         </RadioButton.Triggers>
     </RadioButton>
    

    Заметьте, привязки я перенес в саму анимацию, а также добавил новую {Binding OldSecondAngle, ElementName=uc} о которой позже. Сама анимация очень простая - мы ищем нужное свойство и плавно меняем с одного значения на другое в течении 1 сек.

  3. Имея все это давайте подправим метод UpdateAngles, изменим на примерно следующий:

     public double OldSecondAngle { get; private set; }
    
     public void UpdateAngles()
     {
         // В помощь: https://www.omnicalculator.com/math/clock-angle
    
         OldSecondAngle = SecondAngle;
    
         var totalHours = 12;                                          // Сколько часов в одном круге
         var totalMinutes = 60;                                        // Сколько минут в одном круге
         var totalAngle = 360;                                         // Полная окружность
         double hourAnglePerHour = totalAngle / totalHours;               // На сколько градусов меняется угол часовой стрелки в час = 30
         double hourAnglePerMin = hourAnglePerHour / totalMinutes;        // На сколько градусов меняется угол часовой стрелки в час = 0.5
         double minAndSecAnglePerMin = totalAngle / totalMinutes;         // На сколько градусов меняется угол мин. и сек. за минуту = 6
    
         HourAngle = hourAnglePerHour * Time.Hours + hourAnglePerMin * Time.Minutes;
    
         MinuteAngle = Time.Minutes * minAndSecAnglePerMin;
         SecondAngle = Time.Seconds * minAndSecAnglePerMin;
    
         if (OldSecondAngle > 360) OldSecondAngle -= 360;
         if (SecondAngle == 0 || OldSecondAngle == 360) SecondAngle += 360;
    
         OnProperyChanged(nameof(HourAngle));
         OnProperyChanged(nameof(MinuteAngle));
         OnProperyChanged(nameof(SecondAngle));
         OnProperyChanged(nameof(OldSecondAngle));
    
         SecondArrow.RaiseEvent(new (ClockChangedEvent, this));
     }
    

    Тут как видите, появилось новое свойство, которое хранит в себе предыдущий угол стрелки. SecondArrow.RaiseEvent(...) - мы именно у нашей стрелки вызываем событие. Ну и всякие if (OldSecondAngle > 360) - это для того, чтобы анимация после полного круга не крутила стрелку обратно, ибо новый градус будет 0, а старый 360, из за чего нам надо чуть "протолкнуть" стрелку дальше на пару градусов (366 например) и уже после этого анимации задать From как 6 (старый градус), а To (новый градус) как 12, тем самым стрелка пойдет дальше делать круг.

→ Ссылка