Add opaque “shadow” (outline) to Android TextView

半世苍凉 提交于 2019-11-27 14:43:04

I thought I might offer an alternative to the overlayed TextViews solution. This solution implements a custom TextView subclass which manipulates its TextPaint object's properties to first draw the outline, and then draw the text on top of it.

Using this, you need only deal with one View at a time, so changing something at runtime won't require calls on two separate TextViews. This should also make it easier to utilize other niceties of TextView - like compound drawables - and keep everything square, without redundant settings.

Reflection is used to avoid calling TextView's setTextColor() method, which invalidates the View, and would cause an infinite draw loop, which, I believe, is most likely why solutions like this didn't work for you. Setting the color directly on the Paint object doesn't work, due to how TextView handles that in its onDraw() method, hence the reflection.

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View.BaseSavedState;
import android.widget.TextView;
import java.lang.reflect.Field;


public class OutlineTextView extends TextView {
    private Field colorField;
    private int textColor;
    private int outlineColor;

    public OutlineTextView(Context context) {
        this(context, null);
    }

    public OutlineTextView(Context context, AttributeSet attrs) {
        this(context, attrs, android.R.attr.textViewStyle);
    }

    public OutlineTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        try {
            colorField = TextView.class.getDeclaredField("mCurTextColor");
            colorField.setAccessible(true);

            // If the reflection fails (which really shouldn't happen), we
            // won't need the rest of this stuff, so we keep it in the try-catch

            textColor = getTextColors().getDefaultColor();

            // These can be changed to hard-coded default
            // values if you don't need to use XML attributes

            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.OutlineTextView);
            outlineColor = a.getColor(R.styleable.OutlineTextView_outlineColor, Color.TRANSPARENT);
            setOutlineStrokeWidth(a.getDimensionPixelSize(R.styleable.OutlineTextView_outlineWidth, 0));
            a.recycle();
        }
        catch (NoSuchFieldException e) {
            // Optionally catch Exception and remove print after testing
            e.printStackTrace();
            colorField = null;
        }
    }

    @Override
    public void setTextColor(int color) {
        // We want to track this ourselves
        // The super call will invalidate()

        textColor = color;
        super.setTextColor(color);
    }

    public void setOutlineColor(int color) {
        outlineColor = color;
        invalidate();
    }

    public void setOutlineWidth(float width) {
        setOutlineStrokeWidth(width);
        invalidate();
    }

    private void setOutlineStrokeWidth(float width) {
        getPaint().setStrokeWidth(2 * width + 1);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // If we couldn't get the Field, then we
        // need to skip this, and just draw as usual

        if (colorField != null) {
            // Outline
            setColorField(outlineColor);
            getPaint().setStyle(Paint.Style.STROKE);
            super.onDraw(canvas);

            // Reset for text
            setColorField(textColor);
            getPaint().setStyle(Paint.Style.FILL);
        }

        super.onDraw(canvas);
    }

    private void setColorField(int color) {
        // We did the null check in onDraw()
        try {
            colorField.setInt(this, color);
        }
        catch (IllegalAccessException | IllegalArgumentException e) {
            // Optionally catch Exception and remove print after testing
            e.printStackTrace();
        }
    }

    // Optional saved state stuff

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        SavedState ss = new SavedState(superState);
        ss.textColor = textColor;
        ss.outlineColor = outlineColor;
        ss.outlineWidth = getPaint().getStrokeWidth();
        return ss;
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        SavedState ss = (SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());
        textColor = ss.textColor;
        outlineColor = ss.outlineColor;
        getPaint().setStrokeWidth(ss.outlineWidth);
    }

    private static class SavedState extends BaseSavedState {
        int textColor;
        int outlineColor;
        float outlineWidth;

        SavedState(Parcelable superState) {
            super(superState);
        }

        private SavedState(Parcel in) {
            super(in);
            textColor = in.readInt();
            outlineColor = in.readInt();
            outlineWidth = in.readFloat();
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(textColor);
            out.writeInt(outlineColor);
            out.writeFloat(outlineWidth);
        }

        public static final Parcelable.Creator<SavedState>
            CREATOR = new Parcelable.Creator<SavedState>() {

            public SavedState createFromParcel(Parcel in) {
                return new SavedState(in);
            }

            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };
    }
}

If using the custom XML attributes, the following needs to be in your <resources>, which you can do by just sticking this file in your res/values/ folder, or adding to the one already there. If you don't wish to use the custom attributes, you should remove the relevant attribute processing from the View's third constructor.

attrs.xml

<resources>
    <declare-styleable name="OutlineTextView" >
        <attr name="outlineColor" format="color" />
        <attr name="outlineWidth" format="dimension" />
    </declare-styleable>
</resources>

With the custom attributes, everything can be setup in the layout XML. Note the additional XML namespace, here named app, and specified on the root LinearLayout element.

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#445566">

    <com.example.testapp.OutlineTextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="123 ABC"
        android:textSize="36sp"
        android:textColor="#000000"
        app:outlineColor="#ffffff"
        app:outlineWidth="2px" />

</LinearLayout>

The results:


Notes:

  • If you're using the support libraries, your OutlineTextView class should instead extend AppCompatTextView, to ensure that the tinting and whatnot are handled appropriately on all versions.

  • If the outline width is relatively large compared to the text size, it might be necessary to set additional padding on the View to keep things within their bounds, especially if wrapping the width and/or height. This would be a concern with the overlayed TextViews, too.

  • Relatively large outline widths can also result in undesirable sharp corner effects on certain characters - like "A" and "2" - due to the stroke style. This would also occur with the overlayed TextViews.

  • This class can easily be converted to the EditText equivalent, simply by changing the super class to EditText, and passing android.R.attr.editTextStyle in place of android.R.attr.textViewStyle in the three-paramater constructor chain call. For the support libraries, the super class would be AppCompatEditText, and the constructor argument R.attr.editTextStyle.

  • Just for fun: I would point out that you can get some pretty nifty effects using translucent colors for the text and/or outline, and playing with the fill/stroke/fill-and-stroke styles. This, of course, would be possible with the overlayed TextViews solution, as well.

  • As of API level 28 (Pie), there are certain Restrictions on non-SDK interfaces, including reflection to access normally inaccessible members in the SDK. Despite that, this solution still works, surprisingly, at least on the available Pie emulators, for both the native TextView and the support AppCompatTextView. I will update if that changes in the future.

juergen d

I tried all the hacks, tips and tricks in the other posts like here, here and here.

None of them works that great or looks so good.

Now this is how you really do it (found in the Source of the OsmAnd app):

You use a FrameLayout (which has the characteristic of laying its components over each other) and put 2 TextViews inside at the same position.

MainActivity.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    android:background="#445566">

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top"
        android:layout_weight="1">

        <TextView
            android:id="@+id/textViewShadowId"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="top"
            android:textSize="36sp"
            android:text="123 ABC" 
            android:textColor="#ffffff" />

        <TextView
            android:id="@+id/textViewId"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="top"
            android:textSize="36sp"
            android:text="123 ABC"
            android:textColor="#000000" />
    </FrameLayout>

</LinearLayout>

And in the onCreate method of your activity you set the stroke width of the shadow TextView and change it from FILL to STROKE:

import android.graphics.Paint;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {    

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        //here comes the magic
        TextView textViewShadow = (TextView) findViewById(R.id.textViewShadowId);
        textViewShadow.getPaint().setStrokeWidth(5);
        textViewShadow.getPaint().setStyle(Paint.Style.STROKE);
    }
}

The result looks like this:

I experienced the same issue, with calling setTextColor in onDraw causing infinite draw loop. I wanted to make my custom text view have a different fill color than the outline color when it rendered text. Which is why I was calling setTextColor multiple times in onDraw.

I found an alternative solution using an OutlineSpan see https://github.com/santaevpavel/OutlineSpan. This is better than making the layout hierarchy complicated with multiple TextViews or using reflection and requires minimal changes. See the github page for more details. Example

val outlineSpan = OutlineSpan(
    strokeColor = Color.RED,
    strokeWidth = 4F
)
val text = "Outlined text"
val spannable = SpannableString(text)
spannable.setSpan(outlineSpan, 0, 8, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

// Set text of TextView
binding.outlinedText.text = spannable 
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!