Full Screen Image Viewer (with Pinch to Zoom, Pan to Move, Tap to show captions) for Xamarin forms.

2»

Answers

  • CarLoOSXCarLoOSX USMember ✭✭

    The best solution, before this one I was trying like Xamarin.forms examples... But this is exactly what I need. Thank you so much!!!!

    @djgtram said:
    @Roger, as I looked all over the web, all solutions seemed to be a bit too complicated and still lacking in functionality. Actually, all can be traced back to some original on the dev.xamarin forum, including the overcomplicated calculations. Instead of those, we can simply use the AnchorX/AnchorY properties. This is what I came up with in the end:

    public class ZoomImage : Image {
      private const double MIN_SCALE = 1;
      private const double MAX_SCALE = 4;
      private const double OVERSHOOT = 0.15;
      private double StartScale;
      private double LastX, LastY;
    
      public ZoomImage() {
        var pinch = new PinchGestureRecognizer();
        pinch.PinchUpdated += OnPinchUpdated;
        GestureRecognizers.Add(pinch);
    
        var pan = new PanGestureRecognizer();
        pan.PanUpdated += OnPanUpdated;
        GestureRecognizers.Add(pan);
    
        var tap = new TapGestureRecognizer { NumberOfTapsRequired = 2 };
        tap.Tapped += OnTapped;
        GestureRecognizers.Add(tap);
    
        Scale = MIN_SCALE;
        TranslationX = TranslationY = 0;
        AnchorX = AnchorY = 0;
      }
    
      protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint) {
        Scale = MIN_SCALE;
        TranslationX = TranslationY = 0;
        AnchorX = AnchorY = 0;
        return base.OnMeasure(widthConstraint, heightConstraint);
      }
    
      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; //TODO tapped position
          this.ScaleTo(MAX_SCALE, 250, Easing.CubicInOut);
        }
      }
    
      private void OnPanUpdated(object sender, PanUpdatedEventArgs e) {
        if (Scale > MIN_SCALE)
          switch (e.StatusType) {
            case GestureStatus.Started:
              LastX = TranslationX;
              LastY = TranslationY;
              break;
            case GestureStatus.Running:
              TranslationX = Clamp(LastX + e.TotalX * Scale, -Width / 2, Width / 2);
              TranslationY = Clamp(LastY + e.TotalY * Scale, -Height / 2, Height / 2);
              break;
          }
      }
    
      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;
        }
      }
    
      private T Clamp<T>(T value, T minimum, T maximum) where T : IComparable {
        if (value.CompareTo(minimum) < 0)
          return minimum;
        else if (value.CompareTo(maximum) > 0)
          return maximum;
        else
          return value;
      }
    }
    

    Extra functionality: zoom overshoot with springy bounce-back. Double tap in both directions: to jump to full zoom and restore the original scale. Unfortunately, some poor soul at Xamarin decided not to send co-ordinate info with the Tapped event (I can't fathom what they were thinking), so it jumps to full zoom centered now.

    And the problems: I don't know if this is something universal or it only happens on my device but while zooming in from scale=1 works all right, pinching to zoom out at larger scales results in wildly out-of-tune, alternating e.Scale values coming in from Xamarin (something like alternating 0.7, 1.3, 0.6, 1.4 and so on, in quick succession, making the image flicker). It would be nice to know if somebody else can see this, too.

    Apart from this, this code seems to work all right.

  • CarLoOSXCarLoOSX USMember ✭✭

    @MarkAnsley said:
    Is anyone else having performance issues on android with this? IOS zooms smooth, but Android is very choppy.

    In iOS works perfect , but not in android, the problem is when you try to zoom out

  • CarLoOSXCarLoOSX USMember ✭✭

    Okay Im working with a content view inside a ViewPager, I have corrected the bad behaviour on Android mentioned in other comments. My solution has been combining 2 solutions... The Xamarin Oficial examples like https://developer.xamarin.com/guides/xamarin-forms/application-fundamentals/gestures/pinch/ and @djgtram solution.

    I enable and disable the pan gesture depending on the scale of the image or contentpage because of the CarouselView plugin, but if you don't want this you should create like the other instances in the constructor.

    Android and iOS works nice, but I have 2 "problems":

    Ontap -> It should scale from the touched pointed, but instead is scaling from 0.5
    OnPan -> the same as Ontap it should start moving from the touched point but it doesn't and also The commented code on that method works but I don't know whats the expected behaviour so its up to you.

    This solution should works in a Image too, but if it doen't try to make a custom control (content view with an image).

            public ViewPagerItemPage()
            {
                InitializeComponent();
    
                PinchGestureRecognizer pinch = new PinchGestureRecognizer();
                pinch.PinchUpdated += OnPinchUpdated;
                GestureRecognizers.Add(pinch);
    
                pan = new PanGestureRecognizer();
                pan.PanUpdated += OnPanUpdated;
    
                TapGestureRecognizer tap = new TapGestureRecognizer { NumberOfTapsRequired = 2 };
                tap.Tapped += OnTapped;
                GestureRecognizers.Add(tap);
    
                Content.AnchorX = Content.AnchorY = 0;
            }
    
            void RestoreScaleValues()
            {
                Content.ScaleTo(MIN_SCALE, 250, Easing.CubicInOut);
                Content.TranslateTo(0.5, 0.5, 250, Easing.CubicInOut);
    
                CurrentScale = 1;
    
                Content.TranslationX = 0.5;
                Content.TranslationY = 0.5;
    
                xOffset = Content.TranslationX;
                yOffset = Content.TranslationY;
            }
    
            private void OnTapped(object sender, EventArgs e)
            {
                if (Content.Scale > MIN_SCALE)
                {
                    RestoreScaleValues();
                    if (GestureRecognizers.Contains(pan))
                    {
                        Device.BeginInvokeOnMainThread(() =>
                        {
                            GestureRecognizers.Remove(pan);
    
                        });
                    }
                               //Enable Swipe of the carouselview or view pager here
                }
                else
                {
                    Content.AnchorX = Content.AnchorY = 0.5; //TODO tapped position
                    Content.ScaleTo(MAX_SCALE, 250, Easing.CubicInOut);
                    if (!GestureRecognizers.Contains(pan))
                    {
                        Device.BeginInvokeOnMainThread(() =>
                        {
                            GestureRecognizers.Add(pan);
    
                        });
                    }
                               //Disable Swipe of the carouselview or view pager here
                }
            }
    
            private void OnPanUpdated(object sender, PanUpdatedEventArgs e)
            {
                switch (e.StatusType)
                {
                    case GestureStatus.Started:
                        StartX = (1 - x) * Width;
                        StartY = (1 - y) * Height;
                        break;
                    case GestureStatus.Running:
                        x = Clamp(1 - (StartX + e.TotalX) / Width, 0, 1);
                        y = Clamp(1 - (StartY + e.TotalY) / Height, 0, 1);
    
                        Content.AnchorX = x;
                        Content.AnchorY = y;
    
                        Content.TranslationX = Content.AnchorX;
                        Content.TranslationY = Content.AnchorY;
                        break;
                }
    
                /*  switch (e.StatusType)
                  {
                      case GestureStatus.Running:
                          // Translate and ensure we don't pan beyond the wrapped user interface element bounds.
                          Content.TranslationX =
                              Math.Max(Math.Min(0, x + e.TotalX), -Math.Abs(Content.Width));
                          Content.TranslationY =
                                     Math.Max(Math.Min(0, y + e.TotalY), -Math.Abs(Content.Height));
                          break;
    
                      case GestureStatus.Completed:
                          // Store the translation applied during the pan
                          x = Content.TranslationX;
                          y = Content.TranslationY;
                          break;
                  }
      */
            }
    
            private T Clamp<T>(T value, T minimum, T maximum) where T : IComparable
            {
                if (value.CompareTo(minimum) < 0)
                    return minimum;
                else if (value.CompareTo(maximum) > 0)
                    return maximum;
                else
                    return value;
            }
    
            void OnPinchUpdated(object sender, PinchGestureUpdatedEventArgs e)
            {
                if (e.Status == GestureStatus.Started)
                {
    
                               //Disable Swipe of the carouselview or view pager here
                    // 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;
                }
                if (e.Status == GestureStatus.Completed)
                {
                    // Store the translation delta's of the wrapped user interface element.
                    xOffset = Content.TranslationX;
                    yOffset = Content.TranslationY;
                    if (!GestureRecognizers.Contains(pan))
                    {
                        Device.BeginInvokeOnMainThread(() =>
                        {
                            GestureRecognizers.Add(pan);
    
                        });
                    }
                    if (Content.Scale == MIN_SCALE)
                    {
                        if (GestureRecognizers.Contains(pan))
                        {
                            Device.BeginInvokeOnMainThread(() =>
                            {
                                GestureRecognizers.Remove(pan);
    
                            });
                        }
                               //Enable Swipe of the carouselview or view pager here
                    }
    
                }
            }
    

    If anyone has an idea to make better the tap and pan (my two "problems") please post the answers.

    Sorry about my English :smiley:

  • MulflarMulflar ESUniversity ✭✭

    @CarLoOSX anyd advance on this?

    I been testing your code and works pretty good except for one case:

    If you do a pinch, and then you do pan the first time it moves starts from a different position. The secuent pans work ok.
    Is like I need to update StartX and StartY in the if (e.Status == GestureStatus.Completed) of the pinch, but I don't know what I need to put.
    Thanks

  • CarLoOSXCarLoOSX USMember ✭✭

    Hey there @Mulflar !!

    Exactly you would need the Ponit in (X,Y), now is (0.5 , 0.5) in a 0-1 scale.

    The problem is that I can not detect the initial touch point in xamarin.froms.

    For example in android would be something like this:

    : Java.Lang.Object, GestureDetector.IOnGestureListener
        {
    //Implement interface
            public bool OnScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)
            {
                bool initialTouchIsinActionBar = e1.GetY() <= _activity.ActionBarSize;
                bool isUpScroll = distanceY > 0;
    

    But I don't know how to do this on forms, maybe you will need Mr.Gestures package or make your custom renderers .

    --- Sorry!

  • MulflarMulflar ESUniversity ✭✭

    Ok @CarLoOSX this seems to work pretty good for me. It would be nice to say I'm a mathematic genius, but I mostly copied from here: https://forums.xamarin.com/discussion/60374/pinch-and-pan-gesture-recognizers-not-working-on-android-project No need for render in my case.

        private const double MIN_SCALE = 1;
        private const double MAX_SCALE = 4;
        private double startScale, currentScale;
        private double startX, startY;
        private double xOffset, yOffset;
    
        public PinchToZoomContainer()
        {            
            var pinchGesture = new PinchGestureRecognizer();
            pinchGesture.PinchUpdated += OnPinchUpdated;
            GestureRecognizers.Add(pinchGesture);
    
            var pan = new PanGestureRecognizer();            
            pan.PanUpdated += OnPanUpdated;
            GestureRecognizers.Add(pan);
    
            TapGestureRecognizer tap = new TapGestureRecognizer { NumberOfTapsRequired = 2 };
            tap.Tapped += OnTapped;
            GestureRecognizers.Add(tap);
    
            Scale = MIN_SCALE;
            TranslationX = TranslationY = 0;
            AnchorX = AnchorY = 0;
        }
    
        private void OnTapped(object sender, EventArgs e)
        {
            if (Content.Scale > MIN_SCALE)
            {
                RestoreScaleValues();
            }
            else
            {
                Content.AnchorX = Content.AnchorY = 0.5;
                Content.ScaleTo(MAX_SCALE, 250, Easing.CubicInOut);             
            }
        }
        void RestoreScaleValues()
        {
            Content.ScaleTo(MIN_SCALE, 250, Easing.CubicInOut);
            Content.TranslateTo(0.5, 0.5, 250, Easing.CubicInOut);
    
            currentScale = 1;
    
            Content.TranslationX = 0.5;
            Content.TranslationY = 0.5;
    
            xOffset = Content.TranslationX;
            yOffset = Content.TranslationY;
        }
    
        void OnPinchUpdated(object sender, PinchGestureUpdatedEventArgs e)
        {
            if (e.Status == GestureStatus.Started)
            {
                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;
            }
            if (e.Status == GestureStatus.Completed)
            {
                // Store the translation delta's of the wrapped user interface element.
                xOffset = Content.TranslationX;
                yOffset = Content.TranslationY;
            }
        }
    
        void OnPanUpdated(object sender, PanUpdatedEventArgs e)
        {          
            switch (e.StatusType)
            {
                case GestureStatus.Started:
                    startX = e.TotalX;
                    startY = e.TotalY;
                    Content.AnchorX = 0;
                    Content.AnchorY = 0;
                    break;
    
                case GestureStatus.Running:
                    var maxTranslationX = Content.Scale * Content.Width - Content.Width;
                    Content.TranslationX = Math.Min(0, Math.Max(-maxTranslationX, xOffset + e.TotalX - startX));
    
                    var maxTranslationY = Content.Scale * Content.Height - Content.Height;
                    Content.TranslationY = Math.Min(0, Math.Max(-maxTranslationY, yOffset + e.TotalY - startY));
    
                    break;
    
                case GestureStatus.Completed:
                    xOffset = Content.TranslationX;
                    yOffset = Content.TranslationY;
                    break;                   
            }
        }     
    }
    

    The only "little" fail I see is, when you end the pinch gesture if you don't lift both fingers at same time then it does a pan gesture with the last finger. Enough good for my designers/testers.

  • DanielIonescuDanielIonescu USMember ✭✭
    edited April 30

    Hey guys, I have tried this solution but the problem is that when the image is expanding, I see a bad offset to where my fingers are and what the image does (the image goes slightly to the left when I pinch to zoom instead of going for my two fingers), I'm guessing the math done in OnPinchUpdated, Running case

    So I have tried this solution with the comment that I provided for the tap to return to original size:
    https://forums.xamarin.com/discussion/83455/how-to-implement-image-zoom-in-and-out (comment https://forums.xamarin.com/discussion/comment/329268/#Comment_329268)

    It still lacks tap to zoom, will let you know if I find a solution to that.

2»
Sign In or Register to comment.