ConstraintLayout: Circular Positioning

The ConstraintLayout library for Android is a great addition to the platform, and the team are currently working towards v1.1; yesterday they released their latest beta (beta3).

One of the more interesting additions in this release is something called Circular Positioning. As the name would suggest, this allows you to constrain a view relative to another view based on an angle and radius.

See this example from the official documentation https://developer.android.com/reference/android/support/constraint/ConstraintLayout.html#CircularPositioning

Views are constrained via anchor points in the middle of each view, not the start/end/top/bottom or baseline you might be used to.

Being able to constrain views in this way make some normally very hard layouts and animations extremely easy. Imagine a situation where you’d like to simulate the movements of planets, you might have the Sun in the center of the screen and the planets orbiting around it.

Without using the new features of ConstraintLayout you’d probably tackle this by creating a custom view and drawing bitmaps to a canvas. This would work perfectly well but could be a lot of work and having the planets interact with other views outside of the custom view would be near impossible.

Lets create a sample app which shows some neat tricks you can use these new constraints for. In this sample we’ll animate 3 planets around the sun to keep the code short and easy to follow. The final outcome should look something like this.

Highly accurate modelling of celestial bodies.
// Java code - Old School :-)
public class CircleConstraintsActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

setContentView(R.layout.activity_orbits);

ImageView earthImage = findViewById(R.id.earth_image);
ImageView marsImage = findViewById(R.id.mars_image);
ImageView saturnImage = findViewById(R.id.saturn_image);

ValueAnimator earthAnimator = animatePlanet(earthImage, TimeUnit.SECONDS.toMillis(2));
ValueAnimator marsAnimator = animatePlanet(marsImage, TimeUnit.SECONDS.toMillis(6));
ValueAnimator saturnAnimator = animatePlanet(saturnImage, TimeUnit.SECONDS.toMillis(12));

earthAnimator.start();
marsAnimator.start();
saturnAnimator.start();
}

private ValueAnimator animatePlanet(ImageView planet, long orbitDuration) {
ValueAnimator anim = ValueAnimator.ofInt(0, 359);
anim.addUpdateListener(valueAnimator -> {
int val = (Integer) valueAnimator.getAnimatedValue();
ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) planet.getLayoutParams();
layoutParams.circleAngle = val;
planet.setLayoutParams(layoutParams);
});
anim.setDuration(orbitDuration);
anim.setInterpolator(new LinearInterpolator());
anim.setRepeatMode(ValueAnimator.RESTART);
anim.setRepeatCount(ValueAnimator.INFINITE);

return anim;
}

}

The layout for this screen is extremely simple and the hierarchy is very flat.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="#ffffff"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/sun_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/sun"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<ImageView
android:id="@+id/earth_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/earth"
app:layout_constraintCircle="@+id/sun_image"
app:layout_constraintCircleAngle="45"
app:layout_constraintCircleRadius="90dp"
/>

<ImageView
android:id="@+id/mars_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/mars"
app:layout_constraintCircle="@+id/sun_image"
app:layout_constraintCircleAngle="110"
app:layout_constraintCircleRadius="130dp"
/>

<ImageView
android:id="@+id/saturn_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/saturn"
app:layout_constraintCircle="@+id/sun_image"
app:layout_constraintCircleAngle="235"
app:layout_constraintCircleRadius="180dp"
/>

</android.support.constraint.ConstraintLayout>

I’ve highlighted the new constraints in bold.

At this point I think you’ll agree we have a pretty good looking app that could be released to the Google Play Store, but lets take the power of ConstraintLayout one step further and add a ConstraintSet animation when the Sun is clicked. In this example we’ll show some details about each of the planets.

Animation animating into the details view

The power of the ContraintLayout and ConstraintSet here means it doesn’t matter when we click on the Sun to initiate the change from animation to details, the planets will move in a natural way from their current position to their final resting place.

To achieve this we first add some hidden TextView fields in the first layout file, and give each one a name, then in a second layout file we show these TextViews and set their constraints to sit next to each planet. We also modify the planet constraints to have them laid out vertically. The XML for this final layout looks like this.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff">

<android.support.constraint.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="130dp" />

<ImageView
android:id="@+id/sun_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/sun"
app:layout_constraintEnd_toEndOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/sun_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/lorum_short"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toTopOf="@id/sun_image" />

<ImageView
android:id="@+id/earth_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:src="@drawable/earth"
app:layout_constraintEnd_toEndOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sun_image" />

<TextView
android:id="@+id/earth_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/lorum_short"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toTopOf="@id/earth_image" />

<ImageView
android:id="@+id/mars_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:src="@drawable/mars"
app:layout_constraintEnd_toEndOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/earth_image" />

<TextView
android:id="@+id/mars_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/lorum_short"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toTopOf="@id/mars_image" />

<ImageView
android:id="@+id/saturn_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:src="@drawable/saturn"
app:layout_constraintEnd_toEndOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/mars_image" />

<TextView
android:id="@+id/saturn_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/lorum_short"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/guideline"
app:layout_constraintTop_toTopOf="@id/saturn_image" />

</android.support.constraint.ConstraintLayout>

The final bit of code for our Activity looks like this

private ConstraintSet orbitsConstraint = new ConstraintSet();
private ConstraintSet detailsConstraint = new ConstraintSet();
private ConstraintLayout mConstraintLayout;


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

setContentView(R.layout.activity_orbits);
mConstraintLayout = findViewById(R.id.root);
orbitsConstraint.clone(mConstraintLayout);
detailsConstraint.clone(this, R.layout.activity_details);


ImageView sunImage = findViewById(R.id.sun_image);
ImageView earthImage = findViewById(R.id.earth_image);
ImageView marsImage = findViewById(R.id.mars_image);
ImageView saturnImage = findViewById(R.id.saturn_image);

ValueAnimator earthAnimator = animatePlanet(earthImage, TimeUnit.SECONDS.toMillis(2));
ValueAnimator marsAnimator = animatePlanet(marsImage, TimeUnit.SECONDS.toMillis(6));
ValueAnimator saturnAnimator = animatePlanet(saturnImage, TimeUnit.SECONDS.toMillis(12));

startAmin(earthAnimator, marsAnimator, saturnAnimator);

sunImage.setOnClickListener(view -> {
cancelAnim(earthAnimator, marsAnimator, saturnAnimator);
TransitionManager.beginDelayedTransition(mConstraintLayout);
detailsConstraint.applyTo(mConstraintLayout);
});

}

Here you can see where we’ve added the click listener to the Sun image and we’ve tidied up some of the code by moving the ValueAnimator setup into a method along with some helper classes for starting/cancelling the animations.

I’ll upload the code for this project to Github soon, but in the mean time I’d love to hear your feedback, so please leave me a comment below.