HTML から Android のコンポーネントを作成してみるサンプル
package jp.example.android.util.html | |
import android.content.Context | |
import android.graphics.Bitmap | |
import android.support.v4.content.ContextCompat | |
import android.text.SpannableString | |
import android.text.SpannableStringBuilder | |
import android.text.Spanned.SPAN_INCLUSIVE_INCLUSIVE | |
import android.text.style.TextAppearanceSpan | |
import android.view.Gravity.CENTER | |
import android.view.View | |
import android.view.ViewGroup.LayoutParams.MATCH_PARENT | |
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT | |
import android.widget.* | |
import android.widget.LinearLayout | |
import android.widget.LinearLayout.VERTICAL | |
import com.airbnb.paris.Paris | |
import com.bumptech.glide.Glide | |
import com.bumptech.glide.load.model.GlideUrl | |
import com.bumptech.glide.load.model.LazyHeaders | |
import life.medley.android.R | |
import life.medley.android.util.image.GlideLoggingListener | |
import okhttp3.HttpUrl | |
import org.jsoup.Jsoup | |
import org.jsoup.nodes.Element | |
class HTMLConverter( | |
private val context: Context, | |
private val html: String | |
) { | |
private val resultView = LinearLayout(context).apply { | |
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) | |
orientation = VERTICAL | |
} | |
fun parse() { | |
val body = Jsoup.parse(html) | |
.normalise() | |
.body() | |
inspect(body, ElementViewHolder()) | |
} | |
fun build() = resultView | |
private fun inspect(parentElement: Element, parent: ElementViewHolder) { | |
var brotherCount = 1 | |
parentElement.children().forEach { | |
val prevElement = it.previousElementSibling() | |
if (prevElement != null && prevElement.tagName() == it.tagName()) { | |
brotherCount++ | |
} else { | |
brotherCount = 1 | |
} | |
val type = ElementType.valueOf(it) | |
val view = when (type) { | |
ElementType.TABLE, | |
ElementType.TABLE_BODY -> parseTable(it) | |
ElementType.TABLE_ROW -> createTableRow() | |
ElementType.TABLE_DATA -> parseTableData(it) | |
ElementType.IMAGE -> parseImage(it) | |
ElementType.HEADER1, | |
ElementType.HEADER2, | |
ElementType.HEADER3, | |
ElementType.HEADER4, | |
ElementType.HEADER5, | |
ElementType.HEADER6 -> parseTitle(it, type) | |
ElementType.DEFINITION_TERM, | |
ElementType.PARAGRAPH -> parseParagraph(it) | |
ElementType.LINK -> parseLink(it) | |
ElementType.UNORDERED_LIST, | |
ElementType.ORDERED_LIST, | |
ElementType.DEFINITION_LIST -> createListView() | |
ElementType.LIST_ITEM, | |
ElementType.DEFINITION_DESCRIPTION -> parseListItem(it, parent, brotherCount) | |
else -> null | |
} | |
val children = it.children() | |
if (children.isNotEmpty()) { | |
val holder = ElementViewHolder(view, type, parent.depth + 1) | |
if (type == parent.type) { | |
holder.orderString = parent.orderString + "." + brotherCount | |
} else { | |
holder.orderString = brotherCount.toString() | |
} | |
inspect(it, holder) | |
} | |
// View が存在しないか、parent に合成できた場合は view に追加する必用がない | |
if (view == null || compose(ElementViewHolder(view, ElementType.valueOf(it)), parent)) { | |
return@forEach | |
} | |
if (type == ElementType.TABLE || type == ElementType.TABLE_BODY) { | |
val innerView = LinearLayout(context).apply { | |
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) | |
orientation = LinearLayout.HORIZONTAL | |
addView(view) | |
} | |
val scrollView = HorizontalScrollView(context).apply { | |
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) | |
addView(innerView) | |
} | |
resultView.addView(scrollView) | |
return@forEach | |
} | |
resultView.addView(view, MATCH_PARENT, WRAP_CONTENT) | |
} | |
} | |
private fun parseTable(el: Element): View? { | |
// 自分が table で小要素に tbody が合った場合は tbody で探索を行う必要があるため、探索を打ち切る | |
val child = el.children() | |
.map { ElementType.valueOf(it) } | |
.firstOrNull { it == ElementType.TABLE_BODY } | |
if (child !== null) { | |
return null | |
} | |
return TableLayout(context).apply { | |
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) | |
} | |
} | |
private fun createListView(): View? { | |
return TextView(context).apply { | |
Paris.style(this).apply(R.style.article_item) | |
} | |
} | |
private fun createTableRow(): View? { | |
return TableRow(context).apply { | |
gravity = CENTER | |
} | |
} | |
private fun parseImage(el: Element): View? { | |
val url = HttpUrl.parse(el.absUrl("src")) ?: return null | |
return ImageView(context).apply { | |
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) | |
Paris.style(this).apply(R.style.article_item) | |
val headers = LazyHeaders.Builder() | |
.addHeader("Content-Type", "image/bmp") | |
.build() | |
val glideUrl = GlideUrl(url.url(), headers) | |
Glide.with(context) | |
.asBitmap() | |
.load(glideUrl) | |
.listener(GlideLoggingListener<Bitmap>()) | |
.into(this) | |
} | |
} | |
private fun parseTitle(el: Element, type: ElementType): View? { | |
return TextView(context).apply { | |
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) | |
if (type == ElementType.HEADER2) { | |
background = ContextCompat.getDrawable(context, R.drawable.sub_title) | |
} | |
Paris.style(this).apply(R.style.subtitle) | |
text = el.text() | |
} | |
} | |
private fun parseParagraph(el: Element): View? { | |
// 空行の区切りに使用されているものがあるので無視する | |
if (el.text() == " ") { | |
return null | |
} | |
return TextView(context).apply { | |
Paris.style(this).apply(R.style.article_item) | |
text = SpannableString(el.text()) | |
} | |
} | |
private fun parseLink(el: Element): View? { | |
return TextView(context).apply { | |
Paris.style(this).apply(R.style.article_item) | |
text = el.text() | |
} | |
} | |
private fun parseListItem(el: Element, parent: ElementViewHolder, brotherCount: Int): View? { | |
return TextView(context).apply { | |
Paris.style(this).apply(R.style.article_item) | |
val prefix = "\t".repeat(parent.depth - 1) | |
text = when (parent.type) { | |
ElementType.UNORDERED_LIST -> prefix + UNORDERED_LIST_DECORATOR + el.text() | |
ElementType.ORDERED_LIST -> "$prefix$brotherCount. ${el.text()}" | |
ElementType.DEFINITION_LIST -> prefix + el.text() | |
else -> "" | |
} | |
} | |
} | |
private fun parseTableData(el: Element): View? { | |
return TextView(context).apply { | |
Paris.style(this).apply(R.style.table_data) | |
text = el.text() | |
} | |
} | |
private fun compose(self: ElementViewHolder, parent: ElementViewHolder): Boolean { | |
return when (self.type) { | |
ElementType.DEFINITION_TERM, | |
ElementType.PARAGRAPH, | |
ElementType.UNORDERED_LIST, | |
ElementType.ORDERED_LIST, | |
ElementType.DEFINITION_LIST, | |
ElementType.LIST_ITEM, | |
ElementType.DEFINITION_DESCRIPTION, | |
ElementType.TABLE_DATA -> composeText(self.view as TextView, parent) | |
ElementType.LINK -> composeLink(self.view as TextView, parent) | |
ElementType.TABLE_ROW -> composeTableRow(self.view as TableRow, parent) | |
else -> false | |
} | |
} | |
private fun composeText(self: TextView, parent: ElementViewHolder): Boolean { | |
when (parent.type) { | |
ElementType.DEFINITION_TERM, | |
ElementType.PARAGRAPH, | |
ElementType.UNORDERED_LIST, | |
ElementType.ORDERED_LIST, | |
ElementType.DEFINITION_LIST, | |
ElementType.LIST_ITEM, | |
ElementType.DEFINITION_DESCRIPTION -> { | |
val parentTextView = parent.view as TextView | |
parentTextView.apply { | |
text = String.format("%s\n%s", parentTextView.text.toString(), self.text.trim()) | |
} | |
} | |
ElementType.TABLE_DATA -> return true | |
ElementType.TABLE_ROW -> { | |
val tableRow = parent.view as TableRow | |
tableRow.addView(self) | |
} | |
else -> return false | |
} | |
return true | |
} | |
private fun composeLink(self: TextView, parent: ElementViewHolder): Boolean { | |
when (parent.type) { | |
ElementType.DEFINITION_TERM, | |
ElementType.PARAGRAPH, | |
ElementType.UNORDERED_LIST, | |
ElementType.ORDERED_LIST, | |
ElementType.DEFINITION_LIST, | |
ElementType.LIST_ITEM, | |
ElementType.DEFINITION_DESCRIPTION, | |
ElementType.TABLE_DATA -> { | |
val parentTextView = parent.view as TextView | |
val builder = SpannableStringBuilder(parentTextView.text) | |
Regex(self.text.toString()).findAll(parentTextView.text).forEach { | |
val style = TextAppearanceSpan(context, R.color.link) | |
builder.setSpan(style, it.range.start, it.range.last + 1, SPAN_INCLUSIVE_INCLUSIVE) | |
} | |
parentTextView.text = builder | |
} | |
ElementType.TABLE_ROW -> { | |
val castParentView = parent.view as TableRow | |
val builder = SpannableStringBuilder(self.text).apply { | |
val style = TextAppearanceSpan(context, R.color.link) | |
setSpan(style, 0, self.text.length - 1, SPAN_INCLUSIVE_INCLUSIVE) | |
} | |
self.text = builder | |
castParentView.addView(self) | |
} | |
else -> return false | |
} | |
return true | |
} | |
private fun composeTableRow(self: TableRow, parent: ElementViewHolder): Boolean { | |
val table = parent.view as? TableLayout ?: return false | |
table.addView(self) | |
return true | |
} | |
companion object { | |
private const val UNORDERED_LIST_DECORATOR = "・" | |
} | |
private enum class ElementType(val tagName: String, val level: Int = 0) { | |
UNKNOWN(""), | |
TABLE("table"), | |
TABLE_BODY("tbody"), | |
TABLE_ROW("tr"), | |
TABLE_DATA("td"), | |
IMAGE("img"), | |
HEADER1("h1", 1), | |
HEADER2("h2", 2), | |
HEADER3("h3", 3), | |
HEADER4("h4", 4), | |
HEADER5("h5", 5), | |
HEADER6("h6", 6), | |
PARAGRAPH("p"), | |
LINK("a"), | |
UNORDERED_LIST("ul"), | |
ORDERED_LIST("ol"), | |
LIST_ITEM("li"), | |
DEFINITION_LIST("dl"), | |
DEFINITION_TERM("dt"), | |
DEFINITION_DESCRIPTION("dd"); | |
companion object { | |
fun valueOf(element: Element) = values().firstOrNull { it.tagName === element.tagName() } ?: UNKNOWN | |
} | |
} | |
private data class ElementViewHolder( | |
var view: View? = null, | |
var type: ElementType = ElementType.UNKNOWN, | |
var depth: Int = 1, | |
var orderString: String = "" | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment