diff --git a/app/build.gradle b/app/build.gradle index 7ff6a2d9..208adaee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,6 @@ plugins { id 'com.android.application' + id 'kotlin-android' } def flavor android { diff --git a/app/src/main/java/app/fedilab/android/helper/RecyclerViewThreadLines.kt b/app/src/main/java/app/fedilab/android/helper/RecyclerViewThreadLines.kt new file mode 100644 index 00000000..77007225 --- /dev/null +++ b/app/src/main/java/app/fedilab/android/helper/RecyclerViewThreadLines.kt @@ -0,0 +1,147 @@ +package app.fedilab.android.helper + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Rect +import android.view.View +import androidx.core.content.res.ResourcesCompat +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import app.fedilab.android.R +import app.fedilab.android.helper.RecyclerViewThreadLines.LineInfo +import app.fedilab.android.client.mastodon.entities.Context as StatusContext + +class RecyclerViewThreadLines(context: Context, private val lineInfoList: List) : DividerItemDecoration(context, VERTICAL) { + private val lineColors = threadLineColors.map { ResourcesCompat.getColor(context.resources, it, context.theme) } + private val dashPathEffect = DashPathEffect(floatArrayOf(3.dpToPx, 3.dpToPx), 0F) + private val borderColor = lineColors[0] + private val commonPaint = Paint().apply { + isDither = false + strokeWidth = 1.5F.dpToPx + strokeCap = Paint.Cap.BUTT + strokeJoin = Paint.Join.MITER + color = borderColor + } + private val maxLevel = lineColors.size + private val fontScale = PreferenceManager.getDefaultSharedPreferences(context).getFloat(context.getString(R.string.SET_FONT_SCALE), 1.1f).toInt() + private val baseMargin: Int = 6.dpToPx.toInt() + private val margin: Int = baseMargin * fontScale + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = (view.layoutParams as RecyclerView.LayoutParams).viewLayoutPosition + val level = lineInfoList[position].level + val startMargin = margin * level + margin * fontScale + if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) outRect.left = startMargin else outRect.right = startMargin + } + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val childCount = parent.childCount + for (i in 0 until childCount) { + val view = parent.getChildAt(i) + val position = parent.getChildAdapterPosition(view) + val lineInfo = lineInfoList[position] + val level = lineInfo.level + + for (j in 1..level) { + val lineMargin = margin * j + 3.dpToPx + val lineStart = if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) lineMargin else c.width - lineMargin + var lineTop: Float = (view.top - baseMargin).toFloat() + if (j == 0) lineTop += view.height / 2 + val paint = Paint(commonPaint) + paint.color = lineColors[j - 1] + + // draw lines for below statuses + if (j != level && j >= lineInfo.fullLinesStart && j <= lineInfo.fullLinesEnd) + c.drawLine(lineStart, lineTop, lineStart, view.bottom.toFloat(), paint) + + // draw vertical line for current statuses + if (j == level) { + // top the line starts at the middle of the above status + if (i > 0) lineTop -= parent.getChildAt(i - 1).height / 2 - 1 // '- 1' is to prevent overlapping with above horizontal line + + // bottom of the line ends at the middle of the current status + var lineBottom = view.bottom.toFloat() - view.height / 2 + + // if below status has a full line for current level, extend the line to the bottom + if (i < childCount - 1) { + val nextLineInfo = lineInfoList[position + 1] + if (level >= nextLineInfo.fullLinesStart && level <= nextLineInfo.fullLinesEnd) { + lineBottom = view.bottom.toFloat() + } + } + + // if level is max, use a dashed line + if (j == maxLevel) paint.pathEffect = dashPathEffect + + c.drawLine(lineStart, lineTop, lineStart, lineBottom, paint) + } + + // draw horizontal line for current statuses + if (j == level) { + lineTop = view.bottom.toFloat() - view.height / 2 + val lineEnd = lineStart + margin * 2 + c.drawLine(lineStart - 1, lineTop, lineEnd, lineTop, paint) // 'lineStart - 1' is to properly connect with the vertical line + } + } + } + } + + data class LineInfo(var level: Int, var end: Boolean, var fullLinesStart: Int, var fullLinesEnd: Int) + + private val Int.dpToPx: Float + get() = this * Resources.getSystem().displayMetrics.density + + private val Float.dpToPx: Float + get() = this * Resources.getSystem().displayMetrics.density + + companion object { + val threadLineColors = listOf(R.color.decoration_1, R.color.decoration_2, R.color.decoration_3, R.color.decoration_4, R.color.decoration_5) + } +} + +fun getThreadDecorationInfo(fediContext: StatusContext, selectedStatusId: String): MutableList { + val lineInfoList = mutableListOf() + repeat(fediContext.ancestors.size) { lineInfoList.add(LineInfo(0, true, 0, 0)) } + lineInfoList.add(LineInfo(0, fediContext.descendants.isNotEmpty(), 0, 0)) + val descendantsLineInfoList = List(fediContext.descendants.size) { LineInfo(0, false, 0, 0) } + for (i in fediContext.descendants.indices) { + fediContext.descendants[i].let { status -> + var level = 0 + if (status.in_reply_to_id != null) { + if (status.in_reply_to_id == selectedStatusId) + level = 1 + else { + var replyToId: String? = status.in_reply_to_id + while (replyToId != null && level < RecyclerViewThreadLines.threadLineColors.size) { + level += 1 + replyToId = fediContext.descendants.firstOrNull { it.id == replyToId }?.in_reply_to_id + } + } + } + descendantsLineInfoList[i].level = level + val firstReply = fediContext.descendants.firstOrNull { it.in_reply_to_id == status.id } + if (firstReply == null) descendantsLineInfoList[i].end = true + } + } + for (i in descendantsLineInfoList.indices) { + var fullLinesStart = descendantsLineInfoList[i].level + var fullLinesEnd = descendantsLineInfoList[i].level + var fullLinesEndSet = false + for (j in i + 1 until descendantsLineInfoList.lastIndex) { + if (!fullLinesEndSet && descendantsLineInfoList[j].level < descendantsLineInfoList[i].level) { + fullLinesEnd = descendantsLineInfoList[j].level + fullLinesEndSet = true + } + fullLinesStart = descendantsLineInfoList[j].level.coerceAtMost(fullLinesStart) + } + descendantsLineInfoList[i].fullLinesStart = fullLinesStart + descendantsLineInfoList[i].fullLinesEnd = fullLinesEnd + } + + lineInfoList.addAll(descendantsLineInfoList) + return lineInfoList +} diff --git a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonContext.java b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonContext.java index 6b4332a4..e5258bb9 100644 --- a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonContext.java +++ b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonContext.java @@ -16,6 +16,7 @@ package app.fedilab.android.ui.fragment.timeline; import static app.fedilab.android.activities.ContextActivity.displayCW; import static app.fedilab.android.activities.ContextActivity.expand; +import static app.fedilab.android.helper.RecyclerViewThreadLinesKt.getThreadDecorationInfo; import android.content.BroadcastReceiver; import android.content.Intent; @@ -43,8 +44,9 @@ import app.fedilab.android.client.entities.Timeline; import app.fedilab.android.client.mastodon.entities.Context; import app.fedilab.android.client.mastodon.entities.Status; import app.fedilab.android.databinding.FragmentPaginationBinding; -import app.fedilab.android.helper.DividerDecoration; import app.fedilab.android.helper.Helper; +import app.fedilab.android.helper.RecyclerViewThreadLines; +import app.fedilab.android.helper.RecyclerViewThreadLines.LineInfo; import app.fedilab.android.helper.SpannableHelper; import app.fedilab.android.helper.ThemeHelper; import app.fedilab.android.ui.drawer.StatusAdapter; @@ -252,7 +254,8 @@ public class FragmentMastodonContext extends Fragment { binding.recyclerView.removeItemDecorationAt(i); } } - binding.recyclerView.addItemDecoration(new DividerDecoration(requireActivity(), statuses)); + List threadDecorationInfo = getThreadDecorationInfo(context, focusedStatus.id); + binding.recyclerView.addItemDecoration(new RecyclerViewThreadLines(requireContext(), threadDecorationInfo)); binding.swipeContainer.setRefreshing(false); binding.recyclerView.scrollToPosition(statusPosition); } diff --git a/build.gradle b/build.gradle index 7d485314..d2aa8244 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:7.0.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files