RichEditeText——android圖文混排富文字文章編輯器實現詳解
需求:android 實現富文字編輯器,並且實現html解析和生成。
功能點:
- 字型加粗,斜體,下劃線,刪除線
- 字型設定大小 預設大(18px),中(16px),小(14px)
- 字型設定顏色
- 換行插入圖片
- 編輯內容生成html
- 解析html並且顯示
主要實現方式
- EditText + Span 的實現方式
- WebView + JavaScript 的實現方式
webview方式存在相容性問題,所以還是得走原生路線。EditText + Span
知識準備
span是設定 EditText 內容效果的 物件,是內容表達的載體;span派生類有StyleSpan(加粗斜體),UnderlineSpan(下劃線),StrikethroughSpan(刪除線)等等。
ofollow,noindex">Android中各種Span的用法
Spanable中的常用常量:
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE --- 不包含start和end所在的端點 (a,b)
Spanned.SPAN_EXCLUSIVE_INCLUSIVE --- 不包含端start,但包含end所在的端點 (a,b]
Spanned.SPAN_INCLUSIVE_EXCLUSIVE --- 包含start,但不包含end所在的端點 [a,b)
Spanned.SPAN_INCLUSIVE_INCLUSIVE--- 包含start和end所在的端點 [a,b]
瞭解了大概之後,就開始寫程式碼;
1.定義FontStyle 字型樣式基類,定義初始化Span方法
/** * 返回 初始化 span * @param fontStyle * @return */ private CharacterStyle getInitSpan(FontStyle fontStyle){ if(fontStyle.isBold){ return new StyleSpan(Typeface.BOLD); }else if(fontStyle.isItalic){ return new StyleSpan(Typeface.ITALIC); }else if(fontStyle.isUnderline){ return new UnderlineSpan(); }else if(fontStyle.isStreak){ return new StrikethroughSpan(); }else if(fontStyle.fontSize>0){ return new AbsoluteSizeSpan(fontStyle.fontSize,true); }else if(fontStyle.color!=0){ return new ForegroundColorSpan(fontStyle.color); } returnnull; } /** * 通用set Span * @param fontStyle * @param isSet * @param tClass * @param <T> */ private <T> void setSpan(FontStyle fontStyle,boolean isSet,Class<T> tClass){ Log.d("setSpan",""); int start = getSelectionStart(); int end = getSelectionEnd(); int mode = EXCLUD_INCLUD_MODE; T[] spans = getEditableText().getSpans(start,end,tClass); //獲取 List<SpanPart> spanStyles = getOldFontSytles(spans,fontStyle); for(SpanPart spanStyle : spanStyles){ if(spanStyle.start<start){ if(start==end){mode=EXCLUD_MODE;} getEditableText().setSpan(getInitSpan(spanStyle), spanStyle.start,start,mode); } if(spanStyle.end>end){ getEditableText().setSpan(getInitSpan(spanStyle),end, spanStyle.end,mode); } } if(isSet){ if(start==end){ mode=INCLUD_INCLUD_MODE; } getEditableText().setSpan(getInitSpan(fontStyle),start,end,mode); } } /** *獲取當前 選中 spans * @param spans * @param fontStyle * @param <T> * @return */ private <T> List<SpanPart> getOldFontSytles(T[] spans, FontStyle fontStyle){ List<SpanPart> spanStyles = new ArrayList<>(); for(T span:spans){ boolean isRemove=false; if(span instanceof StyleSpan){//特殊處理 styleSpan int style_type = ((StyleSpan) span).getStyle(); if((fontStyle.isBold&& style_type== Typeface.BOLD) || (fontStyle.isItalic&&style_type== Typeface.ITALIC)){ isRemove=true; } }else{ isRemove=true; } if(isRemove) { SpanPart spanStyle = new SpanPart(fontStyle); spanStyle.start = getEditableText().getSpanStart(span); spanStyle.end = getEditableText().getSpanEnd(span); if(span instanceof AbsoluteSizeSpan){ spanStyle.fontSize = ((AbsoluteSizeSpan) span).getSize(); }else if(span instanceof ForegroundColorSpan){ spanStyle.color = ((ForegroundColorSpan) span).getForegroundColor(); } spanStyles.add(spanStyle); getEditableText().removeSpan(span); } } return spanStyles; }
setSpan 是公共設定樣式方法,通過fontStyle傳參,設定對應的樣式,例如設定加粗和斜體
/** * bold italic * @param isSet * @param type */ private void setStyleSpan(boolean isSet,int type){ FontStyle fontStyle = new FontStyle(); if(type== Typeface.BOLD){ fontStyle.isBold=true; }else if(type== Typeface.ITALIC){ fontStyle.isItalic=true; } setSpan(fontStyle,isSet,StyleSpan.class); }
setSpan處理思路:
- 獲取當前選中位置position,在該位置是否已經設定了 需要處理樣式,如 加粗;
- 如果有,在getOldFontSytles 方法中,會進行判斷移除;(因為假如選中位置有加粗,再設定一次就是取消)
- span設定樣式和 html 類似,是通過始末設tag來控制區間樣式的,所以,你選中區間樣式CD,可能與原有樣式區間AB是包含,交集關係。因此,當你移除舊樣式的時候,需要補始末的tag,這樣才能保持未選中的區間樣式不變。程式碼getOldFontSytles後for 迴圈執行補tag 邏輯。
- 當非選中狀態下,即游標移至某處,設定字型樣式,隨後輸入的文字都是當前設定樣式,需要判斷start =end ,然後變更span設定mode 方式。需要使用SPAN_INCLUSIVE_INCLUSIVE。
2.插入圖片
設定圖片,需要用到ImageSpan ImageSpan(Context context, Bitmap b) 通過重定義RichImageSpan 繼承 ImageSpan 同時重寫getSource方法,賦值uri 這樣利用Glide管理bitmap,防止記憶體溢位。(\nimg\n 是為了讓圖片佔位,可以自行設定別的,沒有要求)
public class RichImageSpan extends ImageSpan { private Uri mUri; public RichImageSpan(Context context, Bitmap b, Uri uri) { super(context, b); mUri = uri; } @Override public String getSource() { return mUri.toString(); } } /** * 圖片載入 * @param path */ public void image(String path) { final Uri uri = Uri.parse(path); final int maxWidth = view.getMeasuredWidth() -view. getPaddingLeft() - view.getPaddingRight(); RequestOptions options = new RequestOptions() .centerCrop() .placeholder(R.mipmap.ic_launcher) .error(R.mipmap.ic_launcher); glideRequests.asBitmap() .load(new File(path)) .apply(options) .into(new SimpleTarget<Bitmap>() { @Override public void onResourceReady(Bitmap resource, Transition<? super Bitmap> transition) { Bitmap bitmap = zoomBitmapToFixWidth(resource, maxWidth); image(uri, bitmap); } }); } public void image(Uri uri, Bitmap pic) { String img_str="img"; int start = view.getSelectionStart(); SpannableString ss = new SpannableString("\nimg\n"); RichImageSpan myImgSpan = new RichImageSpan(mContext, pic, uri); ss.setSpan(myImgSpan, 1, img_str.length()+1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); view.getEditableText().insert(start, ss);// 設定ss要新增的位置 view.requestLayout(); view.requestFocus(); //setClick(ss.getSpanStart(myImgSpan),ss.getSpanEnd(myImgSpan),img_str); }
3.span生成html
目前原生 hmtl 能夠支援進行html 解析,但是想做定製化的解析,需要對其進行修改。拷貝一份Html.java 為CustomHtml.java;
檢視原始碼得知,html 將span 轉化 html 是通過 withinParagraph方法,遍歷當前控制元件樣式CharacterStyle 陣列,然後根據對應樣式,加入對應css 標籤(現在主流是style 方式, 目前我只是簡單使用了常規html標籤做樣式控制,可以改)。
部分核心程式碼如下
private static void withinParagraph(StringBuilder out, Spanned text, int start, int end) { int next; for (int i = start; i < end; i = next) { next = text.nextSpanTransition(i, end, CharacterStyle.class); CharacterStyle[] style = text.getSpans(i, next, CharacterStyle.class); AbsoluteSizeSpan tmp_rel_span = null; ForegroundColorSpan tmp_fColor_span =null; for (int j = 0; j < style.length; j++) { if (style[j] instanceof StyleSpan) { int s = ((StyleSpan) style[j]).getStyle(); if ((s & Typeface.BOLD) != 0) { out.append("<b>"); } if ((s & Typeface.ITALIC) != 0) { out.append("<i>"); } } if (style[j] instanceof TypefaceSpan) { String s = ((TypefaceSpan) style[j]).getFamily(); if ("monospace".equals(s)) { out.append("<tt>"); } } if (style[j] instanceof SuperscriptSpan) { out.append("<sup>"); } if (style[j] instanceof SubscriptSpan) { out.append("<sub>"); } if (style[j] instanceof UnderlineSpan) { out.append("<u>"); } if (style[j] instanceof StrikethroughSpan) { //out.append("<span style=\"text-decoration:line-through;\">"); out.append("<strike>"); } if (style[j] instanceof URLSpan) { out.append("<a href=\""); out.append(((URLSpan) style[j]).getURL()); out.append("\">"); } if (style[j] instanceof ImageSpan) { out.append("<img src=\""); out.append(((ImageSpan) style[j]).getSource()); out.append("\">"); // Don't output the dummy character underlying the image. i = next; } if (style[j] instanceof AbsoluteSizeSpan) { tmp_rel_span= ((AbsoluteSizeSpan) style[j]); //AbsoluteSizeSpan s = ((AbsoluteSizeSpan) style[j]); //float sizeDip = s.getSize(); //if (!s.getDip()) { //Application application = CustomApplication.currentApplication(); //sizeDip /= application.getResources().getDisplayMetrics().density; //} // //// px in CSS is the equivalance of dip in Android //out.append(String.format("<span style=\"font-size:%.0fpx\";>", sizeDip)); } if (style[j] instanceof RelativeSizeSpan) { float sizeEm = ((RelativeSizeSpan) style[j]).getSizeChange(); out.append(String.format("<span style=\"font-size:%.2fem;\">", sizeEm)); } if (style[j] instanceof ForegroundColorSpan) { tmp_fColor_span = ((ForegroundColorSpan) style[j]); //int color = ((ForegroundColorSpan) style[j]).getForegroundColor(); //out.append(String.format("<span style=\"color:#%06X;\">", 0xFFFFFF & color)); } if (style[j] instanceof BackgroundColorSpan) { int color = ((BackgroundColorSpan) style[j]).getBackgroundColor(); out.append(String.format("<span style=\"background-color:#%06X;\">", 0xFFFFFF & color)); } } //處理字型 顏色 StringBuilder style_font = new StringBuilder(); if(tmp_fColor_span!=null||tmp_rel_span!=null){ style_font.append("<font "); } //顏色 if(tmp_fColor_span!=null){ style_font.append(String.format("color='#%06X' ", 0xFFFFFF &tmp_fColor_span.getForegroundColor())); } //字型 if(tmp_rel_span!=null){ String value = "16px"; if(tmp_rel_span.getSize()== FontStyle.BIG){ value="18px"; }else if(tmp_rel_span.getSize()==FontStyle.SMALL){ value="14px"; } style_font.append("style='font-size:"+value+";'"); } if(style_font.length()>0){ out.append(style_font+">"); } withinStyle(out, text, i, next); if(style_font.length()>0){ out.append("</font>"); } for (int j = style.length - 1; j >= 0; j--) { if (style[j] instanceof BackgroundColorSpan) { out.append("</span>"); } if (style[j] instanceof ForegroundColorSpan) { //out.append("</span>"); } if (style[j] instanceof RelativeSizeSpan) { out.append("</span>"); } if (style[j] instanceof AbsoluteSizeSpan) { //out.append("</span>"); } if (style[j] instanceof URLSpan) { out.append("</a>"); } if (style[j] instanceof StrikethroughSpan) { //out.append("</span>"); out.append("</strike>"); } if (style[j] instanceof UnderlineSpan) { out.append("</u>"); } if (style[j] instanceof SubscriptSpan) { out.append("</sub>"); } if (style[j] instanceof SuperscriptSpan) { out.append("</sup>"); } if (style[j] instanceof TypefaceSpan) { String s = ((TypefaceSpan) style[j]).getFamily(); if (s.equals("monospace")) { out.append("</tt>"); } } if (style[j] instanceof StyleSpan) { int s = ((StyleSpan) style[j]).getStyle(); if ((s & Typeface.BOLD) != 0) { out.append("</b>"); } if ((s & Typeface.ITALIC) != 0) { out.append("</i>"); } } } } }
html 轉 span
轉換核心在於 CustomHtmlToSpannedConverter類,它通過識別html的標籤 然後對應處理 生成span;我主要處理了handleStartTag ,handleEndTag 方法,增加了圖片處理通過繼承 ImageGetter (網上一般處理方法)重寫getDrawable。
private void handleStartTag(String tag, Attributes attributes) { if (tag.equalsIgnoreCase("br")) { // We don't need to handle this. TagSoup will ensure that there's a </br> for each <br> // so we can safely emit the linebreaks when we handle the close tag. } else if (tag.equalsIgnoreCase("p")) { startBlockElement(mSpannableStringBuilder, attributes, getMarginParagraph()); startCssStyle(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("ul")) { startBlockElement(mSpannableStringBuilder, attributes, getMarginList()); } else if (tag.equalsIgnoreCase("li")) { startLi(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("div")) { startBlockElement(mSpannableStringBuilder, attributes, getMarginDiv()); } else if (tag.equalsIgnoreCase("span")) { startCssStyle(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("strong")) { start(mSpannableStringBuilder, new Bold()); } else if (tag.equalsIgnoreCase("b")) { start(mSpannableStringBuilder, new Bold()); } else if (tag.equalsIgnoreCase("em")) { start(mSpannableStringBuilder, new Italic()); } else if (tag.equalsIgnoreCase("cite")) { start(mSpannableStringBuilder, new Italic()); } else if (tag.equalsIgnoreCase("dfn")) { start(mSpannableStringBuilder, new Italic()); } else if (tag.equalsIgnoreCase("i")) { start(mSpannableStringBuilder, new Italic()); } else if (tag.equalsIgnoreCase("big")) { start(mSpannableStringBuilder, new Big()); } else if (tag.equalsIgnoreCase("small")) { start(mSpannableStringBuilder, new Small()); } else if (tag.equalsIgnoreCase("font")) { startFont(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("blockquote")) { startBlockquote(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("tt")) { start(mSpannableStringBuilder, new Monospace()); } else if (tag.equalsIgnoreCase("a")) { startA(mSpannableStringBuilder, attributes); } else if (tag.equalsIgnoreCase("u")) { start(mSpannableStringBuilder, new Underline()); } else if (tag.equalsIgnoreCase("del")) { start(mSpannableStringBuilder, new Strikethrough()); } else if (tag.equalsIgnoreCase("s")) { start(mSpannableStringBuilder, new Strikethrough()); } else if (tag.equalsIgnoreCase("strike")) { start(mSpannableStringBuilder, new Strikethrough()); } else if (tag.equalsIgnoreCase("sup")) { start(mSpannableStringBuilder, new Super()); } else if (tag.equalsIgnoreCase("sub")) { start(mSpannableStringBuilder, new Sub()); } else if (tag.length() == 2 && Character.toLowerCase(tag.charAt(0)) == 'h' && tag.charAt(1) >= '1' && tag.charAt(1) <= '6') { startHeading(mSpannableStringBuilder, attributes, tag.charAt(1) - '1'); } else if (tag.equalsIgnoreCase("img")) { startImg(mSpannableStringBuilder, attributes, mImageGetter); } else if (mTagHandler != null) { mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader); } }
如上程式碼所示,可以根據自己定義的協議,修改對應tag標籤處理。
效果圖

tag.gif
已上傳github,喜歡的朋友,可以收藏給個心;