Add recycler-table module

This commit is contained in:
Dimitry Ivanov 2019-03-13 16:30:43 +03:00
parent cd8016eb68
commit 6d28817215
55 changed files with 1298 additions and 754 deletions

View File

@ -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 };

View File

@ -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'
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -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

View File

@ -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();
```

View File

@ -10,62 +10,6 @@ next: /docs/v3/core/getting-started.md
<ArtifactPicker />
# Bundle <Badge text="3.0.0" />
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

View File

@ -0,0 +1,90 @@
# Recycler Table <Badge text="3.0.0" />
Artifact that provides [MarkwonAdapter.Entry](/docs/v3/recycler/) to render `TableBlock` inside
Android-native `TableLayout` widget.
<img :src="$withBase('/assets/recycler-table-screenshot.png')" alt="screenshot" width="45%">
<br>
<small><em><sup>*</sup> It's possible to wrap `TableLayout` inside a `HorizontalScrollView` to include all table content</em></small>
---
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
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingLeft="16dip"
android:paddingTop="8dip"
android:paddingRight="16dip"
android:paddingBottom="8dip"
android:scrollbarStyle="outsideInset">
<TableLayout
android:id="@+id/table_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:stretchColumns="*" />
</HorizontalScrollView>
```

View File

@ -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")
}
}

View File

@ -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<? extends MarkwonPlugin> plugin);
@Nullable
public abstract <P extends MarkwonPlugin> P getPlugin(@NonNull Class<P> type);
/**
* Builder for {@link Markwon}.
* <p>

View File

@ -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<? extends MarkwonPlugin> type) {
boolean result = false;
return getPlugin(type) != null;
}
@Nullable
@Override
public <P extends MarkwonPlugin> P getPlugin(@NonNull Class<P> 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;
}
}

View File

@ -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();

View File

@ -34,6 +34,12 @@ public interface MarkwonSpansFactory {
@NonNull
<N extends Node> Builder setFactory(@NonNull Class<N> node, @Nullable SpanFactory factory);
/**
* Can be useful when <em>enhancing</em> an already defined SpanFactory with another one.
*/
@Nullable
<N extends Node> SpanFactory getFactory(@NonNull Class<N> node);
@NonNull
MarkwonSpansFactory build();
}

View File

@ -52,6 +52,12 @@ class MarkwonSpansFactoryImpl implements MarkwonSpansFactory {
return this;
}
@Nullable
@Override
public <N extends Node> SpanFactory getFactory(@NonNull Class<N> node) {
return factories.get(node);
}
@NonNull
@Override
public MarkwonSpansFactory build() {

View File

@ -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();
}
}

View File

@ -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()));

View File

@ -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;
}

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1 @@
<manifest package="ru.noties.markwon.recycler.table" />

View File

@ -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();
}
}

View File

@ -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<TableBlock, TableEntry.Holder> {
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<TableBlock, Table> 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<Table.Row> 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
);
}
}
}

View File

@ -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 <em>limited</em>
* 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()));
}
}

View File

@ -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;
}
}

View File

@ -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<Pair<Table.Alignment, Integer>> noVerticalAlign = Arrays.asList(
new Pair<Table.Alignment, Integer>(Table.Alignment.LEFT, Gravity.LEFT),
new Pair<Table.Alignment, Integer>(Table.Alignment.CENTER, Gravity.CENTER_HORIZONTAL),
new Pair<Table.Alignment, Integer>(Table.Alignment.RIGHT, Gravity.RIGHT)
);
final List<Pair<Table.Alignment, Integer>> withVerticalAlign = Arrays.asList(
new Pair<Table.Alignment, Integer>(Table.Alignment.LEFT, Gravity.LEFT | Gravity.CENTER_VERTICAL),
new Pair<Table.Alignment, Integer>(Table.Alignment.CENTER, Gravity.CENTER_HORIZONTAL | Gravity.CENTER_VERTICAL),
new Pair<Table.Alignment, Integer>(Table.Alignment.RIGHT, Gravity.RIGHT | Gravity.CENTER_VERTICAL)
);
for (Pair<Table.Alignment, Integer> pair : noVerticalAlign) {
assertEquals(pair.first.name(), pair.second.intValue(), TableEntry.textGravity(pair.first, false));
}
for (Pair<Table.Alignment, Integer> 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());
}
}
}

View File

@ -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)}).
* <p>
* 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<MarkwonAdapter.Holder> {
@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<? extends Node, ? extends Holder> defaultEntry) {
//noinspection unchecked
return new MarkwonAdapterImpl.BuilderImpl((Entry<Node, Holder>) defaultEntry);
}
@NonNull
public static MarkwonAdapter createTextViewIsRoot(@LayoutRes int defaultEntryLayoutResId) {
return builderTextViewIsRoot(defaultEntryLayoutResId)
.build();
}
/**
@ -52,12 +68,16 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter<MarkwonAdapter
* adapter will use default layout for all blocks. Default layout has no styling and should
* be specified explicitly.
*
* @see #create(int)
* @see #create(Entry)
* @see #builder(int, int)
* @see SimpleEntry
*/
@NonNull
public static MarkwonAdapter create() {
return new MarkwonAdapterImpl.BuilderImpl().build();
public static MarkwonAdapter create(
@LayoutRes int defaultEntryLayoutResId,
@IdRes int defaultEntryTextViewResId
) {
return builder(defaultEntryLayoutResId, defaultEntryTextViewResId).build();
}
/**
@ -65,35 +85,19 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter<MarkwonAdapter
* nodes.
*
* @param defaultEntry {@link Entry} to be used for node rendering
* @see SimpleEntry
* @see #builder(Entry)
*/
@NonNull
public static MarkwonAdapter create(@NonNull Entry<? extends Node, ? extends Holder> 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.
*
* <strong>Please note</strong> 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<MarkwonAdapter
@NonNull Class<N> node,
@NonNull Entry<? super N, ? extends Holder> 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<? extends Node, ? extends Holder> defaultEntry);
/**
* Specify which layout {@link SimpleEntry} will use to render all non-explicitly
* registered nodes.
*
* <strong>Please note</strong> 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 <em>reduced</em> 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<MarkwonAdapter
/**
* @see SimpleEntry
*/
public interface Entry<N extends Node, H extends Holder> {
public static abstract class Entry<N extends Node, H extends Holder> {
@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<MarkwonAdapter
protected <V extends View> 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;
}

View File

@ -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<Node, Holder> entry = getEntry(holder.getItemViewType());
entry.onViewRecycled(holder);
}
@SuppressWarnings("unused")
@NonNull
public List<Node> getItems() {
@ -132,9 +141,14 @@ class MarkwonAdapterImpl extends MarkwonAdapter {
private final SparseArray<Entry<Node, Holder>> entries = new SparseArray<>(3);
private Entry<Node, Holder> defaultEntry;
private final Entry<Node, Holder> defaultEntry;
private MarkwonReducer reducer;
BuilderImpl(@NonNull Entry<Node, Holder> defaultEntry) {
this.defaultEntry = defaultEntry;
}
@NonNull
@Override
public <N extends Node> Builder include(
@ -145,22 +159,6 @@ class MarkwonAdapterImpl extends MarkwonAdapter {
return this;
}
@NonNull
@Override
public Builder defaultEntry(@NonNull Entry<? extends Node, ? extends Holder> defaultEntry) {
//noinspection unchecked
this.defaultEntry = (Entry<Node, Holder>) defaultEntry;
return this;
}
@NonNull
@Override
public Builder defaultEntry(int layoutResId) {
//noinspection unchecked
this.defaultEntry = (Entry<Node, Holder>) (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<Node, Holder>) (Entry) new SimpleEntry();
}
if (reducer == null) {
reducer = MarkwonReducer.directChildren();
}

View File

@ -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<Node, SimpleEntry.Holder> {
public class SimpleEntry extends MarkwonAdapter.Entry<Node, SimpleEntry.Holder> {
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<Node, Spanned> 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<Node, SimpleEntry.Holde
markwon.setParsedMarkdown(holder.textView, spanned);
}
@Override
public long id(@NonNull Node node) {
return node.hashCode();
}
@Override
public void clear() {
cache.clear();
@ -68,21 +73,21 @@ public class SimpleEntry implements MarkwonAdapter.Entry<Node, SimpleEntry.Holde
final TextView textView;
protected Holder(@NonNull View itemView) {
protected Holder(@IdRes int textViewIdRes, @NonNull View itemView) {
super(itemView);
this.textView = requireView(R.id.text);
this.textView.setSpannableFactory(NO_COPY_SPANNABLE_FACTORY);
}
}
private static class NoCopySpannableFactory extends Spannable.Factory {
@Override
public Spannable newSpannable(CharSequence source) {
return source instanceof Spannable
? (Spannable) source
: new SpannableString(source);
final TextView textView;
if (textViewIdRes == 0) {
if (!(itemView instanceof TextView)) {
throw new IllegalStateException("TextView is not root of layout " +
"(specify TextView ID explicitly): " + itemView);
}
textView = (TextView) itemView;
} else {
textView = requireView(textViewIdRes);
}
this.textView = textView;
this.textView.setSpannableFactory(NoCopySpannableFactory.getInstance());
}
}
}

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lineSpacingExtra="2dip"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="# Hello there!" />

View File

@ -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']

View File

@ -7,11 +7,9 @@
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/AppTheme"
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
tools:ignore="AllowBackup,GoogleAppIndexingWarning,MissingApplicationIcon">
<activity android:name=".MainActivity">
<intent-filter>

View File

@ -0,0 +1 @@
../../../../README.md

View File

@ -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() {

View File

@ -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<TableBlock, TableEntry.TableNodeHolder> {
private final Map<TableBlock, Table> 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);
}
}
}

View File

@ -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<TableBlock, TableEntry2.TableHolder> {
private final Map<TableBlock, Table> 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;
}
}

View File

@ -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<Table.Row> rows = new ArrayList<>();
for (String row : data.split("\\|")) {
final List<Table.Column> 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<Table.Row> 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<Table.Column> 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;
}
}

View File

@ -1 +0,0 @@
../../../../../README.md

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingLeft="16dip"
android:paddingTop="8dip"
android:paddingRight="16dip"
android:paddingBottom="8dip"
android:scrollbarStyle="outsideInset">
<ru.noties.markwon.sample.recycler.TableEntryView
android:id="@+id/table_entry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:tev_debugData="head1,head2,head3|col1,col2,col3|col1,col2,col3|col1,col2,col3"
app:tev_rowEvenBackgroundColor="#40ff0000" />
</HorizontalScrollView>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="TableEntryView">
<attr name="tev_rowEvenBackgroundColor" format="color" />
<attr name="tev_borderColor" format="color" />
<attr name="tev_borderWidth" format="dimension" />
<attr name="tev_debugData" format="string" />
</declare-styleable>
</resources>

View File

@ -1,24 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M0,0h512v256h-512z"
android:strokeAlpha="0.94117647"
android:strokeWidth="0.40000001"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:fillType="nonZero"
android:fillAlpha="1"
android:strokeLineCap="butt"/>
<path
android:pathData="M0,256h512v256h-512z"
android:strokeAlpha="0.94117647"
android:strokeWidth="0.40000001"
android:fillColor="#333333"
android:strokeColor="#00000000"
android:fillType="nonZero"
android:fillAlpha="1"
android:strokeLineCap="butt"/>
</vector>

View File

@ -13,6 +13,7 @@
<TableLayout
android:id="@+id/table_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:stretchColumns="*" />
</HorizontalScrollView>

View File

@ -2,9 +2,7 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:padding="8dip"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="#000"
android:textSize="16sp"

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -10,5 +10,6 @@ include ':app', ':sample',
':markwon-image-okhttp',
':markwon-image-svg',
':markwon-recycler',
':markwon-recycler-table',
':markwon-syntax-highlight',
':markwon-test-span'