diff --git a/docs/.vuepress/.artifacts.js b/docs/.vuepress/.artifacts.js index 532f38d9..2a1c3f43 100644 --- a/docs/.vuepress/.artifacts.js +++ b/docs/.vuepress/.artifacts.js @@ -1,4 +1,4 @@ // this is a generated file, do not modify. To update it run 'collectArtifacts.js' script -const artifacts = [{"id":"core","name":"Core","group":"ru.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"ext-latex","name":"LaTeX","group":"ru.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"ru.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"ru.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"ru.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"ru.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image-gif","name":"Image GIF","group":"ru.noties.markwon","description":"Adds GIF media support to Markwon markdown"},{"id":"image-okhttp","name":"Image OkHttp","group":"ru.noties.markwon","description":"Adds OkHttp client to retrieve images data from network"},{"id":"image-svg","name":"Image SVG","group":"ru.noties.markwon","description":"Adds SVG media support to Markwon markdown"},{"id":"recycler","name":"Recycler","group":"ru.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"ru.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}]; +const artifacts = [{"id":"core","name":"Core","group":"ru.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"ext-latex","name":"LaTeX","group":"ru.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"ru.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"ru.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"ru.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"ru.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image-gif","name":"Image GIF","group":"ru.noties.markwon","description":"Adds GIF media support to Markwon markdown"},{"id":"image-okhttp","name":"Image OkHttp","group":"ru.noties.markwon","description":"Adds OkHttp client to retrieve images data from network"},{"id":"image-svg","name":"Image SVG","group":"ru.noties.markwon","description":"Adds SVG media support to Markwon markdown"},{"id":"recycler","name":"Recycler","group":"ru.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"ru.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"ru.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}]; export { artifacts }; diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 29ab8971..85ebf762 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -60,6 +60,7 @@ module.exports = { '/docs/v3/image/okhttp.md', '/docs/v3/image/svg.md', '/docs/v3/recycler/', + '/docs/v3/recycler-table/', '/docs/v3/syntax-highlight/', '/docs/v3/migration-2-3.md' ], diff --git a/docs/.vuepress/public/assets/recycler-table-screenshot.png b/docs/.vuepress/public/assets/recycler-table-screenshot.png new file mode 100644 index 00000000..f609c65f Binary files /dev/null and b/docs/.vuepress/public/assets/recycler-table-screenshot.png differ diff --git a/docs/docs/v3/ext-tables/README.md b/docs/docs/v3/ext-tables/README.md index a728645c..fe149ed2 100644 --- a/docs/docs/v3/ext-tables/README.md +++ b/docs/docs/v3/ext-tables/README.md @@ -54,8 +54,11 @@ final Table table = Table.parse(Markwon, TableBlock); myTableWidget.setTable(table); ``` -Unfortunately Markwon does not provide a widget that can be used for tables. But it does -provide API that can be used to achieve desired result. +:::tip +To take advantage of this functionality and render tables without limitations (including +horizontally scrollable layout when its contents exceed screen width), refer to [recycler-table](/docs/v3/recycler-table) +module documentation that adds support for rendering `TableBlock` markdown node inside Android-native `TableLayout` widget. +::: ## Theme diff --git a/docs/docs/v3/ext-tasklist/README.md b/docs/docs/v3/ext-tasklist/README.md index 7dde3d9a..3fb332f4 100644 --- a/docs/docs/v3/ext-tasklist/README.md +++ b/docs/docs/v3/ext-tasklist/README.md @@ -7,4 +7,140 @@ Adds support for GFM (Github-flavored markdown) task-lists: ```java Markwon.builder(context) .usePlugin(TaskListPlugin.create(context)); +``` + +--- + +Create a default instance of `TaskListPlugin` with `TaskListDrawable` initialized to use +`android.R.attr.textColorLink` as primary color and `android.R.attr.colorBackground` as background +```java +TaskListPlugin.create(context); +``` + +--- + +Create an instance of `TaskListPlugin` with exact color values to use: +```java +// obtain color values +final int checkedFillColor = /* */; +final int normalOutlineColor = /* */; +final int checkMarkColor = /* */; + +TaskListPlugin.create(checkedFillColor, normalOutlineColor, checkMarkColor); +``` + +--- + +Specify own drawable for a task list item: + +```java +// obtain drawable +final Drawable drawable = /* */; + +TaskListPlugin.create(drawable); +``` + +:::warning +Please note that custom drawable for a task list item must correctly handle state +in order to display done/not-done: + +```java +public class MyTaskListDrawable extends Drawable { + + private boolean isChecked; + + @Override + public void draw(@NonNull Canvas canvas) { + // draw accordingly to the isChecked value + } + + /* implementation omitted */ + + @Override + protected boolean onStateChange(int[] state) { + final boolean isChecked = contains(state, android.R.attr.state_checked); + final boolean result = this.isChecked != isChecked; + if (result) { + this.isChecked = isChecked; + } + return result; + } + + private static boolean contains(@Nullable int[] states, int value) { + if (states != null) { + for (int state : states) { + if (state == value) { + // NB return here + return true; + } + } + } + return false; + } +} +``` +::: + +## Task list mutation + +It is possible to mutate task list item state (toggle done/not-done). But note +that `Markwon` won't handle state change internally by any means and this change +is merely a visual one. If you need to persist state of a task list +item change you have to implement it yourself. This should get your started: + +```java +final Markwon markwon = Markwon.builder(context) + .usePlugin(TaskListPlugin.create(context)) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + + // obtain original SpanFactory set by TaskListPlugin + final SpanFactory origin = builder.getFactory(TaskListItem.class); + if (origin == null) { + // or throw, as it's a bit weird state and we expect + // this factory to be present + return; + } + + builder.setFactory(TaskListItem.class, new SpanFactory() { + @Override + public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps props) { + // it's a bit non-secure behavior and we should validate + // the type of returned span first, but for the sake of brevity + // we skip this step + final TaskListSpan span = (TaskListSpan) origin.getSpans(configuration, props); + + if (span == null) { + // or throw + return null; + } + + // return an array of spans + return new Object[]{ + span, + new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + // toggle VISUAL state + span.setDone(!span.isDone()); + + // do not forget to invalidate widget + widget.invalidate(); + + // execute your persistence logic + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + // no-op, so appearance is not changed (otherwise + // task list item will look like a link) + } + } + }; + } + }); + } + }) + .build(); ``` \ No newline at end of file diff --git a/docs/docs/v3/install.md b/docs/docs/v3/install.md index 93b6de88..7f7184b7 100644 --- a/docs/docs/v3/install.md +++ b/docs/docs/v3/install.md @@ -10,62 +10,6 @@ next: /docs/v3/core/getting-started.md -# Bundle -If you wish to include all Markwon artifacts or add specific artifacts -in a different manner than explicit gradle dependency definition, you can -use `markwon-bundle.gradle` gradle script: - -*(in your `build.gradle`)* -```groovy -apply plugin: 'com.android.application' -apply from: 'https://raw.githubusercontent.com/noties/Markwon/master/markwon-bundle.gradle' - -android { /* */ } - -ext.markwon = [ - 'version': '3.0.0', - 'includeAll': true -] - -dependencies { /* */ } -``` - -`markwon` object can have these properties: -* `version` - (required) version of `Markwon` -* `includeAll` - if _true_ will add all known Markwon artifacts. Can be used with `exclude` -* * `exclude` - an array of artifacts to _exclude_ (cannot exclude `core`) -* `artifacts` - an array of artifacts (can omit `core`, as it will be added implicitly anyway) - -If `includeAll` property is present and is `true`, then `artifacts` property won't be used. -If there is no `includeAll` property or if it is `false`, `exclude` property won't be used. - -These 2 markwon objects are equal: - -```groovy -// #1 -ext.markwon = [ - 'version': '3.0.0', - 'artifacts': [ - 'ext-latex', - 'ext-strikethrough', - 'ext-tables', - 'ext-tasklist', - 'html', - 'image-gif', - 'image-okhttp', - 'image-svg', - 'recycler', - 'syntax-highlight' - ] -] - -// #2 -ext.markwon = [ - 'version': '3.0.0', - 'includeAll': true -] -``` - ## Snapshot In order to use latest `SNAPSHOT` version add snapshot repository diff --git a/docs/docs/v3/recycler-table/README.md b/docs/docs/v3/recycler-table/README.md new file mode 100644 index 00000000..0cd90583 --- /dev/null +++ b/docs/docs/v3/recycler-table/README.md @@ -0,0 +1,90 @@ +# Recycler Table + +Artifact that provides [MarkwonAdapter.Entry](/docs/v3/recycler/) to render `TableBlock` inside +Android-native `TableLayout` widget. + +screenshot +
+* It's possible to wrap `TableLayout` inside a `HorizontalScrollView` to include all table content + +--- + +Register instance of `TableEntry` with `MarkwonAdapter` to render TableBlocks: +```java +final MarkwonAdapter adapter = MarkwonAdapter.builder(R.layout.adapter_default_entry, R.id.text) + .include(TableBlock.class, TableEntry.create(builder -> builder + .tableLayout(R.layout.adapter_table_block, R.id.table_layout) + .textLayoutIsRoot(R.layout.view_table_entry_cell))) + .build(); +``` + +`TableEntry` requires at least 2 arguments: +* `tableLayout` - layout with `TableLayout` inside +* `textLayout` - layout with `TextView` inside (represents independent table cell) + +In case when required view is the root of layout specific builder methods can be used: +* `tableLayoutIsRoot(int)` +* `textLayoutIsRoot(int)` + +If your layouts have different structure (for example wrap a `TableView` inside a `HorizontalScrollView`) +then you should use methods that accept ID of required view inside layout: +* `tableLayout(int, int)` +* `textLayout(int, int)` + +--- + +To display `TableBlock` as a `TableLayout` specific `MarkwonPlugin` must be used: `TableEntryPlugin`. + +:::warning +Do not use `TablePlugin` if you wish to display markdown tables via `TableEntry`. Use **TableEntryPlugin** instead +::: + +`TableEntryPlugin` can reuse existing `TablePlugin` to make appearance of tables the same in both contexts: +when rendering _natively_ in a TextView and when rendering in RecyclerView with TableEntry. + +* `TableEntryPlugin.create(Context)` - creates plugin with default `TableTheme` +* `TableEntryPlugin.create(TableTheme)` - creates plugin with provided `TableTheme` +* `TableEntryPlugin.create(TablePlugin.ThemeConfigure)` - creates plugin with theme configured by `ThemeConfigure` +* `TableEntryPlugin.create(TablePlugin)` - creates plugin with `TableTheme` used in provided `TablePlugin` + +```java +final Markwon markwon = Markwon.builder(context) + .usePlugin(TableEntryPlugin.create(context)) + // other plugins + .build(); +``` + +```java +final Markwon markwon = Markwon.builder(context) + .usePlugin(TableEntryPlugin.create(builder -> builder + .tableBorderWidth(0) + .tableHeaderRowBackgroundColor(Color.RED))) + // other plugins + .build(); +``` + +## Table with scrollable content + +To stretch table columns to fit the width of screen or to make table scrollable when content exceeds screen width +this layout can be used: + +```xml + + + + + +``` \ No newline at end of file diff --git a/markwon-bundle.gradle b/markwon-bundle.gradle deleted file mode 100644 index 603404d6..00000000 --- a/markwon-bundle.gradle +++ /dev/null @@ -1,59 +0,0 @@ -// await project initialization and check for markwon object then -// (so we do not have to force users to put `apply from` block at the bottom -// of a build.gradle file) -project.afterEvaluate { - - if (!project.hasProperty('markwon')) { - throw new RuntimeException("No `markwon` property object is found. " + - "Define it with `ext.markwon = [prop: value]`") - } - - final def markwon = project.markwon - if (!(markwon instanceof Map)) { - throw new RuntimeException("`markwon` object property must be of type Map. " + - "Groovy short-hand to define: `[:]`.") - } - - final def version = markwon.version - final def includeAll = markwon.computeIfAbsent('includeAll', { false }) - final def artifacts - if (includeAll) { - - // cannot exclude core - final def exclude = markwon.computeIfAbsent('exclude', { [] }) \ - .unique() \ - .findAll { 'core' != it } - - artifacts = [ - 'core', - 'ext-latex', - 'ext-strikethrough', - 'ext-tables', - 'ext-tasklist', - 'html', - 'image-gif', - 'image-okhttp', - 'image-svg', - 'recycler', - 'syntax-highlight' - ].findAll { !exclude.contains(it) } - - } else { - artifacts = (markwon.containsKey('artifacts') ? markwon.artifacts : ['core']).with { - // add implicit core artifact - if (!it.contains('core')) { - it.add('core') - } - return it - } - } - - if (!version) { - throw new RuntimeException("Please specify version of Markwon, for example: " + - "`ext.markwon = [ 'version': '1.0.0']`") - } - - artifacts.forEach { - project.dependencies.add('implementation', "ru.noties.markwon:$it:$version") - } -} \ No newline at end of file diff --git a/markwon-core/src/main/java/ru/noties/markwon/Markwon.java b/markwon-core/src/main/java/ru/noties/markwon/Markwon.java index 6e7e1ba3..0ac110ff 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/Markwon.java +++ b/markwon-core/src/main/java/ru/noties/markwon/Markwon.java @@ -2,6 +2,7 @@ package ru.noties.markwon; import android.content.Context; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.text.Spanned; import android.widget.TextView; @@ -101,6 +102,9 @@ public abstract class Markwon { */ public abstract boolean hasPlugin(@NonNull Class plugin); + @Nullable + public abstract

P getPlugin(@NonNull Class

type); + /** * Builder for {@link Markwon}. *

diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonImpl.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonImpl.java index a51bd62a..e90b7898 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonImpl.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonImpl.java @@ -1,6 +1,7 @@ package ru.noties.markwon; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.text.Spanned; import android.widget.TextView; @@ -91,13 +92,19 @@ class MarkwonImpl extends Markwon { @Override public boolean hasPlugin(@NonNull Class type) { - boolean result = false; + return getPlugin(type) != null; + } + + @Nullable + @Override + public

P getPlugin(@NonNull Class

type) { + MarkwonPlugin out = null; for (MarkwonPlugin plugin : plugins) { if (type.isAssignableFrom(plugin.getClass())) { - result = true; - break; + out = plugin; } } - return result; + //noinspection unchecked + return (P) out; } } diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonReducer.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonReducer.java index 251cf1fe..a139e3f1 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonReducer.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonReducer.java @@ -13,6 +13,10 @@ import java.util.List; */ public abstract class MarkwonReducer { + /** + * @return direct children of supplied Node. In the most usual case + * will return all BlockNodes of a Document + */ @NonNull public static MarkwonReducer directChildren() { return new DirectChildren(); diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactory.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactory.java index dbc6960d..8cf25a28 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactory.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactory.java @@ -34,6 +34,12 @@ public interface MarkwonSpansFactory { @NonNull Builder setFactory(@NonNull Class node, @Nullable SpanFactory factory); + /** + * Can be useful when enhancing an already defined SpanFactory with another one. + */ + @Nullable + SpanFactory getFactory(@NonNull Class node); + @NonNull MarkwonSpansFactory build(); } diff --git a/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactoryImpl.java b/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactoryImpl.java index f3cd0dee..ef1906d8 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactoryImpl.java +++ b/markwon-core/src/main/java/ru/noties/markwon/MarkwonSpansFactoryImpl.java @@ -52,6 +52,12 @@ class MarkwonSpansFactoryImpl implements MarkwonSpansFactory { return this; } + @Nullable + @Override + public SpanFactory getFactory(@NonNull Class node) { + return factories.get(node); + } + @NonNull @Override public MarkwonSpansFactory build() { diff --git a/markwon-core/src/main/java/ru/noties/markwon/utils/NoCopySpannableFactory.java b/markwon-core/src/main/java/ru/noties/markwon/utils/NoCopySpannableFactory.java new file mode 100644 index 00000000..f5eedc2a --- /dev/null +++ b/markwon-core/src/main/java/ru/noties/markwon/utils/NoCopySpannableFactory.java @@ -0,0 +1,30 @@ +package ru.noties.markwon.utils; + +import android.support.annotation.NonNull; +import android.text.Spannable; +import android.text.SpannableString; + +/** + * Utility SpannableFactory that re-uses Spannable instance between multiple + * `TextView#setText` calls. + * + * @since 3.0.0 + */ +public class NoCopySpannableFactory extends Spannable.Factory { + + @NonNull + public static NoCopySpannableFactory getInstance() { + return Holder.INSTANCE; + } + + @Override + public Spannable newSpannable(CharSequence source) { + return source instanceof Spannable + ? (Spannable) source + : new SpannableString(source); + } + + static class Holder { + private static final NoCopySpannableFactory INSTANCE = new NoCopySpannableFactory(); + } +} diff --git a/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TablePlugin.java b/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TablePlugin.java index ece065bf..f05a84fc 100644 --- a/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TablePlugin.java +++ b/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TablePlugin.java @@ -54,13 +54,20 @@ public class TablePlugin extends AbstractMarkwonPlugin { return new TablePlugin(builder.build()); } + private final TableTheme theme; private final TableVisitor visitor; @SuppressWarnings("WeakerAccess") TablePlugin(@NonNull TableTheme tableTheme) { + this.theme = tableTheme; this.visitor = new TableVisitor(tableTheme); } + @NonNull + public TableTheme theme() { + return theme; + } + @Override public void configureParser(@NonNull Parser.Builder builder) { builder.extensions(Collections.singleton(TablesExtension.create())); diff --git a/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TableTheme.java b/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TableTheme.java index 6752e729..e9b1bd47 100644 --- a/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TableTheme.java +++ b/markwon-ext-tables/src/main/java/ru/noties/markwon/ext/tables/TableTheme.java @@ -13,15 +13,19 @@ public class TableTheme { @NonNull public static TableTheme create(@NonNull Context context) { - final Dip dip = Dip.create(context); - return builder() - .tableCellPadding(dip.toPx(4)) - .tableBorderWidth(dip.toPx(1)) - .build(); + return buildWithDefaults(context).build(); } @NonNull - public static Builder builder() { + public static Builder buildWithDefaults(@NonNull Context context) { + final Dip dip = Dip.create(context); + return emptyBuilder() + .tableCellPadding(dip.toPx(4)) + .tableBorderWidth(dip.toPx(1)); + } + + @NonNull + public static Builder emptyBuilder() { return new Builder(); } @@ -58,6 +62,20 @@ public class TableTheme { this.tableHeaderRowBackgroundColor = builder.tableHeaderRowBackgroundColor; } + /** + * @since 3.0.0 + */ + @NonNull + public Builder asBuilder() { + return new Builder() + .tableCellPadding(tableCellPadding) + .tableBorderColor(tableBorderColor) + .tableBorderWidth(tableBorderWidth) + .tableOddRowBackgroundColor(tableOddRowBackgroundColor) + .tableEvenRowBackgroundColor(tableEvenRowBackgroundColor) + .tableHeaderRowBackgroundColor(tableHeaderRowBackgroundColor); + } + public int tableCellPadding() { return tableCellPadding; } diff --git a/markwon-recycler-table/build.gradle b/markwon-recycler-table/build.gradle new file mode 100644 index 00000000..841c2b60 --- /dev/null +++ b/markwon-recycler-table/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'com.android.library' + +android { + + compileSdkVersion config['compile-sdk'] + buildToolsVersion config['build-tools'] + + defaultConfig { + minSdkVersion config['min-sdk'] + targetSdkVersion config['target-sdk'] + versionCode 1 + versionName version + } +} + +dependencies { + + api project(':markwon-core') + api project(':markwon-recycler') + api project(':markwon-ext-tables') + + deps['test'].with { + testImplementation it['junit'] + testImplementation it['robolectric'] + testImplementation it['mockito'] + } +} + +registerArtifact(this) \ No newline at end of file diff --git a/markwon-recycler-table/gradle.properties b/markwon-recycler-table/gradle.properties new file mode 100644 index 00000000..8e87be51 --- /dev/null +++ b/markwon-recycler-table/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Recycler Table +POM_ARTIFACT_ID=recycler-table +POM_DESCRIPTION=Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget +POM_PACKAGING=aar \ No newline at end of file diff --git a/markwon-recycler-table/src/main/AndroidManifest.xml b/markwon-recycler-table/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b215e1f6 --- /dev/null +++ b/markwon-recycler-table/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableBorderDrawable.java b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableBorderDrawable.java new file mode 100644 index 00000000..06a89f24 --- /dev/null +++ b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableBorderDrawable.java @@ -0,0 +1,48 @@ +package ru.noties.markwon.recycler.table; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.Px; + +class TableBorderDrawable extends Drawable { + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + + TableBorderDrawable() { + paint.setStyle(Paint.Style.STROKE); + } + + @Override + public void draw(@NonNull Canvas canvas) { + if (paint.getStrokeWidth() > 0) { + canvas.drawRect(getBounds(), paint); + } + } + + @Override + public void setAlpha(int alpha) { + // no op + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + // no op + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + + void update(@Px int borderWidth, @ColorInt int color) { + paint.setStrokeWidth(borderWidth); + paint.setColor(color); + invalidateSelf(); + } +} diff --git a/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntry.java b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntry.java new file mode 100644 index 00000000..5ff8b25e --- /dev/null +++ b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntry.java @@ -0,0 +1,530 @@ +package ru.noties.markwon.recycler.table; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorInt; +import android.support.annotation.IdRes; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.annotation.Px; +import android.support.annotation.VisibleForTesting; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import org.commonmark.ext.gfm.tables.TableBlock; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ru.noties.markwon.Markwon; +import ru.noties.markwon.ext.tables.Table; +import ru.noties.markwon.recycler.MarkwonAdapter; +import ru.noties.markwon.utils.NoCopySpannableFactory; + +/** + * @since 3.0.0 + */ +public class TableEntry extends MarkwonAdapter.Entry { + + public interface Builder { + + /** + * @param tableLayoutResId layout with TableLayout + * @param tableIdRes id of the TableLayout inside specified layout + * @see #tableLayoutIsRoot(int) + */ + @NonNull + Builder tableLayout(@LayoutRes int tableLayoutResId, @IdRes int tableIdRes); + + /** + * @param tableLayoutResId layout with TableLayout as the root view + * @see #tableLayout(int, int) + */ + @NonNull + Builder tableLayoutIsRoot(@LayoutRes int tableLayoutResId); + + /** + * @param textLayoutResId layout with TextView + * @param textIdRes id of the TextView inside specified layout + * @see #textLayoutIsRoot(int) + */ + @NonNull + Builder textLayout(@LayoutRes int textLayoutResId, @IdRes int textIdRes); + + /** + * @param textLayoutResId layout with TextView as the root view + * @see #textLayout(int, int) + */ + @NonNull + Builder textLayoutIsRoot(@LayoutRes int textLayoutResId); + + /** + * @param cellTextCenterVertical if text inside a table cell should centered + * vertically (by default `true`) + */ + @NonNull + Builder cellTextCenterVertical(boolean cellTextCenterVertical); + + /** + * @param isRecyclable flag to set on RecyclerView.ViewHolder (by default `true`) + */ + @NonNull + Builder isRecyclable(boolean isRecyclable); + + @NonNull + TableEntry build(); + } + + public interface BuilderConfigure { + void configure(@NonNull Builder builder); + } + + @NonNull + public static Builder builder() { + return new BuilderImpl(); + } + + @NonNull + public static TableEntry create(@NonNull BuilderConfigure configure) { + final Builder builder = builder(); + configure.configure(builder); + return builder.build(); + } + + private final int tableLayoutResId; + private final int tableIdRes; + + private final int textLayoutResId; + private final int textIdRes; + + private final boolean isRecyclable; + private final boolean cellTextCenterVertical; // by default true + + private LayoutInflater inflater; + + private final Map map = new HashMap<>(3); + + TableEntry( + @LayoutRes int tableLayoutResId, + @IdRes int tableIdRes, + @LayoutRes int textLayoutResId, + @IdRes int textIdRes, + boolean isRecyclable, + boolean cellTextCenterVertical) { + this.tableLayoutResId = tableLayoutResId; + this.tableIdRes = tableIdRes; + this.textLayoutResId = textLayoutResId; + this.textIdRes = textIdRes; + this.isRecyclable = isRecyclable; + this.cellTextCenterVertical = cellTextCenterVertical; + } + + @NonNull + @Override + public Holder createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { + return new Holder( + isRecyclable, + tableIdRes, + inflater.inflate(tableLayoutResId, parent, false)); + } + + @Override + public void bindHolder(@NonNull Markwon markwon, @NonNull Holder holder, @NonNull TableBlock node) { + + Table table = map.get(node); + if (table == null) { + table = Table.parse(markwon, node); + map.put(node, table); + } + + // check if this exact TableBlock was already applied + // set tag of tableLayoutResId as it's 100% to be present (we still allow 0 as + // tableIdRes if tableLayoutResId has TableLayout as root view) + final TableLayout layout = holder.tableLayout; + if (table == null + || table == layout.getTag(tableLayoutResId)) { + return; + } + + // set this flag to indicate what table instance we current display + layout.setTag(tableLayoutResId, table); + + final TableEntryPlugin plugin = markwon.getPlugin(TableEntryPlugin.class); + if (plugin == null) { + throw new IllegalStateException("No TableEntryPlugin is found. Make sure that it " + + "is _used_ whilst configuring Markwon instance"); + } + + // we must remove unwanted ones (rows and columns) + + final TableEntryTheme theme = plugin.theme(); + final int borderWidth; + final int borderColor; + final int cellPadding; + { + final TextView textView = ensureTextView(layout, 0, 0); + borderWidth = theme.tableBorderWidth(textView.getPaint()); + borderColor = theme.tableBorderColor(textView.getPaint()); + cellPadding = theme.tableCellPadding(); + } + + ensureTableBorderBackground(layout, borderWidth, borderColor); + + //noinspection SuspiciousNameCombination +// layout.setPadding(borderWidth, borderWidth, borderWidth, borderWidth); +// layout.setClipToPadding(borderWidth == 0); + + final List rows = table.rows(); + + final int rowsSize = rows.size(); + + // all rows should have equal number of columns + final int columnsSize = rowsSize > 0 + ? rows.get(0).columns().size() + : 0; + + Table.Row row; + Table.Column column; + + TableRow tableRow; + + for (int y = 0; y < rowsSize; y++) { + + row = rows.get(y); + tableRow = ensureRow(layout, y); + + for (int x = 0; x < columnsSize; x++) { + + column = row.columns().get(x); + + final TextView textView = ensureTextView(layout, y, x); + textView.setGravity(textGravity(column.alignment(), cellTextCenterVertical)); + textView.getPaint().setFakeBoldText(row.header()); + + // apply padding only if not specified in theme (otherwise just use the value from layout) + if (cellPadding > 0) { + textView.setPadding(cellPadding, cellPadding, cellPadding, cellPadding); + } + + ensureTableBorderBackground(textView, borderWidth, borderColor); + markwon.setParsedMarkdown(textView, column.content()); + } + + // row appearance + if (row.header()) { + tableRow.setBackgroundColor(theme.tableHeaderRowBackgroundColor()); + } else { + // as we currently have no support for tables without head + // we shift even/odd calculation a bit (head should not be included in even/odd calculation) + final boolean isEven = (y % 2) == 1; + if (isEven) { + tableRow.setBackgroundColor(theme.tableEvenRowBackgroundColor()); + } else { + // just take first + final TextView textView = ensureTextView(layout, y, 0); + tableRow.setBackgroundColor( + theme.tableOddRowBackgroundColor(textView.getPaint())); + } + } + } + + // clean up here of un-used rows and columns + removeUnused(layout, rowsSize, columnsSize); + } + + @NonNull + private TableRow ensureRow(@NonNull TableLayout layout, int row) { + + final int count = layout.getChildCount(); + + // fill the requested views until we have added the `row` one + if (row >= count) { + + final Context context = layout.getContext(); + + int diff = row - count + 1; + while (diff > 0) { + layout.addView(new TableRow(context)); + diff -= 1; + } + } + + // return requested child (here it always should be the last one) + return (TableRow) layout.getChildAt(row); + } + + @NonNull + private TextView ensureTextView(@NonNull TableLayout layout, int row, int column) { + + final TableRow tableRow = ensureRow(layout, row); + final int count = tableRow.getChildCount(); + + if (column >= count) { + + final LayoutInflater inflater = ensureInflater(layout.getContext()); + + boolean textViewChecked = false; + + View view; + TextView textView; + ViewGroup.LayoutParams layoutParams; + + int diff = column - count + 1; + + while (diff > 0) { + + view = inflater.inflate(textLayoutResId, tableRow, false); + + // we should have `match_parent` as height (important for borders and text-vertical-align) + layoutParams = view.getLayoutParams(); + if (layoutParams.height != ViewGroup.LayoutParams.MATCH_PARENT) { + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + } + + // it will be enough to check only once + if (!textViewChecked) { + + if (textIdRes == 0) { + if (!(view instanceof TextView)) { + final String name = layout.getContext().getResources().getResourceName(textLayoutResId); + throw new IllegalStateException(String.format("textLayoutResId(R.layout.%s) " + + "has other than TextView root view. Specify TextView ID explicitly", name)); + } + textView = (TextView) view; + } else { + textView = view.findViewById(textIdRes); + if (textView == null) { + final Resources r = layout.getContext().getResources(); + final String layoutName = r.getResourceName(textLayoutResId); + final String idName = r.getResourceName(textIdRes); + throw new NullPointerException(String.format("textLayoutResId(R.layout.%s) " + + "has no TextView found by id(R.id.%s): %s", layoutName, idName, view)); + } + } + // mark as checked + textViewChecked = true; + } else { + if (textIdRes == 0) { + textView = (TextView) view; + } else { + textView = view.findViewById(textIdRes); + } + } + + // we should set SpannableFactory during creation (to avoid another setText method) + textView.setSpannableFactory(NoCopySpannableFactory.getInstance()); + tableRow.addView(textView); + + diff -= 1; + } + } + + // we can skip all the validation here as we have validated our views whilst inflating them + final View last = tableRow.getChildAt(column); + if (textIdRes == 0) { + return (TextView) last; + } else { + return last.findViewById(textIdRes); + } + } + + private void ensureTableBorderBackground(@NonNull View view, @Px int borderWidth, @ColorInt int borderColor) { + if (borderWidth == 0) { + view.setBackground(null); + } else { + final Drawable drawable = view.getBackground(); + if (!(drawable instanceof TableBorderDrawable)) { + final TableBorderDrawable borderDrawable = new TableBorderDrawable(); + borderDrawable.update(borderWidth, borderColor); + view.setBackground(borderDrawable); + } else { + ((TableBorderDrawable) drawable).update(borderWidth, borderColor); + } + } + } + + @NonNull + private LayoutInflater ensureInflater(@NonNull Context context) { + if (inflater == null) { + inflater = LayoutInflater.from(context); + } + return inflater; + } + + @SuppressWarnings("WeakerAccess") + @VisibleForTesting + static void removeUnused(@NonNull TableLayout layout, int usedRows, int usedColumns) { + + // clean up rows + final int rowsCount = layout.getChildCount(); + if (rowsCount > usedRows) { + layout.removeViews(usedRows, (rowsCount - usedRows)); + } + + // validate columns + // here we can use usedRows as children count + + TableRow tableRow; + int columnCount; + + for (int i = 0; i < usedRows; i++) { + tableRow = (TableRow) layout.getChildAt(i); + columnCount = tableRow.getChildCount(); + if (columnCount > usedColumns) { + tableRow.removeViews(usedColumns, (columnCount - usedColumns)); + } + } + } + + @Override + public void clear() { + map.clear(); + } + + public static class Holder extends MarkwonAdapter.Holder { + + final TableLayout tableLayout; + + public Holder(boolean isRecyclable, @IdRes int tableLayoutIdRes, @NonNull View itemView) { + super(itemView); + + // we must call this method only once (it's somehow _paired_ inside, so + // any call in `onCreateViewHolder` or `onBindViewHolder` will log an error + // `isRecyclable decremented below 0` which make little sense here) + setIsRecyclable(isRecyclable); + + final TableLayout tableLayout; + if (tableLayoutIdRes == 0) { + // try to cast directly + if (!(itemView instanceof TableLayout)) { + throw new IllegalStateException("Root view is not TableLayout. Please provide " + + "TableLayout ID explicitly"); + } + tableLayout = (TableLayout) itemView; + } else { + tableLayout = requireView(tableLayoutIdRes); + } + this.tableLayout = tableLayout; + } + } + + // we will use gravity instead of textAlignment because min sdk is 16 (textAlignment starts at 17) + @SuppressWarnings("WeakerAccess") + @SuppressLint("RtlHardcoded") + @VisibleForTesting + static int textGravity(@NonNull Table.Alignment alignment, boolean cellTextCenterVertical) { + + final int gravity; + + switch (alignment) { + + case LEFT: + gravity = Gravity.LEFT; + break; + + case CENTER: + gravity = Gravity.CENTER_HORIZONTAL; + break; + + case RIGHT: + gravity = Gravity.RIGHT; + break; + + default: + throw new IllegalStateException("Unknown table alignment: " + alignment); + } + + if (cellTextCenterVertical) { + return gravity | Gravity.CENTER_VERTICAL; + } + + // do not center vertically + return gravity; + } + + static class BuilderImpl implements Builder { + + private int tableLayoutResId; + private int tableIdRes; + + private int textLayoutResId; + private int textIdRes; + + private boolean cellTextCenterVertical = true; + + private boolean isRecyclable = true; + + @NonNull + @Override + public Builder tableLayout(int tableLayoutResId, int tableIdRes) { + this.tableLayoutResId = tableLayoutResId; + this.tableIdRes = tableIdRes; + return this; + } + + @NonNull + @Override + public Builder tableLayoutIsRoot(int tableLayoutResId) { + this.tableLayoutResId = tableLayoutResId; + this.tableIdRes = 0; + return this; + } + + @NonNull + @Override + public Builder textLayout(int textLayoutResId, int textIdRes) { + this.textLayoutResId = textLayoutResId; + this.textIdRes = textIdRes; + return this; + } + + @NonNull + @Override + public Builder textLayoutIsRoot(int textLayoutResId) { + this.textLayoutResId = textLayoutResId; + this.textIdRes = 0; + return this; + } + + @NonNull + @Override + public Builder cellTextCenterVertical(boolean cellTextCenterVertical) { + this.cellTextCenterVertical = cellTextCenterVertical; + return this; + } + + @NonNull + @Override + public Builder isRecyclable(boolean isRecyclable) { + this.isRecyclable = isRecyclable; + return this; + } + + @NonNull + @Override + public TableEntry build() { + + if (tableLayoutResId == 0) { + throw new IllegalStateException("`tableLayoutResId` argument is required"); + } + + if (textLayoutResId == 0) { + throw new IllegalStateException("`textLayoutResId` argument is required"); + } + + return new TableEntry( + tableLayoutResId, tableIdRes, + textLayoutResId, textIdRes, + isRecyclable, cellTextCenterVertical + ); + } + } +} diff --git a/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntryPlugin.java b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntryPlugin.java new file mode 100644 index 00000000..a92400ee --- /dev/null +++ b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntryPlugin.java @@ -0,0 +1,65 @@ +package ru.noties.markwon.recycler.table; + +import android.content.Context; +import android.support.annotation.NonNull; + +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.parser.Parser; + +import java.util.Collections; + +import ru.noties.markwon.AbstractMarkwonPlugin; +import ru.noties.markwon.ext.tables.TablePlugin; +import ru.noties.markwon.ext.tables.TableTheme; + +/** + * This plugin must be used instead of {@link ru.noties.markwon.ext.tables.TablePlugin} when a markdown + * table is intended to be used in a RecyclerView via {@link TableEntry}. This is required + * because TablePlugin additionally processes markdown tables to be displayed in limited + * context of a TextView. If TablePlugin will be used, {@link TableEntry} will display table, + * but no content will be present + * + * @since 3.0.0 + */ +public class TableEntryPlugin extends AbstractMarkwonPlugin { + + @NonNull + public static TableEntryPlugin create(@NonNull Context context) { + final TableTheme tableTheme = TableTheme.create(context); + return create(tableTheme); + } + + @NonNull + public static TableEntryPlugin create(@NonNull TableTheme tableTheme) { + return new TableEntryPlugin(TableEntryTheme.create(tableTheme)); + } + + @NonNull + public static TableEntryPlugin create(@NonNull TablePlugin.ThemeConfigure themeConfigure) { + final TableTheme.Builder builder = new TableTheme.Builder(); + themeConfigure.configureTheme(builder); + return new TableEntryPlugin(new TableEntryTheme(builder)); + } + + @NonNull + public static TableEntryPlugin create(@NonNull TablePlugin plugin) { + return create(plugin.theme()); + } + + private final TableEntryTheme theme; + + @SuppressWarnings("WeakerAccess") + TableEntryPlugin(@NonNull TableEntryTheme tableTheme) { + this.theme = tableTheme; + } + + @NonNull + public TableEntryTheme theme() { + return theme; + } + + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.extensions(Collections.singleton(TablesExtension.create())); + } +} diff --git a/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntryTheme.java b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntryTheme.java new file mode 100644 index 00000000..e9b9b7ca --- /dev/null +++ b/markwon-recycler-table/src/main/java/ru/noties/markwon/recycler/table/TableEntryTheme.java @@ -0,0 +1,67 @@ +package ru.noties.markwon.recycler.table; + +import android.graphics.Paint; +import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; +import android.support.annotation.Px; + +import ru.noties.markwon.ext.tables.TableTheme; +import ru.noties.markwon.utils.ColorUtils; + +/** + * Mimics TableTheme to allow uniform table customization + * + * @see #create(TableTheme) + * @see TableEntryPlugin + * @since 3.0.0 + */ +@SuppressWarnings("WeakerAccess") +public class TableEntryTheme extends TableTheme { + + @NonNull + public static TableEntryTheme create(@NonNull TableTheme tableTheme) { + return new TableEntryTheme(tableTheme.asBuilder()); + } + + protected TableEntryTheme(@NonNull Builder builder) { + super(builder); + } + + @Px + @Override + public int tableCellPadding() { + return tableCellPadding; + } + + @ColorInt + public int tableBorderColor(@NonNull Paint paint) { + return tableBorderColor == 0 + ? ColorUtils.applyAlpha(paint.getColor(), TABLE_BORDER_DEF_ALPHA) + : tableBorderColor; + } + + @Px + @Override + public int tableBorderWidth(@NonNull Paint paint) { + return tableBorderWidth < 0 + ? (int) (paint.getStrokeWidth() + .5F) + : tableBorderWidth; + } + + @ColorInt + public int tableOddRowBackgroundColor(@NonNull Paint paint) { + return tableOddRowBackgroundColor == 0 + ? ColorUtils.applyAlpha(paint.getColor(), TABLE_ODD_ROW_DEF_ALPHA) + : tableOddRowBackgroundColor; + } + + @ColorInt + public int tableEvenRowBackgroundColor() { + return tableEvenRowBackgroundColor; + } + + @ColorInt + public int tableHeaderRowBackgroundColor() { + return tableHeaderRowBackgroundColor; + } +} diff --git a/markwon-recycler-table/src/test/java/ru/noties/markwon/recycler/table/TableEntryTest.java b/markwon-recycler-table/src/test/java/ru/noties/markwon/recycler/table/TableEntryTest.java new file mode 100644 index 00000000..ae55f18e --- /dev/null +++ b/markwon-recycler-table/src/test/java/ru/noties/markwon/recycler/table/TableEntryTest.java @@ -0,0 +1,103 @@ +package ru.noties.markwon.recycler.table; + +import android.content.res.Resources; +import android.util.Pair; +import android.view.Gravity; +import android.view.View; +import android.widget.TableLayout; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Arrays; +import java.util.List; + +import ru.noties.markwon.ext.tables.Table; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class TableEntryTest { + + @Test + public void gravity() { + // test textGravity is calculated correctly + + final List> noVerticalAlign = Arrays.asList( + new Pair(Table.Alignment.LEFT, Gravity.LEFT), + new Pair(Table.Alignment.CENTER, Gravity.CENTER_HORIZONTAL), + new Pair(Table.Alignment.RIGHT, Gravity.RIGHT) + ); + + final List> withVerticalAlign = Arrays.asList( + new Pair(Table.Alignment.LEFT, Gravity.LEFT | Gravity.CENTER_VERTICAL), + new Pair(Table.Alignment.CENTER, Gravity.CENTER_HORIZONTAL | Gravity.CENTER_VERTICAL), + new Pair(Table.Alignment.RIGHT, Gravity.RIGHT | Gravity.CENTER_VERTICAL) + ); + + for (Pair pair : noVerticalAlign) { + assertEquals(pair.first.name(), pair.second.intValue(), TableEntry.textGravity(pair.first, false)); + } + + for (Pair pair : withVerticalAlign) { + assertEquals(pair.first.name(), pair.second.intValue(), TableEntry.textGravity(pair.first, true)); + } + } + + @Test + public void holder_no_table_layout_id() { + // validate that holder correctly obtains TableLayout instance casting root view + + // root is not TableLayout + try { + new TableEntry.Holder(false, 0, mock(View.class)); + fail(); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("Root view is not TableLayout")); + } + + // root is TableLayout + try { + final TableLayout tableLayout = mock(TableLayout.class); + final TableEntry.Holder h = new TableEntry.Holder(false, 0, tableLayout); + assertEquals(tableLayout, h.tableLayout); + } catch (IllegalStateException e) { + fail(e.getMessage()); + } + } + + @Test + public void holder_with_table_layout_id() { + + // not found + try { + + final View view = mock(View.class); + // resources are used to obtain id name for proper error message + when(view.getResources()).thenReturn(mock(Resources.class)); + new TableEntry.Holder(false, 1, view); + fail(); + } catch (NullPointerException e) { + assertTrue(e.getMessage(), e.getMessage().contains("No view with id")); + } + + // found + try { + final TableLayout tableLayout = mock(TableLayout.class); + final View view = mock(View.class); + when(view.findViewById(3)).thenReturn(tableLayout); + final TableEntry.Holder holder = new TableEntry.Holder(false, 3, view); + assertEquals(tableLayout, holder.tableLayout); + } catch (NullPointerException e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } +} \ No newline at end of file diff --git a/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapter.java b/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapter.java index d288a44c..64241d55 100644 --- a/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapter.java +++ b/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapter.java @@ -18,17 +18,13 @@ import ru.noties.markwon.MarkwonReducer; /** * Adapter to display markdown in a RecyclerView. It is done by extracting root blocks from a - * parsed markdown document and rendering each block in a standalone RecyclerView entry. Provides + * parsed markdown document (via {@link MarkwonReducer} and rendering each block in a standalone RecyclerView entry. Provides * ability to customize rendering of blocks. For example display certain blocks in a horizontal * scrolling container or display tables in a specific widget designed for it ({@link Builder#include(Class, Entry)}). - *

- * By default each node will be rendered in a TextView provided by this artifact. It has no styling - * information and thus must be replaced with your own layout ({@link Builder#defaultEntry(int)} or - * {@link Builder#defaultEntry(Entry)}). * - * @see #builder() - * @see #create() - * @see #create(int) + * @see #builder(int, int) + * @see #builder(Entry) + * @see #create(int, int) * @see #create(Entry) * @see #setMarkdown(Markwon, String) * @see #setParsedMarkdown(Markwon, Node) @@ -37,14 +33,34 @@ import ru.noties.markwon.MarkwonReducer; */ public abstract class MarkwonAdapter extends RecyclerView.Adapter { + @NonNull + public static Builder builderTextViewIsRoot(@LayoutRes int defaultEntryLayoutResId) { + return builder(SimpleEntry.createTextViewIsRoot(defaultEntryLayoutResId)); + } + /** * Factory method to obtain {@link Builder} instance. * * @see Builder */ @NonNull - public static Builder builder() { - return new MarkwonAdapterImpl.BuilderImpl(); + public static Builder builder( + @LayoutRes int defaultEntryLayoutResId, + @IdRes int defaultEntryTextViewResId + ) { + return builder(SimpleEntry.create(defaultEntryLayoutResId, defaultEntryTextViewResId)); + } + + @NonNull + public static Builder builder(@NonNull Entry defaultEntry) { + //noinspection unchecked + return new MarkwonAdapterImpl.BuilderImpl((Entry) defaultEntry); + } + + @NonNull + public static MarkwonAdapter createTextViewIsRoot(@LayoutRes int defaultEntryLayoutResId) { + return builderTextViewIsRoot(defaultEntryLayoutResId) + .build(); } /** @@ -52,12 +68,16 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter defaultEntry) { - return new MarkwonAdapterImpl.BuilderImpl().defaultEntry(defaultEntry).build(); - } - - /** - * Factory method to create a {@link MarkwonAdapter} that will use supplied layoutResId view - * to display all nodes. - * - * Please note that supplied layout must have a TextView inside - * with {@code android:id="@+id/text"} - * - * @param layoutResId layout to be used to display all nodes - * @see SimpleEntry - */ - @NonNull - public static MarkwonAdapter create(@LayoutRes int layoutResId) { - return new MarkwonAdapterImpl.BuilderImpl().defaultEntry(layoutResId).build(); + return builder(defaultEntry).build(); } /** * Builder to create an instance of {@link MarkwonAdapter} * * @see #include(Class, Entry) - * @see #defaultEntry(int) - * @see #defaultEntry(Entry) * @see #reducer(MarkwonReducer) + * @see #build() */ public interface Builder { @@ -112,29 +116,6 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter node, @NonNull Entry entry); - /** - * Specify which {@link Entry} to use for all non-explicitly registered nodes - * - * @param defaultEntry {@link Entry} - * @return self - * @see SimpleEntry - */ - @NonNull - Builder defaultEntry(@NonNull Entry defaultEntry); - - /** - * Specify which layout {@link SimpleEntry} will use to render all non-explicitly - * registered nodes. - * - * Please note that supplied layout must have a TextView inside - * with {@code android:id="@+id/text"} - * - * @return self - * @see SimpleEntry - */ - @NonNull - Builder defaultEntry(@LayoutRes int layoutResId); - /** * Specify how root Node will be reduced to a list of nodes. There is a default * {@link MarkwonReducer} that will be used if not provided explicitly (there is no need to @@ -157,17 +138,27 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter { + public static abstract class Entry { @NonNull - H createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent); + public abstract H createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent); - void bindHolder(@NonNull Markwon markwon, @NonNull H holder, @NonNull N node); + public abstract void bindHolder(@NonNull Markwon markwon, @NonNull H holder, @NonNull N node); - long id(@NonNull N node); + /** + * Will be called when new content is available (clear internal cache if any) + */ + public void clear() { - // will be called when new content is available (clear internal cache if any) - void clear(); + } + + public long id(@NonNull N node) { + return node.hashCode(); + } + + public void onViewRecycled(@NonNull H holder) { + + } } public abstract void setMarkdown(@NonNull Markwon markwon, @NonNull String markdown); @@ -196,7 +187,15 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter V requireView(@IdRes int id) { final V v = itemView.findViewById(id); if (v == null) { - throw new NullPointerException(); + final String name; + if (id == 0 + || id == View.NO_ID) { + name = String.valueOf(id); + } else { + name = "R.id." + itemView.getResources().getResourceName(id); + } + throw new NullPointerException(String.format("No view with id(R.id.%s) is found " + + "in layout: %s", name, itemView)); } return v; } diff --git a/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapterImpl.java b/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapterImpl.java index 70406e78..30b093f5 100644 --- a/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapterImpl.java +++ b/markwon-recycler/src/main/java/ru/noties/markwon/recycler/MarkwonAdapterImpl.java @@ -32,6 +32,7 @@ class MarkwonAdapterImpl extends MarkwonAdapter { this.entries = entries; this.defaultEntry = defaultEntry; this.reducer = reducer; + setHasStableIds(true); } @@ -90,6 +91,14 @@ class MarkwonAdapterImpl extends MarkwonAdapter { : 0; } + @Override + public void onViewRecycled(@NonNull Holder holder) { + super.onViewRecycled(holder); + + final Entry entry = getEntry(holder.getItemViewType()); + entry.onViewRecycled(holder); + } + @SuppressWarnings("unused") @NonNull public List getItems() { @@ -132,9 +141,14 @@ class MarkwonAdapterImpl extends MarkwonAdapter { private final SparseArray> entries = new SparseArray<>(3); - private Entry defaultEntry; + private final Entry defaultEntry; + private MarkwonReducer reducer; + BuilderImpl(@NonNull Entry defaultEntry) { + this.defaultEntry = defaultEntry; + } + @NonNull @Override public Builder include( @@ -145,22 +159,6 @@ class MarkwonAdapterImpl extends MarkwonAdapter { return this; } - @NonNull - @Override - public Builder defaultEntry(@NonNull Entry defaultEntry) { - //noinspection unchecked - this.defaultEntry = (Entry) defaultEntry; - return this; - } - - @NonNull - @Override - public Builder defaultEntry(int layoutResId) { - //noinspection unchecked - this.defaultEntry = (Entry) (Entry) new SimpleEntry(layoutResId); - return this; - } - @NonNull @Override public Builder reducer(@NonNull MarkwonReducer reducer) { @@ -172,11 +170,6 @@ class MarkwonAdapterImpl extends MarkwonAdapter { @Override public MarkwonAdapter build() { - if (defaultEntry == null) { - //noinspection unchecked - defaultEntry = (Entry) (Entry) new SimpleEntry(); - } - if (reducer == null) { reducer = MarkwonReducer.directChildren(); } diff --git a/markwon-recycler/src/main/java/ru/noties/markwon/recycler/SimpleEntry.java b/markwon-recycler/src/main/java/ru/noties/markwon/recycler/SimpleEntry.java index 03ce5e56..93ef0d30 100644 --- a/markwon-recycler/src/main/java/ru/noties/markwon/recycler/SimpleEntry.java +++ b/markwon-recycler/src/main/java/ru/noties/markwon/recycler/SimpleEntry.java @@ -1,9 +1,8 @@ package ru.noties.markwon.recycler; +import android.support.annotation.IdRes; import android.support.annotation.LayoutRes; import android.support.annotation.NonNull; -import android.text.Spannable; -import android.text.SpannableString; import android.text.Spanned; import android.view.LayoutInflater; import android.view.View; @@ -16,32 +15,43 @@ import java.util.HashMap; import java.util.Map; import ru.noties.markwon.Markwon; +import ru.noties.markwon.utils.NoCopySpannableFactory; /** * @since 3.0.0 */ @SuppressWarnings("WeakerAccess") -public class SimpleEntry implements MarkwonAdapter.Entry { +public class SimpleEntry extends MarkwonAdapter.Entry { - public static final Spannable.Factory NO_COPY_SPANNABLE_FACTORY = new NoCopySpannableFactory(); + /** + * Create {@link SimpleEntry} that has TextView as the root view of + * specified layout. + */ + @NonNull + public static SimpleEntry createTextViewIsRoot(@LayoutRes int layoutResId) { + return new SimpleEntry(layoutResId, 0); + } + + @NonNull + public static SimpleEntry create(@LayoutRes int layoutResId, @IdRes int textViewIdRes) { + return new SimpleEntry(layoutResId, textViewIdRes); + } // small cache for already rendered nodes private final Map cache = new HashMap<>(); private final int layoutResId; + private final int textViewIdRes; - public SimpleEntry() { - this(R.layout.markwon_adapter_simple_entry); - } - - public SimpleEntry(@LayoutRes int layoutResId) { + public SimpleEntry(@LayoutRes int layoutResId, @IdRes int textViewIdRes) { this.layoutResId = layoutResId; + this.textViewIdRes = textViewIdRes; } @NonNull @Override public Holder createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { - return new Holder(inflater.inflate(layoutResId, parent, false)); + return new Holder(textViewIdRes, inflater.inflate(layoutResId, parent, false)); } @Override @@ -54,11 +64,6 @@ public class SimpleEntry implements MarkwonAdapter.Entry - \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index 35db7389..966d0747 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -29,14 +29,6 @@ android { targetCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8 } - - sourceSets { - main { - // let's use different res directory so sample will have _isolated_ resources from others - res.srcDirs += [ './src/main/recycler/res' ] - assets.srcDirs += ['./src/main/recycler/assets'] - } - } } dependencies { @@ -51,6 +43,7 @@ dependencies { implementation project(':markwon-image-svg') implementation project(':markwon-syntax-highlight') implementation project(':markwon-recycler') + implementation project(':markwon-recycler-table') deps.with { implementation it['support-recycler-view'] diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index d1442bf0..376348ca 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -7,11 +7,9 @@ + tools:ignore="AllowBackup,GoogleAppIndexingWarning,MissingApplicationIcon"> diff --git a/sample/src/main/assets/README.md b/sample/src/main/assets/README.md new file mode 120000 index 00000000..ff5c7960 --- /dev/null +++ b/sample/src/main/assets/README.md @@ -0,0 +1 @@ +../../../../README.md \ No newline at end of file diff --git a/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java b/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java index c230aa6c..adeb6c4b 100644 --- a/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java +++ b/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java @@ -11,15 +11,12 @@ import android.support.v7.widget.RecyclerView; import android.text.TextUtils; import org.commonmark.ext.gfm.tables.TableBlock; -import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.FencedCodeBlock; -import org.commonmark.parser.Parser; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.util.Collections; import ru.noties.debug.AndroidLogDebugOutput; import ru.noties.debug.Debug; @@ -33,6 +30,8 @@ import ru.noties.markwon.image.ImagesPlugin; import ru.noties.markwon.image.svg.SvgPlugin; import ru.noties.markwon.recycler.MarkwonAdapter; import ru.noties.markwon.recycler.SimpleEntry; +import ru.noties.markwon.recycler.table.TableEntry; +import ru.noties.markwon.recycler.table.TableEntryPlugin; import ru.noties.markwon.sample.R; import ru.noties.markwon.urlprocessor.UrlProcessor; import ru.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; @@ -51,14 +50,12 @@ public class RecyclerActivity extends Activity { // create MarkwonAdapter and register two blocks that will be rendered differently // * fenced code block (can also specify the same Entry for indended code block) // * table block - final MarkwonAdapter adapter = MarkwonAdapter.builder() - // we can simply use bundled SimpleEntry, that will lookup a TextView - // with `@+id/text` id - .include(FencedCodeBlock.class, new SimpleEntry(R.layout.adapter_fenced_code_block)) - // create own implementation of entry for different rendering - .include(TableBlock.class, new TableEntry2()) - // specify default entry (for all other blocks) - .defaultEntry(new SimpleEntry(R.layout.adapter_default_entry)) + final MarkwonAdapter adapter = MarkwonAdapter.builder(R.layout.adapter_default_entry, R.id.text) + // we can simply use bundled SimpleEntry + .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.adapter_fenced_code_block, R.id.text)) + .include(TableBlock.class, TableEntry.create(builder -> builder + .tableLayout(R.layout.adapter_table_block, R.id.table_layout) + .textLayoutIsRoot(R.layout.view_table_entry_cell))) .build(); final RecyclerView recyclerView = findViewById(R.id.recycler_view); @@ -71,10 +68,6 @@ public class RecyclerActivity extends Activity { // please note that we should notify updates (adapter doesn't do it implicitly) adapter.notifyDataSetChanged(); - - // NB, there is no currently available widget to render tables gracefully - // TableEntryView is here for demonstration purposes only (to show that rendering - // tables } @NonNull @@ -83,14 +76,8 @@ public class RecyclerActivity extends Activity { .usePlugin(CorePlugin.create()) .usePlugin(ImagesPlugin.createWithAssets(context)) .usePlugin(SvgPlugin.create(context.getResources())) - .usePlugin(new AbstractMarkwonPlugin() { - @Override - public void configureParser(@NonNull Parser.Builder builder) { - // it's important NOT to use TablePlugin - // the only thing we want from it is commonmark-java parser extension - builder.extensions(Collections.singleton(TablesExtension.create())); - } - }) + // important to use TableEntryPlugin instead of TablePlugin + .usePlugin(TableEntryPlugin.create(context)) .usePlugin(HtmlPlugin.create()) // .usePlugin(SyntaxHighlightPlugin.create()) .usePlugin(new AbstractMarkwonPlugin() { diff --git a/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntry.java b/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntry.java deleted file mode 100644 index 900ce719..00000000 --- a/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntry.java +++ /dev/null @@ -1,67 +0,0 @@ -package ru.noties.markwon.sample.recycler; - -import android.support.annotation.NonNull; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.commonmark.ext.gfm.tables.TableBlock; - -import java.util.HashMap; -import java.util.Map; - -import ru.noties.debug.Debug; -import ru.noties.markwon.Markwon; -import ru.noties.markwon.ext.tables.Table; -import ru.noties.markwon.recycler.MarkwonAdapter; -import ru.noties.markwon.sample.R; - -// do not use in real applications, this is just a showcase -public class TableEntry implements MarkwonAdapter.Entry { - - private final Map cache = new HashMap<>(2); - - @NonNull - @Override - public TableNodeHolder createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { - return new TableNodeHolder(inflater.inflate(R.layout.adapter_table_block, parent, false)); - } - - @Override - public void bindHolder(@NonNull Markwon markwon, @NonNull TableNodeHolder holder, @NonNull TableBlock node) { - - Table table = cache.get(node); - if (table == null) { - table = Table.parse(markwon, node); - cache.put(node, table); - } - - Debug.i(table); - - if (table != null) { - holder.tableEntryView.setTable(table); - // render table - } // we need to do something with null table... - } - - @Override - public long id(@NonNull TableBlock node) { - return node.hashCode(); - } - - @Override - public void clear() { - cache.clear(); - } - - static class TableNodeHolder extends MarkwonAdapter.Holder { - - final TableEntryView tableEntryView; - - TableNodeHolder(@NonNull View itemView) { - super(itemView); - - this.tableEntryView = requireView(R.id.table_entry); - } - } -} diff --git a/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntry2.java b/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntry2.java deleted file mode 100644 index dcf8d061..00000000 --- a/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntry2.java +++ /dev/null @@ -1,121 +0,0 @@ -package ru.noties.markwon.sample.recycler; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.support.annotation.NonNull; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TableLayout; -import android.widget.TableRow; -import android.widget.TextView; - -import org.commonmark.ext.gfm.tables.TableBlock; - -import java.util.HashMap; -import java.util.Map; - -import ru.noties.markwon.Markwon; -import ru.noties.markwon.ext.tables.Table; -import ru.noties.markwon.recycler.MarkwonAdapter; -import ru.noties.markwon.sample.R; - -public class TableEntry2 implements MarkwonAdapter.Entry { - - private final Map map = new HashMap<>(3); - - @NonNull - @Override - public TableHolder createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { - return new TableHolder(inflater.inflate(R.layout.adapter_table_block_2, parent, false)); - } - - @Override - public void bindHolder(@NonNull Markwon markwon, @NonNull TableHolder holder, @NonNull TableBlock node) { - - Table table = map.get(node); - if (table == null) { - table = Table.parse(markwon, node); - map.put(node, table); - } - - // check if this exact TableBlock was already - final TableLayout layout = holder.layout; - if (table == null - || table == layout.getTag(R.id.table_layout)) { - return; - } - - layout.setTag(R.id.table_layout, table); - layout.removeAllViews(); - layout.setBackgroundResource(R.drawable.bg_table_cell); - - final Context context = layout.getContext(); - final LayoutInflater inflater = LayoutInflater.from(context); - - TableRow tableRow; - TextView textView; - - for (Table.Row row : table.rows()) { - tableRow = new TableRow(context); - for (Table.Column column : row.columns()) { - textView = (TextView) inflater.inflate(R.layout.view_table_entry_cell, tableRow, false); - textView.setGravity(textGravity(column.alignment())); - markwon.setParsedMarkdown(textView, column.content()); - textView.getPaint().setFakeBoldText(row.header()); - textView.setBackgroundResource(R.drawable.bg_table_cell); - tableRow.addView(textView); - } - layout.addView(tableRow); - } - } - - @Override - public long id(@NonNull TableBlock node) { - return node.hashCode(); - } - - @Override - public void clear() { - map.clear(); - } - - static class TableHolder extends MarkwonAdapter.Holder { - - final TableLayout layout; - - TableHolder(@NonNull View itemView) { - super(itemView); - - this.layout = requireView(R.id.table_layout); - } - } - - // we will use gravity instead of textAlignment because min sdk is 16 (textAlignment starts at 17) - @SuppressLint("RtlHardcoded") - private static int textGravity(@NonNull Table.Alignment alignment) { - - final int gravity; - - switch (alignment) { - - case LEFT: - gravity = Gravity.LEFT; - break; - - case CENTER: - gravity = Gravity.CENTER_HORIZONTAL; - break; - - case RIGHT: - gravity = Gravity.RIGHT; - break; - - default: - throw new IllegalStateException("Unknown table alignment: " + alignment); - } - - return gravity | Gravity.CENTER_VERTICAL; - } -} diff --git a/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntryView.java b/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntryView.java deleted file mode 100644 index df789728..00000000 --- a/sample/src/main/java/ru/noties/markwon/sample/recycler/TableEntryView.java +++ /dev/null @@ -1,219 +0,0 @@ -package ru.noties.markwon.sample.recycler; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.text.SpannedString; -import android.util.AttributeSet; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; -import android.widget.TextView; - -import java.util.ArrayList; -import java.util.List; - -import ru.noties.markwon.ext.tables.Table; -import ru.noties.markwon.sample.R; - -public class TableEntryView extends LinearLayout { - - // paint and rect to draw borders - private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Rect rect = new Rect(); - - private LayoutInflater inflater; - - private int rowEvenBackgroundColor; - - public TableEntryView(Context context) { - super(context); - init(context, null); - } - - public TableEntryView(Context context, AttributeSet attrs) { - super(context, attrs); - init(context, attrs); - } - - private void init(Context context, @Nullable AttributeSet attrs) { - inflater = LayoutInflater.from(context); - setOrientation(VERTICAL); - - if (attrs != null) { - final TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TableEntryView); - try { - - rowEvenBackgroundColor = array.getColor(R.styleable.TableEntryView_tev_rowEvenBackgroundColor, 0); - - final int stroke = array.getDimensionPixelSize(R.styleable.TableEntryView_tev_borderWidth, 0); - - // half of requested - final float strokeWidth = stroke > 0 - ? stroke / 2.F - : context.getResources().getDisplayMetrics().density / 2.F; - - paint.setStyle(Paint.Style.STROKE); - paint.setStrokeWidth(strokeWidth); - paint.setColor(array.getColor(R.styleable.TableEntryView_tev_borderColor, Color.BLACK)); - - if (isInEditMode()) { - final String data = array.getString(R.styleable.TableEntryView_tev_debugData); - if (data != null) { - - boolean first = true; - - final List rows = new ArrayList<>(); - for (String row : data.split("\\|")) { - final List columns = new ArrayList<>(); - for (String column : row.split(",")) { - columns.add(new Table.Column(Table.Alignment.LEFT, new SpannedString(column))); - } - final boolean header = first; - first = false; - rows.add(new Table.Row(header, columns)); - } - final Table table = new Table(rows); - setTable(table); - } - } - } finally { - array.recycle(); - } - } - - setWillNotDraw(false); - } - - public void setTable(@NonNull Table table) { - final List rows = table.rows(); - for (int i = 0, size = rows.size(); i < size; i++) { - addRow(i, rows.get(i)); - } - requestLayout(); - } - - private void addRow(int index, @NonNull Table.Row row) { - - final ViewGroup group = ensureRow(index); - - final int backgroundColor = !row.header() && (index % 2) == 0 - ? rowEvenBackgroundColor - : 0; - - group.setBackgroundColor(backgroundColor); - - final List columns = row.columns(); - - TextView textView; - Table.Column column; - - for (int i = 0, size = columns.size(); i < size; i++) { - textView = ensureCell(group, i); - column = columns.get(i); - textView.setGravity(textGravity(column.alignment())); - textView.setText(column.content()); - textView.getPaint().setFakeBoldText(row.header()); - } - - group.requestLayout(); - } - - @NonNull - private ViewGroup ensureRow(int index) { - - final int count = getChildCount(); - if (index >= count) { - - // count=0,index=1, diff=2 - // count=0,index=5, diff=6 - // count=1,index=2, diff=2 - int diff = index - count + 1; - while (diff > 0) { - addView(inflater.inflate(R.layout.view_table_entry_row, this, false)); - diff -= 1; - } - } - - return (ViewGroup) getChildAt(index); - } - - @NonNull - private TextView ensureCell(@NonNull ViewGroup group, int index) { - - final int count = group.getChildCount(); - if (index >= count) { - int diff = index - count + 1; - while (diff > 0) { - group.addView(inflater.inflate(R.layout.view_table_entry_cell, group, false)); - diff -= 1; - } - } - - return (TextView) group.getChildAt(index); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - - final int rows = getChildCount(); - if (rows == 0) { - return; - } - - // first draw the whole border - rect.set(0, 0, getWidth(), getHeight()); - canvas.drawRect(rect, paint); - - ViewGroup group; - View view; - - int top; - - for (int row = 0; row < rows; row++) { - group = (ViewGroup) getChildAt(row); - top = group.getTop(); - for (int col = 0, cols = group.getChildCount(); col < cols; col++) { - view = group.getChildAt(col); - rect.set(view.getLeft(), top + view.getTop(), view.getRight(), top + view.getBottom()); - canvas.drawRect(rect, paint); - } - } - } - - // we will use gravity instead of textAlignment because min sdk is 16 (textAlignment starts at 17) - @SuppressLint("RtlHardcoded") - private static int textGravity(@NonNull Table.Alignment alignment) { - - final int gravity; - - switch (alignment) { - - case LEFT: - gravity = Gravity.LEFT; - break; - - case CENTER: - gravity = Gravity.CENTER_HORIZONTAL; - break; - - case RIGHT: - gravity = Gravity.RIGHT; - break; - - default: - throw new IllegalStateException("Unknown table alignment: " + alignment); - } - - return gravity; - } -} diff --git a/sample/src/main/recycler/assets/README.md b/sample/src/main/recycler/assets/README.md deleted file mode 120000 index 1dfab242..00000000 --- a/sample/src/main/recycler/assets/README.md +++ /dev/null @@ -1 +0,0 @@ -../../../../../README.md \ No newline at end of file diff --git a/sample/src/main/recycler/res/layout/adapter_table_block.xml b/sample/src/main/recycler/res/layout/adapter_table_block.xml deleted file mode 100644 index 3358ca5a..00000000 --- a/sample/src/main/recycler/res/layout/adapter_table_block.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/sample/src/main/recycler/res/values/attrs.xml b/sample/src/main/recycler/res/values/attrs.xml deleted file mode 100644 index 1827819d..00000000 --- a/sample/src/main/recycler/res/values/attrs.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/sample/src/main/res/drawable-v26/ic_launcher_background.xml b/sample/src/main/res/drawable-v26/ic_launcher_background.xml deleted file mode 100644 index a197b896..00000000 --- a/sample/src/main/res/drawable-v26/ic_launcher_background.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - diff --git a/sample/src/main/recycler/res/layout/activity_recycler.xml b/sample/src/main/res/layout/activity_recycler.xml similarity index 100% rename from sample/src/main/recycler/res/layout/activity_recycler.xml rename to sample/src/main/res/layout/activity_recycler.xml diff --git a/sample/src/main/recycler/res/layout/adapter_default_entry.xml b/sample/src/main/res/layout/adapter_default_entry.xml similarity index 100% rename from sample/src/main/recycler/res/layout/adapter_default_entry.xml rename to sample/src/main/res/layout/adapter_default_entry.xml diff --git a/sample/src/main/recycler/res/layout/adapter_fenced_code_block.xml b/sample/src/main/res/layout/adapter_fenced_code_block.xml similarity index 100% rename from sample/src/main/recycler/res/layout/adapter_fenced_code_block.xml rename to sample/src/main/res/layout/adapter_fenced_code_block.xml diff --git a/sample/src/main/recycler/res/layout/adapter_table_block_2.xml b/sample/src/main/res/layout/adapter_table_block.xml similarity index 83% rename from sample/src/main/recycler/res/layout/adapter_table_block_2.xml rename to sample/src/main/res/layout/adapter_table_block.xml index 6cdb3be6..aaaaa369 100644 --- a/sample/src/main/recycler/res/layout/adapter_table_block_2.xml +++ b/sample/src/main/res/layout/adapter_table_block.xml @@ -13,6 +13,7 @@ + android:layout_height="wrap_content" + android:stretchColumns="*" /> \ No newline at end of file diff --git a/sample/src/main/recycler/res/layout/view_table_entry_cell.xml b/sample/src/main/res/layout/view_table_entry_cell.xml similarity index 69% rename from sample/src/main/recycler/res/layout/view_table_entry_cell.xml rename to sample/src/main/res/layout/view_table_entry_cell.xml index 6261bacb..6d544918 100644 --- a/sample/src/main/recycler/res/layout/view_table_entry_cell.xml +++ b/sample/src/main/res/layout/view_table_entry_cell.xml @@ -2,9 +2,7 @@ - - - - \ No newline at end of file diff --git a/sample/src/main/res/mipmap-hdpi-v26/ic_launcher_foreground.png b/sample/src/main/res/mipmap-hdpi-v26/ic_launcher_foreground.png deleted file mode 100644 index 01bae147..00000000 Binary files a/sample/src/main/res/mipmap-hdpi-v26/ic_launcher_foreground.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/sample/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 1ba57872..00000000 Binary files a/sample/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-xhdpi-v26/ic_launcher_foreground.png b/sample/src/main/res/mipmap-xhdpi-v26/ic_launcher_foreground.png deleted file mode 100644 index 7361012f..00000000 Binary files a/sample/src/main/res/mipmap-xhdpi-v26/ic_launcher_foreground.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 8025e6dc..00000000 Binary files a/sample/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-xxhdpi-v26/ic_launcher_foreground.png b/sample/src/main/res/mipmap-xxhdpi-v26/ic_launcher_foreground.png deleted file mode 100644 index 24f9ddbc..00000000 Binary files a/sample/src/main/res/mipmap-xxhdpi-v26/ic_launcher_foreground.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 11dcdbc9..00000000 Binary files a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-xxxhdpi-v26/ic_launcher_foreground.png b/sample/src/main/res/mipmap-xxxhdpi-v26/ic_launcher_foreground.png deleted file mode 100644 index b64bf44f..00000000 Binary files a/sample/src/main/res/mipmap-xxxhdpi-v26/ic_launcher_foreground.png and /dev/null differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 2439acf0..00000000 Binary files a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/settings.gradle b/settings.gradle index e3177a74..faf09343 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,5 +10,6 @@ include ':app', ':sample', ':markwon-image-okhttp', ':markwon-image-svg', ':markwon-recycler', + ':markwon-recycler-table', ':markwon-syntax-highlight', ':markwon-test-span'