1

Look at the example pictures provided. What I am trying to achieve is to cut out a random form (in the example a semi circle) from a card view background (or any other image for that matter).

So that in the end I have the background image with the cutout shape. The final result would be picture three where there is transparancy around the action button.

Notes:

  • Elevation and Shadows shall not get lost
  • I do not want to use a static background so it has to be a real cutout and not a overlay with background color as in the examples
  • UI has to adjust and resize dynamically - so does the cutout

How to achieve this?

Without cutout

With cutout

With transparency

Avinta
  • 678
  • 1
  • 9
  • 26

1 Answers1

2

This semi-circle cutout can be achieved using a MaterialShapeDrawable with a custom ShapeAppearanceModel. This cut-out is a similar behaviour used in BottomAppBar which has this semi circle into its Top Edge side. The BottomAppBar uses a BottomAppBarTopEdgeTreatment in its ShapeAppearanceModel for the Top side only. What you need is a similar behaviour but instead of Top Edge side to be on the Bottom Edge side.

In the below example i have used the same code used in BottomAppBarTopEdgeTreatment with the only difference that you can move the semi-circle to a specific x position using the cutoutEndSpacingDp used in the constructor of CutoutCircleEdgeTreatment.

1.Get the latest Material Design Library ('com.google.android.material:material:1.4.0') and add it into your grandle depedencies.

2.Create a subclass of EdgeTreatment with name CutoutCircleEdgeTreatment like below:

public class CutoutCircleEdgeTreatment extends EdgeTreatment {

    private static final int ARC_QUARTER = 90;
    private static final int ARC_HALF = 180;
    private static final int ANGLE_UP = 270;
    private static final int ANGLE_LEFT = 180;
    private static final float ROUNDED_CORNER_FAB_OFFSET = 1.75f;

    private float roundedCornerRadius;
    private float fabMargin;
    private float fabDiameter;
    private float cradleVerticalOffset;
    private float horizontalOffset;
    private float fabCornerSize = -1f;
    private float cutoutEndSpacing = 0;

    public CutoutCircleEdgeTreatment(Resources res, float fabDiameterDp, float cutoutEndSpacingDp){

        fabMargin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 0, res.getDisplayMetrics());
        roundedCornerRadius = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 0, res.getDisplayMetrics());
        cradleVerticalOffset = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 0, res.getDisplayMetrics());
        fabDiameter = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, fabDiameterDp, res.getDisplayMetrics());
        cutoutEndSpacing = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, cutoutEndSpacingDp, res.getDisplayMetrics());
    }

    @Override
    public void getEdgePath(float length, float center, float interpolation, @NonNull ShapePath shapePath) {

        horizontalOffset = -center + ((fabMargin * 2 + fabDiameter)/2) + cutoutEndSpacing;

        //below is the same code used in BottomAppBarTopEdgeTreatment getEdgePath()
        if (fabDiameter == 0) {
            // There is no cutout to draw.
            shapePath.lineTo(length, 0);
            return;
        }

        float cradleDiameter = fabMargin * 2 + fabDiameter;
        float cradleRadius = cradleDiameter / 2f;
        float roundedCornerOffset = interpolation * roundedCornerRadius;
        float middle = center + horizontalOffset;

        // The center offset of the cutout tweens between the vertical offset when attached, and the
        // cradleRadius as it becomes detached.
        float verticalOffset =
                interpolation * cradleVerticalOffset + (1 - interpolation) * cradleRadius;
        float verticalOffsetRatio = verticalOffset / cradleRadius;
        if (verticalOffsetRatio >= 1.0f) {
            // Vertical offset is so high that there's no curve to draw in the edge, i.e., the fab is
            // actually above the edge so just draw a straight line.
            shapePath.lineTo(length, 0);
            return; // Early exit.
        }

        // Calculate the path of the cutout by calculating the location of two adjacent circles. One
        // circle is for the rounded corner. If the rounded corner circle radius is 0 the corner will
        // not be rounded. The other circle is the cutout.

        // Calculate the X distance between the center of the two adjacent circles using pythagorean
        // theorem.
        float cornerSize = fabCornerSize * interpolation;
        boolean useCircleCutout = fabCornerSize == -1 || java.lang.Math.abs(fabCornerSize * 2f - fabDiameter) < .1f;
        float arcOffset = 0;
        if (!useCircleCutout) {
            verticalOffset = 0;
            arcOffset = ROUNDED_CORNER_FAB_OFFSET;
        }

        float distanceBetweenCenters = cradleRadius + roundedCornerOffset;
        float distanceBetweenCentersSquared = distanceBetweenCenters * distanceBetweenCenters;
        float distanceY = verticalOffset + roundedCornerOffset;
        float distanceX = (float) Math.sqrt(distanceBetweenCentersSquared - (distanceY * distanceY));

        // Calculate the x position of the rounded corner circles.
        float leftRoundedCornerCircleX = middle - distanceX;
        float rightRoundedCornerCircleX = middle + distanceX;

        // Calculate the arc between the center of the two circles.
        float cornerRadiusArcLength = (float) Math.toDegrees(Math.atan(distanceX / distanceY));
        float cutoutArcOffset = (ARC_QUARTER - cornerRadiusArcLength) + arcOffset;

        // Draw the starting line up to the left rounded corner.
        shapePath.lineTo(/* x= */ leftRoundedCornerCircleX, /* y= */ 0);

        // Draw the arc for the left rounded corner circle. The bounding box is the area around the
        // circle's center which is at `(leftRoundedCornerCircleX, roundedCornerOffset)`.
        shapePath.addArc(
                /* left= */ leftRoundedCornerCircleX - roundedCornerOffset,
                /* top= */ 0,
                /* right= */ leftRoundedCornerCircleX + roundedCornerOffset,
                /* bottom= */ roundedCornerOffset * 2,
                /* startAngle= */ ANGLE_UP,
                /* sweepAngle= */ cornerRadiusArcLength);

        if (useCircleCutout) {
            // Draw the cutout circle.
            shapePath.addArc(
                    /* left= */ middle - cradleRadius,
                    /* top= */ -cradleRadius - verticalOffset,
                    /* right= */ middle + cradleRadius,
                    /* bottom= */ cradleRadius - verticalOffset,
                    /* startAngle= */ ANGLE_LEFT - cutoutArcOffset,
                    /* sweepAngle= */ cutoutArcOffset * 2 - ARC_HALF);
        } else {
            float cutoutDiameter = fabMargin + cornerSize * 2f;
            shapePath.addArc(
                    /* left= */ middle - cradleRadius,
                    /* top= */ -(cornerSize + fabMargin),
                    /* right= */ middle - cradleRadius + cutoutDiameter,
                    /* bottom= */ (fabMargin + cornerSize),
                    /* startAngle= */ ANGLE_LEFT - cutoutArcOffset,
                    /* sweepAngle= */ (cutoutArcOffset * 2 - ARC_HALF) / 2f);

            shapePath.lineTo(middle + cradleRadius - (cornerSize + fabMargin / 2f), /* y= */
                    (cornerSize + fabMargin));

            shapePath.addArc(
                    /* left= */ middle + cradleRadius - (cornerSize * 2f + fabMargin),
                    /* top= */  -(cornerSize + fabMargin),
                    /* right= */ middle + cradleRadius,
                    /* bottom= */ (fabMargin + cornerSize),
                    /* startAngle= */ 90,
                    /* sweepAngle= */ -90 + cutoutArcOffset);
        }

        // Draw an arc for the right rounded corner circle. The bounding box is the area around the
        // circle's center which is at `(rightRoundedCornerCircleX, roundedCornerOffset)`.
        shapePath.addArc(
                /* left= */ rightRoundedCornerCircleX - roundedCornerOffset,
                /* top= */ 0,
                /* right= */ rightRoundedCornerCircleX + roundedCornerOffset,
                /* bottom= */ roundedCornerOffset * 2,
                /* startAngle= */ ANGLE_UP - cornerRadiusArcLength,
                /* sweepAngle= */ cornerRadiusArcLength);

        // Draw the ending line after the right rounded corner.
        shapePath.lineTo(/* x= */ length, /* y= */ 0);
    }
}

From the constructor above you can pass the fabDiameterDp which is the circle diameter in dps and the cutoutEndSpacingDp which is the end spacing in dps needed to move the semi-circle to a specific x position from the end. Of course you can make any other modifications you want based on your needs.

3.You can use the above CutoutCircleEdgeTreatment like below:

For Material Components like MaterialCardView etc. which they have already a MaterialShapeDrawable as a background:

MaterialCardView materialCardView = findViewById(R.id.materialCardView);
materialCardView.setShapeAppearanceModel(materialCardView.getShapeAppearanceModel()
        .toBuilder()
        .setBottomEdge(new CutoutCircleEdgeTreatment(getResources(), 80, 20))
        .build());

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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#81afdc"
    android:padding="20dp">

    <com.google.android.material.card.MaterialCardView
        android:id="@+id/materialCardView"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        app:cardBackgroundColor="@android:color/white"
        app:cardCornerRadius="10dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="-35dp"
        android:layout_marginEnd="35dp"
        app:backgroundTint="@android:color/holo_orange_dark"
        app:elevation="3dp"
        app:fabCustomSize="70dp"
        app:layout_constraintEnd_toEndOf="@+id/materialCardView"
        app:layout_constraintTop_toBottomOf="@+id/materialCardView"
        app:srcCompat="@android:drawable/ic_input_add"
        app:tint="@android:color/white" />

</androidx.constraintlayout.widget.ConstraintLayout>

For Non-Material Components like ConstraintLayout, RelativeLayout etc. which they don't have a MaterialShapeDrawable:

RelativeLayout relativeLayout = findViewById(R.id.relativeLayout);
ShapeAppearanceModel shapeAppearanceModel = new ShapeAppearanceModel()
        .toBuilder()
        .setBottomEdge(new CutoutCircleEdgeTreatment(getResources(), 80, 20))
        .build();
MaterialShapeDrawable shapeDrawable = new MaterialShapeDrawable(shapeAppearanceModel);
shapeDrawable.setFillColor(ContextCompat.getColorStateList(this, android.R.color.white));
ViewCompat.setBackground(relativeLayout, shapeDrawable);

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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#81afdc"
    android:padding="20dp">

    <RelativeLayout
        android:id="@+id/relativeLayout"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="@android:color/white"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="-35dp"
        android:layout_marginEnd="25dp"
        app:backgroundTint="@android:color/holo_orange_dark"
        app:elevation="3dp"
        app:fabCustomSize="70dp"
        app:layout_constraintEnd_toEndOf="@+id/relativeLayout"
        app:layout_constraintTop_toBottomOf="@+id/relativeLayout"
        app:srcCompat="@android:drawable/ic_input_add"
        app:tint="@android:color/white" />

</androidx.constraintlayout.widget.ConstraintLayout>

Result:

cutout_semicircle

MariosP
  • 8,300
  • 1
  • 9
  • 30
  • 1
    Thanks for the detailed answer. Also I did not expect that this will be such a complex task to achieve. I expected some blend mode via XML. I think the framework has to consider this in the future. But ya it works great – Avinta Nov 01 '21 at 13:22