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 // 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 }; export { artifacts };

View File

@ -60,6 +60,7 @@ module.exports = {
'/docs/v3/image/okhttp.md', '/docs/v3/image/okhttp.md',
'/docs/v3/image/svg.md', '/docs/v3/image/svg.md',
'/docs/v3/recycler/', '/docs/v3/recycler/',
'/docs/v3/recycler-table/',
'/docs/v3/syntax-highlight/', '/docs/v3/syntax-highlight/',
'/docs/v3/migration-2-3.md' '/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); myTableWidget.setTable(table);
``` ```
Unfortunately Markwon does not provide a widget that can be used for tables. But it does :::tip
provide API that can be used to achieve desired result. 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 ## Theme

View File

@ -8,3 +8,139 @@ Adds support for GFM (Github-flavored markdown) task-lists:
Markwon.builder(context) Markwon.builder(context)
.usePlugin(TaskListPlugin.create(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 /> <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 ## Snapshot
In order to use latest `SNAPSHOT` version add snapshot repository 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.content.Context;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Spanned; import android.text.Spanned;
import android.widget.TextView; import android.widget.TextView;
@ -101,6 +102,9 @@ public abstract class Markwon {
*/ */
public abstract boolean hasPlugin(@NonNull Class<? extends MarkwonPlugin> plugin); 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}. * Builder for {@link Markwon}.
* <p> * <p>

View File

@ -1,6 +1,7 @@
package ru.noties.markwon; package ru.noties.markwon;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Spanned; import android.text.Spanned;
import android.widget.TextView; import android.widget.TextView;
@ -91,13 +92,19 @@ class MarkwonImpl extends Markwon {
@Override @Override
public boolean hasPlugin(@NonNull Class<? extends MarkwonPlugin> type) { 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) { for (MarkwonPlugin plugin : plugins) {
if (type.isAssignableFrom(plugin.getClass())) { if (type.isAssignableFrom(plugin.getClass())) {
result = true; out = plugin;
break;
} }
} }
return result; //noinspection unchecked
return (P) out;
} }
} }

View File

@ -13,6 +13,10 @@ import java.util.List;
*/ */
public abstract class MarkwonReducer { public abstract class MarkwonReducer {
/**
* @return direct children of supplied Node. In the most usual case
* will return all BlockNodes of a Document
*/
@NonNull @NonNull
public static MarkwonReducer directChildren() { public static MarkwonReducer directChildren() {
return new DirectChildren(); return new DirectChildren();

View File

@ -34,6 +34,12 @@ public interface MarkwonSpansFactory {
@NonNull @NonNull
<N extends Node> Builder setFactory(@NonNull Class<N> node, @Nullable SpanFactory factory); <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 @NonNull
MarkwonSpansFactory build(); MarkwonSpansFactory build();
} }

View File

@ -52,6 +52,12 @@ class MarkwonSpansFactoryImpl implements MarkwonSpansFactory {
return this; return this;
} }
@Nullable
@Override
public <N extends Node> SpanFactory getFactory(@NonNull Class<N> node) {
return factories.get(node);
}
@NonNull @NonNull
@Override @Override
public MarkwonSpansFactory build() { 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()); return new TablePlugin(builder.build());
} }
private final TableTheme theme;
private final TableVisitor visitor; private final TableVisitor visitor;
@SuppressWarnings("WeakerAccess") @SuppressWarnings("WeakerAccess")
TablePlugin(@NonNull TableTheme tableTheme) { TablePlugin(@NonNull TableTheme tableTheme) {
this.theme = tableTheme;
this.visitor = new TableVisitor(tableTheme); this.visitor = new TableVisitor(tableTheme);
} }
@NonNull
public TableTheme theme() {
return theme;
}
@Override @Override
public void configureParser(@NonNull Parser.Builder builder) { public void configureParser(@NonNull Parser.Builder builder) {
builder.extensions(Collections.singleton(TablesExtension.create())); builder.extensions(Collections.singleton(TablesExtension.create()));

View File

@ -13,15 +13,19 @@ public class TableTheme {
@NonNull @NonNull
public static TableTheme create(@NonNull Context context) { public static TableTheme create(@NonNull Context context) {
final Dip dip = Dip.create(context); return buildWithDefaults(context).build();
return builder()
.tableCellPadding(dip.toPx(4))
.tableBorderWidth(dip.toPx(1))
.build();
} }
@NonNull @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(); return new Builder();
} }
@ -58,6 +62,20 @@ public class TableTheme {
this.tableHeaderRowBackgroundColor = builder.tableHeaderRowBackgroundColor; 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() { public int tableCellPadding() {
return 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 * 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 * 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)}). * 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 #builder(int, int)
* @see #create() * @see #builder(Entry)
* @see #create(int) * @see #create(int, int)
* @see #create(Entry) * @see #create(Entry)
* @see #setMarkdown(Markwon, String) * @see #setMarkdown(Markwon, String)
* @see #setParsedMarkdown(Markwon, Node) * @see #setParsedMarkdown(Markwon, Node)
@ -37,14 +33,34 @@ import ru.noties.markwon.MarkwonReducer;
*/ */
public abstract class MarkwonAdapter extends RecyclerView.Adapter<MarkwonAdapter.Holder> { 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. * Factory method to obtain {@link Builder} instance.
* *
* @see Builder * @see Builder
*/ */
@NonNull @NonNull
public static Builder builder() { public static Builder builder(
return new MarkwonAdapterImpl.BuilderImpl(); @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 * adapter will use default layout for all blocks. Default layout has no styling and should
* be specified explicitly. * be specified explicitly.
* *
* @see #create(int)
* @see #create(Entry) * @see #create(Entry)
* @see #builder(int, int)
* @see SimpleEntry
*/ */
@NonNull @NonNull
public static MarkwonAdapter create() { public static MarkwonAdapter create(
return new MarkwonAdapterImpl.BuilderImpl().build(); @LayoutRes int defaultEntryLayoutResId,
@IdRes int defaultEntryTextViewResId
) {
return builder(defaultEntryLayoutResId, defaultEntryTextViewResId).build();
} }
/** /**
@ -65,35 +85,19 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter<MarkwonAdapter
* nodes. * nodes.
* *
* @param defaultEntry {@link Entry} to be used for node rendering * @param defaultEntry {@link Entry} to be used for node rendering
* @see SimpleEntry * @see #builder(Entry)
*/ */
@NonNull @NonNull
public static MarkwonAdapter create(@NonNull Entry<? extends Node, ? extends Holder> defaultEntry) { public static MarkwonAdapter create(@NonNull Entry<? extends Node, ? extends Holder> defaultEntry) {
return new MarkwonAdapterImpl.BuilderImpl().defaultEntry(defaultEntry).build(); return builder(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();
} }
/** /**
* Builder to create an instance of {@link MarkwonAdapter} * Builder to create an instance of {@link MarkwonAdapter}
* *
* @see #include(Class, Entry) * @see #include(Class, Entry)
* @see #defaultEntry(int)
* @see #defaultEntry(Entry)
* @see #reducer(MarkwonReducer) * @see #reducer(MarkwonReducer)
* @see #build()
*/ */
public interface Builder { public interface Builder {
@ -112,29 +116,6 @@ public abstract class MarkwonAdapter extends RecyclerView.Adapter<MarkwonAdapter
@NonNull Class<N> node, @NonNull Class<N> node,
@NonNull Entry<? super N, ? extends Holder> entry); @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 * 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 * {@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 * @see SimpleEntry
*/ */
public interface Entry<N extends Node, H extends Holder> { public static abstract class Entry<N extends Node, H extends Holder> {
@NonNull @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); 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) { protected <V extends View> V requireView(@IdRes int id) {
final V v = itemView.findViewById(id); final V v = itemView.findViewById(id);
if (v == null) { 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; return v;
} }

View File

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

View File

@ -1,9 +1,8 @@
package ru.noties.markwon.recycler; package ru.noties.markwon.recycler;
import android.support.annotation.IdRes;
import android.support.annotation.LayoutRes; import android.support.annotation.LayoutRes;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned; import android.text.Spanned;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -16,32 +15,43 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import ru.noties.markwon.Markwon; import ru.noties.markwon.Markwon;
import ru.noties.markwon.utils.NoCopySpannableFactory;
/** /**
* @since 3.0.0 * @since 3.0.0
*/ */
@SuppressWarnings("WeakerAccess") @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 // small cache for already rendered nodes
private final Map<Node, Spanned> cache = new HashMap<>(); private final Map<Node, Spanned> cache = new HashMap<>();
private final int layoutResId; private final int layoutResId;
private final int textViewIdRes;
public SimpleEntry() { public SimpleEntry(@LayoutRes int layoutResId, @IdRes int textViewIdRes) {
this(R.layout.markwon_adapter_simple_entry);
}
public SimpleEntry(@LayoutRes int layoutResId) {
this.layoutResId = layoutResId; this.layoutResId = layoutResId;
this.textViewIdRes = textViewIdRes;
} }
@NonNull @NonNull
@Override @Override
public Holder createHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) { 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 @Override
@ -54,11 +64,6 @@ public class SimpleEntry implements MarkwonAdapter.Entry<Node, SimpleEntry.Holde
markwon.setParsedMarkdown(holder.textView, spanned); markwon.setParsedMarkdown(holder.textView, spanned);
} }
@Override
public long id(@NonNull Node node) {
return node.hashCode();
}
@Override @Override
public void clear() { public void clear() {
cache.clear(); cache.clear();
@ -68,21 +73,21 @@ public class SimpleEntry implements MarkwonAdapter.Entry<Node, SimpleEntry.Holde
final TextView textView; final TextView textView;
protected Holder(@NonNull View itemView) { protected Holder(@IdRes int textViewIdRes, @NonNull View itemView) {
super(itemView); super(itemView);
this.textView = requireView(R.id.text); final TextView textView;
this.textView.setSpannableFactory(NO_COPY_SPANNABLE_FACTORY); if (textViewIdRes == 0) {
} if (!(itemView instanceof TextView)) {
} throw new IllegalStateException("TextView is not root of layout " +
"(specify TextView ID explicitly): " + itemView);
private static class NoCopySpannableFactory extends Spannable.Factory { }
textView = (TextView) itemView;
@Override } else {
public Spannable newSpannable(CharSequence source) { textView = requireView(textViewIdRes);
return source instanceof Spannable }
? (Spannable) source this.textView = textView;
: new SpannableString(source); 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 targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility 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 { dependencies {
@ -51,6 +43,7 @@ dependencies {
implementation project(':markwon-image-svg') implementation project(':markwon-image-svg')
implementation project(':markwon-syntax-highlight') implementation project(':markwon-syntax-highlight')
implementation project(':markwon-recycler') implementation project(':markwon-recycler')
implementation project(':markwon-recycler-table')
deps.with { deps.with {
implementation it['support-recycler-view'] implementation it['support-recycler-view']

View File

@ -7,11 +7,9 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
tools:ignore="AllowBackup,GoogleAppIndexingWarning"> tools:ignore="AllowBackup,GoogleAppIndexingWarning,MissingApplicationIcon">
<activity android:name=".MainActivity"> <activity android:name=".MainActivity">
<intent-filter> <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 android.text.TextUtils;
import org.commonmark.ext.gfm.tables.TableBlock; import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.FencedCodeBlock; import org.commonmark.node.FencedCodeBlock;
import org.commonmark.parser.Parser;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.Collections;
import ru.noties.debug.AndroidLogDebugOutput; import ru.noties.debug.AndroidLogDebugOutput;
import ru.noties.debug.Debug; 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.image.svg.SvgPlugin;
import ru.noties.markwon.recycler.MarkwonAdapter; import ru.noties.markwon.recycler.MarkwonAdapter;
import ru.noties.markwon.recycler.SimpleEntry; 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.sample.R;
import ru.noties.markwon.urlprocessor.UrlProcessor; import ru.noties.markwon.urlprocessor.UrlProcessor;
import ru.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; 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 // create MarkwonAdapter and register two blocks that will be rendered differently
// * fenced code block (can also specify the same Entry for indended code block) // * fenced code block (can also specify the same Entry for indended code block)
// * table block // * table block
final MarkwonAdapter adapter = MarkwonAdapter.builder() final MarkwonAdapter adapter = MarkwonAdapter.builder(R.layout.adapter_default_entry, R.id.text)
// we can simply use bundled SimpleEntry, that will lookup a TextView // we can simply use bundled SimpleEntry
// with `@+id/text` id .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.adapter_fenced_code_block, R.id.text))
.include(FencedCodeBlock.class, new SimpleEntry(R.layout.adapter_fenced_code_block)) .include(TableBlock.class, TableEntry.create(builder -> builder
// create own implementation of entry for different rendering .tableLayout(R.layout.adapter_table_block, R.id.table_layout)
.include(TableBlock.class, new TableEntry2()) .textLayoutIsRoot(R.layout.view_table_entry_cell)))
// specify default entry (for all other blocks)
.defaultEntry(new SimpleEntry(R.layout.adapter_default_entry))
.build(); .build();
final RecyclerView recyclerView = findViewById(R.id.recycler_view); 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) // please note that we should notify updates (adapter doesn't do it implicitly)
adapter.notifyDataSetChanged(); 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 @NonNull
@ -83,14 +76,8 @@ public class RecyclerActivity extends Activity {
.usePlugin(CorePlugin.create()) .usePlugin(CorePlugin.create())
.usePlugin(ImagesPlugin.createWithAssets(context)) .usePlugin(ImagesPlugin.createWithAssets(context))
.usePlugin(SvgPlugin.create(context.getResources())) .usePlugin(SvgPlugin.create(context.getResources()))
.usePlugin(new AbstractMarkwonPlugin() { // important to use TableEntryPlugin instead of TablePlugin
@Override .usePlugin(TableEntryPlugin.create(context))
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()));
}
})
.usePlugin(HtmlPlugin.create()) .usePlugin(HtmlPlugin.create())
// .usePlugin(SyntaxHighlightPlugin.create()) // .usePlugin(SyntaxHighlightPlugin.create())
.usePlugin(new AbstractMarkwonPlugin() { .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 <TableLayout
android:id="@+id/table_layout" android:id="@+id/table_layout"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content"
android:stretchColumns="*" />
</HorizontalScrollView> </HorizontalScrollView>

View File

@ -2,9 +2,7 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:gravity="center_vertical"
android:padding="8dip"
android:textAppearance="?android:attr/textAppearanceMedium" android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="#000" android:textColor="#000"
android:textSize="16sp" 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-okhttp',
':markwon-image-svg', ':markwon-image-svg',
':markwon-recycler', ':markwon-recycler',
':markwon-recycler-table',
':markwon-syntax-highlight', ':markwon-syntax-highlight',
':markwon-test-span' ':markwon-test-span'