Etykieta pionowa (obrócona) w systemie Android


110

Potrzebuję dwóch sposobów wyświetlania etykiety pionowej w systemie Android:

  1. Etykieta pozioma obrócona o 90 stopni w kierunku przeciwnym do ruchu wskazówek zegara (litery z boku)
  2. Pozioma etykieta z literami jedna pod drugą (jak szyld sklepu)

Czy muszę opracować niestandardowe widżety dla obu przypadków (jeden przypadek), czy mogę sprawić, by TextView renderował w ten sposób i jaki byłby dobry sposób na zrobienie czegoś takiego, jeśli muszę przejść całkowicie niestandardowo?


Można to zrobić w języku XML od API 11 (Android 3.0). stackoverflow.com/questions/3774770/…
chobok

Odpowiedzi:


239

Oto moja elegancka i prosta implementacja tekstu pionowego, rozszerzająca TextView. Oznacza to, że można używać wszystkich standardowych stylów TextView, ponieważ jest to rozszerzony TextView.

public class VerticalTextView extends TextView{
   final boolean topDown;

   public VerticalTextView(Context context, AttributeSet attrs){
      super(context, attrs);
      final int gravity = getGravity();
      if(Gravity.isVertical(gravity) && (gravity&Gravity.VERTICAL_GRAVITY_MASK) == Gravity.BOTTOM) {
         setGravity((gravity&Gravity.HORIZONTAL_GRAVITY_MASK) | Gravity.TOP);
         topDown = false;
      }else
         topDown = true;
   }

   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
      super.onMeasure(heightMeasureSpec, widthMeasureSpec);
      setMeasuredDimension(getMeasuredHeight(), getMeasuredWidth());
   }

   @Override
   protected boolean setFrame(int l, int t, int r, int b){
      return super.setFrame(l, t, l+(b-t), t+(r-l));
   }

   @Override
   public void draw(Canvas canvas){
      if(topDown){
         canvas.translate(getHeight(), 0);
         canvas.rotate(90);
      }else {
         canvas.translate(0, getWidth());
         canvas.rotate(-90);
      }
      canvas.clipRect(0, 0, getWidth(), getHeight(), android.graphics.Region.Op.REPLACE);
      super.draw(canvas);
   }
}

Domyślnie tekst obrócony jest od góry do dołu. Jeśli ustawisz android: gravity = "bottom", będzie rysowany od dołu do góry.

Technicznie rzecz biorąc, to oszukuje podstawowy TextView, myśląc, że jest to normalny obrót (zamiana szerokości / wysokości w kilku miejscach), podczas rysowania jest obracany. Działa dobrze również wtedy, gdy jest używany w układzie XML.

EDYCJA: wysyłanie innej wersji, powyżej ma problemy z animacjami. Ta nowa wersja działa lepiej, ale traci niektóre funkcje TextView, takie jak marquee i podobne specjalizacje.

public class VerticalTextView extends TextView{
   final boolean topDown;

   public VerticalTextView(Context context, AttributeSet attrs){
      super(context, attrs);
      final int gravity = getGravity();
      if(Gravity.isVertical(gravity) && (gravity&Gravity.VERTICAL_GRAVITY_MASK) == Gravity.BOTTOM) {
         setGravity((gravity&Gravity.HORIZONTAL_GRAVITY_MASK) | Gravity.TOP);
         topDown = false;
      }else
         topDown = true;
   }

   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
      super.onMeasure(heightMeasureSpec, widthMeasureSpec);
      setMeasuredDimension(getMeasuredHeight(), getMeasuredWidth());
   }

   @Override
   protected void onDraw(Canvas canvas){
      TextPaint textPaint = getPaint(); 
      textPaint.setColor(getCurrentTextColor());
      textPaint.drawableState = getDrawableState();

      canvas.save();

      if(topDown){
         canvas.translate(getWidth(), 0);
         canvas.rotate(90);
      }else {
         canvas.translate(0, getHeight());
         canvas.rotate(-90);
      }


      canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());

      getLayout().draw(canvas);
      canvas.restore();
  }
}

EDYTUJ wersję Kotlin:

import android.content.Context
import android.graphics.Canvas
import android.text.BoringLayout
import android.text.Layout
import android.text.TextUtils.TruncateAt
import android.util.AttributeSet
import android.view.Gravity
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.graphics.withSave

class VerticalTextView(context: Context, attrs: AttributeSet) : AppCompatTextView(context, attrs) {
    private val topDown = gravity.let { g ->
        !(Gravity.isVertical(g) && g.and(Gravity.VERTICAL_GRAVITY_MASK) == Gravity.BOTTOM)
    }
    private val metrics = BoringLayout.Metrics()
    private var padLeft = 0
    private var padTop = 0

    private var layout1: Layout? = null

    override fun setText(text: CharSequence, type: BufferType) {
        super.setText(text, type)
        layout1 = null
    }

    private fun makeLayout(): Layout {
        if (layout1 == null) {
            metrics.width = height
            paint.color = currentTextColor
            paint.drawableState = drawableState
            layout1 = BoringLayout.make(text, paint, metrics.width, Layout.Alignment.ALIGN_NORMAL, 2f, 0f, metrics, false, TruncateAt.END, height - compoundPaddingLeft - compoundPaddingRight)
            padLeft = compoundPaddingLeft
            padTop = extendedPaddingTop
        }
        return layout1!!
    }

    override fun onDraw(c: Canvas) {
        //      c.drawColor(0xffffff80); // TEST
        if (layout == null)
            return
        c.withSave {
            if (topDown) {
                val fm = paint.fontMetrics
                translate(textSize - (fm.bottom + fm.descent), 0f)
                rotate(90f)
            } else {
                translate(textSize, height.toFloat())
                rotate(-90f)
            }
            translate(padLeft.toFloat(), padTop.toFloat())
            makeLayout().draw(this)
        }
    }
}

2
Twoje rozwiązanie wyłącza linki w TextView. Właściwie linki są podkreślone, ale nie reagują na kliknięcie.
Gurnetko

5
ma to problemy z wielowierszowymi i paskami przewijania.
Cynichniy Bandera,

1
przepraszam za moje podstawowe pytanie, jeśli mam TextView (w pliku XML) i chcę go obrócić, jak mam wywołać klasę VerticalTextView?
blackst0ne

2
@ blackst0ne zamiast tagów <TextView>, użyj niestandardowego tagu widoku: <com.YOUR_PACKAGE_NAME.VerticalTextView>
Daiwik Daarun

1
działa świetnie, w moim przypadku musiałem rozszerzyć android.support.v7.widget.AppCompatTextView zamiast TextView, aby mój atrybut stylu działał
Louis

32

Zaimplementowałem to dla mojego projektu ChartDroid . Utwórz VerticalLabelView.java:

public class VerticalLabelView extends View {
    private TextPaint mTextPaint;
    private String mText;
    private int mAscent;
    private Rect text_bounds = new Rect();

    final static int DEFAULT_TEXT_SIZE = 15;

    public VerticalLabelView(Context context) {
        super(context);
        initLabelView();
    }

    public VerticalLabelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initLabelView();

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.VerticalLabelView);

        CharSequence s = a.getString(R.styleable.VerticalLabelView_text);
        if (s != null) setText(s.toString());

        setTextColor(a.getColor(R.styleable.VerticalLabelView_textColor, 0xFF000000));

        int textSize = a.getDimensionPixelOffset(R.styleable.VerticalLabelView_textSize, 0);
        if (textSize > 0) setTextSize(textSize);

        a.recycle();
    }

    private final void initLabelView() {
        mTextPaint = new TextPaint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setTextSize(DEFAULT_TEXT_SIZE);
        mTextPaint.setColor(0xFF000000);
        mTextPaint.setTextAlign(Align.CENTER);
        setPadding(3, 3, 3, 3);
    }

    public void setText(String text) {
        mText = text;
        requestLayout();
        invalidate();
    }

    public void setTextSize(int size) {
        mTextPaint.setTextSize(size);
        requestLayout();
        invalidate();
    }

    public void setTextColor(int color) {
        mTextPaint.setColor(color);
        invalidate();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        mTextPaint.getTextBounds(mText, 0, mText.length(), text_bounds);
        setMeasuredDimension(
                measureWidth(widthMeasureSpec),
                measureHeight(heightMeasureSpec));
    }

    private int measureWidth(int measureSpec) {
        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text
            result = text_bounds.height() + getPaddingLeft() + getPaddingRight();

            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }
        return result;
    }

    private int measureHeight(int measureSpec) {
        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        mAscent = (int) mTextPaint.ascent();
        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text
            result = text_bounds.width() + getPaddingTop() + getPaddingBottom();

            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }
        return result;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        float text_horizontally_centered_origin_x = getPaddingLeft() + text_bounds.width()/2f;
        float text_horizontally_centered_origin_y = getPaddingTop() - mAscent;

        canvas.translate(text_horizontally_centered_origin_y, text_horizontally_centered_origin_x);
        canvas.rotate(-90);
        canvas.drawText(mText, 0, 0, mTextPaint);
    }
}

A w attrs.xml:

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

Bardzo przydatne. Oto link do wersji głównej kodu
rds

Nie przydatne, tekst nie jest wyświetlany w całości… jest odcięty od końca i początku.
Siddarth G

Pracował również w powyższej wersji Androida 28
Ajeet Choudhary

Twoja wersja jest najlepszą opcją dla mojego projektu, ale setTextColor nie działa, również chciałbym zastosować styl (background i fontFamily), czy da się to zrobić?
Pablo R.

9

Jednym ze sposobów osiągnięcia tego byłoby:

  1. Napisz własny widok niestandardowy i nadpisz onDraw (Canvas). Możesz narysować tekst na płótnie, a następnie obrócić płótno.
  2. To samo co 1. z tym wyjątkiem, że tym razem użyj Path i narysuj tekst za pomocą drawTextOnPath (...)

Więc zanim poszedłem tą trasą (patrzyłem na metodę onDraw dla TextView - jest ogromna) zauważyłem, że cała różnica między TextView a rozszerzającym przyciskiem to wewnętrzny identyfikator stylu (com.android.internal.R.attr.buttonStyle) Czy byłoby to możliwe po prostu zdefiniować niestandardowy styl i rozszerzyć TextView podobnie do Button? Zgaduję, że odpowiedź brzmiałaby nie, ponieważ prawdopodobnie nie można stylizować tekstu na układ pionowy
Bostone

Czy to podejście faktycznie działa? Nie odniosłem
nategood

1
2. drawTextOnPath () rysuje tekst tak, jak został obrócony, tak samo jak 1. Aby pisać litery jedna pod drugą, użyj \npo każdym znaku lub jeśli masz czcionkę o stałej szerokości, ogranicz szerokość TextView, aby zmieściła się tylko na jednym znaku.
blub

8

Wypróbowałem obie klasy VerticalTextView w zatwierdzonej odpowiedzi i działały dość dobrze.

Ale bez względu na to, co próbowałem, nie mogłem umieścić tych VerticalTextViews w środku zawierającego układu (RelativeLayout, który jest częścią elementu zawyżonego dla RecyclerView).

FWIW, po rozejrzeniu się, znalazłem klasę VerticalTextView yoog568 na GitHub:

https://github.com/yoog568/VerticalTextView/blob/master/src/com/yoog/widget/VerticalTextView.java

które mogłem ustawić zgodnie z życzeniem. W swoim projekcie musisz również uwzględnić następujące definicje atrybutów:

https://github.com/yoog568/VerticalTextView/blob/master/res/values/attr.xml


1
Uważam, że ta implementacja jest naprawdę łatwa!
Hashim Akhtar

4
check = (TextView)findViewById(R.id.check);
check.setRotation(-90);

To zadziałało dla mnie, po prostu dobrze. Co do liter opadających pionowo - nie wiem.


7
ale nawet zajmuje miejsce w poziomie i obraca je w pionie
Prabs,

3

Jest kilka drobnych rzeczy, na które należy zwrócić uwagę.

Zależy to od zestawu znaków przy wybieraniu obrotu lub ścieżek. na przykład, jeśli docelowy zestaw znaków jest podobny do angielskiego, a oczekiwany efekt wygląda tak,

a
b
c
d

możesz uzyskać ten efekt, rysując każdą postać po kolei, bez obracania lub ścieżki.

wprowadź opis obrazu tutaj

aby uzyskać ten efekt, może być potrzebny obrót lub ścieżka.

trudna część polega na tym, że próbujesz renderować zestaw znaków, taki jak mongolski. glif w kroju pisma należy obrócić o 90 stopni, więc drawTextOnPath () będzie dobrym kandydatem do użycia.


Jak można to zrobić w inny sposób, od lewej do prawej strony
Rakesh

3
textview.setTextDirection (View.TEXT_DIRECTION_RTL) lub textview.setTextDirection (View.TEXT_DIRECTION_ANY_RTL) mogą działać powyżej 17 poziomu API, możesz to przetestować.
Zephyr,

sprytny i prosty
Abdulrahman Abdelkader

1

Podążając za odpowiedzią Pointer Null , mogłem wyśrodkować tekst w poziomie, modyfikując onDrawmetodę w ten sposób:

@Override
protected void onDraw(Canvas canvas){
    TextPaint textPaint = getPaint();
    textPaint.setColor(getCurrentTextColor());
    textPaint.drawableState = getDrawableState();
    canvas.save();
    if(topDown){
        canvas.translate(getWidth()/2, 0);
        canvas.rotate(90);
    }else{
        TextView temp = new TextView(getContext());
        temp.setText(this.getText().toString());
        temp.setTypeface(this.getTypeface());
        temp.measure(0, 0);
        canvas.rotate(-90);
        int max = -1 * ((getWidth() - temp.getMeasuredHeight())/2);
        canvas.translate(canvas.getClipBounds().left, canvas.getClipBounds().top - max);
    }
    canvas.translate(getCompoundPaddingLeft(), getExtendedPaddingTop());
    getLayout().draw(canvas);
    canvas.restore();
}

Może być konieczne dodanie części elementu TextView MeasuringWidth, aby wyśrodkować tekst wielowierszowy.


1

Możesz po prostu dodać do swojego TextView lub innej wartości obrotu XML widoku. To najłatwiejszy sposób i jak dla mnie działa poprawnie.

<LinearLayout
    android:rotation="-90"
    android:layout_below="@id/image_view_qr_code"
    android:layout_above="@+id/text_view_savva_club"
    android:layout_marginTop="20dp"
    android:gravity="bottom"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

   <TextView
       android:textColor="@color/colorPrimary"
       android:layout_marginStart="40dp"
       android:textSize="20sp"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="Дмитриевский Дмитрий Дмитриевич"
       android:maxLines="2"
       android:id="@+id/vertical_text_view_name"/>
    <TextView
        android:textColor="#B32B2A29"
        android:layout_marginStart="40dp"
        android:layout_marginTop="15dp"
        android:textSize="16sp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/vertical_text_view_phone"
        android:text="+38 (000) 000-00-00"/>

</LinearLayout>

Wynik


0

Moje wstępne podejście do renderowania tekstu pionowego wewnątrz pionowego LinearLayout było następujące (jest to Kotlin, w Javie setRoatationitp.):

val tv = TextView(context)
tv.gravity = Gravity.CENTER
tv.rotation = 90F
tv.height = calcHeight(...)
linearLabels.addView(tv)

podejście # 1

Jak widać, problem polega na tym, że TextView przesuwa się pionowo, ale nadal traktuje swoją szerokość tak, jakby był zorientowany poziomo! = /

Zatem podejście nr 2 polegało na dodatkowym ręcznym przełączaniu szerokości i wysokości w celu uwzględnienia tego:

tv.measure(0, 0)
// tv.setSingleLine()
tv.width = tv.measuredHeight
tv.height = calcHeight(...)

podejście # 2

Spowodowało to jednak zawijanie etykiet do następnego wiersza (lub przycinanie, jeśli ty setSingleLine ) po stosunkowo małej szerokości. Ponownie, sprowadza się to do pomylenia x z y.

Moje podejście nr 3 polegało więc na zawinięciu TextView w RelativeLayout. Chodzi o to, aby pozwolić TextView na dowolną szerokość, rozszerzając go daleko w lewo i w prawo (tutaj 200 pikseli w obu kierunkach). Ale potem podaję ujemne marginesy RelativeLayout, aby upewnić się, że jest rysowany jako wąska kolumna. Oto mój pełny kod tego zrzutu ekranu:

val tv = TextView(context)
tv.text = getLabel(...)
tv.gravity = Gravity.CENTER
tv.rotation = 90F

tv.measure(0, 0)
tv.width = tv.measuredHeight + 400  // 400 IQ
tv.height = calcHeight(...)

val tvHolder = RelativeLayout(context)
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT,
    LinearLayout.LayoutParams.WRAP_CONTENT)
lp.setMargins(-200, 0, -200, 0)
tvHolder.layoutParams = lp
tvHolder.addView(tv)
linearLabels.addView(tvHolder)

val iv = ImageView(context)
iv.setImageResource(R.drawable.divider)
linearLabels.addView(iv)

podejście # 3

Ogólnie rzecz biorąc, ta strategia polegająca na utrzymywaniu widoku w innym widoku była dla mnie bardzo przydatna w pozycjonowaniu rzeczy w systemie Android! Na przykład okno informacyjne poniżej paska akcji używa tej samej taktyki!

W przypadku tekstu wyglądającego jak znak sklepu wystarczy wstawić nowe linie po każdym znaku, np. "N\nu\nt\ns"Będzie:

przykład szyldu sklepu


-1

Podobało mi się podejście @ kostmo. Zmodyfikowałem to trochę, bo miałem problem - odcinanie obróconej w pionie etykiety, gdy ustawiłem jej parametry na WRAP_CONTENT. W związku z tym tekst nie był w pełni widoczny.

Oto jak to rozwiązałem:

import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.os.Build;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

public class VerticalLabelView extends View
{
    private final String LOG_TAG           = "VerticalLabelView";
    private final int    DEFAULT_TEXT_SIZE = 30;
    private int          _ascent           = 0;
    private int          _leftPadding      = 0;
    private int          _topPadding       = 0;
    private int          _rightPadding     = 0;
    private int          _bottomPadding    = 0;
    private int          _textSize         = 0;
    private int          _measuredWidth;
    private int          _measuredHeight;
    private Rect         _textBounds;
    private TextPaint    _textPaint;
    private String       _text             = "";
    private TextView     _tempView;
    private Typeface     _typeface         = null;
    private boolean      _topToDown = false;

    public VerticalLabelView(Context context)
    {
        super(context);
        initLabelView();
    }

    public VerticalLabelView(Context context, AttributeSet attrs)
    {
        super(context, attrs);
        initLabelView();
    }

    public VerticalLabelView(Context context, AttributeSet attrs, int defStyleAttr)
    {
        super(context, attrs, defStyleAttr);
        initLabelView();
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public VerticalLabelView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)
    {
        super(context, attrs, defStyleAttr, defStyleRes);
        initLabelView();
    }

    private final void initLabelView()
    {
        this._textBounds = new Rect();
        this._textPaint = new TextPaint();
        this._textPaint.setAntiAlias(true);
        this._textPaint.setTextAlign(Paint.Align.CENTER);
        this._textPaint.setTextSize(DEFAULT_TEXT_SIZE);
        this._textSize = DEFAULT_TEXT_SIZE;
    }

    public void setText(String text)
    {
        this._text = text;
        requestLayout();
        invalidate();
    }

    public void topToDown(boolean topToDown)
    {
        this._topToDown = topToDown;
    }

    public void setPadding(int padding)
    {
        setPadding(padding, padding, padding, padding);
    }

    public void setPadding(int left, int top, int right, int bottom)
    {
        this._leftPadding = left;
        this._topPadding = top;
        this._rightPadding = right;
        this._bottomPadding = bottom;
        requestLayout();
        invalidate();
    }

    public void setTextSize(int size)
    {
        this._textSize = size;
        this._textPaint.setTextSize(size);
        requestLayout();
        invalidate();
    }

    public void setTextColor(int color)
    {
        this._textPaint.setColor(color);
        invalidate();
    }

    public void setTypeFace(Typeface typeface)
    {
        this._typeface = typeface;
        this._textPaint.setTypeface(typeface);
        requestLayout();
        invalidate();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
    {
        try
        {
            this._textPaint.getTextBounds(this._text, 0, this._text.length(), this._textBounds);

            this._tempView = new TextView(getContext());
            this._tempView.setPadding(this._leftPadding, this._topPadding, this._rightPadding, this._bottomPadding);
            this._tempView.setText(this._text);
            this._tempView.setTextSize(TypedValue.COMPLEX_UNIT_PX, this._textSize);
            this._tempView.setTypeface(this._typeface);

            this._tempView.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);

            this._measuredWidth = this._tempView.getMeasuredHeight();
            this._measuredHeight = this._tempView.getMeasuredWidth();

            this._ascent = this._textBounds.height() / 2 + this._measuredWidth / 2;

            setMeasuredDimension(this._measuredWidth, this._measuredHeight);
        }
        catch (Exception e)
        {
            setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
            Log.e(LOG_TAG, Log.getStackTraceString(e));
        }
    }

    @Override
    protected void onDraw(Canvas canvas)
    {
        super.onDraw(canvas);

        if (!this._text.isEmpty())
        {
            float textHorizontallyCenteredOriginX = this._measuredHeight / 2f;
            float textHorizontallyCenteredOriginY = this._ascent;

            canvas.translate(textHorizontallyCenteredOriginY, textHorizontallyCenteredOriginX);

            float rotateDegree = -90;
            float y = 0;

            if (this._topToDown)
            {
                rotateDegree = 90;
                y = this._measuredWidth / 2;
            }

            canvas.rotate(rotateDegree);
            canvas.drawText(this._text, 0, y, this._textPaint);
        }
    }
}

Jeśli chcesz mieć tekst od góry do dołu, użyj topToDown(true)metody.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.