在Day16時PreviewFragment的Layout只單純放TextView顯示文章內容,今天的內容首先是先把TextView拿掉改成RecyclerView,這部份很單純就不放程式碼了,主要還是在RecyclerView的Item Layout:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="@color/transparent"
android:paddingStart="@dimen/one_grid_unit"
android:paddingTop="@dimen/half_grid_unit"
android:paddingEnd="@dimen/one_grid_unit"
android:paddingBottom="@dimen/half_grid_unit">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/like"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="推" />
<TextView
android:id="@+id/id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/one_grid_unit"
android:fontFamily="sans-serif"
android:textColor="@color/userid"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@id/like"
app:layout_constraintTop_toTopOf="parent"
tools:text="AN511" />
<TextView
android:id="@+id/time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif"
android:textColor="@color/text_normal"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="10/03 12:40" />
<TextView
android:id="@+id/content"
android:layout_width="0dp"
android:layout_height="0dp"
android:fontFamily="sans-serif"
android:maxLines="2"
android:textColor="@color/content"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/id"
app:layout_constraintTop_toBottomOf="@id/like"
tools:text="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
預覽畫面:
接著是RecyclerView的Adapter設計
class CommentAdapter : RecyclerView.Adapter<CommentAdapter.ViewHolder>() {
private val commentList = mutableListOf<Comment>()
var moreCommentCallback: (() -> Unit)? = null
public fun setData(newList: List<Comment>) {
val result = DiffUtil.calculateDiff(CommentDiffUtilCallbackImpl(commentList, newList))
commentList.clear()
commentList.addAll(newList)
result.dispatchUpdatesTo(this)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
ViewHolder(
ItemCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
override fun getItemCount(): Int = commentList.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bindView(commentList[position])
if (position == 0) {
moreCommentCallback?.invoke()
}
}
inner class ViewHolder(private val binding: ItemCommentBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bindView(comment: Comment) {
binding.like.text = comment.like
when (comment.like) {
"推" -> {
binding.like.setTextColor(itemView.context.getColor(R.color.label_push))
}
"噓" -> {
binding.like.setTextColor(itemView.context.getColor(R.color.label_boo))
}
"→" -> {
binding.like.setTextColor(itemView.context.getColor(R.color.label_normal))
}
}
binding.id.text = comment.id
binding.time.text = comment.time
binding.content.text = comment.content
}
}
}
基本上結合Day17和Day18的內容,不算有太特別的地方,值得一提的是呼叫moreCommentCallback的條件,設為if (position == 0)
,這是因為我的RecyclerView會設為倒序讀取,因此最開始顯示的會是commentList的最後一項並往前讀取內容。
最後看一下Fragment的內容,其中會分成幾個部分:parseComments、RecyclerView設定、moreCommentCallback。
基本上是將Day17的內容提取成一個method再稍作修改:
private val adapter = CommentAdapter()
private val commentList = mutableListOf<Comment>()
private var hasMore = true
private fun parseComments(screen: String) {
val rows = screen.split("\n")
val commentPattern =
Pattern.compile("(?<like>[推]|[→]|[噓])*[ ](?<id>.*)[:][ ](?<content>.*)[ ](?<ip>((.*\\.){3}.*|[ ]))(?<date>../.. ..:..)")
val currentCommentList = mutableListOf<Comment>()
rows.forEach {
val matcher = commentPattern.matcher(it)
if (matcher.find()) {
val like = matcher.group("like")!!.trim()
val id = matcher.group("id")!!.trim()
val content = matcher.group("content")!!.trim()
val ip = matcher.group("ip")!!.trim()
val date = matcher.group("date")!!.trim()
val comment = Comment(
like,
id,
content,
ip,
date
)
Log.d(mTag, "comment:$comment")
if (!commentList.contains(comment)) {
currentCommentList.add(comment)
}
}
}
hasMore = currentCommentList.isNotEmpty()
commentList.addAll(0, currentCommentList)
adapter.setData(commentList)
}
commentList是用來儲存已解析的所有推文,currentCommentList則是目前頁面的推文內容,每加入新的currentCommentList時都要加在commentList的最前面,以確保舊的推文內容在List前面的位置。
示意圖
hasMore是用來判斷是否還有推文能更新,判斷的依據就是看這次解析出來的currentCommentList是否為空,為空的話代表已無內容可在新增。會這樣判斷是因為Ptt的內文是可以讓使用者任意修改的,用其它Pattern都有不存在的風險,因此使用這輪是否還有解析東西來判斷是我目前思考後比較適合的方法。
private val adapter = CommentAdapter()
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext()).apply {
stackFromEnd = true
}
setHasFixedSize(true)
adapter = this@PreviewFragment.adapter
}
主要就是使用LinearLayoutManager的setStackFromEnd方法來讓內容倒序讀取。
首先要看一下Ptt文章內的相關指令
可以看到上翻一頁的指令有四種:^B ^H PgUp BS
。(^代表的是ctrl按鍵)
接著根據ASCII Table可以查到^B
和^H
各自的Char數值,這邊我是使用^B
來做為向上一頁的指令。
adapter.moreCommentCallback = {
if (hasMore) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
PttClient.getInstance().send(Char(2).toString())
delay(100L)
Log.d(mTag, "current page:\n${PttClient.getInstance().getScreen()}")
parseComments(PttClient.getInstance().getScreen())
}
}
}
在parseComments中有一個判斷式if (!commentList.contains(comment))
,會使用這個的理由是在Ptt文章中使用上翻一頁時,目前頁面的第一行會成為前一頁的最後一行,或者前一頁的內容不滿螢幕高度時,會將目前頁面的前幾行內容拿來補齊頁面,因此會有推文重複的情形。
另外記得我在Day17的Comment data class中有覆寫equals方法,主要就是為了判斷此情形。
不過這個方法會有一個side effect,就是若有使用者在同一分鐘內重複推文兩次,也會被判斷成重複推文而不加入。但這種狀態我認為其實顯示兩次也沒有太大意義,就讓它發生好了。