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:
Edit:
Observations:
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>
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.
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: