Skip to content

Commit

Permalink
[TextField] Fix label cutout doesn't work on API < 18
Browse files Browse the repository at this point in the history
Android framework Canvas.clipRect() has a bug with Region.Op.DIFFERENCE when handling bounds.left on APIs lower than 18, which causes text field outlines are still drawn over the label on lower APIs, despite the label area is supposed to be cut out.

Fixes this by bringing back the old solution we were using - "painting out" the label area after the stroke is drawn. Since the implementation is quite complicated, this CL also splits the CutoutDrawable to two inner impl classes to have a better code structure.

Resolves #2811 (comment)

PiperOrigin-RevId: 482013070
  • Loading branch information
drchen authored and hunterstich committed Oct 19, 2022
1 parent bdb8253 commit a352178
Show file tree
Hide file tree
Showing 2 changed files with 101 additions and 16 deletions.
115 changes: 100 additions & 15 deletions lib/java/com/google/android/material/textfield/CutoutDrawable.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@

package com.google.android.material.textfield;

import android.annotation.TargetApi;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PorterDuff.Mode;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.graphics.Region.Op;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.shape.MaterialShapeDrawable;
Expand All @@ -31,13 +38,15 @@
* outline mode.
*/
class CutoutDrawable extends MaterialShapeDrawable {
@NonNull private final RectF cutoutBounds;
@NonNull protected final RectF cutoutBounds;

CutoutDrawable() {
this(null);
static CutoutDrawable create(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
return VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR2
? new ImplApi18(shapeAppearanceModel)
: new ImplApi14(shapeAppearanceModel);
}

CutoutDrawable(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
private CutoutDrawable(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
super(shapeAppearanceModel != null ? shapeAppearanceModel : new ShapeAppearanceModel());
cutoutBounds = new RectF();
}
Expand Down Expand Up @@ -67,20 +76,96 @@ void removeCutout() {
setCutout(0, 0, 0, 0);
}

@Override
protected void drawStrokeShape(@NonNull Canvas canvas) {
if (cutoutBounds.isEmpty()) {
super.drawStrokeShape(canvas);
} else {
// Saves the canvas so we can restore the clip after drawing the stroke.
canvas.save();
if (VERSION.SDK_INT >= VERSION_CODES.O) {
canvas.clipOutRect(cutoutBounds);
@TargetApi(VERSION_CODES.JELLY_BEAN_MR2)
private static class ImplApi18 extends CutoutDrawable {
ImplApi18(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
super(shapeAppearanceModel);
}

@Override
protected void drawStrokeShape(@NonNull Canvas canvas) {
if (cutoutBounds.isEmpty()) {
super.drawStrokeShape(canvas);
} else {
canvas.clipRect(cutoutBounds, Op.DIFFERENCE);
// Saves the canvas so we can restore the clip after drawing the stroke.
canvas.save();
if (VERSION.SDK_INT >= VERSION_CODES.O) {
canvas.clipOutRect(cutoutBounds);
} else {
canvas.clipRect(cutoutBounds, Op.DIFFERENCE);
}
super.drawStrokeShape(canvas);
canvas.restore();
}
}
}

// Workaround: Canvas.clipRect() had a bug before API 18 - bound.left didn't work correctly
// with Region.Op.DIFFERENCE. "Paints out" the cutout area instead on lower APIs.
private static class ImplApi14 extends CutoutDrawable {
private Paint cutoutPaint;
private int savedLayer;

ImplApi14(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
super(shapeAppearanceModel);
}

@Override
public void draw(@NonNull Canvas canvas) {
preDraw(canvas);
super.draw(canvas);
postDraw(canvas);
}

@Override
protected void drawStrokeShape(@NonNull Canvas canvas) {
super.drawStrokeShape(canvas);
canvas.restore();
canvas.drawRect(cutoutBounds, getCutoutPaint());
}

private Paint getCutoutPaint() {
if (cutoutPaint == null) {
cutoutPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
cutoutPaint.setStyle(Style.FILL_AND_STROKE);
cutoutPaint.setColor(Color.WHITE);
cutoutPaint.setXfermode(new PorterDuffXfermode(Mode.DST_OUT));
}
return cutoutPaint;
}

private void preDraw(@NonNull Canvas canvas) {
Callback callback = getCallback();

if (useHardwareLayer(callback)) {
View viewCallback = (View) callback;
// Make sure we're using a hardware layer.
if (viewCallback.getLayerType() != View.LAYER_TYPE_HARDWARE) {
viewCallback.setLayerType(View.LAYER_TYPE_HARDWARE, null);
}
} else {
// If we're not using a hardware layer, save the canvas layer.
saveCanvasLayer(canvas);
}
}

private void saveCanvasLayer(@NonNull Canvas canvas) {
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
savedLayer = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), null);
} else {
savedLayer =
canvas.saveLayer(
0, 0, canvas.getWidth(), canvas.getHeight(), null, Canvas.ALL_SAVE_FLAG);
}
}

private void postDraw(@NonNull Canvas canvas) {
if (!useHardwareLayer(getCallback())) {
canvas.restoreToCount(savedLayer);
}
}

private boolean useHardwareLayer(Callback callback) {
return callback instanceof View;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ private void assignBoxBackgroundByMode() {
break;
case BOX_BACKGROUND_OUTLINE:
if (hintEnabled && !(boxBackground instanceof CutoutDrawable)) {
boxBackground = new CutoutDrawable(shapeAppearanceModel);
boxBackground = CutoutDrawable.create(shapeAppearanceModel);
} else {
boxBackground = new MaterialShapeDrawable(shapeAppearanceModel);
}
Expand Down

0 comments on commit a352178

Please sign in to comment.