using Avalonia; using Avalonia.Media; using Avalonia.Input; using Avalonia.Controls; using GSS2.Core.UI.Extensions; namespace GSS2.UI.Core; public class ZoomPanImage : Control { private Point? _lastMousePosition = null; private Dictionary? _lastTouchPositions = null; public static readonly StyledProperty BackgroundProperty = AvaloniaProperty.Register(nameof(Background), defaultValue: Colors.Transparent); public Color Background { get => GetValue(BackgroundProperty); set => SetValue(BackgroundProperty, value); } public static readonly StyledProperty StretchModeProperty = AvaloniaProperty.Register(nameof(StretchMode), defaultValue: Stretch.Uniform); public Stretch StretchMode { get => GetValue(StretchModeProperty); set => SetValue(StretchModeProperty, value); } public static readonly StyledProperty TileModeProperty = AvaloniaProperty.Register(nameof(TileMode), defaultValue: TileMode.None); public TileMode TileMode { get => GetValue(TileModeProperty); set => SetValue(TileModeProperty, value); } public static readonly StyledProperty ImageSourceProperty = AvaloniaProperty.Register(nameof(ImageSource), defaultValue: null); public IImage? ImageSource { get => GetValue(ImageSourceProperty); set => SetValue(ImageSourceProperty, value); } public static readonly StyledProperty ZoomProperty = AvaloniaProperty.Register(nameof(Zoom), defaultValue: 1); public float Zoom { get => GetValue(ZoomProperty); set => SetValue(ZoomProperty, value); } public static readonly StyledProperty PanXProperty = AvaloniaProperty.Register(nameof(PanX), defaultValue: 0); public float PanX { get => GetValue(PanXProperty); set => SetValue(PanXProperty, value); } public static readonly StyledProperty PanYProperty = AvaloniaProperty.Register(nameof(PanY), defaultValue: 0); public float PanY { get => GetValue(PanYProperty); set => SetValue(PanYProperty, value); } public static readonly StyledProperty CanUserZoomPanProperty = AvaloniaProperty.Register(nameof(CanUserZoomPan), defaultValue: true); public bool CanUserZoomPan { get => GetValue(CanUserZoomPanProperty); set => SetValue(CanUserZoomPanProperty, value); } public ZoomPanImage() : base() { DoubleTapped += (_, _) => { Zoom = 1f; PanX = 0f; PanY = 0f; }; AffectsRender(ZoomProperty); AffectsRender(PanXProperty); AffectsRender(PanYProperty); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { if (change.Property == ImageSourceProperty) InvalidateVisual(); base.OnPropertyChanged(change); } protected override void OnPointerWheelChanged(PointerWheelEventArgs ea) { if (!CanUserZoomPan) return; if (ea.Pointer.Type == PointerType.Mouse) { var position = ea.GetPosition(this); // Рассчитываем позицию в координатах изображения (до масштабирования) var imageX = (position.X - Bounds.Width / 2 - PanX) / Zoom; var imageY = (position.Y - Bounds.Height / 2 - PanY) / Zoom; // Применяем масштаб float zoomFactor = ea.Delta.Y > 0 ? 1.1f : 1 / 1.1f; Zoom *= zoomFactor; Zoom = Math.Clamp(Zoom, 1f, 10f); Zoom = (float)Math.Round(Zoom, 2); if (Zoom != 1f) { // Пересчитываем смещение так, чтобы указатель остался на месте PanX = (float)(position.X - Bounds.Width / 2 - imageX * Zoom); PanY = (float)(position.Y - Bounds.Height / 2 - imageY * Zoom); } else { // Если зум == 1, то возвращаем изображение к исходному смещению PanX = 0f; PanY = 0f; } } base.OnPointerWheelChanged(ea); InvalidateVisual(); } protected override void OnPointerPressed(PointerPressedEventArgs ea) { if (!CanUserZoomPan) return; if (ea.Pointer.Type == PointerType.Mouse) { if (ea.Properties.IsLeftButtonPressed) { if (ea.ClickCount == 2) // По двойному клику возвращаем изображение к смещение и зум к стандартным значениям { Zoom = 1f; PanX = 0f; PanY = 0f; } else _lastMousePosition = ea.GetPosition(this); } } else if (ea.Pointer.Type == PointerType.Touch) { if (_lastTouchPositions is null) _lastTouchPositions = new Dictionary(); if (!_lastTouchPositions.ContainsKey(ea.Pointer.Id) && _lastTouchPositions.Count() < 2) _lastTouchPositions.Add(ea.Pointer.Id, ea.GetPosition(this)); else if (_lastTouchPositions.ContainsKey(ea.Pointer.Id)) _lastTouchPositions[ea.Pointer.Id] = ea.GetPosition(this); } base.OnPointerPressed(ea); InvalidateVisual(); } protected override void OnPointerReleased(PointerReleasedEventArgs ea) { if (!CanUserZoomPan) return; if (ea.Pointer.Type == PointerType.Mouse) { if (!ea.Properties.IsLeftButtonPressed) _lastMousePosition = null; } else if (ea.Pointer.Type == PointerType.Touch) { if (_lastTouchPositions is not null && _lastTouchPositions.ContainsKey(ea.Pointer.Id)) _lastTouchPositions.Remove(ea.Pointer.Id); } base.OnPointerReleased(ea); InvalidateVisual(); } protected override void OnPointerMoved(PointerEventArgs ea) { if (!CanUserZoomPan) return; if (ea.Pointer.Type == PointerType.Mouse) { if (_lastMousePosition is null || Zoom == 1) // Если кнопка не нажата или зум == 1, то ничего не делаем { base.OnPointerMoved(ea); return; } var position = ea.GetPosition(this); PanX -= (float)(_lastMousePosition.Value.X - position.X); PanY -= (float)(_lastMousePosition.Value.Y - position.Y); _lastMousePosition = position; } else if (ea.Pointer.Type == PointerType.Touch) { if (_lastTouchPositions is null) { base.OnPointerMoved(ea); return; } if (!_lastTouchPositions.ContainsKey(ea.Pointer.Id) && _lastTouchPositions.Count() < 2) { _lastTouchPositions.Add(ea.Pointer.Id, ea.GetPosition(this)); base.OnPointerMoved(ea); return; } if (_lastTouchPositions.Count() == 1 && Zoom != 1) // Если есть только одно прикосновение и зум != 1, то только перемещение { var position = ea.GetPosition(this); PanX -= (float)(_lastTouchPositions[ea.Pointer.Id].X - position.X); PanY -= (float)(_lastTouchPositions[ea.Pointer.Id].Y - position.Y); // Ось Y инвертирована _lastTouchPositions[ea.Pointer.Id] = position; } else if (_lastTouchPositions.Count() == 2) // Если два прикосновения, то перемещение с приближением { var lastPosition1 = _lastTouchPositions.Skip(0).First().Value; var lastPosition2 = _lastTouchPositions.Skip(1).First().Value; var lastDistance = Point.Distance(lastPosition1, lastPosition2); var lastCenter = PointHelper.Center(lastPosition1, lastPosition2); _lastTouchPositions[ea.Pointer.Id] = ea.GetPosition(this); var newPosition1 = _lastTouchPositions.Skip(0).First().Value; var newPosition2 = _lastTouchPositions.Skip(1).First().Value; var newDistance = Point.Distance(newPosition1, newPosition2); var newCenter = PointHelper.Center(newPosition1, newPosition2); var zoomFactor = newDistance / lastDistance; var newZoom = (float)(Zoom * zoomFactor); newZoom = (float)Math.Clamp(newZoom, 1f, 10f); var imageX = (newCenter.X - Bounds.Width / 2 - PanX) / Zoom; var imageY = (newCenter.Y - Bounds.Height / 2 - PanY) / Zoom; Zoom = newZoom; if (Zoom > 1f) { PanX = (float)(newCenter.X - Bounds.Width / 2 - imageX * Zoom); PanY = (float)(newCenter.Y - Bounds.Height / 2 - imageY * Zoom); } else { Zoom = 1f; PanX = 0f; PanY = 0f; } } } base.OnPointerMoved(ea); InvalidateVisual(); } public override void Render(DrawingContext context) { using (context.PushClip(new Rect(Bounds.Size))) { context.FillRectangle(new SolidColorBrush(Background), new Rect(0, 0, Bounds.Width, Bounds.Height)); if (ImageSource is null) { base.Render(context); return; } var (sx, sy) = ComputeStretchScale(StretchMode, ImageSource.Size.Width, ImageSource.Size.Height); sx *= Zoom; sy *= Zoom; var drawWidth = ImageSource.Size.Width * sx; var drawHeight = ImageSource.Size.Height * sy; var destRect = new Rect( (Bounds.Width - drawWidth) / 2 + PanX, (Bounds.Height - drawHeight) / 2 + PanY, drawWidth, drawHeight ); var srcRect = ComputeSourceRect(TileMode, (int)ImageSource.Size.Width, (int)ImageSource.Size.Height); context.DrawImage(ImageSource, srcRect, destRect); } base.Render(context); } private (double sx, double sy) ComputeStretchScale(Stretch stretch, double imageWidth, double imageHeight) { double viewWidth = Bounds.Width; double viewHeight = Bounds.Height; var viewAspect = viewWidth / viewHeight; var imageAspect = imageWidth / imageHeight; switch (stretch) { case Stretch.None: return (1, 1); case Stretch.Fill: return (viewWidth / imageWidth, viewHeight / imageHeight); case Stretch.Uniform: if (imageAspect > viewAspect) { var s = viewWidth / imageWidth; return (s, s); } else { var s = viewHeight / imageHeight; return (s, s); } case Stretch.UniformToFill: if (imageAspect > viewAspect) { var s = viewHeight / imageHeight; return (s, s); } else { var s = viewWidth / imageWidth; return (s, s); } default: return (1, 1); } } private Rect ComputeSourceRect(TileMode mode, int imageWidth, int imageHeight) { switch (mode) { case TileMode.None: return new Rect(0, 0, imageWidth, imageHeight); case TileMode.Tile: return new Rect(0, 0, imageWidth * 2, imageHeight * 2); case TileMode.FlipX: return new Rect(imageWidth, 0, -imageWidth, imageHeight); case TileMode.FlipY: return new Rect(0, imageHeight, imageWidth, -imageHeight); case TileMode.FlipXY: return new Rect(imageWidth, imageHeight, -imageWidth, -imageHeight); default: return new Rect(0, 0, imageWidth, imageHeight); } } }