title: Pinch zoom on SurfaceView
body: <p>I'm trying to implement pinch zoom on a <code>SurfaceView</code>. I've done a lot of research regarding this and I found <a href="https://gist.github.com/anorth/9845602" rel="noreferrer">this</a> class to implement pinch zoom. Here is how I modified it:</p>
  
  <pre><code>public class ZoomLayout extends FrameLayout implements ScaleGestureDetector.OnScaleGestureListener {
  
  private SurfaceView mSurfaceView;
  
  private enum Mode {
      NONE,
      DRAG,
      ZOOM
  }
  
  private static final String TAG = "ZoomLayout";
  private static final float MIN_ZOOM = 1.0f;
  private static final float MAX_ZOOM = 4.0f;
  
  private Mode mode;
  private float scale = 1.0f;
  private float lastScaleFactor = 0f;
  
  // Where the finger first  touches the screen
  private float startX = 0f;
  private float startY = 0f;
  
  // How much to translate the canvas
  private float dx = 0f;
  private float dy = 0f;
  private float prevDx = 0f;
  private float prevDy = 0f;
  
  public ZoomLayout(Context context) {
      super(context);
      init(context);
  }
  
  public ZoomLayout(Context context, AttributeSet attrs) {
      super(context, attrs);
      init(context);
  }
  
  public ZoomLayout(Context context, AttributeSet attrs, int defStyle) {
      super(context, attrs, defStyle);
      init(context);
  }
  
  private void init(Context context) {
      final ScaleGestureDetector scaleDetector = new ScaleGestureDetector(context, this);
      setOnTouchListener(new OnTouchListener() {
          public boolean onTouch(View view, MotionEvent motionEvent) {
              ZoomLayout.this.mSurfaceView = (SurfaceView) view.findViewById(R.id.mSurfaceView);
  
              switch (motionEvent.getAction() &amp; MotionEvent.ACTION_MASK) {
                  case MotionEvent.ACTION_DOWN:
                      Log.i(TAG, "DOWN");
                      if (scale &gt; MIN_ZOOM) {
                          mode = Mode.DRAG;
                          startX = motionEvent.getX() - prevDx;
                          startY = motionEvent.getY() - prevDy;
                      }
                      break;
                  case MotionEvent.ACTION_MOVE:
                      if (mode == Mode.DRAG) {
                          dx = motionEvent.getX() - startX;
                          dy = motionEvent.getY() - startY;
                      }
                      break;
                  case MotionEvent.ACTION_POINTER_DOWN:
                      mode = Mode.ZOOM;
                      break;
                  case MotionEvent.ACTION_POINTER_UP:
                      mode = Mode.NONE;
                      break;
                  case MotionEvent.ACTION_UP:
                      Log.i(TAG, "UP");
                      mode = Mode.NONE;
                      prevDx = dx;
                      prevDy = dy;
                      break;
              }
              scaleDetector.onTouchEvent(motionEvent);
  
              if ((mode == Mode.DRAG &amp;&amp; scale &gt;= MIN_ZOOM) || mode == Mode.ZOOM) {
                  getParent().requestDisallowInterceptTouchEvent(true);
                  float maxDx = (child().getWidth() - (child().getWidth() / scale)) / 2 * scale;
                  float maxDy = (child().getHeight() - (child().getHeight() / scale))/ 2 * scale;
                  dx = Math.min(Math.max(dx, -maxDx), maxDx);
                  dy = Math.min(Math.max(dy, -maxDy), maxDy);
                  Log.i(TAG, "Width: " + child().getWidth() + ", scale " + scale + ", dx " + dx
                          + ", max " + maxDx);
                  applyScaleAndTranslation();
              }
  
              return true;
          }
      });
  }
  
  // ScaleGestureDetector
  
  public boolean onScaleBegin(ScaleGestureDetector scaleDetector) {
      Log.i(TAG, "onScaleBegin");
      return true;
  }
  
  public boolean onScale(ScaleGestureDetector scaleDetector) {
      float scaleFactor = scaleDetector.getScaleFactor();
      Log.i(TAG, "mode:" + this.mode + ", onScale:" + scaleFactor);
      if (this.lastScaleFactor == 0.0f || Math.signum(scaleFactor) == Math.signum(this.lastScaleFactor)) {
          this.scale *= scaleFactor;
          this.scale = Math.max(MIN_ZOOM, Math.min(this.scale, MAX_ZOOM));
          this.lastScaleFactor = scaleFactor;
      } else {
          this.lastScaleFactor = 0.0f;
      }
      if (this.mSurfaceView != null) {
          int orgWidth = getWidth();
          int _width = (int) (((float) orgWidth) * this.scale);
          int _height = (int) (((float) getHeight()) * this.scale);
          LayoutParams params = (LayoutParams) this.mSurfaceView.getLayoutParams();
          params.height = _height;
          params.width = _width;
          this.mSurfaceView.setLayoutParams(params);
          child().setScaleX(this.scale);
          child().setScaleY(this.scale);
      }
      return true;
  }
  
  @Override
  public void onScaleEnd(ScaleGestureDetector scaleDetector) {
      Log.i(TAG, "onScaleEnd");
  }
  
  private void applyScaleAndTranslation() {
      child().setScaleX(scale);
      child().setScaleY(scale);
      child().setTranslationX(dx);
      child().setTranslationY(dy);
  }
  
  private View child() {
      return getChildAt(0);
  }
  </code></pre>
  
  <p>and I implement it into my layout like this:</p>
  
  <pre><code>&lt;pacageName.control.ZoomLayout
      android:id="@+id/mZoomLayout"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:measureAllChildren="true"&gt;
  
          &lt;SurfaceView
              android:id="@+id/mSurfaceView"
              android:layout_width="match_parent"
              android:layout_height="match_parent" /&gt;
  
  &lt;/pacageName.control.ZoomLayout&gt;
  </code></pre>
  
  <p>The problem I'm having is that the zoom is not 'smooth' and the behavior of the zoom is different in different devices (Samsung S4 (Lollipop) and J7Pro (Nougat)).</p>
  
  <p>I'm not sure why pinch zoom is "glitchy" and why it doesn't zoom the same way on the two different devices.</p>
  
  <hr>
  
  <p><strong>Edit 1:</strong> Memory and CPU consumption image added below - </p>
  
  <p><a href="https://i.stack.imgur.com/TzSVq.png" rel="noreferrer"><img src="https://i.stack.imgur.com/TzSVq.png" alt="CPU"></a></p>
  
  <hr>
  
  <p><strong>EDIT 2:</strong> After trying something else I'm facing a new issue -</p>
  
  <p>I changed my <code>ZoomLayout</code> completely to the following:</p>
  
  <pre><code>public class ZoomableSurfaceView extends SurfaceView {
  
  private ScaleGestureDetector SGD;
  private Context context;
  private boolean isSingleTouch;
  private float width, height = 0;
  private float scale = 1f;
  private float minScale = 1f;
  private float maxScale = 5f;
  int left, top, right, bottom;
  
  public ZoomableSurfaceView(Context context) {
      super(context);
      this.context = context;
      init();
  }
  
  public ZoomableSurfaceView(Context context, AttributeSet attrs) {
      super(context, attrs);
      this.context = context;
      init();
  }
  
  public ZoomableSurfaceView(Context context, AttributeSet attrs, int defStyleAttr) {
      super(context, attrs, defStyleAttr);
      this.context = context;
      init();
  }
  
  private void init() {
      setOnTouchListener(new MyTouchListeners());
      SGD = new ScaleGestureDetector(context, new ScaleListener());
      this.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
          @Override
          public void onGlobalLayout() {
  
          }
      });
  }
  
  @Override
  protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
      super.onLayout(changed, left, top, right, bottom);
      if (width == 0 &amp;&amp; height == 0) {
          width = ZoomableSurfaceView.this.getWidth();
          height = ZoomableSurfaceView.this.getHeight();
          this.left = left;
          this.right = right;
          this.top = top;
          this.bottom = bottom;
      }
  
  }
  
  private class MyTouchListeners implements View.OnTouchListener {
  
      float dX, dY;
  
      MyTouchListeners() {
          super();
      }
  
      @Override
      public boolean onTouch(View view, MotionEvent event) {
          SGD.onTouchEvent(event);
          if (event.getPointerCount() &gt; 1) {
              isSingleTouch = false;
          } else {
              if (event.getAction() == MotionEvent.ACTION_UP) {
                  isSingleTouch = true;
              }
          }
          switch (event.getAction()) {
              case MotionEvent.ACTION_DOWN:
                  dX = ZoomableSurfaceView.this.getX() - event.getRawX();
                  dY = ZoomableSurfaceView.this.getY() - event.getRawY();
                  break;
  
              case MotionEvent.ACTION_MOVE:
                  if (isSingleTouch) {
                      ZoomableSurfaceView.this.animate()
                              .x(event.getRawX() + dX)
                              .y(event.getRawY() + dY)
                              .setDuration(0)
                              .start();
                      checkDimension(ZoomableSurfaceView.this);
                  }
                  break;
              default:
                  return true;
          }
          return true;
      }
  }
  
  private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
      @Override
      public boolean onScale(ScaleGestureDetector detector) {
          //Log.e("onGlobalLayout: ", scale + " " + width + " " + height);
          scale *= detector.getScaleFactor();
          scale = Math.max(minScale, Math.min(scale, maxScale));
          FrameLayout.LayoutParams params = new FrameLayout.LayoutParams((int) (width * scale), (int) (height * scale));
          Log.e("onGlobalLayout: ", (int) (width * scale) + " " + (int) (height * scale));
          ZoomableSurfaceView.this.setLayoutParams(params);
          checkDimension(ZoomableSurfaceView.this);
          return true;
      }
  }
  
  private void checkDimension(View vi) {
      if (vi.getX() &gt; left) {
          vi.animate()
                  .x(left)
                  .y(vi.getY())
                  .setDuration(0)
                  .start();
      }
  
      if ((vi.getWidth() + vi.getX()) &lt; right) {
          vi.animate()
                  .x(right - vi.getWidth())
                  .y(vi.getY())
                  .setDuration(0)
                  .start();
      }
  
      if (vi.getY() &gt; top) {
          vi.animate()
                  .x(vi.getX())
                  .y(top)
                  .setDuration(0)
                  .start();
      }
  
      if ((vi.getHeight() + vi.getY()) &lt; bottom) {
          vi.animate()
                  .x(vi.getX())
                  .y(bottom - vi.getHeight())
                  .setDuration(0)
                  .start();
      }
  }
  }
  </code></pre>
  
  <p>The problem I'm facing now is that it zooms to X - 0 and Y - 0 meaning that it zooms to the top-left of the screen instead of zooming to the point between my fingers.. I think it could be related to the following:</p>
  
  <pre><code>@Override
      public boolean onScale(ScaleGestureDetector detector) {
          //Log.e("onGlobalLayout: ", scale + " " + width + " " + height);
          scale *= detector.getScaleFactor();
          scale = Math.max(minScale, Math.min(scale, maxScale));
          FrameLayout.LayoutParams params = new FrameLayout.LayoutParams((int) (width * scale), (int) (height * scale));
          Log.e("onGlobalLayout: ", (int) (width * scale) + " " + (int) (height * scale));
          ZoomableSurfaceView.this.setLayoutParams(params);
          checkDimension(ZoomableSurfaceView.this);
          return true;
      }
  </code></pre>
  
  <p>I have an idea that I should detect the centre point of the 2 fingers and add that to <code>OnScale</code>. How should I proceed?</p>
  
  <hr>
  
  <p><strong>EDIT 3:</strong></p>
  
  <p>Ok I finally got the zoom working perfectly on my J7 Pro running Nougat. But the problem now, is that my <code>SurfaceView</code> in my S4 running Lollipop doesn't get zoomed in, instead my <code>SurfaceView</code> gets moved to the top left corner. I have tested placing a <code>ImageView</code> inside my custom zoomview and the image gets zoomed perfectly like expected. This is how my custom zoom class looks like now:</p>
  
  <pre><code>public class ZoomLayout extends FrameLayout implements ScaleGestureDetector.OnScaleGestureListener {
  
  private enum Mode {
      NONE,
      DRAG,
      ZOOM
  }
  
  private static final String TAG = "ZoomLayout";
  private static final float MIN_ZOOM = 1.0f;
  private static final float MAX_ZOOM = 4.0f;
  
  private Mode mode = Mode.NONE;
  private float scale = 1.0f;
  private float lastScaleFactor = 0f;
  
  // Where the finger first  touches the screen
  private float startX = 0f;
  private float startY = 0f;
  
  // How much to translate the canvas
  private float dx = 0f;
  private float dy = 0f;
  private float prevDx = 0f;
  private float prevDy = 0f;
  
  public ZoomLayout(Context context) {
      super(context);
      init(context);
  }
  
  public ZoomLayout(Context context, AttributeSet attrs) {
      super(context, attrs);
      init(context);
  }
  
  public ZoomLayout(Context context, AttributeSet attrs, int defStyle) {
      super(context, attrs, defStyle);
      init(context);
  }
  
  private void init(Context context) {
      final ScaleGestureDetector scaleDetector = new ScaleGestureDetector(context, this);
      setOnTouchListener(new View.OnTouchListener() {
          @Override
          public boolean onTouch(View view, MotionEvent motionEvent) {
              switch (motionEvent.getAction() &amp; MotionEvent.ACTION_MASK) {
                  case MotionEvent.ACTION_DOWN:
                      Log.i(TAG, "DOWN");
                      if (scale &gt; MIN_ZOOM) {
                          mode = Mode.DRAG;
                          startX = motionEvent.getX() - prevDx;
                          startY = motionEvent.getY() - prevDy;
                      }
                      break;
                  case MotionEvent.ACTION_MOVE:
                      if (mode == Mode.DRAG) {
                          dx = motionEvent.getX() - startX;
                          dy = motionEvent.getY() - startY;
                      }
                      break;
                  case MotionEvent.ACTION_POINTER_DOWN:
                      mode = Mode.ZOOM;
                      break;
                  case MotionEvent.ACTION_POINTER_UP:
                      mode = Mode.NONE; // changed from DRAG, was messing up zoom
                      break;
                  case MotionEvent.ACTION_UP:
                      Log.i(TAG, "UP");
                      mode = Mode.NONE;
                      prevDx = dx;
                      prevDy = dy;
                      break;
              }
              scaleDetector.onTouchEvent(motionEvent);
  
              if ((mode == Mode.DRAG &amp;&amp; scale &gt;= MIN_ZOOM) || mode == Mode.ZOOM) {
                  getParent().requestDisallowInterceptTouchEvent(true);
                  float maxDx = child().getWidth() * (scale - 1);  // adjusted for zero pivot
                  float maxDy = child().getHeight() * (scale - 1);  // adjusted for zero pivot
                  dx = Math.min(Math.max(dx, -maxDx), 0);  // adjusted for zero pivot
                  dy = Math.min(Math.max(dy, -maxDy), 0);  // adjusted for zero pivot
                  Log.i(TAG, "Width: " + child().getWidth() + ", scale " + scale + ", dx " + dx
                          + ", max " + maxDx);
                  applyScaleAndTranslation();
              }
  
              return true;
          }
      });
  }
  
  // ScaleGestureDetector
  @Override
  public boolean onScaleBegin(ScaleGestureDetector scaleDetector) {
      Log.i(TAG, "onScaleBegin");
      return true;
  }
  
  @Override
  public boolean onScale(ScaleGestureDetector scaleDetector) {
      float scaleFactor = scaleDetector.getScaleFactor();
      Log.i(TAG, "onScale(), scaleFactor = " + scaleFactor);
      if (lastScaleFactor == 0 || (Math.signum(scaleFactor) == Math.signum(lastScaleFactor))) {
          float prevScale = scale;
          scale *= scaleFactor;
          scale = Math.max(MIN_ZOOM, Math.min(scale, MAX_ZOOM));
          lastScaleFactor = scaleFactor;
          float adjustedScaleFactor = scale / prevScale;
          // added logic to adjust dx and dy for pinch/zoom pivot point
          Log.d(TAG, "onScale, adjustedScaleFactor = " + adjustedScaleFactor);
          Log.d(TAG, "onScale, BEFORE dx/dy = " + dx + "/" + dy);
          float focusX = scaleDetector.getFocusX();
          float focusY = scaleDetector.getFocusY();
          Log.d(TAG, "onScale, focusX/focusy = " + focusX + "/" + focusY);
          dx += (dx - focusX) * (adjustedScaleFactor - 1);
          dy += (dy - focusY) * (adjustedScaleFactor - 1);
          Log.d(TAG, "onScale, dx/dy = " + dx + "/" + dy);
      } else {
          lastScaleFactor = 0;
      }
      return true;
  }
  
  @Override
  public void onScaleEnd(ScaleGestureDetector scaleDetector) {
      Log.i(TAG, "onScaleEnd");
  }
  
  private void applyScaleAndTranslation() {
      child().setScaleX(scale);
      child().setScaleY(scale);
      child().setPivotX(0f);  // default is to pivot at view center
      child().setPivotY(0f);  // default is to pivot at view center
      child().setTranslationX(dx);
      child().setTranslationY(dy);
  }
  
  private View child() {
      return getChildAt(0);
  }
  </code></pre>
  
  <hr>
  
  <p><strong>Edit 4:</strong> Added video of behaviour:</p>
  
  <p>Please see how the current zoom looks like after updating my zoomLayout class to edit 3 - </p>
  
  <p><a href="https://streamable.com/gddbt" rel="noreferrer">Video of SurfaceView zoom behaviour</a></p>