Как по координатам найти объект на Canvas'е и переместить его?

1,00
р.
На интерфейсе располагается в центре кнопка, вокруг него элементы типа Image. С виду это выглядит как квадрат 3 на 3. При клике на кнопку окружающие картинки должны сместится на одну клетку при каждом клике. Например, картинка которая находится (объясняю на языке двухмерного массива), которая располагается на позиции 1 0 от кнопки, должна сместится на позицию 0 0, а другая картинка с позиции 0 0, на позицию 0 1.
При помощи такого подхода:
image10.BeginAnimation(MarginProperty, new ThicknessAnimation { From = new Thickness(99, 66, 361, 212), To = new Thickness(99, 24, 361, 254), Duration = TimeSpan.FromSeconds(0.85) })
мне удается выполнить такое действие до второго клика. После второго клика картинки возвращаются в начальные положения и повторяют действие первого клика, а нужно чтобы при каждом клике ничего не сбивалось и картинки перемещались по кругу. Чтобы реализовать такой подход, я понял, что строго к элементам обращаться не получится, ибо выходит то, что написал выше. Поэтому вопрос таков, как при помощи координат в коде C# мне обратится к объекту, который располагается в данном месте и анимированно переместить в другое место? Как я прочитал в интернете для поиска и перемещения нужно, чтобы все объекты располагались на канвасе. Однако скажу сразу, что я пытался при помощи такого подхода получить координаты.
Canvas.SetLeft(image10, 0) Canvas.SetTop(image10, 0)
Но при помощи такого подхода не выходит прицепить анимацию перемещения, а она очень важна.
P.S. Буду признателен, если озвучите более просто подход выполнения этой задумки.

Ответ
Давайте напишем класс, представляющий "плитку":
class Tile : Vm { int x public int X { get => x set => Set(ref x, value) }
int y public int Y { get => y set => Set(ref y, value) }
string caption public string Caption { get => caption set => Set(ref caption, value) } }
Вы в своем классе будете хранить картинку или ссылку на нее, я использую просто строковую надпись string Caption
Теперь главная ViewModel:
class MainVM : Vm { ObservableCollection tiles public ObservableCollection Tiles { get => tiles set => Set(ref tiles, value) }
RelayCommand turnCommand public ICommand TurnCommand => turnCommand
public MainVM() { Tiles = new ObservableCollection { new Tile { Caption="1", X = 0, Y = 0 }, new Tile { Caption="2", X = 1, Y = 0 }, new Tile { Caption="3", X = 2, Y = 0 }, new Tile { Caption="4", X = 2, Y = 1 }, new Tile { Caption="5", X = 2, Y = 2 }, new Tile { Caption="6", X = 1, Y = 2 }, new Tile { Caption="7", X = 0, Y = 2 }, new Tile { Caption="8", X = 0, Y = 1 }, }
turnCommand = new RelayCommand(_ => TurnTiles()) }
void TurnTiles() { // Здесь используются фичи C# 7.0 и .NET Framework 4.7 Dictionary<(int, int), (int, int)> transitions = new Dictionary<(int, int), (int, int)> { [(0, 0)] = (0, 1), [(1, 0)] = (0, 0), [(2, 0)] = (1, 0), [(2, 1)] = (2, 0), [(2, 2)] = (2, 1), [(1, 2)] = (2, 2), [(0, 2)] = (1, 2), [(0, 1)] = (0, 2), } foreach (var tile in Tiles) (tile.X, tile.Y) = transitions[(tile.X, tile.Y)] /* Вариант "по-старинке", для тех, у кого по каким-то причинам не работает вариант выше Dictionary, Tuple> transitions = new Dictionary, Tuple> { [Tuple.Create(0, 0)] = Tuple.Create(0, 1), [Tuple.Create(1, 0)] = Tuple.Create(0, 0), [Tuple.Create(2, 0)] = Tuple.Create(1, 0), [Tuple.Create(2, 1)] = Tuple.Create(2, 0), [Tuple.Create(2, 2)] = Tuple.Create(2, 1), [Tuple.Create(1, 2)] = Tuple.Create(2, 2), [Tuple.Create(0, 2)] = Tuple.Create(1, 2), [Tuple.Create(0, 1)] = Tuple.Create(0, 2), } foreach (var tile in Tiles) { var coords = transitions[Tuple.Create(tile.X, tile.Y)] tile.X = coords.Item1 tile.Y = coords.Item2 } */ } }
В общем-то все просто, коллекция с "плитками" и команда для их перемещения по кругу. Причем в этом примере использовать ObservableCollection даже не обязательно, вы можете использовать обычный List. transitions - это словарь переходов, ключ словаря - начальные координаты плитки, значение - конечные
Теперь займемся представлением. Напишем конвертеры координат VM => View:
class XToLeftConverter : IValueConverter { public double TileWidth { get set }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return (int)value * TileWidth }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException() } }
И
class YToTopConverter : IValueConverter { public double TileHeight { get set }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return (int)value * TileHeight }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException() } }
В них определено публичное свойство, которое будет устанавливаться из разметки.
Теперь сама разметка окна, в качестве примера я использовал этот ответ:




Немного кривовато, но пока так. В принципе это уже работает, но, пока, без анимации. При клике по кнопке в центре плитки перепрыгивают по кругу.
Теперь это все дело нужно анимировать, я использовал этот ответ. Я пока не знаю, как использовать этот класс-хелпер для анимации двух свойств, поэтому завел два класса с одинаковым содержимым (как появится информация - обновлю ответ): AnimatableLeftHelper и AnimatableTopHelper. Теперь используем эти хелперы чтобы анимировать свойства:

Готово!

Здесь я использовал "стандартные" классы для MVVM WPF Vm:
abstract class Vm : INotifyPropertyChanged { protected bool Set(ref T field, T value, [CallerMemberName] string propertyName = null) { if (EqualityComparer.Default.Equals(field, value)) return false field = value NotifyPropertyChanged(propertyName) return true }
protected void NotifyPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)) }
public event PropertyChangedEventHandler PropertyChanged }
RelayCommand:
class RelayCommand : ICommand { protected readonly Predicate _canExecute protected readonly Action _execute
public event EventHandler CanExecuteChanged
public RelayCommand(Action execute) : this(execute, _ => true) { }
public RelayCommand(Action execute, Predicate canExecute) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)) _canExecute = canExecute ?? throw new ArgumentNullException(nameof(canExecute)) }
public bool CanExecute(object parameter) => _canExecute(parameter)
public void Execute(object parameter) => _execute(parameter)
public void RaiseCanExecuteChanged() { CanExecuteChanged?.Invoke(this, EventArgs.Empty) } }

Благодаря помощи @VladD'а удалось переписать пример с использованием PointAnimation:
"Плитка":
class Tile : Vm { Point coords public Point Coords { get => coords set => Set(ref coords, value) }
string caption public string Caption { get => caption set => Set(ref caption, value) } }
Главная VM:
class MainVM : Vm { ObservableCollection tiles public ObservableCollection Tiles { get => tiles set => Set(ref tiles, value) }
public ICommand TurnCommand { get }
public MainVM() { Tiles = new ObservableCollection { new Tile { Caption="1", Coords = new Point(0, 0) }, new Tile { Caption="2", Coords = new Point(1, 0) }, new Tile { Caption="3", Coords = new Point(2, 0) }, new Tile { Caption="4", Coords = new Point(2, 1) }, new Tile { Caption="5", Coords = new Point(2, 2) }, new Tile { Caption="6", Coords = new Point(1, 2) }, new Tile { Caption="7", Coords = new Point(0, 2) }, new Tile { Caption="8", Coords = new Point(0, 1) } }
TurnCommand = new DelegateCommand(_ => TurnTiles()) }
void TurnTiles() { Dictionary transitions = new Dictionary { [new Point(0, 0)] = new Point(0, 1), [new Point(1, 0)] = new Point(0, 0), [new Point(2, 0)] = new Point(1, 0), [new Point(2, 1)] = new Point(2, 0), [new Point(2, 2)] = new Point(2, 1), [new Point(1, 2)] = new Point(2, 2), [new Point(0, 2)] = new Point(1, 2), [new Point(0, 1)] = new Point(0, 2), } foreach (var tile in Tiles) tile.Coords = transitions[tile.Coords] } }
Конвертер всего один теперь:
class XYToPointConverter : IValueConverter { public double TileWidth { get set } public double TileHeight { get set } public double LeftOffset { get set } public double TopOffset { get set }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var typedValue = (Point)value return new Point(typedValue.X * TileWidth + LeftOffset, typedValue.Y * TileHeight + TopOffset) }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException() } }
Я добавил в него еще пару свойств со смещениями, чтобы настраивать положение плиток можно было более гибко.
Разметка окна:




Ну и класс-хелпер для анимации:
static class AnimatablePointHelper { public static Point GetOriginalProperty(DependencyObject obj) => (Point)obj.GetValue(OriginalPropertyProperty) public static void SetOriginalProperty(DependencyObject obj, Point value) => obj.SetValue(OriginalPropertyProperty, value) public static readonly DependencyProperty OriginalPropertyProperty = DependencyProperty.RegisterAttached( "OriginalProperty", typeof(Point), typeof(AnimatablePointHelper), new PropertyMetadata(OnOriginalUpdatedStatic))
public static Point GetAnimatedProperty(DependencyObject obj) => (Point)obj.GetValue(AnimatedPropertyProperty) public static void SetAnimatedProperty(DependencyObject obj, Point value) => obj.SetValue(AnimatedPropertyProperty, value) public static readonly DependencyProperty AnimatedPropertyProperty = DependencyProperty.RegisterAttached( "AnimatedProperty", typeof(Point), typeof(AnimatablePointHelper))
static void OnOriginalUpdatedStatic(DependencyObject o, DependencyPropertyChangedEventArgs e) { Point newValue = (Point)e.NewValue FrameworkElement self = (FrameworkElement)o AnimationTimeline animation = new PointAnimation(newValue, new Duration(TimeSpan.FromSeconds(0.3))) self.BeginAnimation(AnimatedPropertyProperty, animation) } }
Результат тот же.