Search code examples
javaandroidandroid-studiouser-interfacetextview

TextView with outline in Android


I want to have a textview with a double stroke in Android, which is not natively supported (not even a single stroke). Following Android textview outline text it can be easily achieved.

public class StrokedTextView extends androidx.appcompat.widget.AppCompatTextView {

// fields
private int strokeColorW, strokeColorB;
private float strokeWidthW, strokeWidthB;

// constructors
public StrokedTextView(Context context) {
    this(context, null, 0);
}

public StrokedTextView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}

public StrokedTextView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);

    strokeColorW = context.getColor(R.color.white);
    strokeWidthW = dpToPx(context, 2);
    strokeColorB = context.getColor(R.color.main_color_dark);
    strokeWidthB = dpToPx(context, 3);
}

// overridden methods
@Override
protected void onDraw(Canvas canvas) {
    //set paint to fill mode
    Paint p = getPaint();
    p.setStyle(Paint.Style.FILL);
    //draw the fill part of text
    super.onDraw(canvas);
    //save the text color
    int currentTextColor = getCurrentTextColor();
    //set paint to stroke mode and specify
    //stroke 1 color and width
    p.setStyle(Paint.Style.STROKE);
    p.setStrokeWidth(strokeWidthB);
    setTextColor(strokeColorB);
    //draw text stroke
    super.onDraw(canvas);
    //set paint to stroke mode and specify
    //stroke 2 color and width
    p.setStyle(Paint.Style.STROKE);
    p.setStrokeWidth(strokeWidthW);
    setTextColor(strokeColorW);
    //draw text stroke
    super.onDraw(canvas);
    //revert the color back to the one
    //initially specified
    setTextColor(currentTextColor);
}

public static int dpToPx(Context context, float dp)
{
    final float scale= context.getResources().getDisplayMetrics().density;
    return (int) (dp * scale + 0.5f);
}

}

The problem is that setTextColor calls invalidate, so this falls into infinite calls of onDraw. As some suggest, I've tried to control it with a flag, that indicates whether the invalidate is caused by the setTextColor or not. But it still calls onDraw infinitely

private boolean isDrawing = false;

@Override
public void invalidate() {
    if(isDrawing) return;
    super.invalidate();
}

@Override
protected void onDraw(Canvas canvas) {
    isDrawing = true;

    Paint p = getPaint();
    p.setStyle(Paint.Style.FILL);
    super.onDraw(canvas);
    int currentTextColor = getCurrentTextColor();

    p.setStyle(Paint.Style.STROKE);
    p.setStrokeWidth(strokeWidthB);
    setTextColor(strokeColorB);
    super.onDraw(canvas);

    p.setStyle(Paint.Style.STROKE);
    p.setStrokeWidth(strokeWidthW);
    setTextColor(strokeColorW);
    super.onDraw(canvas);

    setTextColor(currentTextColor);

    isDrawing = false;
}

As other user states in that question, I've also tried using Reflection to access the private field of the TextView and brute-forcely set it:

private void setColor(int color){
    Field field = TextView.class.getDeclaredField("mCurTextColor");
    field.setAccessible(true);
    field.set(this, color);
}

However, it states Reflective access to mCurTextColor will throw an Exception when targeting API 33 and above

So I'm asking if someone sees a way to overcome this issue different from the ones I've already tried and failed at.


Solution

  • The solution would be to create your own custom View from scratch.

    public class StrokedTextView extends View {
    private final Paint TEXT_PAINT;
    private final Paint WHITE_BORDER_PAINT;
    private final Paint BROWN_BORDER_PAINT;
    
    private String text;
    
    private int desiredWidth, desiredHeight;
    private final int bigBorderSize, halfMargin;
    
    // constructors
    public StrokedTextView(Context context) {
        this(context, null, 0);
    }
    
    public StrokedTextView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    
    public StrokedTextView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    
        float textSize = 0;
        int textColor = context.getColor(R.color.main_color);
        text = "";
        if(attrs != null) {
            @SuppressLint("CustomViewStyleable") TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.StrokedTextAttrs);
            textSize = a.getDimensionPixelSize(R.styleable.StickerTextAttrs_textSize, 0);
            textColor = a.getColor(R.styleable.StrokedTextAttrs_textColor, textColor);
            text = a.getString(R.styleable.StrokedTextAttrs_text);
            a.recycle();
        }
    
        TEXT_PAINT = new Paint();
        TEXT_PAINT.setTextSize(textSize);
        TEXT_PAINT.setStyle(Paint.Style.FILL);
        TEXT_PAINT.setColor(textColor);
    
        int smallBorderSize = dpToPx(context, 2);
        bigBorderSize = smallBorderSize * 3;
        halfMargin = bigBorderSize / 2;
    
        WHITE_BORDER_PAINT = new Paint();
        WHITE_BORDER_PAINT.setTextSize(textSize);
        WHITE_BORDER_PAINT.setStyle(Paint.Style.STROKE);
        WHITE_BORDER_PAINT.setStrokeWidth(bigBorderSize);
        WHITE_BORDER_PAINT.setColor(context.getColor(R.color.white));
    
        BROWN_BORDER_PAINT = new Paint();
        BROWN_BORDER_PAINT.setTextSize(textSize);
        BROWN_BORDER_PAINT.setStyle(Paint.Style.STROKE);
        BROWN_BORDER_PAINT.setStrokeWidth(smallBorderSize);
        BROWN_BORDER_PAINT.setColor(context.getColor(R.color.main_color_dark));
        measure();
    }
    
    public void setText(String t){
        text = t;
        measure();
        invalidate();
        requestLayout();
    }
    
    private void measure(){
        Rect bounds = new Rect();
        TEXT_PAINT.getTextBounds(text, 0, text.length(), bounds);
        desiredHeight = bounds.height() + bigBorderSize;
        desiredWidth = bounds.width() + bigBorderSize;
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int bottom = getHeight() - halfMargin;
        canvas.drawText(text, halfMargin, bottom, WHITE_BORDER_PAINT);
        canvas.drawText(text, halfMargin, bottom, BROWN_BORDER_PAINT);
        canvas.drawText(text, halfMargin, bottom, TEXT_PAINT);
    }
    
    public static int dpToPx(Context context, float dp)
    {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dp * scale + 0.5f);
    }
    
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    
        int width;
        int height;
    
        //Measure Width
        if (widthMode == MeasureSpec.EXACTLY) {
            //Must be this size
            width = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            //Can't be bigger than...
            width = Math.min(desiredWidth, widthSize);
        } else {
            //Be whatever you want
            width = desiredWidth;
        }
    
        //Measure Height
        if (heightMode == MeasureSpec.EXACTLY) {
            //Must be this size
            height = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            //Can't be bigger than...
            height = Math.min(desiredHeight, heightSize);
        } else {
            //Be whatever you want
            height = desiredHeight;
        }
    
        setMeasuredDimension(width, height);
    }
    

    and in the attr.xml:

    <declare-styleable name="StickerTextAttrs">
        <attr name="textColor" format="color"/>
        <attr name="textSize" format="dimension"/>
        <attr name="text" format="string"/>
    </declare-styleable>