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 шт):
Ну, вы правы, код весьма странный.
Пройдемся по недостаткам
Установка
DataContextвнутри View объекта плохо, ведь тем самым ваш View знает про другие слои, а также (что еще хуже), управляет созданием других слоев. То есть, вот этого у вас быть вообще не должно:<Window.DataContext> <vm:MainWindowViewModel/> </Window.DataContext>как и этого
this.DataContext = new MainWindowViewModel();На SO есть отличный ответ эту тему, советую почитать.
ClockUserControl- С именами я думаю вы понимаете, что стоит поработать, к чему тут допустимUserControlприписка? Вы у стандартных контролов видите такое?TextBoxUserControl,BorderUserControl? Я - нет. Так почему вы так городите?У
ClockUserControlустановлен свойDataContext, когда он должен быть независимым от чего либо, у него должно бытьDependencyProperty, которое принимает, к примеру время, все, на основе этих данных он дальше и работает, но внутри него не должно быть каких-либо данных, как и не должно быть указания каких-либоDataContext. Вот просто смотрите на стандартные контролы, вы видите уTextBoxдопустим установленногоDataContext? Да вроде нет.Вы создаете в коде контролы, например это:
Ellipse ellipse = new Ellipse();Зачем тогда вы используете WPF проект, в котором есть XAML разметка созданная специально для дизайна, не используя ее? Берите WinForms, пишите там по старинке, будет еще более-менее уместно, но в WPF, вы не должны вообще хотеть в коде использовать и создавать контролы, 90% задач спокойно берет на себя чистый XAML.
Ваша логика часов весьма странная, ибо вы постоянно добавляете новый контрол (
ClockF.Children.Add(line);), постоянно перерисовываете все часы целиком, ради того, чтобы просто поменять угол наклона стрелочки. В WPF есть механизмы, которые позволяют задавать угол любому объекту, делается это черезRotateTransform, просто установите его, привяжите свойствоAngleи меняйте просто привязанное свойство, чтобы подвинуть стрелку, все.
Теперь давайте попробуем сделать как положено:
Создадим контрол
Цель - сделать контрол, который на вход будет принимать объект времени (в C# это TimeSpan) и на его основе просто ставить стрелки в нужном положении.
Создаем
UserControl, назовем простоClock.Теперь давайте создадим просто равномерный круг, который будет подстраиваться под размеры контейнера:
<Viewbox MinHeight="100" Stretch="Uniform" StretchDirection="Both"> <Grid> <Ellipse Width="100" Height="100" Fill="WhiteSmoke" Stroke="Silver" StrokeThickness=".5" /> </Grid> </Viewbox>Viewbox- автоматически подгоняет внутренние объекты под размеры контейнера, свойства у него заданы такие, чтобы при изменении размера менялись все стороны пропорционально, а само содержимое заполняло полностью весь контейнер. Внутри простая сетка с кругом, у которого лишь заданы цвета.Теперь давайте сделаем три стрелки (час, мин, сек), получим полностью примерно следующее:
<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, конкретно в коде выше, это низ объекта, по центру.
На данном моменте у нас получается нечто такое:
Теперь давайте автоматизировать это все. Как я писал ранее, на вход контрол будет принимать только
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));Отлично, теперь мы знаем время, осталось немного, а именно, подсчитать градусы для стрелок и привязать их. Давайте по каждому изменению свойства времени, будем вызывать определенный метод. В
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() { }Сделаем теперь публичные свойства, которые будут содержать в себе угол каждой стрелки, ну и посчитаем их:
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, с которым вы уже знакомы, я поленился делать отдельный класс, запихнул это прям в контрол и вызвал в методе. Вам советую вынести это в сами свойства. Со всеми подсчетами думаю разберетесь, я оставил ссылку, где отлично объясняют как посчитать углы.Теперь открываем XAML контрола, задаем самому контролу имя
x:Name="uc", далее привязываем угол каждой стрелки к своему свойству примерно следующим образом:<RotateTransform Angle="{Binding MinuteAngle, ElementName=uc}" />Контрол наш готов, использовать его так:
<local:Clock Time="23:40:15" />
Результат:
Привязываем к реальному времени
Давайте запустим фоновую задачу, которая просто будет менять значение свойства, к которому мы привяжемся.
Делаем класс, например
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.Открываем
App.xamlи убираем тамStartupUri="MainWindow.xaml".Открываем
App.xaml.csи переопределяем тамOnStartupна нечто такое:protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); new MainWindow() { DataContext = new MainViewModel() }.Show(); }Открываем
MainWindow.xamlи прописываем там наш контрол:<local:Clock Time="{Binding Time}" />Как видите, через
Bindingмы привязались к свойству, которое отдает нам текущее время.
Результат всей этой работы, следующий:
Собственно, вот мы и сделали контрол, который на основе входного времени показывает его в виде аналоговых часов. Заметьте, контрол не знает ничего про ViewModel слои, он независим вообще от чего либо, а само время у нас в ViewModel слое (если время получается из вне, то должно быть в Model). Так что, старайтесь делить все на мало связанные друг с другом слои, это основы основ MVVM)
Дополнение №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. Также тут интересен триггер, который меняет цвет стрелки, если по ней нажать.Теперь нам нужно еще одно
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));Имея команду и стиль, мы можем теперь переделать все стрелки на
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- передаваемые в команду значения (в мое случае просто строковое обозначение кнопки).С контролом мы закончили, осталось сделать команду в 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); } }Дальшее в
MainViewModelделаем публичное свойство командыpublic ICommand ArrowClickCommand { get; set; }Инициализируем эту команду в конструкторе:
public MainViewModel() { Start(); ArrowClickCommand = new RelayCommand(param => OnArrowClick(param)); }Ну и сам метод. Я его реализовывать не буду, оставлю это вам, я лишь сделаю заглушку, которая в отладочную консоль будет выводить тег стрелки:
private void OnArrowClick(object arrow) { if (arrow is string arrowKey) { Debug.WriteLine(arrowKey); } }
Все, теперь при клике должна выделяться одна конкретная стрелка и вызываться команда, которая уже сделает все необходимые действия.
Дополнение №2: Анимация.
Для начала давайте сделаем событие, которое будет оповещать нас о том, что у нас изменилось время, для этого в 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); }Теперь идем в 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 сек.Имея все это давайте подправим метод
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, тем самым стрелка пойдет дальше делать круг.


