Добавьте непрозрачную "тень" (контур) в Android TextView

У меня в Activity есть TextView к которому я хочу добавить тень. Он должен выглядеть в OsmAnd (непрозрачный на 100%):

what I want

Но это выглядит так:

What I have

Вы можете видеть, что текущая тень размыта и исчезает. Я хочу твердую непрозрачную тень. Но как?

Мой текущий код:

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:id="@+id/speedTextView"
    android:text="25 km/h"

    android:textSize="24sp"
    android:textStyle="bold"
    android:textColor="#000000"
    android:shadowColor="#ffffff"
    android:shadowDx="0"
    android:shadowDy="0"
    android:shadowRadius="6"
/>

Ответ 1

Я подумал, что мог бы предложить альтернативу TextView решению TextView. В этом решении реализован пользовательский подкласс TextView который манипулирует TextPaint объекта TextPaint чтобы сначала нарисовать контур, а затем нарисовать текст поверх него.

Используя это, вам нужно иметь дело только с одним View за раз, поэтому изменение чего-либо во время выполнения не потребует вызовов двух отдельных TextView. Это также должно упростить использование других тонкостей TextView - таких как составные элементы рисования - и сохранять все квадратными без лишних настроек.

Reflection используется для того, чтобы избежать вызова setTextColor() TextView setTextColor(), который делает недействительным View и вызывает бесконечный цикл рисования, что, я полагаю, наиболее вероятно, поэтому такие решения не сработали для вас. Задание цвета непосредственно для объекта Paint не работает из-за того, как TextView обрабатывает это в своем onDraw(), отсюда и отражение.

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];
            }
        };
    }
}

Если вы используете пользовательские атрибуты XML, в вашем <resources> должно быть следующее, что вы можете сделать, просто вставив этот файл в свою папку res/values/ или добавив к уже существующему. Если вы не хотите использовать пользовательские атрибуты, вы должны удалить соответствующую обработку атрибутов из третьего конструктора View.

attrs.xml

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

С помощью пользовательских атрибутов все можно настроить в макете XML. Обратите внимание на дополнительное пространство имен XML, называемое здесь app и указанное в корневом элементе LinearLayout.

<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>

Результаты, достижения:

screenshot


Заметки:

  • Если вы используете библиотеки поддержки, ваш класс OutlineTextView должен вместо этого расширить AppCompatTextView, чтобы гарантировать, что тонирование и еще много чего будет обрабатываться надлежащим образом во всех версиях.

  • Если ширина контура относительно велика по сравнению с размером текста, может потребоваться установить дополнительные отступы в View чтобы объекты не выходили за их границы, особенно при переносе ширины и/или высоты. Это было бы проблемой и для наложенных TextView.

  • Относительно большая ширина контура также может привести к нежелательным острым угловым эффектам для некоторых символов, таких как "А" и "2", из-за стиля обводки. Это также может произойти с наложенными TextView.

  • Этот класс можно легко преобразовать в эквивалент EditText, просто изменив суперкласс на EditText и передав android.R.attr.editTextStyle вместо android.R.attr.textViewStyle в вызове цепочки конструктора из трех параметров. Для вспомогательных библиотек суперклассом будет AppCompatEditText и аргумент конструктора R.attr.editTextStyle.

  • Просто для удовольствия: я хотел бы отметить, что вы можете получить довольно изящные эффекты, используя полупрозрачные цвета для текста и/или контура, и играя со стилями fill/stroke/fill-and-stroke. Это, конечно, было бы возможно и с TextView решением TextView.

  • Что касается уровня API 28 (Pie), существуют определенные ограничения для интерфейсов, не относящихся к SDK, включая отражение для доступа к обычно недоступным элементам в SDK. Несмотря на это, это решение по-прежнему работает, на удивление, по крайней мере на доступных эмуляторах Pie, как для родного TextView и для поддержки AppCompatTextView. Я буду обновлять, если это изменится в будущем.

Ответ 2

Я пробовал все хаки, подсказки и трюки в других сообщениях, например здесь, здесь и здесь.

Никто из них не работает так хорошо или выглядит так хорошо.

Теперь вы так и делаете это (найдено в источнике OsmAnd):

Вы используете FrameLayout (который имеет свойство укладывать свои компоненты друг над другом) и помещают 2 TextView внутри внутри той же позиции.

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>

И в методе onCreate вашей активности вы задаете ширину штриха теневого TextView и измените ее с FILL на 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);
    }
}

Результат выглядит следующим образом:

скриншот результата

Ответ 3

У меня setTextColor та же проблема, с вызовом setTextColor в onDraw вызывающим бесконечный цикл отрисовки. Я хотел, чтобы мой пользовательский текстовый вид имел другой цвет заливки, чем цвет контура при визуализации текста. Вот почему я вызывал setTextColor несколько раз в onDraw.

Я нашел альтернативное решение, используя OutlineSpan см. Https://github.com/santaevpavel/OutlineSpan. Это лучше, чем усложнять иерархию макетов с помощью нескольких TextView или использования отражения, и требует минимальных изменений. Смотрите страницу GitHub для более подробной информации. пример

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