Home > Software engineering >  C# winforms - How to combine scrollbar with mouse wheel zooming?
C# winforms - How to combine scrollbar with mouse wheel zooming?

Time:08-09

Problem - I'm writing a program that draws graphics, and zooming is one of the features. Currently, a picturebox is placed on a panel, and the picturebox has vertical and horizontal scroll bars on the right and bottom. How to combine scrollbar with mouse wheel zooming? And I'm not sure if I should use paint to draw the graphics or set a bitmap to draw the graphics onto it?

Expected - When the mouse wheel is scrolled, the entire canvas(picturebox) include drawn graphics are scaled according to the current mouse position as the center (the horizontal and vertical scroll bars change according to the zoom center). When the mouse wheel is pressed and moved, the canvas can be dragged freely.

Expected as follows: enter image description here

The initial code

private List<Point> _points;
private int _pointRadius = 50;
private float _scale = 1f;
private float _offsetX = 0f;
private float _offsetY = 0f;

private void picturebox_MouseDown(object sender, MouseEventArgs e)
{
    _points.Add(e.Location);
}

private void picturebox_MouseWheel(object sender, MouseEvnetArgs e)
{
    if(e.Delta < 0)
    {
        _scale  = 0.1f;
        _offsetX = e.X * (1f - _scale);
        _offsetY = e.X * (1f - _scale);
    }
    else
    {
        _scale -= 0.1f;
        _offsetX = e.X * (1f - _scale);
        _offsetY = e.X * (1f - _scale);
    }
    picturebox.Invalidate();
}

private void picturebox_Paint(object sender, PaintEventArgs e)
{
    e.Graphics.TranslateTransform(_offsetX, _offsetY);
    e.Graphics.ScaleTransform(_scaleX, _scaleY);
    foreach (Point p in _points)
    {
        e.Graphics.FillEllipse(Brushes.Black, p.X, - _pointRadius, p.Y - _pointRadius, 2 * _pointRadius, 2 * _pointRadius);
    }
}

Hope the answer is modified based on the initial code.

Thanks in advance to everyone who helped me.

CodePudding user response:

Would it be easier if I drew the graphics on a Bitmap?

Considering the nature of your task and the already implemented solutions in my ImageViewer I created a solution that draws the result in a Metafile, which is both elegant, consumes minimal memory and allows zooming without quality issues.

Here is the stripped version of my ImageViewer:

public class MetafileViewer : Control
{
    private HScrollBar sbHorizontal = new HScrollBar { Visible = false };
    private VScrollBar sbVertical = new VScrollBar { Visible = false };
    private Metafile? image;
    private Size imageSize;
    private Rectangle targetRectangle;
    private Rectangle clientRectangle;
    private float zoom = 1;
    private bool sbHorizontalVisible;
    private bool sbVerticalVisible;
    private int scrollFractionVertical;

    public MetafileViewer()
    {
        Controls.AddRange(new Control[] { sbHorizontal, sbVertical });
        sbHorizontal.ValueChanged  = ScrollbarValueChanged;
        sbVertical.ValueChanged  = ScrollbarValueChanged;
    }

    void ScrollbarValueChanged(object? sender, EventArgs e) => Invalidate();

    public Metafile? Image
    {
        get => image;
        set
        {
            image = value;
            imageSize = image?.Size ?? default;
            InvalidateLayout();
        }
    }

    public bool TryTranslate(Point mouseCoord, out PointF canvasCoord)
    {
        canvasCoord = default;
        if (!targetRectangle.Contains(mouseCoord))
            return false;
        canvasCoord = new PointF((mouseCoord.X - targetRectangle.X) / zoom, (mouseCoord.Y - targetRectangle.Y) / zoom);
        if (sbHorizontalVisible)
            canvasCoord.X  = sbHorizontal.Value / zoom;
        if (sbVerticalVisible)
            canvasCoord.Y  = sbVertical.Value / zoom;

        return true;
    }

    private void InvalidateLayout()
    {
        Invalidate();
        if (imageSize.IsEmpty)
        {
            sbHorizontal.Visible = sbVertical.Visible = sbHorizontalVisible = sbVerticalVisible = false;
            targetRectangle = Rectangle.Empty;
            return;
        }

        Size clientSize = ClientSize;
        if (clientSize.Width < 1 || clientSize.Height < 1)
        {
            targetRectangle = Rectangle.Empty;
            return;
        }

        Size scaledSize = imageSize.Scale(zoom);

        // scrollbars visibility
        sbHorizontalVisible = scaledSize.Width > clientSize.Width
            || scaledSize.Width > clientSize.Width - SystemInformation.VerticalScrollBarWidth && scaledSize.Height > clientSize.Height;
        sbVerticalVisible = scaledSize.Height > clientSize.Height
            || scaledSize.Height > clientSize.Height - SystemInformation.HorizontalScrollBarHeight && scaledSize.Width > clientSize.Width;

        if (sbHorizontalVisible)
            clientSize.Height -= SystemInformation.HorizontalScrollBarHeight;
        if (sbVerticalVisible)
            clientSize.Width -= SystemInformation.VerticalScrollBarWidth;
        if (clientSize.Width < 1 || clientSize.Height < 1)
        {
            targetRectangle = Rectangle.Empty;
            return;
        }

        Point clientLocation = Point.Empty;
        var targetLocation = new Point((clientSize.Width >> 1) - (scaledSize.Width >> 1),
            (clientSize.Height >> 1) - (scaledSize.Height >> 1));

        // both scrollbars
        if (sbHorizontalVisible && sbVerticalVisible)
        {
            sbHorizontal.Dock = sbVertical.Dock = DockStyle.None;
            sbHorizontal.Width = clientSize.Width;
            sbHorizontal.Top = clientSize.Height;
            sbHorizontal.Left = 0;
            sbVertical.Height = clientSize.Height;
            sbVertical.Left = clientSize.Width;
        }
        // horizontal scrollbar
        else if (sbHorizontalVisible)
            sbHorizontal.Dock = DockStyle.Bottom;
        // vertical scrollbar
        else if (sbVerticalVisible)
            sbVertical.Dock = DockStyle.Right;

        // adjust scrollbar values
        if (sbHorizontalVisible)
        {
            sbHorizontal.Minimum = targetLocation.X;
            sbHorizontal.Maximum = targetLocation.X   scaledSize.Width;
            sbHorizontal.LargeChange = clientSize.Width;
            sbHorizontal.SmallChange = 32;
            sbHorizontal.Value = Math.Min(sbHorizontal.Value, sbHorizontal.Maximum - sbHorizontal.LargeChange);
        }

        if (sbVerticalVisible)
        {
            sbVertical.Minimum = targetLocation.Y;
            sbVertical.Maximum = targetLocation.Y   scaledSize.Height;
            sbVertical.LargeChange = clientSize.Height;
            sbVertical.SmallChange = 32;
            sbVertical.Value = Math.Min(sbVertical.Value, sbVertical.Maximum - sbVertical.LargeChange);
        }

        sbHorizontal.Visible = sbHorizontalVisible;
        sbVertical.Visible = sbVerticalVisible;

        clientRectangle = new Rectangle(clientLocation, clientSize);
        targetRectangle = new Rectangle(targetLocation, scaledSize);
        if (sbVerticalVisible)
            clientRectangle.X = SystemInformation.VerticalScrollBarWidth;
    }

    protected override void OnSizeChanged(EventArgs e)
    {
        base.OnSizeChanged(e);
        InvalidateLayout();
    }

    protected override void OnPaint(PaintEventArgs e)
    {
        base.OnPaint(e);
        if (image == null || e.ClipRectangle.Width <= 0 || e.ClipRectangle.Height <= 0)
            return;

        if (targetRectangle.IsEmpty)
            InvalidateLayout();
        if (targetRectangle.IsEmpty)
            return;

        Graphics g = e.Graphics;
        g.IntersectClip(clientRectangle);
        Rectangle dest = targetRectangle;
        if (sbHorizontalVisible)
            dest.X -= sbHorizontal.Value;
        if (sbVerticalVisible)
            dest.Y -= sbVertical.Value;
        g.DrawImage(image, dest);
        g.DrawRectangle(SystemPens.ControlText, Rectangle.Inflate(targetRectangle, 1, 1));
    }

    protected override void onm ouseWheel(MouseEventArgs e)
    {
        base.OnMouseWheel(e);
        switch (ModifierKeys)
        {
            // zoom
            case Keys.Control:
                float delta = (float)e.Delta / SystemInformation.MouseWheelScrollDelta / 5;
                if (delta.Equals(0f))
                    return;
                delta  = 1;
                SetZoom(zoom * delta);
                break;

            // vertical scroll
            case Keys.None:
                VerticalScroll(e.Delta);
                break;
        }
    }

    private void VerticalScroll(int delta)
    {
        // When scrolling by mouse, delta is always  -120 so this will be a small change on the scrollbar.
        // But we collect the fractional changes caused by the touchpad scrolling so it will not be lost either.
        int totalDelta = scrollFractionVertical   delta * sbVertical.SmallChange;
        scrollFractionVertical = totalDelta % SystemInformation.MouseWheelScrollDelta;
        int newValue = sbVertical.Value - totalDelta / SystemInformation.MouseWheelScrollDelta;
        SetValueSafe(sbVertical, newValue);
    }

    internal static void SetValueSafe(ScrollBar scrollBar, int value)
    {
        if (value < scrollBar.Minimum)
            value = scrollBar.Minimum;
        else if (value > scrollBar.Maximum - scrollBar.LargeChange   1)
            value = scrollBar.Maximum - scrollBar.LargeChange   1;

        scrollBar.Value = value;
    }

    private void SetZoom(float value)
    {
        const float maxZoom = 10f;
        float minZoom = image == null ? 1f : 1f / Math.Min(imageSize.Width, imageSize.Height);
        if (value < minZoom)
            value = minZoom;

        if (value > maxZoom)
            value = maxZoom;

        if (zoom.Equals(value))
            return;

        zoom = value;
        InvalidateLayout();
    }
}

And then the updated version of your initial code (add a new point by right click, zoom by Ctrl mouse scroll):

public partial class RenderMetafileForm : Form
{
    private static Size canvasSize = new Size(300, 200);
    private List<PointF> points = new List<PointF>();
    private const float pointRadius = 5;

    public RenderMetafileForm()
    {
        InitializeComponent();
        metafileViewer.MouseClick  = MetafileViewer_MouseClick;
        UpdateMetafile();
    }

    private void MetafileViewer_MouseClick(object? sender, MouseEventArgs e)
    {
        if (e.Button == MouseButtons.Right && metafileViewer.TryTranslate(e.Location, out var coord))
        {
            points.Add(coord);
            UpdateMetafile();
        }
    }

    private void UpdateMetafile()
    {
        Graphics refGraph = Graphics.FromHwnd(IntPtr.Zero);
        IntPtr hdc = refGraph.GetHdc();
        Metafile result;
        try
        {
            result = new Metafile(hdc, new Rectangle(Point.Empty, canvasSize), MetafileFrameUnit.Pixel, EmfType.EmfOnly, "Canvas");
            using (var g = Graphics.FromImage(result))
            {
                foreach (PointF point in points)
                    g.FillEllipse(Brushes.Navy, point.X - pointRadius, point.Y - pointRadius, pointRadius * 2, pointRadius * 2);
            }
        }
        finally
        {
            refGraph.ReleaseHdc(hdc);
            refGraph.Dispose();
        }

        Metafile? previous = metafileViewer.Image;
        metafileViewer.Image = result;
        previous?.Dispose();
    }
}

Result:

Metafile rendering with zooming

⚠️ Note: I did not add panning by keyboard or by grabbing the image but you can extract those from the original ImageViewer. Also, I removed DPI-aware scaling but see the ScaleSize extensions in the linked project.

  • Related