com.ruesga.rview.widget.TagEditTextView.java Source code

Java tutorial

Introduction

Here is the source code for com.ruesga.rview.widget.TagEditTextView.java

Source

/*
 * Copyright (C) 2016 Jorge Ruesga
 *
 * 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.ruesga.rview.widget;

import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.os.Handler;
import android.os.Message;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.v4.view.AbsSavedState;
import android.support.v4.view.ViewCompat;
import android.support.v7.widget.AppCompatEditText;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.KeyListener;
import android.text.style.ImageSpan;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.SoundEffectConstants;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.LinearLayout;

import com.ruesga.rview.R;
import com.ruesga.rview.drawer.DrawerNavigationView;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A {@link View} to edit hash tags(#) and user tags(@).
 *
 * TODO Add support for RTL
 */
@SuppressWarnings("unused")
public class TagEditTextView extends LinearLayout {

    private class TagEditText extends AppCompatEditText {
        public TagEditText(Context context, AttributeSet attrs) {
            super(context, attrs);
        }

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

        @Override
        protected void onSelectionChanged(int selStart, int selEnd) {
            super.onSelectionChanged(selStart, selEnd);
            int minSelPos = mTagList.size();
            if (selStart < minSelPos) {
                setSelection(minSelPos);
            }
        }

        @Override
        public boolean onTouchEvent(@NonNull MotionEvent event) {
            if (!isEnabled()) {
                return false;
            }

            int action = event.getAction();
            switch (action) {
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_DOWN:
                int x = (int) event.getX();
                int y = (int) event.getY();

                // Compute where the user has touched (is in a remove tag area?)
                int w = getWidth();
                int x1 = 0, y1 = 0;
                for (Tag tag : mTagList) {
                    x1 += tag.w;
                    if (x1 > w) {
                        x1 = tag.w;
                        y1 = tag.h;
                    }
                    if ((x > (x1 - tag.w) && x < x1) && (y > y1 && y < (y1 + tag.h))) {
                        if (x >= (x1 - mChipRemoveAreaWidth)) {
                            // User click in a remove tag area
                            if (action == MotionEvent.ACTION_UP) {
                                onTagRemoveClick(tag);
                                playSoundEffect(SoundEffectConstants.CLICK);
                            }
                            return true;
                        }

                        // Tag clicked
                        if (action == MotionEvent.ACTION_UP) {
                            if (mTagClickCallBack != null) {
                                mTagClickCallBack.onTagClick(tag);
                                playSoundEffect(SoundEffectConstants.CLICK);
                            }
                        }
                    }
                }
                break;
            }
            return super.onTouchEvent(event);
        }
    }

    public interface OnTagEventListener {
        void onTagCreate(Tag tag);

        void onTagRemove(Tag tag);
    }

    public interface OnTagClickListener {
        void onTagClick(Tag tag);
    }

    public interface OnComputedTagEndedListener {
        void onComputedTagEnded();
    }

    public static class Tag {
        private CharSequence mTag;
        private int mColor;

        private int w;
        private int h;

        private Tag() {
        }

        public Tag(TAG_MODE mode, CharSequence tag) {
            mTag = (mode.equals(TAG_MODE.HASH) ? "#" : "@") + tag;
        }

        public Tag(TAG_MODE mode, CharSequence tag, int color) {
            mTag = (mode.equals(TAG_MODE.HASH) ? "#" : "@") + tag;
            mColor = color;
        }

        public CharSequence getTag() {
            return mTag;
        }

        public CharSequence toPlainTag() {
            if (mTag != null && mTag.length() > 2) {
                return mTag.subSequence(1, mTag.length());
            }
            return "";
        }

        public Tag copy() {
            Tag tag = new Tag();
            tag.mTag = mTag;
            tag.mColor = mColor;
            return tag;
        }

        private String toSavedState() {
            String tag = "";
            if (mTag != null && mTag.length() > 2) {
                tag = mTag.toString();
            }
            return mColor + "|" + tag;
        }

        private void fromSavedState(String savedState) {
            String[] s = savedState.split("\\|");
            mColor = Integer.valueOf(s[0]);
            mTag = s[1];
        }

        @Override
        public boolean equals(Object o) {
            if (this == o)
                return true;
            if (o == null || getClass() != o.getClass())
                return false;

            Tag tag = (Tag) o;

            return mTag != null ? mTag.equals(tag.mTag) : tag.mTag == null;

        }

        @Override
        public int hashCode() {
            return mTag != null ? mTag.hashCode() : 0;
        }
    }

    public enum TAG_MODE {
        HASH, USER
    }

    private final TextWatcher mEditListener = new TextWatcher() {
        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            if (mLockEdit) {
                return;
            }
            if (count == 0 && start == (mTagList.size() - 1)) {
                mLockEdit = true;
                try {
                    Editable e = mTagEdit.getEditableText();
                    ImageSpan[] spans = e.getSpans(start, start + 1, ImageSpan.class);
                    for (ImageSpan span : spans) {
                        e.removeSpan(span);
                    }
                    Tag tag = mTagList.get(start);
                    mTagList.remove(start);

                    notifyTagRemoved(tag);

                } finally {
                    mLockEdit = false;
                }
            }
        }

        @Override
        public void afterTextChanged(Editable s) {
            // Prevent any pending message to be called
            mHandler.removeMessages(MESSAGE_CREATE_CHIP);
            performComputeChipsLocked(s);
        }
    };

    private Handler.Callback mTagMessenger = new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            switch (msg.what) {
            case MESSAGE_CREATE_CHIP:
                Editable s = mTagEdit.getEditableText();
                s.insert(s.length(), CHIP_REPLACEMENT_CHAR);
                break;
            }
            return false;
        }
    };

    private static final Pattern HASH_TAG_PATTERN = Pattern
            .compile("(?<=^|(?<=[^a-zA-Z0-9-_\\\\.]))#([\\p{L}]+[\\p{L}0-9_]+)");
    private static final Pattern USER_TAG_PATTERN = Pattern
            .compile("(?<=^|(?<=[^a-zA-Z0-9-_\\\\.]))@([\\p{L}]+[\\p{L}0-9_]+)");
    private static final Pattern NON_UNICODE_CHAR_PATTERN = Pattern.compile("[^\\p{L}0-9_#@]");

    private static final String VALID_TAGS = "#@";

    private static final String CHIP_SEPARATOR_CHAR = " ";
    private static final String CHIP_REPLACEMENT_CHAR = ".";

    private static final int MESSAGE_CREATE_CHIP = 0;

    private static final long CREATE_CHIP_LENGTH_THRESHOLD = 3L;
    private static final long CREATE_CHIP_DEFAULT_DELAYED_TIMEOUT = 1500L;

    private static float ONE_PIXEL = 0f;
    private static final Typeface CHIP_TYPEFACE = Typeface.create("Helvetica", Typeface.BOLD);
    private static final String CHIP_REMOVE_TEXT = " | x ";
    private Paint mChipBgPaint;
    private Paint mChipFgPaint;
    private int mChipRemoveAreaWidth;
    private int mChipBackgroundColor = 0;

    private TagEditText mTagEdit;
    private List<Tag> mTagList = new ArrayList<>();

    private long mTriggerTagCreationThreshold;
    private boolean mReadOnly;
    private KeyListener mEditModeKeyListener;

    private TAG_MODE mDefaultTagMode;
    private boolean mSupportsUserTags = true;

    private Handler mHandler;
    private final List<OnTagEventListener> mTagEventCallBacks = new ArrayList<>();
    private OnTagClickListener mTagClickCallBack = null;
    private final List<OnComputedTagEndedListener> mComputeTagCallbacks = new ArrayList<>();

    private boolean mLockEdit;

    public TagEditTextView(Context ctx) {
        this(ctx, null);
    }

    public TagEditTextView(Context ctx, AttributeSet attrs) {
        this(ctx, attrs, 0);
    }

    public TagEditTextView(Context ctx, AttributeSet attrs, int defStyleAttr) {
        super(ctx, attrs, defStyleAttr);
        init(ctx, attrs, defStyleAttr);
    }

    private void init(Context ctx, AttributeSet attrs, int defStyleAttr) {
        mHandler = new Handler(mTagMessenger);
        mTriggerTagCreationThreshold = CREATE_CHIP_DEFAULT_DELAYED_TIMEOUT;

        Resources.Theme theme = ctx.getTheme();
        TypedArray a = theme.obtainStyledAttributes(attrs, R.styleable.TagEditTextView, defStyleAttr, 0);

        mReadOnly = a.getBoolean(R.styleable.TagEditTextView_readonly, false);
        mDefaultTagMode = TAG_MODE.HASH;

        // Create the internal EditText that holds the tag logic
        mTagEdit = mReadOnly ? new TagEditText(ctx, attrs, defStyleAttr) : new TagEditText(ctx, attrs);
        mTagEdit.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, 0));
        mTagEdit.setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
        mTagEdit.addTextChangedListener(mEditListener);
        mTagEdit.setTextIsSelectable(false);
        mTagEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
        mTagEdit.setOnFocusChangeListener((v, hasFocus) -> {
            // Remove any pending message
            mHandler.removeMessages(MESSAGE_CREATE_CHIP);
        });
        mTagEdit.setCustomSelectionActionModeCallback(new ActionMode.Callback() {
            @Override
            public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                return false;
            }

            @Override
            public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                return false;
            }

            @Override
            public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                return false;
            }

            @Override
            public void onDestroyActionMode(ActionMode mode) {

            }
        });
        addView(mTagEdit);

        // Configure the window mode for landscape orientation, to disallow hide the
        // EditText control, and show characters instead of chips
        int orientation = ctx.getResources().getConfiguration().orientation;
        if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
            if (ctx instanceof Activity) {
                Window window = ((Activity) ctx).getWindow();
                if (window != null) {
                    window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
                    mTagEdit.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
                }
            }
        }

        // Save the keyListener for later restore
        mEditModeKeyListener = mTagEdit.getKeyListener();

        // Initialize resources for chips
        mChipBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

        mChipFgPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mChipFgPaint.setTextSize(mTagEdit.getTextSize() * (mReadOnly ? 1 : 0.8f));
        if (CHIP_TYPEFACE != null) {
            mChipFgPaint.setTypeface(CHIP_TYPEFACE);
        }
        mChipFgPaint.setTextAlign(Paint.Align.LEFT);

        // Calculate the width area used to remove the tag in the chip
        mChipRemoveAreaWidth = (int) (mChipFgPaint.measureText(CHIP_REMOVE_TEXT) + 0.5f);

        if (ONE_PIXEL <= 0) {
            Resources res = getResources();
            ONE_PIXEL = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, res.getDisplayMetrics());
        }

        int n = a.getIndexCount();
        for (int i = 0; i < n; i++) {
            int attr = a.getIndex(i);
            switch (attr) {
            case R.styleable.TagEditTextView_supportUserTags:
                setSupportsUserTags(a.getBoolean(attr, false));
                break;

            case R.styleable.TagEditTextView_chipBackgroundColor:
                mChipBackgroundColor = a.getColor(attr, mChipBackgroundColor);
                break;

            case R.styleable.TagEditTextView_chipTextColor:
                mChipFgPaint.setColor(a.getColor(attr, Color.WHITE));
                break;
            }
        }
        a.recycle();
    }

    public void computeTags(OnComputedTagEndedListener cb) {
        mHandler.removeMessages(MESSAGE_CREATE_CHIP);
        Editable s = mTagEdit.getEditableText();
        s = s.append(" ");
        performComputeChipsLocked(s);
    }

    private void onTagRemoveClick(final Tag tag) {
        Editable s = mTagEdit.getEditableText();
        int position = mTagList.indexOf(tag);
        mLockEdit = true;
        mTagList.remove(position);
        ImageSpan[] spans = s.getSpans(position, position + 1, ImageSpan.class);
        for (ImageSpan span : spans) {
            s.removeSpan(span);
        }
        s.delete(position, position + 1);
        mLockEdit = false;

        notifyTagRemoved(tag);
    }

    public boolean isSupportsUserTags() {
        return mSupportsUserTags;
    }

    public void setSupportsUserTags(boolean supportsUserTags) {
        mSupportsUserTags = supportsUserTags;
        mDefaultTagMode = TAG_MODE.HASH;
        if (!supportsUserTags) {
            for (Tag tag : getTags()) {
                if (tag.mTag.charAt(0) == VALID_TAGS.charAt(1)) {
                    tag.mTag = VALID_TAGS.substring(0, 1) + tag.mTag.subSequence(1, tag.mTag.length());
                }
            }
            refresh();
        }
    }

    @Override
    public void setEnabled(boolean enabled) {
        super.setEnabled(enabled);
        internalReadOnlyMode();
        refresh();
    }

    public boolean getReadOnlyMode() {
        return mReadOnly;
    }

    public void setReadOnlyMode(boolean readOnly) {
        mReadOnly = readOnly;
        internalReadOnlyMode();
    }

    private void internalReadOnlyMode() {
        boolean enabled = mReadOnly || isEnabled();
        mTagEdit.setCursorVisible(!enabled);
        mTagEdit.setFocusable(!enabled);
        mTagEdit.setFocusableInTouchMode(!enabled);
        mTagEdit.setKeyListener(enabled ? null : mEditModeKeyListener);
        mTagEdit.setEnabled(!enabled);
        mTagEdit.setFocusable(!enabled);
        mTagEdit.setFocusableInTouchMode(!enabled);
    }

    public TAG_MODE getDefaultTagMode() {
        return mDefaultTagMode;
    }

    public void setDefaultTagMode(TAG_MODE defaultTagMode) {
        this.mDefaultTagMode = defaultTagMode;
    }

    public long getTriggerTagCreationThreshold() {
        return mTriggerTagCreationThreshold;
    }

    public void setTriggerTagCreationThreshold(long triggerTagCreationThreshold) {
        this.mTriggerTagCreationThreshold = triggerTagCreationThreshold;
    }

    public void addTagEventListener(OnTagEventListener callback) {
        if (!mTagEventCallBacks.contains(callback)) {
            mTagEventCallBacks.add(callback);
        }
    }

    public void removeTagEventListener(OnTagEventListener callback) {
        if (mTagEventCallBacks.contains(callback)) {
            mTagEventCallBacks.remove(callback);
        }
    }

    public void setOnTagClickListener(OnTagClickListener callback) {
        mTagClickCallBack = callback;
    }

    private void refresh() {
        setTags(getTags());
    }

    public Tag[] getTags() {
        Tag[] tags = new Tag[mTagList.size()];
        int count = mTagList.size();
        for (int i = 0; i < count; i++) {
            tags[i] = mTagList.get(i).copy();
        }
        return tags;
    }

    public void setTags(Tag[] tags) {
        // Delete any existent data
        try {
            mTagEdit.getEditableText().clearSpans();
        } catch (Exception ex) {
            // Ignore
        }
        int count = mTagList.size() - 1;
        for (int i = count; i >= 0; i--) {
            onTagRemoveClick(mTagList.get(i));
        }
        mTagEdit.setText("");

        if (tags == null) {
            return;
        }

        // Filter invalid tags
        for (Tag tag : tags) {
            Matcher hashTagMatcher = HASH_TAG_PATTERN.matcher(tag.mTag);
            Matcher userTagMatcher = USER_TAG_PATTERN.matcher(tag.mTag);
            if (hashTagMatcher.matches() || (mSupportsUserTags && userTagMatcher.matches())) {
                mTagList.add(tag);
            }
        }

        // Build the spans
        SpannableStringBuilder builder;
        if (tags.length > 0) {
            final String text = String.format("%" + tags.length + "s", CHIP_SEPARATOR_CHAR)
                    .replaceAll(CHIP_SEPARATOR_CHAR, CHIP_REPLACEMENT_CHAR);
            builder = new SpannableStringBuilder(text);
        } else {
            builder = new SpannableStringBuilder("");
        }

        int pos = 0;
        for (final Tag tag : mTagList) {
            Bitmap b = createTagChip(tag);
            tag.w = b.getWidth();
            tag.h = b.getHeight();
            ImageSpan span = new ImageSpan(getContext(), b, ImageSpan.ALIGN_BOTTOM);
            builder.setSpan(span, pos, pos + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            pos++;

            notifyTagCreated(tag);
        }
        mTagEdit.setText(builder);
        mTagEdit.setSelection(mTagEdit.getText().length());
    }

    private void performComputeChipsLocked(Editable s) {
        // If we are removing skip chip creation code
        if (mLockEdit) {
            return;
        }

        // Check if we need to create a new chip
        mLockEdit = true;
        try {
            String text = s.toString();
            int textLength = text.length();
            boolean isCreateChip = false;
            boolean nextIsTag = false;
            if (textLength > 0) {
                String lastChar = text.substring(textLength - 1);
                if (lastChar.charAt(0) != VALID_TAGS.charAt(1) || mSupportsUserTags) {
                    isCreateChip = NON_UNICODE_CHAR_PATTERN.matcher(lastChar).matches();
                    nextIsTag = VALID_TAGS.contains(lastChar);
                }
            }
            if (isCreateChip || nextIsTag) {
                createChip(s, nextIsTag);
                notifyComputeTagEnded();
            } else if (mTriggerTagCreationThreshold > 0) {
                int start = mTagList.size();
                String tagText = s.subSequence(start, textLength).toString().trim();
                if (tagText.length() >= CREATE_CHIP_LENGTH_THRESHOLD) {
                    mHandler.removeMessages(MESSAGE_CREATE_CHIP);
                    mHandler.sendMessageDelayed(mHandler.obtainMessage(MESSAGE_CREATE_CHIP),
                            mTriggerTagCreationThreshold);
                }
            }
        } finally {
            mLockEdit = false;
        }
    }

    private void createChip(Editable s, boolean nextIsTag) {
        int start = mTagList.size();
        int end = s.length() + (nextIsTag ? -1 : 0);
        String tagText = s.subSequence(start, end).toString().trim();
        tagText = NON_UNICODE_CHAR_PATTERN.matcher(tagText).replaceAll("");
        if (tagText.isEmpty() || tagText.length() <= 1) {
            // User is still writing
            return;
        }
        String charText = tagText.substring(0, 1);
        if (!VALID_TAGS.contains(charText) || (charText.charAt(0) == VALID_TAGS.charAt(1) && !mSupportsUserTags)) {
            char tag = mDefaultTagMode == TAG_MODE.HASH ? VALID_TAGS.charAt(0) : VALID_TAGS.charAt(1);
            tagText = tag + tagText;
        }

        // Replace the new tag
        s.replace(start, end, CHIP_REPLACEMENT_CHAR);

        // Create the tag and its spannable
        final Tag tag = new Tag();
        tag.mTag = NON_UNICODE_CHAR_PATTERN.matcher(tagText).replaceAll("");
        Bitmap b = createTagChip(tag);
        ImageSpan span = new ImageSpan(getContext(), b);
        s.setSpan(span, start, start + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        tag.w = b.getWidth();
        tag.h = b.getHeight();
        mTagList.add(tag);

        notifyTagCreated(tag);
    }

    private Bitmap createTagChip(Tag tag) {
        // Create the tag string (prepend/append spaces to better ux). Create a clickable
        // area for deleting the tag in non-readonly mode
        String tagText = String.format(" %s " + (mReadOnly || !isEnabled() ? "" : CHIP_REMOVE_TEXT), tag.mTag);

        // Create a new color for the tag if necessary
        if (tag.mColor == 0) {
            if (mChipBackgroundColor == 0) {
                tag.mColor = newRandomColor();
            } else {
                tag.mColor = mChipBackgroundColor;
            }
        }
        mChipBgPaint.setColor((isEnabled()) ? tag.mColor : Color.LTGRAY);

        // Measure the chip rect
        Rect bounds = new Rect();
        mChipFgPaint.getTextBounds("|", 0, 1, bounds);
        int minHeight = bounds.height();
        mChipFgPaint.getTextBounds(tagText, 0, tagText.length(), bounds);
        int padding = (int) ONE_PIXEL * 2;
        int w = (int) (mChipFgPaint.measureText(tagText) + (padding * 2));
        int h = Math.max(bounds.height() + (padding * 4), minHeight + (padding * 4));
        float baseline = h / 2 + bounds.height() / 2;

        // Create the bitmap
        Bitmap bitmap = Bitmap.createBitmap(w + padding, h + padding, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);

        // Draw the bitmap
        canvas.drawRoundRect(new RectF(0, (padding / 2), w, h), 6, 6, mChipBgPaint);
        canvas.drawText(tagText, (padding / 2), baseline, mChipFgPaint);
        return bitmap;
    }

    public static int newRandomColor() {
        int random = (int) (Math.floor(Math.random() * 0xff0f0f0f) + 0xff000000);
        int color = Color.argb(0xff, Color.red(random), Color.green(random), Color.blue(random));

        float[] hsv = new float[3];
        Color.colorToHSV(color, hsv);
        hsv[2] *= 0.8f; // value component
        color = Color.HSVToColor(hsv);
        return color;
    }

    private void notifyTagCreated(final Tag tag) {
        ViewCompat.postOnAnimation(this, () -> {
            Tag copy = tag.copy();
            for (OnTagEventListener cb : mTagEventCallBacks) {
                cb.onTagCreate(copy);
            }
        });

    }

    private void notifyTagRemoved(final Tag tag) {
        ViewCompat.postOnAnimation(this, () -> {
            Tag copy = tag.copy();
            for (OnTagEventListener cb : mTagEventCallBacks) {
                cb.onTagRemove(copy);
            }
        });
    }

    private void notifyComputeTagEnded() {
        ViewCompat.postOnAnimation(this, () -> {
            int count = mComputeTagCallbacks.size();
            for (int i = count - 1; i >= 0; i--) {
                mComputeTagCallbacks.get(i).onComputedTagEnded();
                mComputeTagCallbacks.remove(i);
            }
        });
    }

    public String toPlainTags() {
        StringBuilder sb = new StringBuilder();
        for (Tag tag : mTagList) {
            sb.append(tag.toSavedState()).append("\0");
        }
        return sb.toString();
    }

    public void fromPlainTags(String plainTags) {
        List<Tag> tags = new ArrayList<>();
        if (!TextUtils.isEmpty(plainTags)) {
            String[] v = plainTags.split("\0");
            for (String t : v) {
                if (!t.isEmpty()) {
                    Tag tag = new Tag();
                    tag.fromSavedState(t);
                    tags.add(tag);
                }
            }
        }
        setTags(tags.toArray(new Tag[tags.size()]));
    }

    @Override
    protected Parcelable onSaveInstanceState() {
        SavedState savedState = new SavedState(super.onSaveInstanceState());
        savedState.mTags = new ArrayList<>(mTagList);
        return savedState;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        //begin boilerplate code so parent classes can restore state
        if (!(state instanceof SavedState)) {
            // TODO Something got a bad state from a wrong class
            // "Wrong state class, expecting View State but received class
            // android.widget.TextView$SavedState instead. This usually happens when two views of
            // different type have the same id in the same hierarchy. This view's id
            // is id/tags_labels. Make sure other views do not use the same id."
            // Not sure where this comes from, since tags_labels is unique and
            // this class has a consistent layout, but receiving the state of a TextView.
            // For now just ensure we don't crash the app because a wrong saved state.
            try {
                super.onRestoreInstanceState(state);
            } catch (IllegalArgumentException ex) {
                // Ignore
            }
            return;
        }

        SavedState savedState = (SavedState) state;
        super.onRestoreInstanceState(savedState.getSuperState());

        mTagList = new ArrayList<>(savedState.mTags);
        refresh();
    }

    @SuppressWarnings("WeakerAccess")
    public static class SavedState extends AbsSavedState {
        public List<Tag> mTags;

        public SavedState(Parcel in, ClassLoader loader) {
            super(in, loader);
            int count = in.readInt();
            mTags = new ArrayList<>(count);
            for (int i = 0; i < count; i++) {
                Tag tag = new Tag();
                tag.fromSavedState(in.readString());
                mTags.add(tag);
            }
        }

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

        @Override
        public void writeToParcel(@NonNull Parcel dest, int flags) {
            super.writeToParcel(dest, flags);
            int count = mTags == null ? 0 : mTags.size();
            dest.writeInt(count);
            for (int i = 0; i < count; i++) {
                dest.writeString(mTags.get(i).toSavedState());
            }
        }

        public static final Parcelable.ClassLoaderCreator<DrawerNavigationView.SavedState> CREATOR = new Parcelable.ClassLoaderCreator<DrawerNavigationView.SavedState>() {
            @Override
            public DrawerNavigationView.SavedState createFromParcel(Parcel source) {
                return createFromParcel(source, null);
            }

            @Override
            public DrawerNavigationView.SavedState createFromParcel(Parcel source, ClassLoader loader) {
                return new DrawerNavigationView.SavedState(source, loader);
            }

            @Override
            public DrawerNavigationView.SavedState[] newArray(int size) {
                return new DrawerNavigationView.SavedState[size];
            }
        };
    }
}