From dc52771459bccb7fb70cc7388c4d204f9adb76a8 Mon Sep 17 00:00:00 2001 From: Cyrus Bakhtiari-Haftlang Date: Tue, 7 Aug 2018 14:51:01 +0200 Subject: [PATCH] MarkwonView and MarkwonViewCompat now support styling from XML The styles that are denoted with an asterisk (*), when defined, completely override the default spans. The others, when defined, add spans before the default spans (if any) so that when reversed, take precedence. Example: ``` ``` In the above case, as ?android:textAppearanceLarge most likely is resolved into an actual style, the spans that are added by the Markwon- library (including heading line break) will no be added. ``` ``` Conversely, defining a style for mv_CodeSpanStyle will still result in that background is added to the code text and it will by styled according to the resolved style of `android:textAppearanceSmall`. Supported style attributes: *mv_H1Style *mv_H2Style *mv_H3Style *mv_H4Style *mv_H5Style *mv_H6Style *mv_EmphasisStyle *mv_StrongEmphasisStyle mv_BlockQuoteStyle mv_CodeSpanStyle mv_MultilineCodeSpanStyle mv_OrderedListItemStyle mv_BulletListItemStyle mv_TaskListItemStyle mv_TableRowStyle mv_LinkStyle --- library-view/build.gradle | 2 +- .../ru/noties/markwon/view/MarkwonView.java | 33 +- .../markwon/view/MarkwonViewCompat.java | 22 +- .../markwon/view/MarkwonViewHelper.java | 404 ++++++++++++++++-- library-view/src/main/res/values/attrs.xml | 23 + 5 files changed, 447 insertions(+), 37 deletions(-) diff --git a/library-view/build.gradle b/library-view/build.gradle index fb709db9..bc4a3c3b 100644 --- a/library-view/build.gradle +++ b/library-view/build.gradle @@ -15,7 +15,7 @@ android { dependencies { api project(':library') - compileOnly SUPPORT_APP_COMPAT + implementation SUPPORT_APP_COMPAT } afterEvaluate { diff --git a/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java b/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java index 19ab09b0..3f2130af 100644 --- a/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java +++ b/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java @@ -2,8 +2,12 @@ package ru.noties.markwon.view; import android.annotation.SuppressLint; import android.content.Context; +import android.os.Build; +import android.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.annotation.StyleRes; import android.util.AttributeSet; import android.widget.TextView; @@ -14,19 +18,36 @@ public class MarkwonView extends TextView implements IMarkwonView { private MarkwonViewHelper helper; - public MarkwonView(Context context) { + public MarkwonView(@NonNull Context context) { super(context); - init(context, null); + init(context, null, R.attr.markwonViewStyle, 0); } - public MarkwonView(Context context, AttributeSet attrs) { + public MarkwonView(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); - init(context, attrs); + init(context, attrs, R.attr.markwonViewStyle, 0); } - private void init(Context context, AttributeSet attributeSet) { + public MarkwonView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr, 0); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public MarkwonView(Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr, defStyleRes); + } + + private void init(@NonNull Context context, + @Nullable AttributeSet attributeSet, + @AttrRes int defStyleAttr, + @StyleRes int defStyleRes) { helper = MarkwonViewHelper.create(this); - helper.init(context, attributeSet); + helper.init(context, attributeSet, defStyleAttr, defStyleRes); } @Override diff --git a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java index da5c1934..9619b995 100644 --- a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java +++ b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java @@ -1,6 +1,7 @@ package ru.noties.markwon.view; import android.content.Context; +import android.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.AppCompatTextView; @@ -12,19 +13,28 @@ public class MarkwonViewCompat extends AppCompatTextView implements IMarkwonView private MarkwonViewHelper helper; - public MarkwonViewCompat(Context context) { + public MarkwonViewCompat(@NonNull Context context) { super(context); - init(context, null); + init(context, null, R.attr.markwonViewStyle); } - public MarkwonViewCompat(Context context, AttributeSet attrs) { + public MarkwonViewCompat(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); - init(context, attrs); + init(context, attrs, R.attr.markwonViewStyle); } - private void init(Context context, AttributeSet attributeSet) { + public MarkwonViewCompat(@NonNull Context context, + @Nullable AttributeSet attrs, + @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr); + } + + private void init(@NonNull Context context, + @Nullable AttributeSet attributeSet, + @AttrRes int defStyleAttr) { helper = MarkwonViewHelper.create(this); - helper.init(context, attributeSet); + helper.init(context, attributeSet, defStyleAttr, 0); } @Override diff --git a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java index c8c813f6..4d1eeecf 100644 --- a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java +++ b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java @@ -1,15 +1,40 @@ package ru.noties.markwon.view; import android.content.Context; +import android.content.res.Resources; import android.content.res.TypedArray; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.os.Build; +import android.support.annotation.AttrRes; +import android.support.annotation.FontRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.StyleRes; +import android.support.annotation.StyleableRes; +import android.support.v4.content.res.ResourcesCompat; +import android.text.TextPaint; import android.text.TextUtils; +import android.text.style.MetricAffectingSpan; +import android.text.style.TextAppearanceSpan; import android.util.AttributeSet; +import android.util.SparseIntArray; import android.widget.TextView; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + import ru.noties.markwon.Markwon; import ru.noties.markwon.SpannableConfiguration; +import ru.noties.markwon.SpannableFactory; +import ru.noties.markwon.SpannableFactoryDef; +import ru.noties.markwon.renderer.ImageSize; +import ru.noties.markwon.renderer.ImageSizeResolver; +import ru.noties.markwon.spans.AsyncDrawable; +import ru.noties.markwon.spans.LinkSpan; +import ru.noties.markwon.spans.SpannableTheme; +import ru.noties.markwon.spans.TableRowSpan; public class MarkwonViewHelper implements IMarkwonView { @@ -24,34 +49,48 @@ public class MarkwonViewHelper implements IMarkwonView { private SpannableConfiguration configuration; private String markdown; + @NonNull + private final SparseIntArray styles; + private MarkwonViewHelper(@NonNull TextView textView) { this.textView = textView; + this.styles = new SparseIntArray(); } - public void init(Context context, AttributeSet attributeSet) { + public void init(@NonNull Context context, + @Nullable AttributeSet attributeSet, + @AttrRes int defStyleAttr, + @StyleRes int defStyleRes) { + final TypedArray array = context.obtainStyledAttributes( + attributeSet, + R.styleable.MarkwonView, + defStyleAttr, + defStyleRes); + try { + final int count = array.getIndexCount(); + for (int idx = 0; idx < count; idx++) { + @StyleableRes final int relativeIndex = array.getIndex(idx); - if (attributeSet != null) { - final TypedArray array = context.obtainStyledAttributes(attributeSet, R.styleable.MarkwonView); - try { - - final String configurationProvider = array.getString(R.styleable.MarkwonView_mv_configurationProvider); - final ConfigurationProvider provider; - if (!TextUtils.isEmpty(configurationProvider)) { - provider = MarkwonViewHelper.obtainProvider(configurationProvider); - } else { - provider = null; - } - if (provider != null) { - setConfigurationProvider(provider); - } - - final String markdown = array.getString(R.styleable.MarkwonView_mv_markdown); - if (!TextUtils.isEmpty(markdown)) { - setMarkdown(markdown); - } - } finally { - array.recycle(); + styles.put(relativeIndex, array.getResourceId(relativeIndex, 0)); } + + final String configurationProvider = array.getString(R.styleable.MarkwonView_mv_configurationProvider); + final ConfigurationProvider provider; + if (!TextUtils.isEmpty(configurationProvider)) { + provider = MarkwonViewHelper.obtainProvider(configurationProvider); + } else { + provider = null; + } + if (provider != null) { + setConfigurationProvider(provider); + } + + final String markdown = array.getString(R.styleable.MarkwonView_mv_markdown); + if (!TextUtils.isEmpty(markdown)) { + setMarkdown(markdown); + } + } finally { + array.recycle(); } } @@ -78,7 +117,10 @@ public class MarkwonViewHelper implements IMarkwonView { if (provider != null) { this.configuration = provider.provide(textView.getContext()); } else { - this.configuration = SpannableConfiguration.create(textView.getContext()); + this.configuration = SpannableConfiguration + .builder(textView.getContext()) + .factory(new StyleableSpanFactory(textView.getContext(), styles)) + .build(); } } configuration = this.configuration; @@ -93,7 +135,7 @@ public class MarkwonViewHelper implements IMarkwonView { } @Nullable - public static IMarkwonView.ConfigurationProvider obtainProvider(@NonNull String className) { + private static IMarkwonView.ConfigurationProvider obtainProvider(@NonNull String className) { try { final Class cl = Class.forName(className); return (IMarkwonView.ConfigurationProvider) cl.newInstance(); @@ -102,4 +144,318 @@ public class MarkwonViewHelper implements IMarkwonView { return null; } } + + /** + * A SpannableFactory that creates spans based on style attributes of MarkwonView + */ + private static final class StyleableSpanFactory implements SpannableFactory { + @NonNull + private static final int[] TEXT_APPEARANCE_ATTR = new int[]{android.R.attr.textAppearance}; + + private static final int NO_VALUE = -1; + + @NonNull + private static final SparseIntArray HEADING_STYLE_MAP = new SparseIntArray(); + + static { + // Unlike library attributes, the index of styleables are stable across builds making + // them safe to declare statically. + HEADING_STYLE_MAP.put(1, R.styleable.MarkwonView_mv_H1Style); + HEADING_STYLE_MAP.put(2, R.styleable.MarkwonView_mv_H2Style); + HEADING_STYLE_MAP.put(3, R.styleable.MarkwonView_mv_H3Style); + HEADING_STYLE_MAP.put(4, R.styleable.MarkwonView_mv_H4Style); + HEADING_STYLE_MAP.put(5, R.styleable.MarkwonView_mv_H5Style); + HEADING_STYLE_MAP.put(6, R.styleable.MarkwonView_mv_H6Style); + } + + @NonNull + private final Context context; + + @NonNull + private final SparseIntArray styles; + + @NonNull + private final SpannableFactory defaultFactory; + + StyleableSpanFactory(@NonNull Context context, @NonNull SparseIntArray styles) { + this.context = context; + this.styles = styles; + this.defaultFactory = SpannableFactoryDef.create(); + + } + + @Nullable + @Override + public Object strongEmphasis() { + final int style = styles.get(R.styleable.MarkwonView_mv_StrongEmphasisStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } else { + return defaultFactory.strongEmphasis(); + } + } + + @Nullable + @Override + public Object emphasis() { + final int style = styles.get(R.styleable.MarkwonView_mv_EmphasisStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } else { + return defaultFactory.emphasis(); + } + } + + @Nullable + @Override + public Object blockQuote(@NonNull SpannableTheme theme) { + final int style = styles.get(R.styleable.MarkwonView_mv_BlockQuoteStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.blockQuote(theme))); + } + + return defaultFactory.blockQuote(theme); + } + + @Nullable + @Override + public Object code(@NonNull SpannableTheme theme, boolean multiline) { + final int styleAttr = multiline ? R.styleable.MarkwonView_mv_MultilineCodeSpanStyle : + R.styleable.MarkwonView_mv_CodeSpanStyle; + final int style = styles.get(styleAttr, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.code(theme, multiline))); + } + + return defaultFactory.code(theme, multiline); + } + + @Nullable + @Override + public Object orderedListItem(@NonNull SpannableTheme theme, int startNumber) { + final int style = styles.get(R.styleable.MarkwonView_mv_OrderedListItemStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.orderedListItem(theme, startNumber))); + } + + return defaultFactory.orderedListItem(theme, startNumber); + } + + @Nullable + @Override + public Object bulletListItem(@NonNull SpannableTheme theme, int level) { + final int style = styles.get(R.styleable.MarkwonView_mv_BulletListItemStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.bulletListItem(theme, level))); + } + return defaultFactory.bulletListItem(theme, level); + } + + @Nullable + @Override + public Object thematicBreak(@NonNull SpannableTheme theme) { + return defaultFactory.thematicBreak(theme); + } + + @Nullable + @Override + public Object heading(@NonNull SpannableTheme theme, int level) { + final int style = styles.get(HEADING_STYLE_MAP.get(level), NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } + return defaultFactory.heading(theme, level); + } + + @Nullable + @Override + public Object strikethrough() { + return defaultFactory.strikethrough(); + } + + @Nullable + @Override + public Object taskListItem(@NonNull SpannableTheme theme, int blockIndent, boolean isDone) { + final int style = styles.get(R.styleable.MarkwonView_mv_TaskListItemStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory + .taskListItem(theme, blockIndent, isDone))); + } + + return defaultFactory.taskListItem(theme, blockIndent, isDone); + } + + @Nullable + @Override + public Object tableRow(@NonNull SpannableTheme theme, + @NonNull List cells, + boolean isHeader, + boolean isOdd) { + final int style = styles.get(R.styleable.MarkwonView_mv_TableRowStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory + .tableRow(theme, cells, isHeader, isOdd))); + } + + return defaultFactory.tableRow(theme, cells, isHeader, isOdd); + } + + @Nullable + @Override + public Object image(@NonNull SpannableTheme theme, + @NonNull String destination, + @NonNull AsyncDrawable.Loader loader, + @NonNull ImageSizeResolver imageSizeResolver, + @Nullable ImageSize imageSize, + boolean replacementTextIsLink) { + return defaultFactory.image( + theme, + destination, + loader, + imageSizeResolver, + imageSize, + replacementTextIsLink); + } + + @Nullable + @Override + public Object link(@NonNull SpannableTheme theme, + @NonNull String destination, + @NonNull LinkSpan.Resolver resolver) { + final int style = styles.get(R.styleable.MarkwonView_mv_LinkStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } + + return defaultFactory.link(theme, destination, resolver); + } + + @Nullable + @Override + public Object superScript(@NonNull SpannableTheme theme) { + return defaultFactory.superScript(theme); + } + + @Nullable + @Override + public Object subScript(@NonNull SpannableTheme theme) { + return defaultFactory.subScript(theme); + } + + @Nullable + @Override + public Object underline() { + return defaultFactory.underline(); + } + + + @NonNull + private Object[] createFromStyle(@StyleRes int styleResource) { + return createFromStyle(styleResource, new ArrayDeque<>()); + } + + @NonNull + private Object[] createFromStyle(@StyleRes int styleResource, + @NonNull Deque spans) { + TypedArray a = context.obtainStyledAttributes(styleResource, TEXT_APPEARANCE_ATTR); + final int ap = a.getResourceId(0, NO_VALUE); + a.recycle(); + if (ap != -1) { + spans.addFirst(new TextAppearanceSpan(context, ap)); + handleCustomFont(ap, spans); + } + + spans.addFirst(new TextAppearanceSpan(context, styleResource)); + handleCustomFont(styleResource, spans); + + return spans.toArray(); + } + + private void handleCustomFont(@StyleRes int style, @NonNull Deque spans) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + // In SDK 27, custom fonts are handled in TextAppearanceSpan so no need to re-invent + // the wheel. + + return; + } + + final TypedArray a = context.obtainStyledAttributes( + style, R.styleable.MV_CustomFonts); + + if (a.hasValue(R.styleable.MV_CustomFonts_android_fontFamily) || + a.hasValue(R.styleable.MV_CustomFonts_fontFamily)) { + @StyleableRes int resolvedFontFamily = + a.hasValue(R.styleable.MV_CustomFonts_android_fontFamily) + ? R.styleable.MV_CustomFonts_android_fontFamily + : R.styleable.MV_CustomFonts_fontFamily; + try { + @FontRes int fontResId = a.getResourceId(resolvedFontFamily, NO_VALUE); + if (fontResId != NO_VALUE) { + final Typeface typeface = ResourcesCompat.getFont(context, fontResId); + if (typeface != null) { + spans.addFirst(new CustomTypefaceSpan(typeface)); + } + } + } catch (UnsupportedOperationException | Resources.NotFoundException e) { + // Expected if it is not a font resource. + } + } + + a.recycle(); + } + + private static Deque createSpanCollection(@Nullable Object defaultSpan) { + final Deque spanCollection = new ArrayDeque<>(); + if (defaultSpan instanceof Object[]) { + for (final Object span : (Object[]) defaultSpan) { + spanCollection.addLast(span); + } + } else if (defaultSpan != null) { + spanCollection.addLast(defaultSpan); + } + + return spanCollection; + } + + // taken from https://stackoverflow.com/a/17961854 + private static class CustomTypefaceSpan extends MetricAffectingSpan { + private final Typeface typeface; + + CustomTypefaceSpan(final Typeface typeface) { + this.typeface = typeface; + } + + @Override + public void updateDrawState(final TextPaint drawState) { + apply(drawState); + } + + @Override + public void updateMeasureState(final TextPaint paint) { + apply(paint); + } + + private void apply(final Paint paint) { + final Typeface oldTypeface = paint.getTypeface(); + final int oldStyle = oldTypeface != null ? oldTypeface.getStyle() : 0; + final int fakeStyle = oldStyle & ~typeface.getStyle(); + + if ((fakeStyle & Typeface.BOLD) != 0) { + paint.setFakeBoldText(true); + } + + if ((fakeStyle & Typeface.ITALIC) != 0) { + paint.setTextSkewX(-0.25f); + } + + paint.setTypeface(typeface); + } + } + } } diff --git a/library-view/src/main/res/values/attrs.xml b/library-view/src/main/res/values/attrs.xml index 33a532f3..6805313b 100644 --- a/library-view/src/main/res/values/attrs.xml +++ b/library-view/src/main/res/values/attrs.xml @@ -1,9 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file