diff --git a/app-sample/build.gradle b/app-sample/build.gradle index 5883ac72..9e4f0f23 100644 --- a/app-sample/build.gradle +++ b/app-sample/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' android { @@ -40,14 +41,23 @@ kapt { } } +androidExtensions { + features = ["parcelize"] +} + dependencies { kapt project(':sample-utils:processor') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation project(':markwon-core') + deps.with { - api it['x-recycler-view'] - api it['adapt'] - api it['debug'] + implementation it['x-recycler-view'] + implementation it['x-cardview'] + implementation it['x-fragment'] + implementation it['gson'] + implementation it['adapt'] + implementation it['debug'] } } diff --git a/app-sample/src/debug/res/layout/flowlayout_preview.xml b/app-sample/src/debug/res/layout/flowlayout_preview.xml new file mode 100644 index 00000000..4f9f98a1 --- /dev/null +++ b/app-sample/src/debug/res/layout/flowlayout_preview.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/App.kt b/app-sample/src/main/java/io/noties/markwon/app/App.kt index 0ae04ee6..444ded43 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/App.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/App.kt @@ -3,12 +3,20 @@ package io.noties.markwon.app import android.app.Application import io.noties.debug.AndroidLogDebugOutput import io.noties.debug.Debug +import java.util.concurrent.Executors +@Suppress("unused") class App : Application() { override fun onCreate() { super.onCreate() Debug.init(AndroidLogDebugOutput(BuildConfig.DEBUG)) + + sampleManager = SampleManager(this, Executors.newCachedThreadPool()) + } + + companion object { + lateinit var sampleManager: SampleManager } } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/MainActivity.kt b/app-sample/src/main/java/io/noties/markwon/app/MainActivity.kt index c265ce04..f0fc8e35 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/MainActivity.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/MainActivity.kt @@ -1,61 +1,19 @@ package io.noties.markwon.app -import android.app.Activity import android.os.Bundle -import android.view.ViewTreeObserver -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.noties.adapt.Adapt -import io.noties.debug.Debug -import io.noties.markwon.app.adapt.SampleItem -import io.noties.markwon.app.base.SearchBar -import io.noties.markwon.sample.annotations.MarkwonArtifact +import android.view.Window +import androidx.fragment.app.FragmentActivity +import io.noties.markwon.app.ui.SampleListFragment -class MainActivity : Activity() { +class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - val searchBar: SearchBar = findViewById(R.id.search_bar) - searchBar.onSearchListener = { - Debug.i("search: '$it'") + if (supportFragmentManager.findFragmentById(Window.ID_ANDROID_CONTENT) == null) { + supportFragmentManager.beginTransaction() + .add(Window.ID_ANDROID_CONTENT, SampleListFragment.init()) + .commitNowAllowingStateLoss() } - - val recyclerView: RecyclerView = findViewById(R.id.recycler_view) - recyclerView.layoutManager = LinearLayoutManager(this) - recyclerView.itemAnimator = DefaultItemAnimator() - recyclerView.setHasFixedSize(true) - recyclerView.clipToPadding = false - - searchBar.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - searchBar.viewTreeObserver.removeOnPreDrawListener(this) - recyclerView.setPadding( - recyclerView.paddingLeft, - recyclerView.paddingTop + searchBar.height, - recyclerView.paddingRight, - recyclerView.paddingBottom - ) - return true - } - }) - - val adapt = Adapt.create() - recyclerView.adapter = adapt - - val list = listOf( - MarkwonSampleItem( - "first", - "1", - "Title first", - "Description her egoes and goes ang goes, so will it ever stop?", - listOf(MarkwonArtifact.CORE, MarkwonArtifact.EDITOR), - listOf("first", "second") - ) - ) - - adapt.setItems(list.map { SampleItem(it) }) } } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/MarkwonSample.kt b/app-sample/src/main/java/io/noties/markwon/app/MarkwonSample.kt deleted file mode 100644 index ed4f4d43..00000000 --- a/app-sample/src/main/java/io/noties/markwon/app/MarkwonSample.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.noties.markwon.app - -abstract class MarkwonSample { - -} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/MarkwonSampleItem.kt b/app-sample/src/main/java/io/noties/markwon/app/Sample.kt similarity index 71% rename from app-sample/src/main/java/io/noties/markwon/app/MarkwonSampleItem.kt rename to app-sample/src/main/java/io/noties/markwon/app/Sample.kt index 29942fba..5a82c7cb 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/MarkwonSampleItem.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/Sample.kt @@ -1,12 +1,15 @@ package io.noties.markwon.app +import android.os.Parcelable import io.noties.markwon.sample.annotations.MarkwonArtifact +import kotlinx.android.parcel.Parcelize -data class MarkwonSampleItem( +@Parcelize +data class Sample( val javaClassName: String, val id: String, val title: String, val description: String, val artifacts: List, val tags: List -) \ No newline at end of file +) : Parcelable \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/SampleManager.kt b/app-sample/src/main/java/io/noties/markwon/app/SampleManager.kt new file mode 100644 index 00000000..7addbabb --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/SampleManager.kt @@ -0,0 +1,71 @@ +package io.noties.markwon.app + +import android.content.Context +import io.noties.markwon.app.utils.Cancellable +import io.noties.markwon.app.utils.SampleUtils +import io.noties.markwon.sample.annotations.MarkwonArtifact +import java.util.concurrent.ExecutorService + +class SampleManager( + private val context: Context, + private val executorService: ExecutorService +) { + + private val samples: List by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + SampleUtils.readSamples(context) + } + + fun samples(search: SampleSearch?, callback: (List) -> Unit): Cancellable { + + var action: ((List) -> Unit)? = callback + + val future = executorService.submit { + + val source = when (search) { + is SampleSearch.Artifact -> samples.filter { it.artifacts.contains(search.artifact) } + is SampleSearch.Tag -> samples.filter { it.tags.contains(search.tag) } + else -> samples.toList() // just copy all + } + + val text = search?.text + val results = if (text == null) { + // no further filtering, just return the full source here + source + } else { + source.filter { filter(it, text) } + } + + action?.invoke(results) + } + + return object : Cancellable { + override val isCancelled: Boolean + get() = future.isDone + + override fun cancel() { + action = null + future.cancel(true) + } + } + } + + // if title contains, + // if description contains, + // if tags contains + // if artifacts contains, + private fun filter(sample: Sample, text: String): Boolean { + return sample.javaClassName.contains(text, true) + || sample.title.contains(text, true) + || sample.description.contains(text, true) + || filterTags(sample.tags, text) + || filterArtifacts(sample.artifacts, text) + } + + private fun filterTags(tags: List, text: String): Boolean { + return tags.firstOrNull { it.contains(text, true) } != null + } + + private fun filterArtifacts(artifacts: List, text: String): Boolean { + return artifacts.firstOrNull { it.artifactName().contains(text, true) } != null + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/SampleSearch.kt b/app-sample/src/main/java/io/noties/markwon/app/SampleSearch.kt new file mode 100644 index 00000000..6f79aef4 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/SampleSearch.kt @@ -0,0 +1,13 @@ +package io.noties.markwon.app + +import io.noties.markwon.sample.annotations.MarkwonArtifact + +sealed class SampleSearch(val text: String?) { + class Artifact(text: String?, val artifact: MarkwonArtifact) : SampleSearch(text) + class Tag(text: String?, val tag: String) : SampleSearch(text) + class All(text: String?) : SampleSearch(text) + + override fun toString(): String { + return "SampleSearch(text=$text,type=${javaClass.simpleName})" + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/adapt/ArtifactItem.kt b/app-sample/src/main/java/io/noties/markwon/app/adapt/ArtifactItem.kt deleted file mode 100644 index 1dcfe5c0..00000000 --- a/app-sample/src/main/java/io/noties/markwon/app/adapt/ArtifactItem.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.noties.markwon.app.adapt - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import io.noties.adapt.Item -import io.noties.markwon.sample.annotations.MarkwonArtifact - -class ArtifactItem(artifact: MarkwonArtifact): Item(artifact.name.hashCode().toLong()) { - - override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder { - return Holder(inflater.inflate(0, parent, false)) - } - - override fun render(holder: Holder) { - } - - class Holder(itemView: View): Item.Holder(itemView) { - val textView: TextView = requireView(0) - } -} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/adapt/SampleItem.kt b/app-sample/src/main/java/io/noties/markwon/app/adapt/SampleItem.kt index 9aa75ff9..64d1c605 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/adapt/SampleItem.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/adapt/SampleItem.kt @@ -1,89 +1,100 @@ package io.noties.markwon.app.adapt -import android.graphics.Color -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.TextPaint -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan +import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import io.noties.adapt.Item -import io.noties.debug.Debug -import io.noties.markwon.app.MarkwonSampleItem +import io.noties.markwon.Markwon import io.noties.markwon.app.R +import io.noties.markwon.app.Sample +import io.noties.markwon.app.base.FlowLayout +import io.noties.markwon.app.utils.displayName +import io.noties.markwon.app.utils.hidden +import io.noties.markwon.app.utils.tagDisplayName import io.noties.markwon.sample.annotations.MarkwonArtifact +import io.noties.markwon.utils.NoCopySpannableFactory -class SampleItem(private val item: MarkwonSampleItem) : Item(item.id.hashCode().toLong()) { +class SampleItem( + private val markwon: Markwon, + private val sample: Sample, + private val onArtifactClick: (MarkwonArtifact) -> Unit, + private val onTagClick: (String) -> Unit, + private val onSampleClick: (Sample) -> Unit +) : Item(sample.id.hashCode().toLong()) { - var search: String? = null +// var search: String? = null + + private val text: Spanned by lazy(LazyThreadSafetyMode.NONE) { + markwon.toMarkdown(sample.description) + } override fun createHolder(inflater: LayoutInflater, parent: ViewGroup): Holder { - val holder = Holder(inflater.inflate(R.layout.adapt_sample, parent, false)) - holder.artifactsAndTags.movementMethod = LinkMovementMethod.getInstance() - return holder + return Holder(inflater.inflate(R.layout.adapt_sample, parent, false)).apply { + description.setSpannableFactory(NoCopySpannableFactory.getInstance()) + } } override fun render(holder: Holder) { holder.apply { - title.text = item.title - description.text = item.description - artifactsAndTags.text = buildArtifactsAndTags - } - } + title.text = sample.title - private val buildArtifactsAndTags: CharSequence - get() { - val builder = SpannableStringBuilder() - - item.artifacts - .forEach { - val length = builder.length - builder.append("\u00a0${it.name}\u00a0") - builder.setSpan(ArtifactSpan(it), length, builder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - if (!builder.isEmpty()) { - builder.append("\n") + val text = this@SampleItem.text + if (text.isEmpty()) { + description.text = "" + description.hidden = true + } else { + markwon.setParsedMarkdown(description, text) + description.hidden = false } - item.tags - .forEach { - val length = builder.length - builder.append("\u00a0$it\u00a0") - builder.setSpan(TagSpan(it), length, builder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + // there is no need to display the core artifact (it is implicit), + // hide if empty (removed core) + artifacts.ensure(sample.artifacts.size, R.layout.view_artifact) + .zip(sample.artifacts) + .forEach { (view, artifact) -> + (view as TextView).text = artifact.displayName + view.setOnClickListener { + onArtifactClick(artifact) + } } - return builder + tags.ensure(sample.tags.size, R.layout.view_tag) + .zip(sample.tags) + .forEach { (view, tag) -> + (view as TextView).text = tag.tagDisplayName + view.setOnClickListener { + onTagClick(tag) + } + } + + itemView.setOnClickListener { + onSampleClick(sample) + } } + } class Holder(itemView: View) : Item.Holder(itemView) { val title: TextView = requireView(R.id.title) val description: TextView = requireView(R.id.description) - val artifactsAndTags: TextView = requireView(R.id.artifacts_and_tags) + val artifacts: FlowLayout = requireView(R.id.artifacts) + val tags: FlowLayout = requireView(R.id.tags) } +} - private class ArtifactSpan(val artifact: MarkwonArtifact) : ClickableSpan() { - override fun onClick(widget: View) { - Debug.i("clicked artifact: $artifact") +private fun FlowLayout.ensure(viewsCount: Int, layoutResId: Int): List { + if (viewsCount > childCount) { + // inflate new views + val inflater = LayoutInflater.from(context) + for (i in 0 until (viewsCount - childCount)) { + addView(inflater.inflate(layoutResId, this, false)) } - - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - ds.bgColor = Color.GREEN + } else { + // return requested vies and GONE the rest + for (i in viewsCount until childCount) { + getChildAt(i).hidden = true } } - - private class TagSpan(val tag: String) : ClickableSpan() { - override fun onClick(widget: View) { - Debug.i("clicked tag: $tag") - } - - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - ds.bgColor = Color.BLUE - } - } -} \ No newline at end of file + return (0 until viewsCount).map { getChildAt(it) } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/base/FlowLayout.kt b/app-sample/src/main/java/io/noties/markwon/app/base/FlowLayout.kt index 6aa1ad10..be8fca71 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/base/FlowLayout.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/base/FlowLayout.kt @@ -3,8 +3,25 @@ package io.noties.markwon.app.base import android.content.Context import android.util.AttributeSet import android.view.ViewGroup +import io.noties.markwon.app.R +import kotlin.math.max class FlowLayout(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs) { + + private val spacingVertical: Int + private val spacingHorizontal: Int + + init { + val array = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout) + try { + val spacing = array.getDimensionPixelSize(R.styleable.FlowLayout_fl_spacing, 0) + spacingVertical = array.getDimensionPixelSize(R.styleable.FlowLayout_fl_spacingVertical, spacing) + spacingHorizontal = array.getDimensionPixelSize(R.styleable.FlowLayout_fl_spacingHorizontal, spacing) + } finally { + array.recycle() + } + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for (i in 0 until childCount) { val child = getChildAt(i) @@ -21,7 +38,61 @@ class FlowLayout(context: Context, attrs: AttributeSet) : ViewGroup(context, att } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = MeasureSpec.getSize(widthMeasureSpec) + + // we must have width (match_parent or exact dimension) + if (width <= 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + + val availableWidth = width - paddingLeft - paddingRight + + // child must not exceed our width + val childWidthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST) + + // we also could enforce flexible height here (instead of exact one) + val childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + + var x = 0 + var y = 0 + + var lineHeight = 0 + + for (i in 0 until childCount) { + val child = getChildAt(i) + + // measure + child.measure(childWidthSpec, childHeightSpec) + + val params = child.layoutParams as LayoutParams + val measuredWidth = child.measuredWidth + + if (measuredWidth > (availableWidth - x)) { + // new line + // make next child start at child measure width (starting at x = 0) + params.x = 0 + params.y = y + lineHeight + spacingVertical + + x = measuredWidth + spacingHorizontal + // move vertically by max value of child height on this line + y += lineHeight + spacingVertical + + lineHeight = child.measuredHeight + + } else { + // we fit this line + params.x = x + params.y = y + + x += measuredWidth + spacingHorizontal + lineHeight = max(lineHeight, child.measuredHeight) + } + } + + val height = y + lineHeight + paddingTop + paddingBottom + + setMeasuredDimension(width, height) } override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams { diff --git a/app-sample/src/main/java/io/noties/markwon/app/base/SearchBar.kt b/app-sample/src/main/java/io/noties/markwon/app/base/SearchBar.kt index 6ab10838..ba4f644d 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/base/SearchBar.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/base/SearchBar.kt @@ -61,6 +61,12 @@ class SearchBar(context: Context, attrs: AttributeSet?) : LinearLayout(context, clear.setOnClickListener { textField.setText("") + // ensure that we have focus when clear is clicked + if (!textField.hasFocus()) { + textField.requestFocus() + // additionally ensure keyboard is showing + KeyboardUtils.show(textField) + } } cancel.setOnClickListener { @@ -69,6 +75,10 @@ class SearchBar(context: Context, attrs: AttributeSet?) : LinearLayout(context, } } + fun search(text: String) { + textField.setText(text) + } + private fun textFieldChanged(text: CharSequence) { val isEmpty = text.isEmpty() clear.hidden = isEmpty diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/FirstSample.kt b/app-sample/src/main/java/io/noties/markwon/app/samples/FirstSample.kt index 20b65e25..f7c484b4 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/samples/FirstSample.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/FirstSample.kt @@ -1,6 +1,6 @@ package io.noties.markwon.app.samples -import io.noties.markwon.app.MarkwonSample +import io.noties.markwon.app.ui.MarkwonSample import io.noties.markwon.sample.annotations.MarkwonArtifact import io.noties.markwon.sample.annotations.MarkwonSampleInfo diff --git a/app-sample/src/main/java/io/noties/markwon/app/ui/MarkwonSample.kt b/app-sample/src/main/java/io/noties/markwon/app/ui/MarkwonSample.kt new file mode 100644 index 00000000..c339bcf0 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/ui/MarkwonSample.kt @@ -0,0 +1,5 @@ +package io.noties.markwon.app.ui + +abstract class MarkwonSample { + +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/ui/SampleFragment.kt b/app-sample/src/main/java/io/noties/markwon/app/ui/SampleFragment.kt new file mode 100644 index 00000000..ae5babe6 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/ui/SampleFragment.kt @@ -0,0 +1,20 @@ +package io.noties.markwon.app.ui + +import android.os.Bundle +import androidx.fragment.app.Fragment +import io.noties.markwon.app.Sample + +class SampleFragment : Fragment() { + + companion object { + private const val ARG_SAMPLE = "arg.Sample" + + fun init(sample: Sample): SampleFragment { + val fragment = SampleFragment() + fragment.arguments = Bundle().apply { + putParcelable(ARG_SAMPLE, sample) + } + return fragment + } + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/ui/SampleListFragment.kt b/app-sample/src/main/java/io/noties/markwon/app/ui/SampleListFragment.kt new file mode 100644 index 00000000..57f906ca --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/ui/SampleListFragment.kt @@ -0,0 +1,288 @@ +package io.noties.markwon.app.ui + +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.ImageView +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.noties.adapt.Adapt +import io.noties.adapt.DiffUtilDataSetChanged +import io.noties.debug.Debug +import io.noties.markwon.Markwon +import io.noties.markwon.app.App +import io.noties.markwon.app.R +import io.noties.markwon.app.Sample +import io.noties.markwon.app.SampleManager +import io.noties.markwon.app.SampleSearch +import io.noties.markwon.app.adapt.SampleItem +import io.noties.markwon.app.base.SearchBar +import io.noties.markwon.app.utils.Cancellable +import io.noties.markwon.app.utils.displayName +import io.noties.markwon.app.utils.onPreDraw +import io.noties.markwon.app.utils.recyclerView +import io.noties.markwon.app.utils.tagDisplayName +import io.noties.markwon.movement.MovementMethodPlugin +import io.noties.markwon.sample.annotations.MarkwonArtifact +import kotlinx.android.parcel.Parcelize + +class SampleListFragment : Fragment() { + + private val adapt: Adapt = Adapt.create(DiffUtilDataSetChanged.create()) + private lateinit var markwon: Markwon + + private val type: Type by lazy(LazyThreadSafetyMode.NONE) { + parseType(arguments!!) + } + + private var search: String? = null + + // postpone state restoration + private var pendingRecyclerScrollPosition: RecyclerScrollPosition? = null + + private var cancellable: Cancellable? = null + + private val sampleManager: SampleManager + get() = App.sampleManager + + override fun onAttach(context: Context?) { + super.onAttach(context) + + context?.also { + markwon = markwon(it) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_sample_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initAppBar(view) + + val context = requireContext() + + val searchBar: SearchBar = view.findViewById(R.id.search_bar) + searchBar.onSearchListener = { + search = it + fetch() + } + + val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) + recyclerView.layoutManager = LinearLayoutManager(context) + recyclerView.itemAnimator = DefaultItemAnimator() + recyclerView.setHasFixedSize(true) + recyclerView.adapter = adapt + + // additional padding for RecyclerView + searchBar.onPreDraw { + recyclerView.setPadding( + recyclerView.paddingLeft, + recyclerView.paddingTop + searchBar.height, + recyclerView.paddingRight, + recyclerView.paddingBottom + ) + } + + val state: State? = savedInstanceState?.getParcelable(STATE) + pendingRecyclerScrollPosition = state?.recyclerScrollPosition + if (state?.search != null) { + searchBar.search(state.search) + } else { + fetch() + } + } + + override fun onDestroyView() { + val cancellable = this.cancellable + if (cancellable != null && !cancellable.isCancelled) { + cancellable.cancel() + this.cancellable = null + } + super.onDestroyView() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + val state = State( + search, + adapt.recyclerView?.scrollPosition + ) + outState.putParcelable(STATE, state) + } + + private fun initAppBar(view: View) { + val appBar = view.findViewById(R.id.app_bar) + + val appBarIcon: ImageView = appBar.findViewById(R.id.app_bar_icon) + val appBarTitle: TextView = appBar.findViewById(R.id.app_bar_title) + + val type = this.type + if (type is Type.All) { + return + } + + appBarIcon.setImageResource(R.drawable.ic_arrow_back_white_24dp) + appBarIcon.setOnClickListener { + requireActivity().onBackPressed() + } + + val (text, background) = when (type) { + is Type.Artifact -> Pair(type.artifact.displayName, R.drawable.bg_artifact) + is Type.Tag -> Pair(type.tag.tagDisplayName, R.drawable.bg_tag) + else -> error("Unexpected type: $type") + } + + appBarTitle.text = text + appBarTitle.setBackgroundResource(background) + } + + private fun bindSamples(samples: List) { + val items = samples.map { + SampleItem( + markwon, + it, + { artifact -> openArtifact(artifact) }, + { tag -> openTag(tag) }, + { sample -> openSample(sample) } + ) + } + adapt.setItems(items) + + val scrollPosition = pendingRecyclerScrollPosition + if (scrollPosition != null) { + pendingRecyclerScrollPosition = null + val recyclerView = adapt.recyclerView ?: return + recyclerView.onPreDraw { + (recyclerView.layoutManager as? LinearLayoutManager) + ?.scrollToPositionWithOffset(scrollPosition.position, scrollPosition.offset) + } + } + } + + private fun openArtifact(artifact: MarkwonArtifact) { + Debug.i(artifact) + openResultFragment(init(artifact)) + } + + private fun openTag(tag: String) { + Debug.i(tag) + openResultFragment(init(tag)) + } + + private fun openResultFragment(fragment: SampleListFragment) { + openFragment(fragment) + } + + private fun openSample(sample: Sample) { + openFragment(SampleFragment.init(sample)) + } + + private fun openFragment(fragment: Fragment) { + fragmentManager!!.beginTransaction() + .setCustomAnimations(R.anim.screen_in, R.anim.screen_out, R.anim.screen_in_pop, R.anim.screen_out_pop) + .replace(Window.ID_ANDROID_CONTENT, fragment) + .addToBackStack(null) + .commitAllowingStateLoss() + } + + private fun fetch() { + + val sampleSearch: SampleSearch = when (val type = this.type) { + is Type.Artifact -> SampleSearch.Artifact(search, type.artifact) + is Type.Tag -> SampleSearch.Tag(search, type.tag) + else -> SampleSearch.All(search) + } + + // clear current + cancellable?.let { + if (!it.isCancelled) { + it.cancel() + } + } + + cancellable = sampleManager.samples(sampleSearch) { + bindSamples(it) + } + } + + companion object { + private const val ARG_ARTIFACT = "arg.Artifact" + private const val ARG_TAG = "arg.Tag" + private const val STATE = "key.State" + + fun init(): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle() + return fragment + } + + fun init(artifact: MarkwonArtifact): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle().apply { + putString(ARG_ARTIFACT, artifact.name) + } + return fragment + } + + fun init(tag: String): SampleListFragment { + val fragment = SampleListFragment() + fragment.arguments = Bundle().apply { + putString(ARG_TAG, tag) + } + return fragment + } + + fun markwon(context: Context): Markwon { + return Markwon.builder(context) + .usePlugin(MovementMethodPlugin.none()) + .build() + } + + private fun parseType(arguments: Bundle): Type { + val name = arguments.getString(ARG_ARTIFACT) + val tag = arguments.getString(ARG_TAG) + return when { + name != null -> Type.Artifact(MarkwonArtifact.valueOf(name)) + tag != null -> Type.Tag(tag) + else -> Type.All + } + } + } + + @Parcelize + private data class State( + val search: String?, + val recyclerScrollPosition: RecyclerScrollPosition? + ) : Parcelable + + @Parcelize + private data class RecyclerScrollPosition( + val position: Int, + val offset: Int + ) : Parcelable + + private val RecyclerView.scrollPosition: RecyclerScrollPosition? + get() { + val holder = findViewHolderForLayoutPosition(0) ?: return null + val position = holder.adapterPosition + val offset = holder.itemView.top + return RecyclerScrollPosition(position, offset) + } + + private sealed class Type { + class Artifact(val artifact: MarkwonArtifact) : Type() + class Tag(val tag: String) : Type() + object All : Type() + } +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/AdaptUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/AdaptUtils.kt new file mode 100644 index 00000000..0c4637e8 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/AdaptUtils.kt @@ -0,0 +1,16 @@ +package io.noties.markwon.app.utils + +import androidx.recyclerview.widget.RecyclerView +import io.noties.adapt.Adapt +import io.noties.debug.Debug + +val Adapt.recyclerView: RecyclerView? + get() { + // internally throws if recycler is not present (detached from recyclerView) + return try { + recyclerView() + } catch (t: Throwable) { + Debug.e(t) + null + } + } \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/Cancellable.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/Cancellable.kt new file mode 100644 index 00000000..4f519ad4 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/Cancellable.kt @@ -0,0 +1,7 @@ +package io.noties.markwon.app.utils + +interface Cancellable { + val isCancelled: Boolean + + fun cancel() +} \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtils.java b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtils.java new file mode 100644 index 00000000..2f4195e3 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtils.java @@ -0,0 +1,37 @@ +package io.noties.markwon.app.utils; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.List; + +import io.noties.markwon.app.Sample; + +public abstract class SampleUtils { + + @NonNull + public static List readSamples(@NonNull Context context) { + + final Gson gson = new Gson(); + + try (InputStream inputStream = context.getAssets().open("samples.json")) { + return gson.fromJson( + new InputStreamReader(inputStream), + new TypeToken>() { + }.getType() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private SampleUtils() { + } +} diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtilsKt.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtilsKt.kt new file mode 100644 index 00000000..c01f251a --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/SampleUtilsKt.kt @@ -0,0 +1,9 @@ +package io.noties.markwon.app.utils + +import io.noties.markwon.sample.annotations.MarkwonArtifact + +val MarkwonArtifact.displayName: String + get() = "@${artifactName()}" + +val String.tagDisplayName: String + get() = "#$this" \ No newline at end of file diff --git a/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt b/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt index 497ba370..b6a5e8f9 100644 --- a/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt +++ b/app-sample/src/main/java/io/noties/markwon/app/utils/ViewUtils.kt @@ -3,9 +3,24 @@ package io.noties.markwon.app.utils import android.view.View import android.view.View.GONE import android.view.View.VISIBLE +import android.view.ViewTreeObserver var View.hidden: Boolean get() = visibility == GONE set(value) { visibility = if (value) GONE else VISIBLE - } \ No newline at end of file + } + +fun View.onPreDraw(action: () -> Unit) { + viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val vto = viewTreeObserver + if (vto.isAlive) { + vto.removeOnPreDrawListener(this) + } + action() + // do not block drawing + return true + } + }) +} \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_in.xml b/app-sample/src/main/res/anim/screen_in.xml new file mode 100644 index 00000000..ee314aa4 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_in.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_in_pop.xml b/app-sample/src/main/res/anim/screen_in_pop.xml new file mode 100644 index 00000000..44de9d54 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_in_pop.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_out.xml b/app-sample/src/main/res/anim/screen_out.xml new file mode 100644 index 00000000..4ce2f3e6 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_out.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/anim/screen_out_pop.xml b/app-sample/src/main/res/anim/screen_out_pop.xml new file mode 100644 index 00000000..dd168243 --- /dev/null +++ b/app-sample/src/main/res/anim/screen_out_pop.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/bg_search_bar.xml b/app-sample/src/main/res/drawable/bg_search_bar.xml new file mode 100644 index 00000000..c7417ecd --- /dev/null +++ b/app-sample/src/main/res/drawable/bg_search_bar.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/bg_splash.xml b/app-sample/src/main/res/drawable/bg_splash.xml new file mode 100644 index 00000000..66e4b273 --- /dev/null +++ b/app-sample/src/main/res/drawable/bg_splash.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/app-sample/src/main/res/drawable/ic_arrow_back_white_24dp.xml new file mode 100644 index 00000000..fa122e18 --- /dev/null +++ b/app-sample/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app-sample/src/main/res/layout/adapt_artifact.xml b/app-sample/src/main/res/layout/adapt_artifact.xml deleted file mode 100644 index 27d5d894..00000000 --- a/app-sample/src/main/res/layout/adapt_artifact.xml +++ /dev/null @@ -1,17 +0,0 @@ - - \ No newline at end of file diff --git a/app-sample/src/main/res/layout/adapt_sample.xml b/app-sample/src/main/res/layout/adapt_sample.xml index 8499e6a7..2885673d 100644 --- a/app-sample/src/main/res/layout/adapt_sample.xml +++ b/app-sample/src/main/res/layout/adapt_sample.xml @@ -1,51 +1,87 @@ - + android:orientation="vertical" + android:paddingTop="@dimen/content_padding_half" + android:paddingEnd="@dimen/content_padding" + android:paddingBottom="@dimen/content_padding"> - - - - + android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + + - - - - - \ No newline at end of file + diff --git a/app-sample/src/main/res/layout/activity_main.xml b/app-sample/src/main/res/layout/fragment_sample_list.xml similarity index 50% rename from app-sample/src/main/res/layout/activity_main.xml rename to app-sample/src/main/res/layout/fragment_sample_list.xml index 9a866cab..a5c4ec48 100644 --- a/app-sample/src/main/res/layout/activity_main.xml +++ b/app-sample/src/main/res/layout/fragment_sample_list.xml @@ -7,25 +7,39 @@ android:orientation="vertical"> + android:orientation="horizontal" + tools:ignore="UseCompoundDrawables"> - + android:layout_marginEnd="@dimen/app_bar_height"> + + + + @@ -37,13 +51,18 @@ android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" - tools:translationY="56dip" /> + android:clipChildren="false" + android:clipToPadding="false" + android:overScrollMode="never" + android:paddingBottom="36dip" + tools:layout_marginTop="56dip" /> diff --git a/app-sample/src/main/res/layout/sample_text_view.xml b/app-sample/src/main/res/layout/sample_text_view.xml new file mode 100644 index 00000000..bde44965 --- /dev/null +++ b/app-sample/src/main/res/layout/sample_text_view.xml @@ -0,0 +1,23 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/layout/view_artifact.xml b/app-sample/src/main/res/layout/view_artifact.xml new file mode 100644 index 00000000..2d1e5049 --- /dev/null +++ b/app-sample/src/main/res/layout/view_artifact.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app-sample/src/main/res/layout/view_search_bar.xml b/app-sample/src/main/res/layout/view_search_bar.xml index de614f06..56a04e50 100644 --- a/app-sample/src/main/res/layout/view_search_bar.xml +++ b/app-sample/src/main/res/layout/view_search_bar.xml @@ -1,7 +1,7 @@ @@ -31,10 +32,10 @@ + \ No newline at end of file diff --git a/app-sample/src/main/res/values/attrs.xml b/app-sample/src/main/res/values/attrs.xml new file mode 100644 index 00000000..b20ac7fb --- /dev/null +++ b/app-sample/src/main/res/values/attrs.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/values/colors.xml b/app-sample/src/main/res/values/colors.xml index 7f60f058..0703abc1 100644 --- a/app-sample/src/main/res/values/colors.xml +++ b/app-sample/src/main/res/values/colors.xml @@ -6,6 +6,10 @@ #999999 #FFFFFF - #EEEEEE + @color/white #FF0000 + + #eee + #BFFFFFFF + \ No newline at end of file diff --git a/app-sample/src/main/res/values/dimens.xml b/app-sample/src/main/res/values/dimens.xml index c4f1ce17..2b409b32 100644 --- a/app-sample/src/main/res/values/dimens.xml +++ b/app-sample/src/main/res/values/dimens.xml @@ -4,6 +4,7 @@ 8dip 16dip + 4dip 36dip 36dip diff --git a/app-sample/src/main/res/values/ids.xml b/app-sample/src/main/res/values/ids.xml new file mode 100644 index 00000000..15c85d84 --- /dev/null +++ b/app-sample/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app-sample/src/main/res/values/styles.xml b/app-sample/src/main/res/values/styles.xml index 19eec27c..e1416f34 100644 --- a/app-sample/src/main/res/values/styles.xml +++ b/app-sample/src/main/res/values/styles.xml @@ -1,11 +1,12 @@ - + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8d0b833d..d02d46bc 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { // on `3.5.3` tests are not run from CLI - classpath 'com.android.tools.build:gradle:3.5.2' + classpath 'com.android.tools.build:gradle:4.0.0' classpath 'com.github.ben-manes:gradle-versions-plugin:0.27.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } @@ -69,6 +69,8 @@ ext { 'x-recycler-view' : 'androidx.recyclerview:recyclerview:1.0.0', 'x-core' : 'androidx.core:core:1.0.2', 'x-appcompat' : 'androidx.appcompat:appcompat:1.1.0', + 'x-cardview' : 'androidx.cardview:cardview:1.0.0', + 'x-fragment' : 'androidx.fragment:fragment:1.0.0', 'commonmark' : "com.atlassian.commonmark:commonmark:$commonMarkVersion", 'commonmark-strikethrough': "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion", 'commonmark-table' : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion", diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 430dfabc..76c5eda1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Jun 17 17:05:04 MSK 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.5.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/sample-utils/annotations/io/noties/markwon/sample/annotations/MarkwonArtifact.java b/sample-utils/annotations/io/noties/markwon/sample/annotations/MarkwonArtifact.java index d9abd329..dffc0da4 100644 --- a/sample-utils/annotations/io/noties/markwon/sample/annotations/MarkwonArtifact.java +++ b/sample-utils/annotations/io/noties/markwon/sample/annotations/MarkwonArtifact.java @@ -1,5 +1,9 @@ package io.noties.markwon.sample.annotations; +import androidx.annotation.NonNull; + +import java.util.Locale; + public enum MarkwonArtifact { CORE, EDITOR, @@ -17,5 +21,10 @@ public enum MarkwonArtifact { RECYCLER, RECYCLER_TABLE, SIMPLE_EXT, - SYNTAX_HIGHLIGHT + SYNTAX_HIGHLIGHT; + + @NonNull + public String artifactName() { + return name().toLowerCase(Locale.US).replace('_', '-'); + } }