conradchen a3521784e8 [TextField] Fix label cutout doesn't work on API < 18
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 https://github.com/material-components/material-components-android/issues/2811#issuecomment-1196016169

PiperOrigin-RevId: 482013070
2022-10-19 09:20:41 -07:00

172 lines
5.5 KiB
Java

/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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;
import com.google.android.material.shape.ShapeAppearanceModel;
/**
* A {@link MaterialShapeDrawable} that can draw a cutout for the label in {@link TextInputLayout}'s
* outline mode.
*/
class CutoutDrawable extends MaterialShapeDrawable {
@NonNull protected final RectF cutoutBounds;
static CutoutDrawable create(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
return VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR2
? new ImplApi18(shapeAppearanceModel)
: new ImplApi14(shapeAppearanceModel);
}
private CutoutDrawable(@Nullable ShapeAppearanceModel shapeAppearanceModel) {
super(shapeAppearanceModel != null ? shapeAppearanceModel : new ShapeAppearanceModel());
cutoutBounds = new RectF();
}
boolean hasCutout() {
return !cutoutBounds.isEmpty();
}
void setCutout(float left, float top, float right, float bottom) {
// Avoid expensive redraws by only calling invalidateSelf if one of the cutout's dimensions has
// changed.
if (left != cutoutBounds.left
|| top != cutoutBounds.top
|| right != cutoutBounds.right
|| bottom != cutoutBounds.bottom) {
cutoutBounds.set(left, top, right, bottom);
invalidateSelf();
}
}
void setCutout(@NonNull RectF bounds) {
setCutout(bounds.left, bounds.top, bounds.right, bounds.bottom);
}
void removeCutout() {
// Call setCutout with empty bounds to remove the cutout.
setCutout(0, 0, 0, 0);
}
@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 {
// 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.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;
}
}
}