Search code examples
javaandroidandroid-custom-view

Issue with Custom View Position Jump when Changing Pivot Point


I'm encountering an unexpected behavior in my Android app involving a custom view named MyView. I'm attempting to achieve both translation and rotation effects on this view. When the pivot point is set to the center of the view, everything work as expected. However, as soon as I change the pivot point (for instance, to "Point 2" selected from a spinner), the view unexpectedly jumps to a different position, as illustrated in this GIF:

enter image description here

Edit:

Observations:

  • The issue primarily arises when the view is rotated. If the view has no rotation (rotation = 0), changing the pivot point doesn't cause any problems.
  • Also if before changing the pivot point, I restore the view to its initial rotation (rotation = 0) this prevent also the issue from occurring. To illustrate this point further, here's an example:

enter image description here

Could anyone kindly advise on what might be causing the view displacement when changing the pivot point and suggest potential fixes? Your help is much appreciated.

Below is the complete code:

// MainActivity.java

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        FixedPosLayout fixedPosLayout = findViewById(R.id.fixedPosLayout);

        MyView myView = new MyView(this);

        FixedPosLayout.LayoutParams params =
                new FixedPosLayout.LayoutParams(200, 400, 300, 80);

        fixedPosLayout.addView(myView, params);

        ImageButton moveLeftButton = findViewById(R.id.moveLeftButton);
        ImageButton moveRightButton = findViewById(R.id.moveRightButton);
        ImageButton moveUpButton = findViewById(R.id.moveUpButton);
        ImageButton moveBottomButton = findViewById(R.id.moveBottomButton);

        ImageButton rotateLeftButton = findViewById(R.id.rotateLeftButton);
        ImageButton rotateRightButton = findViewById(R.id.rotateRightButton);

        AppCompatButton resetButton = findViewById(R.id.resetButton);

        Spinner spinner = findViewById(R.id.pivotPointSpinner);

        float d = 10;
        float r = 10;

        moveLeftButton.setOnClickListener(v -> myView.moveLeftBy(d));
        moveRightButton.setOnClickListener(v -> myView.moveRightBy(d));
        moveUpButton.setOnClickListener(v -> myView.moveUpBy(d));
        moveBottomButton.setOnClickListener(v -> myView.moveDownBy(d));
        rotateRightButton.setOnClickListener(v -> myView.rotateClockwiseBy(r));
        rotateLeftButton.setOnClickListener(v -> myView.rotateConterClockwiseBy(r));

        resetButton.setOnClickListener(v -> {
            myView.reset();
            spinner.setSelection(0); // Select first item
        });

        // Spinner
        String[] items = {"Center Point", "Point 1", "Point 2"};

        ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
                android.R.layout.simple_spinner_item, items);

        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);

        spinner.setAdapter(adapter);

        // Handle spinner click
        spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
            @Override
            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
                myView.setPivotPoint(position);
            }

            @Override
            public void onNothingSelected(AdapterView<?> parent) {
                myView.setPivotPoint(0); // Set center point as default pivot point
            }
        });
    }
}

// MyView.java

public class MyView extends View {

    public static final int RADIUS = 24; // Anchor radius
    public static final int OFFSET = 1;

    private final Paint paint;

    public RectF rectPoint1;
    public RectF rectPoint2;

    public MyView(Context context) {
        super(context);

        paint = new Paint();
        paint.setStyle(Paint.Style.STROKE);

        rectPoint1 = new RectF();
        rectPoint2 = new RectF();
    }

    public void moveRightBy(float dRight) {
        setX(getX() + dRight);
    }

    public void moveLeftBy(float dLeft) {
        moveRightBy(-dLeft);
    }

    public void moveDownBy(float dDown) {
        setY(getY() + dDown);
    }

    public void moveUpBy(float dUp) {
        moveDownBy(-dUp);
    }

    public void rotateClockwiseBy(float dDegree) {
        setRotation(getRotation() + dDegree);
    }

    public void rotateConterClockwiseBy(float dDegree){
        rotateClockwiseBy(-dDegree);
    }

    public void setPivotPoint(int position) {
        switch (position) {
            // Set center point as pivot
            case 0: {
                float width = getWidth();
                float height = getHeight();

                setPivotX(width/2);
                setPivotY(height/2);
                break;
            }

            // Set Point 1 as pivot
            case 1: {
                setPivotX(rectPoint1.centerX());
                setPivotY(rectPoint1.centerY());
                break;
            }

            // Set Point 2 as pivot
            case 2: {
                setPivotX(rectPoint2.centerX());
                setPivotY(rectPoint2.centerY());
                break;
            }
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        float width = getWidth();
        float height = getHeight();

        //Draw line between anchor points
        canvas.drawLine(
                rectPoint1.centerX(), rectPoint1.centerY(),
                rectPoint2.centerX(), rectPoint2.centerY(), paint);

        //Draw anchor circle
        canvas.drawCircle(rectPoint1.centerX(), rectPoint1.centerY(), RADIUS, paint);
        canvas.drawCircle(rectPoint2.centerX(), rectPoint2.centerY(), RADIUS, paint);

        //Draw bounding rect
        canvas.drawRect(OFFSET, OFFSET, width - OFFSET, height - OFFSET, paint);

        // Draw anchors label
        canvas.drawText("1", rectPoint1.centerX(), rectPoint1.centerY(), paint);
        canvas.drawText("2", rectPoint2.centerX(), rectPoint2.centerY(), paint);

        // Draw small circle at the center
        canvas.drawCircle(width/2f, height/2f, 4, paint);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        //Recompute anchors rect
        rectPoint1.set(
                OFFSET, h /2f - RADIUS + OFFSET,
                2*RADIUS, h /2f + RADIUS - OFFSET
        );

        rectPoint2.set(
                w - 2*RADIUS, h /2f - RADIUS + OFFSET,
                w - OFFSET, h /2f + RADIUS - OFFSET
        );
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Measure exactly
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);

        setMeasuredDimension(width, height);
    }


    public void reset() {
        setPivotPoint(0);

        setRotation(0);

        setX(200);
        setY(400);
    }
}

// FixedPosLayout.java

public class FixedPosLayout extends ViewGroup {

    public FixedPosLayout(Context context) {
        super(context);
    }

    public FixedPosLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public FixedPosLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();

        for (int i = 0; i<count; ++i) {
            View child = getChildAt(i);

            FixedPosLayout.LayoutParams params =
                    (FixedPosLayout.LayoutParams) child.getLayoutParams();

            int left = params.x;
            int top = params.y;

            child.layout(left, top,
                    left + child.getMeasuredWidth(),
                    top + child.getMeasuredHeight());
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Measure this view
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        // Measure children
        int count = getChildCount();
        for (int i = 0; i<count; ++i) {
            View child = getChildAt(i);

            FixedPosLayout.LayoutParams lp =
                    (FixedPosLayout.LayoutParams) child.getLayoutParams();

            int childWidthMeasureSpec =
                    MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY);

            int childHeightMeasureSpec =
                    MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY);

            child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        }
    }

    public static class LayoutParams extends ViewGroup.LayoutParams {
        public int x;
        public int y;

        public LayoutParams(int x, int y, int width, int height) {
            super(width, height);
            this.x = x;
            this.y = y;
        }
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity"
    android:layout_height="match_parent"
    android:layout_width="match_parent">

    <com.abdo.rotateviewquestion.FixedPosLayout
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:id="@+id/fixedPosLayout"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/barrier_horizontal_top"/>
    
    <ImageButton
        android:id="@+id/moveRightButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/move_right"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/barrier_vertical_right"/>

    <ImageButton
        android:id="@+id/moveLeftButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/move_left"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/barrier_vertical_left"/>

    <ImageButton
        android:id="@+id/moveUpButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/move_up"
        app:layout_constraintBottom_toTopOf="@+id/moveBottomButton"
        app:layout_constraintEnd_toEndOf="@id/moveBottomButton"
        app:layout_constraintStart_toStartOf="@id/moveBottomButton" />

    <ImageButton
        android:id="@+id/moveBottomButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/move_down"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/moveLeftButton"/>

    <ImageButton
        android:id="@+id/rotateLeftButton"
        android:layout_width="70dp"
        android:layout_height="wrap_content"
        android:src="@drawable/rotate_left"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />
    
    <ImageButton
        android:id="@+id/rotateRightButton"
        android:layout_width="70dp"
        android:layout_height="wrap_content"
        android:src="@drawable/rotate_right"
        app:layout_constraintEnd_toStartOf="@id/rotateLeftButton"
        app:layout_constraintBottom_toBottomOf="parent" />
    
    <Spinner
        android:id="@+id/pivotPointSpinner"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/barrier_horizontal_top"/>

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/resetButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Reset"
        app:layout_constraintTop_toBottomOf="@id/barrier_horizontal_top"
        app:layout_constraintEnd_toStartOf="@id/pivotPointSpinner"/>

    <androidx.constraintlayout.widget.Barrier
        android:id="@+id/barrier_vertical_right"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:barrierDirection="right"
        app:constraint_referenced_ids="moveUpButton, moveBottomButton"/>


    <androidx.constraintlayout.widget.Barrier
        android:id="@+id/barrier_vertical_left"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:barrierDirection="left"
        app:constraint_referenced_ids="moveUpButton, moveBottomButton"/>

    <androidx.constraintlayout.widget.Barrier
        android:id="@+id/barrier_horizontal_top"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:barrierDirection="top"
        app:constraint_referenced_ids="moveUpButton, rotateLeftButton, rotateRightButton"/>
    
</androidx.constraintlayout.widget.ConstraintLayout>

Solution

  • Cause:

    setRotation Sets the degrees that the view is rotated around the pivot point. Increasing values result in clockwise rotation.

    The documentation says that the rotation changes around the pivot point; so when you change the rotation before modifying the pivot point, the rotation considered the original pivot point; then after you modify the pivot, still the rotation considers the original pivot; and therefore you'd see the view jumps.

    In addition, changing the pivotX/pivotY values of a view would change the view position on the screen in case that the screen coordinates are different than the pivot point. This answer has a nice breakdown for that. So, you'd not notice a change for the initial rotation because in that case the pivot point are the same as the screen coordinates. But, that will not be the case when the view is rotated.

    Solution:

    So, one simple solution is to get the difference between the view location before and after modifying the pivot; and relocate the view position x/y with that difference:

    String TAG = "LOG_TAG";
    
    public void setPivotPoint(int position) {
    
        // Get the location of the View on the screen
        int[] locationBefore = new int[2];
        getLocationOnScreen(locationBefore);
        Log.d(TAG, "\ngetLocationOnScreen: x = " + locationBefore[0] + " y = " + locationBefore[1]);
    
        switch (position) {
            // Set center point as pivot
            case 0: {
                float width = getWidth();
                float height = getHeight();
                setPivotX(width / 2);
                setPivotY(height / 2);
                break;
            }
    
            // Set Point 1 as pivot
            case 1: {
                setPivotX(rectPoint1.centerX());
                setPivotY(rectPoint1.centerY());
                break;
            }
    
            // Set Point 2 as pivot
            case 2: {
                setPivotX(rectPoint2.centerX());
                setPivotY(rectPoint2.centerY());
                break;
            }
        }
    
        // Get the new location of the View on the screen after changing the pivotX & pivotY
        int[] locationAfter = new int[2];
        getLocationOnScreen(locationAfter);
        Log.d(TAG, "getLocationOnScreen: x = " + locationAfter[0] + " y = " + locationAfter[1]);
    
        // Change the current location of the View back to the original location
        int xDiff = locationAfter[0] - locationBefore[0];
        setX(getX() - xDiff);
        int yDiff = locationAfter[1] - locationBefore[1];
        setY(getY() - yDiff);
    
        // Checking the new location
        getLocationOnScreen(locationAfter);
        Log.d(TAG, "getLocationOnScreen: x = " + locationAfter[0] + " y = " + locationAfter[1]);
    
    }
    

    Preview: