Xamarin Forms pinch and pan together

Gábor

The main reason might be that everybody seems to copy and use this code (coming from the dev.xamarin site) with its very convoluted and very unnecessary co-ordinate calculations :-). Unnecessary because we could simply ask the view to do the heavy lifting for us, using the AnchorX and AnchorY properties which serve exactly this purpose.

We can have a double tap operation to zoom in and to revert to the original scale. Note that because Xamarin fails to provide coordinate values with its Tap events (a very unwise decision, actually), we can only zoom from the center now:

private void OnTapped(object sender, EventArgs e) 
{
    if (Scale > MIN_SCALE) 
    {
        this.ScaleTo(MIN_SCALE, 250, Easing.CubicInOut);
        this.TranslateTo(0, 0, 250, Easing.CubicInOut);
    }
    else 
    {
        AnchorX = AnchorY = 0.5;
        this.ScaleTo(MAX_SCALE, 250, Easing.CubicInOut);
    }
}

The pinch handler is similarly simple, no need to calculate any translations at all. All we have to do is to set the anchors to the pinch starting point and the framework will do the rest, the scaling will occur around this point. Note that we even have an extra feature here, springy bounce-back on overshoot at both ends of the zoom scale.

private void OnPinchUpdated(object sender, PinchGestureUpdatedEventArgs e) 
{
    switch (e.Status) 
    {
        case GestureStatus.Started:
            StartScale = Scale;
            AnchorX = e.ScaleOrigin.X;
            AnchorY = e.ScaleOrigin.Y;
            break;

        case GestureStatus.Running:
            double current = Scale + (e.Scale - 1) * StartScale;
            Scale = Clamp(current, MIN_SCALE * (1 - OVERSHOOT), MAX_SCALE * (1 + OVERSHOOT));
            break;

        case GestureStatus.Completed:
            if (Scale > MAX_SCALE)
                this.ScaleTo(MAX_SCALE, 250, Easing.SpringOut);
            else if (Scale < MIN_SCALE)
                this.ScaleTo(MIN_SCALE, 250, Easing.SpringOut);
            break;
    }
}

And the panning handler, even simpler. On start, we calculate the starting point from the anchor and during panning, we keep changing the anchor. This anchor being relative to the view area, we can easily clamp it between 0 and 1 and this stops the panning at the extremes without any translation calculation at all.

private void OnPanUpdated(object sender, PanUpdatedEventArgs e) 
{
    switch (e.StatusType) 
    {
        case GestureStatus.Started:
            StartX = (1 - AnchorX) * Width;
            StartY = (1 - AnchorY) * Height;
            break;

        case GestureStatus.Running:
            AnchorX = Clamp(1 - (StartX + e.TotalX) / Width, 0, 1);
            AnchorY = Clamp(1 - (StartY + e.TotalY) / Height, 0, 1);
            break;
    }
}

The constants and variables used are just these:

private const double MIN_SCALE = 1;
private const double MAX_SCALE = 8;
private const double OVERSHOOT = 0.15;
private double StartX, StartY;
private double StartScale;
jdmdevdotnet

Went with a completely different method of handling this. For anyone who is having issues, this works 100%.

OnPanUpdated

void OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
    var s = (ContentView)sender;

    // do not allow pan if the image is in its intial size
    if (currentScale == 1)
        return;

    switch (e.StatusType)
    {
        case GestureStatus.Running:
            double xTrans = xOffset + e.TotalX, yTrans = yOffset + e.TotalY;
            // do not allow verical scorlling unless the image size is bigger than the screen
            s.Content.TranslateTo(xTrans, yTrans, 0, Easing.Linear);
            break;

        case GestureStatus.Completed:
            // Store the translation applied during the pan
            xOffset = s.Content.TranslationX;
            yOffset = s.Content.TranslationY;

            // center the image if the width of the image is smaller than the screen width
            if (originalWidth * currentScale < ScreenWidth && ScreenWidth > ScreenHeight)
                xOffset = (ScreenWidth - originalWidth * currentScale) / 2 - s.Content.X;
            else
                xOffset = System.Math.Max(System.Math.Min(0, xOffset), -System.Math.Abs(originalWidth * currentScale - ScreenWidth));

            // center the image if the height of the image is smaller than the screen height
            if (originalHeight * currentScale < ScreenHeight && ScreenHeight > ScreenWidth)
                yOffset = (ScreenHeight - originalHeight * currentScale) / 2 - s.Content.Y;
            else
                //yOffset = System.Math.Max(System.Math.Min((originalHeight - (ScreenHeight)) / 2, yOffset), -System.Math.Abs((originalHeight * currentScale - ScreenHeight - (originalHeight - ScreenHeight) / 2)) + (NavBar.Height + App.StatusBarHeight));
                yOffset = System.Math.Max(System.Math.Min((originalHeight - (ScreenHeight)) / 2, yOffset), -System.Math.Abs((originalHeight * currentScale - ScreenHeight - (originalHeight - ScreenHeight) / 2)));

            // bounce the image back to inside the bounds
            s.Content.TranslateTo(xOffset, yOffset, 500, Easing.BounceOut);
            break;
    }
}

OnPinchUpdated

void OnPinchUpdated(object sender, PinchGestureUpdatedEventArgs e)
{
    var s = (ContentView)sender;

    if (e.Status == GestureStatus.Started)
    {
        // Store the current scale factor applied to the wrapped user interface element,
        // and zero the components for the center point of the translate transform.
        startScale = s.Content.Scale;

        s.Content.AnchorX = 0;
        s.Content.AnchorY = 0;
    }
    if (e.Status == GestureStatus.Running)
    {

        // Calculate the scale factor to be applied.
        currentScale += (e.Scale - 1) * startScale;
        currentScale = System.Math.Max(1, currentScale);
        currentScale = System.Math.Min(currentScale, 5);

        //scaleLabel.Text = "Scale: " + currentScale.ToString ();

        // The ScaleOrigin is in relative coordinates to the wrapped user interface element,
        // so get the X pixel coordinate.
        double renderedX = s.Content.X + xOffset;
        double deltaX = renderedX / App.ScreenWidth;
        double deltaWidth = App.ScreenWidth / (s.Content.Width * startScale);
        double originX = (e.ScaleOrigin.X - deltaX) * deltaWidth;

        // The ScaleOrigin is in relative coordinates to the wrapped user interface element,
        // so get the Y pixel coordinate.
        double renderedY = s.Content.Y + yOffset;

        double deltaY = renderedY / App.ScreenHeight;
        double deltaHeight = App.ScreenHeight / (s.Content.Height * startScale);
        double originY = (e.ScaleOrigin.Y - deltaY) * deltaHeight;

        // Calculate the transformed element pixel coordinates.
        double targetX = xOffset - (originX * s.Content.Width) * (currentScale - startScale);
        double targetY = yOffset - (originY * s.Content.Height) * (currentScale - startScale);

        // Apply translation based on the change in origin.
        var transX = targetX.Clamp(-s.Content.Width * (currentScale - 1), 0);
        var transY = targetY.Clamp(-s.Content.Height * (currentScale - 1), 0);


        s.Content.TranslateTo(transX, transY, 0, Easing.Linear);
        // Apply scale factor.
        s.Content.Scale = currentScale;
    }
    if (e.Status == GestureStatus.Completed)
    {
        // Store the translation applied during the pan
        xOffset = s.Content.TranslationX;
        yOffset = s.Content.TranslationY;

        // center the image if the width of the image is smaller than the screen width
        if (originalWidth * currentScale < ScreenWidth && ScreenWidth > ScreenHeight)
            xOffset = (ScreenWidth - originalWidth * currentScale) / 2 - s.Content.X;
        else
            xOffset = System.Math.Max(System.Math.Min(0, xOffset), -System.Math.Abs(originalWidth * currentScale - ScreenWidth));

        // center the image if the height of the image is smaller than the screen height
        if (originalHeight * currentScale < ScreenHeight && ScreenHeight > ScreenWidth)
            yOffset = (ScreenHeight - originalHeight * currentScale) / 2 - s.Content.Y;
        else
            yOffset = System.Math.Max(System.Math.Min((originalHeight - ScreenHeight) / 2, yOffset), -System.Math.Abs(originalHeight * currentScale - ScreenHeight - (originalHeight - ScreenHeight) / 2));

        // bounce the image back to inside the bounds
        s.Content.TranslateTo(xOffset, yOffset, 500, Easing.BounceOut);
    }
}

OnSizeAllocated (most of this you probably dont need, but some you do. consider ScreenWidth, ScreenHeight, yOffset, xOffset, currentScale)

protected override void OnSizeAllocated(double width, double height)
{            
    base.OnSizeAllocated(width, height); //must be called

    if (width != -1 &&  (ScreenWidth != width || ScreenHeight != height))
    {
        ResetLayout(width, height);

        originalWidth = initialLoad ?
            ImageWidth >= 960 ?
               App.ScreenWidth > 320 
                    ? 768 
                    : 320 
                :  ImageWidth / 3
            : imageContainer.Content.Width / imageContainer.Content.Scale;

        var normalizedHeight = ImageWidth >= 960 ?
                App.ScreenWidth > 320 ? ImageHeight / (ImageWidth / 768) 
                : ImageHeight / (ImageWidth / 320) 
            : ImageHeight / 3;

        originalHeight = initialLoad ? 
            normalizedHeight : (imageContainer.Content.Height / imageContainer.Content.Scale);

        ScreenWidth = width;
        ScreenHeight = height;

        xOffset = imageContainer.TranslationX;
        yOffset = imageContainer.TranslationY;

        currentScale = imageContainer.Scale;

        if (initialLoad)
            initialLoad = false;
    }
}

Layout (XAML in C#)

ImageMain = new Image
{
    HorizontalOptions = LayoutOptions.CenterAndExpand,
    VerticalOptions = LayoutOptions.CenterAndExpand,
    Aspect = Aspect.AspectFill,
    Source = ImageMainSource
};

imageContainer = new ContentView
{
    Content = ImageMain,
    BackgroundColor = Xamarin.Forms.Color.Black,
    WidthRequest = App.ScreenWidth - 250
};

var panGesture = new PanGestureRecognizer();
panGesture.PanUpdated += OnPanUpdated;
imageContainer.GestureRecognizers.Add(panGesture);

var pinchGesture = new PinchGestureRecognizer();
pinchGesture.PinchUpdated += OnPinchUpdated;
imageContainer.GestureRecognizers.Add(pinchGesture);

double smallImageHeight = ImageHeight / (ImageWidth / 320);

absoluteLayout = new AbsoluteLayout
{
    HeightRequest = App.ScreenHeight,
    BackgroundColor = Xamarin.Forms.Color.Black,
};

AbsoluteLayout.SetLayoutFlags(imageContainer, AbsoluteLayoutFlags.All);
AbsoluteLayout.SetLayoutBounds(imageContainer, new Rectangle(0f, 0f, AbsoluteLayout.AutoSize, AbsoluteLayout.AutoSize));
absoluteLayout.Children.Add(imageContainer, new Rectangle(0, 0, 1, 1), AbsoluteLayoutFlags.All);
Content = absoluteLayout;

For me it worked like below, just did some changes in the code given in the question,

    void OnPinchUpdated(object sender, PinchGestureUpdatedEventArgs e)
     {


if (e.Status == GestureStatus.Started)
            {
                // Store the current scale factor applied to the wrapped user interface element,


     // and zero the components for the center point of the translate transform.
        startScale = Content.Scale;
        Content.AnchorX = 0;
        Content.AnchorY = 0;
    }
    if (e.Status == GestureStatus.Running)
    {
        // Calculate the scale factor to be applied.
        currentScale += (e.Scale - 1) * startScale;
        currentScale = Math.Max(1, currentScale);

        // The ScaleOrigin is in relative coordinates to the wrapped user 
        interface element,
        // so get the X pixel coordinate.
        double renderedX = Content.X + xOffset;
        double deltaX = renderedX / Width;
        double deltaWidth = Width / (Content.Width * startScale);
        double originX = (e.ScaleOrigin.X - deltaX) * deltaWidth;

        // The ScaleOrigin is in relative coordinates to the wrapped user 
        interface element,
        // so get the Y pixel coordinate.
        double renderedY = Content.Y + yOffset;
        double deltaY = renderedY / Height;
        double deltaHeight = Height / (Content.Height * startScale);
        double originY = (e.ScaleOrigin.Y - deltaY) * deltaHeight;

        // Calculate the transformed element pixel coordinates.
        double targetX = xOffset - (originX * Content.Width) * (currentScale - 
        startScale);
        double targetY = yOffset - (originY * Content.Height) * (currentScale - 
        startScale);

        // Apply translation based on the change in origin.
        Content.TranslationX = targetX.Clamp(-Content.Width * (currentScale - 1), 0);
        Content.TranslationY = targetY.Clamp(-Content.Height * (currentScale - 1), 0);

        // Apply scale factor.
        Content.Scale = currentScale;
        width = Content.Width * currentScale;
        height = Content.Height * currentScale;

    }

    if (e.Status == GestureStatus.Completed)
    {
        // Store the translation delta's of the wrapped user interface element.
        xOffset = Content.TranslationX;
        yOffset = Content.TranslationY;
        x = Content.TranslationX;
        y = Content.TranslationY;
    }
}

Pan Code

void OnPanUpdated(object sender, PanUpdatedEventArgs e)
    {
        if (!width.Equals(Content.Width) && !height.Equals(Content.Height))
        {
            switch (e.StatusType)
            {
                case GestureStatus.Started:
                    startX = Content.TranslationX;
                    startY = Content.TranslationY;
                    break;
                case GestureStatus.Running:
                    if (!width.Equals(0))
                    {
                        Content.TranslationX = Math.Max(Math.Min(0, x + e.TotalX), -Math.Abs(Content.Width - width));// App.ScreenWidth));
                    }
                    if (!height.Equals(0))
                    {
                        Content.TranslationY = Math.Max(Math.Min(0, y + e.TotalY), -Math.Abs(Content.Height - height)); //App.ScreenHeight));    
                    }
                    break;
                case GestureStatus.Completed:
                    // Store the translation applied during the pan
                    x = Content.TranslationX;
                    y = Content.TranslationY;
                    xOffset = Content.TranslationX;
                    yOffset = Content.TranslationY;
                    break;
            }
        }
    }