1

У меня есть код (логично) , 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>
&lt;Grid&gt;
    &lt;StackPanel Margin=&quot;0 20 0 0&quot;&gt;
        &lt;Clocker:ClockUserControl Length=&quot;{Binding Path=DataContext.Length, ElementName=MainWindowForm}&quot;/&gt;
        &lt;TextBox Text=&quot;{Binding Length , UpdateSourceTrigger=PropertyChanged}&quot; Height=&quot;30&quot; Margin=&quot;0 20&quot; FontSize=&quot;22&quot; FontWeight=&quot;Bold&quot;/&gt;
    &lt;/StackPanel&gt;



&lt;/Grid&gt;


</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(&quot;Click&quot;);
    }
    public ICommand SendMessage { get; set; }






    private double length = 30;
    public double Length
    {
        get =&gt; 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"
             >
&lt;UserControl.DataContext&gt;
    &lt;vm:ClockViewModel/&gt;
&lt;/UserControl.DataContext&gt;


&lt;Grid&gt;
    &lt;Canvas Width=&quot;200&quot; Height=&quot;200&quot; x:Name=&quot;ClockF&quot; Background=&quot;WhiteSmoke&quot; &gt;&lt;/Canvas&gt;
&lt;/Grid&gt;


</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(&quot;Length&quot;, typeof(double), typeof(ClockUserControl), new PropertyMetadata(90.0));
    public double Length
    {
        get =&gt; (double)GetValue(LengthKey);
        set =&gt; 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(() =&gt;
        {
            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 &amp;&amp; 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>
            &lt;RadioButton.Triggers&gt;
                &lt;EventTrigger RoutedEvent=&quot;Loaded&quot;&gt;
                    &lt;BeginStoryboard&gt;
                        &lt;Storyboard TargetProperty=&quot;(RenderTransform).(RotateTransform.Angle)&quot;&gt;
                            &lt;DoubleAnimation 
                                         To=&quot;{Binding SecondAngle, ElementName=uc}&quot;
                                         Duration=&quot;0:0:1&quot;  
                                         AutoReverse=&quot;False&quot; 
                                         RepeatBehavior=&quot;Forever&quot; /&gt;
                        &lt;/Storyboard&gt;
                    &lt;/BeginStoryboard&gt;
                &lt;/EventTrigger&gt;
            &lt;/RadioButton.Triggers&gt;
        &lt;/RadioButton&gt;

1 Answers1

3

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

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

  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 =&gt; _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" />
    
         &lt;Setter Property=&quot;Template&quot;&gt;
             &lt;Setter.Value&gt;
                 &lt;ControlTemplate TargetType=&quot;ToggleButton&quot;&gt;
                     &lt;Border
                         x:Name=&quot;border&quot;
                         Width=&quot;{TemplateBinding Width}&quot;
                         Height=&quot;{TemplateBinding Height}&quot;
                         Background=&quot;{TemplateBinding Background}&quot; /&gt;
    
                     &lt;ControlTemplate.Triggers&gt;
                         &lt;Trigger Property=&quot;IsChecked&quot; Value=&quot;True&quot;&gt;
                             &lt;Setter TargetName=&quot;border&quot; Property=&quot;Background&quot; Value=&quot;Red&quot; /&gt;
                         &lt;/Trigger&gt;
                     &lt;/ControlTemplate.Triggers&gt;
                 &lt;/ControlTemplate&gt;
             &lt;/Setter.Value&gt;
         &lt;/Setter&gt;
     &lt;/Style&gt;
    

    </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 =&gt; CommandManager.RequerySuggested += value;
         remove =&gt; CommandManager.RequerySuggested -= value;
     }
    
     public RelayCommand(Action&lt;object&gt; execute, Func&lt;object, bool&gt; 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 &gt; 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, тем самым стрелка пойдет дальше делать круг.

EvgeniyZ
  • 15,694