android: how to persistently store a Spanned?
Asked Answered
B

7

12

I want to save a Spanned object persistently. (I'm saving the String it's based on persistently now, but it takes over 1 second to run Html.fromHtml() on it, noticeably slowing the UI.)

I see things like ParcelableSpan and SpannedString and SpannableString but I'm not sure which to use.

Bearden answered 12/5, 2012 at 18:50 Comment(0)
D
6

Right now, Html.toHtml() is your only built-in option. Parcelable is used for inter-process communication and is not designed to be durable. If toHtml() does not cover all the particular types of spans that you are using, you will have to cook up your own serialization mechanism.

Since saving the object involves disk I/O, you should be doing that in a background thread anyway, regardless of the speed of toHtml().

Denude answered 12/5, 2012 at 19:11 Comment(4)
toHtml() is perfectly functional, but slow. I have a bunch of formatted text to display that rarely changes. I'm loading it all up from saved storage as Strings but I need to run toHTML on each of one of them before I can run setText to display them to the user. The conversion from String to Spanned is the real bottleneck and I'm trying to figure out how to save the work I've done instead of recalculating every time.Bearden
@DanJameson: Sorry, there's nothing in the SDK that will help here. Moreover, your real problem would appear to be fromHtml(). That implementation assumes ill-formed HTML and (IIRC) uses TagSoup under the covers. You might be able to create a more performant fromHtml() workalike using a SAX or XmlPullParser, if you can limit yourself to XHTML. Or, if you have C skills, use the NDK to leverage a C (X)HTML parser and generate the SpannedString that way.Denude
Thanks, now that I see just what Spanned is, I can peel out the parts that I need and recreate the needed spans.Bearden
I've had bad experience with toHtml() - it wraps the string with <p> element which when restored adds two \n at the end. Trimming the result isn't simple because its a SpannableString.Floats
J
4

I had a similar problem; I used a SpannableStringBuilder to hold a string and a bunch of spans, and I wanted to be able to save and restore this object. I wrote this code to accomplish this manually using SharedPreferences:

    // Save Log
    SpannableStringBuilder logText = log.getText();
    editor.putString(SAVE_LOG, logText.toString());
    ForegroundColorSpan[] spans = logText
            .getSpans(0, logText.length(), ForegroundColorSpan.class);
    editor.putInt(SAVE_LOG_SPANS, spans.length);
    for (int i = 0; i < spans.length; i++){
        int col = spans[i].getForegroundColor();
        int start = logText.getSpanStart(spans[i]);
        int end = logText.getSpanEnd(spans[i]);
        editor.putInt(SAVE_LOG_SPAN_COLOUR + i, col);
        editor.putInt(SAVE_LOG_SPAN_START + i, start);
        editor.putInt(SAVE_LOG_SPAN_END + i, end);
    }

    // Load Log
    String logText = save.getString(SAVE_LOG, "");
    log.setText(logText);
    int numSpans = save.getInt(SAVE_LOG_SPANS, 0);
    for (int i = 0; i < numSpans; i++){
        int col = save.getInt(SAVE_LOG_SPAN_COLOUR + i, 0);
        int start = save.getInt(SAVE_LOG_SPAN_START + i, 0);
        int end = save.getInt(SAVE_LOG_SPAN_END + i, 0);
        log.getText().setSpan(new ForegroundColorSpan(col), start, end, 
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

I my case I knew that all the spans were of type ForegroundColorSpan and with flags SPAN_EXCLUSIVE_EXCLUSIVE, but this code can be easily adapted to accomodate other types.

Jonijonie answered 14/11, 2013 at 12:14 Comment(1)
this is the best option we haveStatus
C
4

My use case was about putting a Spanned into a Bundle, and Google brought me here. @CommonsWare is right that Parcelable is no good for persistent storage, but it's fine for storing into a Bundle. Most spans seems to extend ParcelableSpan, and so this worked for me in onSaveInstanceState:

ParcelableSpan spanObjects[] = mStringBuilder.getSpans(0, mStringBuilder.length(), ParcelableSpan.class);
int spanStart[] = new int[spanObjects.length];
int spanEnd[] = new int[spanObjects.length];
int spanFlags[] = new int[spanObjects.length];
for(int i = 0; i < spanObjects.length; ++i)
{
    spanStart[i] = mStringBuilder.getSpanStart(spanObjects[i]);
    spanEnd[i] = mStringBuilder.getSpanEnd(spanObjects[i]);
    spanFlags[i] = mStringBuilder.getSpanFlags(spanObjects[i]);
}

outState.putString("mStringBuilder:string", mStringBuilder.toString());
outState.putParcelableArray("mStringBuilder:spanObjects", spanObjects);
outState.putIntArray("mStringBuilder:spanStart", spanStart);
outState.putIntArray("mStringBuilder:spanEnd", spanEnd);
outState.putIntArray("mStringBuilder:spanFlags", spanFlags);

Then the state can be restored with something like this:

mStringBuilder = new SpannableStringBuilder(savedInstanceState.getString("mStringBuilder:string"));
ParcelableSpan spanObjects[] = (ParcelableSpan[])savedInstanceState.getParcelableArray("mStringBuilder:spanObjects");
int spanStart[] = savedInstanceState.getIntArray("mStringBuilder:spanStart");
int spanEnd[] = savedInstanceState.getIntArray("mStringBuilder:spanEnd");
int spanFlags[] = savedInstanceState.getIntArray("mStringBuilder:spanFlags");
for(int i = 0; i < spanObjects.length; ++i)
    mStringBuilder.setSpan(spanObjects[i], spanStart[i], spanEnd[i], spanFlags[i]);

I've used a SpannableStringBuilder here but it should work with any class implementing Spanned as far as I can tell. It's probably possible to wrap this code into a ParcelableSpanned, but this version seems fine for now.

Catalepsy answered 17/12, 2014 at 9:19 Comment(0)
R
2

From Dan's idea:

public static String spannableString2JsonString(SpannableString ss) throws JSONException {
    JSONObject json = new JSONObject();
    json.put("text",ss.toString());
    JSONArray ja = new JSONArray();

    ForegroundColorSpan[] spans = ss.getSpans(0, ss.length(), ForegroundColorSpan.class);
    for (int i = 0; i < spans.length; i++){
        int col = spans[i].getForegroundColor();
        int start = ss.getSpanStart(spans[i]);
        int end = ss.getSpanEnd(spans[i]);
        JSONObject ij = new JSONObject();
        ij.put("color",col);
        ij.put("start",start);
        ij.put("end",end);
        ja.put(ij);
    }
    json.put("spans",ja);
    return json.toString();
}
public static SpannableString jsonString2SpannableString(String strjson) throws JSONException{
    JSONObject json = new JSONObject(strjson);
    SpannableString ss = new SpannableString(json.getString("text"));
    JSONArray ja = json.getJSONArray("spans");
    for (int i=0;i<ja.length();i++){
        JSONObject jo = ja.getJSONObject(i);
        int col = jo.getInt("color");
        int start = jo.getInt("start");
        int end = jo.getInt("end");
        ss.setSpan(new ForegroundColorSpan(col),start,end,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
    }
    return ss;
}
Respective answered 9/6, 2017 at 16:33 Comment(0)
S
0

A solution I came up with is using GSON with a custom serializer/deserializer. The solution combines some of the ideas mentioned in other answers.

Define some JSON Keys

/* JSON Property Keys */
private static final String PREFIX = "SpannableStringBuilder:";
private static final String PROP_INPUT_STRING = PREFIX + "string";
private static final String PROP_SPAN_OBJECTS= PREFIX + "spanObjects";
private static final String PROP_SPAN_START= PREFIX + "spanStart";
private static final String PROP_SPAN_END = PREFIX + "spanEnd";
private static final String PROP_SPAN_FLAGS = PREFIX + "spanFlags";

Gson Serializer

public static class SpannableSerializer implements JsonSerializer<SpannableStringBuilder> {
    @Override
    public JsonElement serialize(SpannableStringBuilder spannableStringBuilder, Type type, JsonSerializationContext context) {
        ParcelableSpan[] spanObjects = spannableStringBuilder.getSpans(0, spannableStringBuilder.length(), ParcelableSpan.class);

        int[] spanStart = new int[spanObjects.length];
        int[] spanEnd= new int[spanObjects.length];
        int[] spanFlags = new int[spanObjects.length];
        for(int i = 0; i < spanObjects.length; ++i) {
            spanStart[i] = spannableStringBuilder.getSpanStart(spanObjects[i]);
            spanEnd[i] = spannableStringBuilder.getSpanEnd(spanObjects[i]);
            spanFlags[i] = spannableStringBuilder.getSpanFlags(spanObjects[i]);
        }
        JsonObject jsonSpannable = new JsonObject();
        jsonSpannable.addProperty(PROP_INPUT_STRING, spannableStringBuilder.toString());
        jsonSpannable.addProperty(PROP_SPAN_OBJECTS, gson.toJson(spanObjects));
        jsonSpannable.addProperty(PROP_SPAN_START, gson.toJson(spanStart));
        jsonSpannable.addProperty(PROP_SPAN_END, gson.toJson(spanEnd));
        jsonSpannable.addProperty(PROP_SPAN_FLAGS, gson.toJson(spanFlags));
        return jsonSpannable;
    }
}

Gson Deserializer

public static class SpannableDeserializer implements JsonDeserializer<SpannableStringBuilder> {
    @Override
    public SpannableStringBuilder deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
        JsonObject jsonSpannable = jsonElement.getAsJsonObject();
        try {
            String spannableString = jsonSpannable.get(PROP_INPUT_STRING).getAsString();
            SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(spannableString);
            String spanObjectJson = jsonSpannable.get(PROP_SPAN_OBJECTS).getAsString();
            ParcelableSpan[] spanObjects = gson.fromJson(spanObjectJson, ParcelableSpan[].class);
            String spanStartJson = jsonSpannable.get(PROP_SPAN_START).getAsString();
            int[] spanStart = gson.fromJson(spanStartJson, int[].class);
            String spanEndJson = jsonSpannable.get(PROP_SPAN_END).getAsString();
            int[] spanEnd = gson.fromJson(spanEndJson, int[].class);
            String spanFlagsJson = jsonSpannable.get(PROP_SPAN_FLAGS).getAsString();
            int[] spanFlags = gson.fromJson(spanFlagsJson, int[].class);
            for (int i = 0; i <spanObjects.length; ++i) {
                spannableStringBuilder.setSpan(spanObjects[i], spanStart[i], spanEnd[i], spanFlags[i]);
            }
            return spannableStringBuilder;
        } catch (Exception ex) {
            Log.e(TAG, Log.getStackTraceString(ex));
        }
        return null;
    }
}

For ParcelableSpan you might need to register the types to GSON like so:

RuntimeTypeAdapterFactory
  .of(ParcelableSpan.class)
  .registerSubtype(ForegroundColorSpan.class);
  .registerSubtype(StyleSpan.class); //etc.
Supersede answered 2/2, 2018 at 1:5 Comment(1)
this wont save all the spans' information, like color, or size, etc.Nelan
P
0

My use case was converting a TextView's contents, including color and style, to/from a hex string. Building off Dan's answer, I came up with the following code. Hopefully if someone has a similar use case, it'll save you some headache.

Store textBox's contents to string:

String actualText = textBox.getText().toString();
SpannableString spanStr = new SpannableString(textBox.getText());

ForegroundColorSpan[] fSpans = spanStr.getSpans(0,spanStr.length(),ForegroundColorSpan.class);
StyleSpan[] sSpans = spanStr.getSpans(0,spanStr.length(),StyleSpan.class);

int nSpans = fSpans.length;
String spanInfo = "";
String headerInfo = String.format("%08X",nSpans);
for (int i = 0; i < nSpans; i++) {
    spanInfo += String.format("%08X",fSpans[i].getForegroundColor());
    spanInfo += String.format("%08X",spanStr.getSpanStart(fSpans[i]));
    spanInfo += String.format("%08X",spanStr.getSpanEnd(fSpans[i]));
}

nSpans = sSpans.length;
headerInfo += String.format("%08X",nSpans);
for (int i = 0; i < nSpans; i++) {
    spanInfo += String.format("%08X",sSpans[i].getStyle());
    spanInfo += String.format("%08X",spanStr.getSpanStart(sSpans[i]));
    spanInfo += String.format("%08X",spanStr.getSpanEnd(sSpans[i]));
}

headerInfo += spanInfo;
headerInfo += actualText;
return headerInfo;

Retrieve textBox's contents from string:

        String header = tvString.substring(0,8);
        int fSpans = Integer.parseInt(header,16);
        header = tvString.substring(8,16);
        int sSpans = Integer.parseInt(header,16);
        int nSpans = fSpans + sSpans;
        SpannableString tvText = new SpannableString(tvString.substring(nSpans*24+16));
        tvString = tvString.substring(16,nSpans*24+16);

        int cc, ss, ee;
        int begin;
        for (int i = 0; i < fSpans; i++) {
            begin = i*24;
            cc = (int) Long.parseLong(tvString.substring(begin,begin+8),16);
            ss = (int) Long.parseLong(tvString.substring(begin+8,begin+16),16);
            ee = (int) Long.parseLong(tvString.substring(begin+16,begin+24),16);
            tvText.setSpan(new ForegroundColorSpan(cc), ss, ee, 0);
        }
        for (int i = 0; i < sSpans; i++) {
            begin = i*24+fSpans*24;
            cc = (int) Long.parseLong(tvString.substring(begin,begin+8),16);
            ss = (int) Long.parseLong(tvString.substring(begin+8,begin+16),16);
            ee = (int) Long.parseLong(tvString.substring(begin+16,begin+24),16);
            tvText.setSpan(new StyleSpan(cc), ss, ee, 0);
        }

        textBox.setText(tvText);

The reason for the (int) Long.parseLong in the retrieval code is because the style/color can be negative numbers. This trips up parseInt and results in an overflow error. But, doing parseLong and then casting to int gives the correct (positive or negative) integer.

Placebo answered 19/9, 2020 at 3:26 Comment(0)
N
0

This problem is interesting, because you have to save all the information you want from the SpannableString or SpannableStringBuilder, Gson doesn't pick them automatically. Using HTML didn't work properly for my implementation, so here's another working solution. All answers here are incomplete, you have to do something like this:

class SpannableSerializer : JsonSerializer<SpannableStringBuilder?>, JsonDeserializer<SpannableStringBuilder?> {

    private val gson: Gson
        get() {
            val rtaf = RuntimeTypeAdapterFactory
                    .of(ParcelableSpan::class.java, ParcelableSpan::class.java.simpleName)
                    .registerSubtype(ForegroundColorSpan::class.java, ForegroundColorSpan::class.java.simpleName)
                    .registerSubtype(StyleSpan::class.java, StyleSpan::class.java.simpleName)
                    .registerSubtype(RelativeSizeSpan::class.java, RelativeSizeSpan::class.java.simpleName)
                    .registerSubtype(SuperscriptSpan::class.java, SuperscriptSpan::class.java.simpleName)
                    .registerSubtype(UnderlineSpan::class.java, UnderlineSpan::class.java.simpleName)
            return GsonBuilder()
                    .registerTypeAdapterFactory(rtaf)
                    .create()
        }

    override fun serialize(spannableStringBuilder: SpannableStringBuilder?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
        val spanTypes = spannableStringBuilder?.getSpans(0, spannableStringBuilder.length, ParcelableSpan::class.java)
        val spanStart = IntArray(spanTypes?.size ?: 0)
        val spanEnd = IntArray(spanTypes?.size ?: 0)
        val spanFlags = IntArray(spanTypes?.size ?: 0)
        val spanInfo = DoubleArray(spanTypes?.size ?: 0)
        spanTypes?.forEachIndexed { i, span ->
            when (span) {
                is ForegroundColorSpan -> spanInfo[i] = span.foregroundColor.toDouble()
                is StyleSpan -> spanInfo[i] = span.style.toDouble()
                is RelativeSizeSpan -> spanInfo[i] = span.sizeChange.toDouble()
            }
            spanStart[i] = spannableStringBuilder.getSpanStart(span)
            spanEnd[i] = spannableStringBuilder.getSpanEnd(span)
            spanFlags[i] = spannableStringBuilder.getSpanFlags(span)
        }

        val jsonSpannable = JsonObject()
        jsonSpannable.addProperty(INPUT_STRING, spannableStringBuilder.toString())
        jsonSpannable.addProperty(SPAN_TYPES, gson.toJson(spanTypes))
        jsonSpannable.addProperty(SPAN_START, gson.toJson(spanStart))
        jsonSpannable.addProperty(SPAN_END, gson.toJson(spanEnd))
        jsonSpannable.addProperty(SPAN_FLAGS, gson.toJson(spanFlags))
        jsonSpannable.addProperty(SPAN_INFO, gson.toJson(spanInfo))
        return jsonSpannable
    }

    override fun deserialize(jsonElement: JsonElement, type: Type, jsonDeserializationContext: JsonDeserializationContext): SpannableStringBuilder {
        val jsonSpannable = jsonElement.asJsonObject
        val spannableString = jsonSpannable[INPUT_STRING].asString
        val spannableStringBuilder = SpannableStringBuilder(spannableString)
        val spanObjectJson = jsonSpannable[SPAN_TYPES].asString
        val spanTypes: Array<ParcelableSpan> = gson.fromJson(spanObjectJson, Array<ParcelableSpan>::class.java)
        val spanStartJson = jsonSpannable[SPAN_START].asString
        val spanStart: IntArray = gson.fromJson(spanStartJson, IntArray::class.java)
        val spanEndJson = jsonSpannable[SPAN_END].asString
        val spanEnd: IntArray = gson.fromJson(spanEndJson, IntArray::class.java)
        val spanFlagsJson = jsonSpannable[SPAN_FLAGS].asString
        val spanFlags: IntArray = gson.fromJson(spanFlagsJson, IntArray::class.java)
        val spanInfoJson = jsonSpannable[SPAN_INFO].asString
        val spanInfo: DoubleArray = gson.fromJson(spanInfoJson, DoubleArray::class.java)
        for (i in spanTypes.indices) {
            when (spanTypes[i]) {
                is ForegroundColorSpan -> spannableStringBuilder.setSpan(ForegroundColorSpan(spanInfo[i].toInt()), spanStart[i], spanEnd[i], spanFlags[i])
                is StyleSpan -> spannableStringBuilder.setSpan(StyleSpan(spanInfo[i].toInt()), spanStart[i], spanEnd[i], spanFlags[i])
                is RelativeSizeSpan -> spannableStringBuilder.setSpan(RelativeSizeSpan(spanInfo[i].toFloat()), spanStart[i], spanEnd[i], spanFlags[i])
                else -> spannableStringBuilder.setSpan(spanTypes[i], spanStart[i], spanEnd[i], spanFlags[i])
            }
        }
        return spannableStringBuilder
    }

    companion object {
        private const val PREFIX = "SSB:"
        private const val INPUT_STRING = PREFIX + "string"
        private const val SPAN_TYPES = PREFIX + "spanTypes"
        private const val SPAN_START = PREFIX + "spanStart"
        private const val SPAN_END = PREFIX + "spanEnd"
        private const val SPAN_FLAGS = PREFIX + "spanFlags"
        private const val SPAN_INFO = PREFIX + "spanInfo"
    }
}

If there are other types of spans you have to add them in the when sections and pick the associated information of the span, it's easy to add them all.

RuntimeTypeAdapterFactory is private in the the gson library, you have to copy it to your project. https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java

now use it!

val gson by lazy {
    val type: Type = object : TypeToken<SpannableStringBuilder>() {}.type
    GsonBuilder()
            .registerTypeAdapter(type, SpannableSerializer())
            .create()
}
val ssb = gson.fromJson("your json here", SpannableStringBuilder::class.java)
Nelan answered 21/2, 2021 at 18:19 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.