From 900d10bd448021848f7bc9a789f6e105b3f1857a Mon Sep 17 00:00:00 2001 From: 5ec1cff Date: Thu, 19 Oct 2023 13:07:07 +0800 Subject: [PATCH] add thread bar --- .../a13e300/ro_tieba/cache/CachedThread.kt | 2 +- .../ro_tieba/misc/ContainerBehavior.kt | 38 ++++ .../ro_tieba/ui/thread/ThreadFragment.kt | 180 ++++++++++++------ .../ro_tieba/ui/thread/ThreadViewModel.kt | 18 +- .../io/github/a13e300/ro_tieba/utils/Utils.kt | 16 ++ app/src/main/res/drawable/ic_jump.xml | 10 + app/src/main/res/layout/fragment_thread.xml | 12 ++ .../main/res/layout/fragment_thread_bar.xml | 60 ++++++ 8 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/io/github/a13e300/ro_tieba/misc/ContainerBehavior.kt create mode 100644 app/src/main/res/drawable/ic_jump.xml create mode 100644 app/src/main/res/layout/fragment_thread_bar.xml diff --git a/app/src/main/java/io/github/a13e300/ro_tieba/cache/CachedThread.kt b/app/src/main/java/io/github/a13e300/ro_tieba/cache/CachedThread.kt index 535ec74..465bfb0 100644 --- a/app/src/main/java/io/github/a13e300/ro_tieba/cache/CachedThread.kt +++ b/app/src/main/java/io/github/a13e300/ro_tieba/cache/CachedThread.kt @@ -243,7 +243,7 @@ class CachedThread private constructor(val tid: Long) { title = response.thread.title, author = response.thread.author.toUser(), content = listOf(), - replyNum = response.thread.replyNum, + replyNum = response.thread.replyNum - 1, // TODO: fix reply num time = Date(response.thread.createTime.toLong() * 1000), // TODO: remove this useless date postId = response.thread.postId, isGood = response.thread.isGood == 1, diff --git a/app/src/main/java/io/github/a13e300/ro_tieba/misc/ContainerBehavior.kt b/app/src/main/java/io/github/a13e300/ro_tieba/misc/ContainerBehavior.kt new file mode 100644 index 0000000..3509868 --- /dev/null +++ b/app/src/main/java/io/github/a13e300/ro_tieba/misc/ContainerBehavior.kt @@ -0,0 +1,38 @@ +package io.github.a13e300.ro_tieba.misc + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import io.github.a13e300.ro_tieba.R + +class ContainerBehavior(context: Context, attrs: AttributeSet) : + CoordinatorLayout.Behavior(context, attrs) { + override fun onMeasureChild( + parent: CoordinatorLayout, + child: View, + parentWidthMeasureSpec: Int, + widthUsed: Int, + parentHeightMeasureSpec: Int, + heightUsed: Int + ): Boolean { + parent.onMeasureChild( + child, + parentWidthMeasureSpec, + widthUsed, + parentHeightMeasureSpec, + heightUsed + ) + return true + } + + override fun onLayoutChild( + parent: CoordinatorLayout, + child: View, + layoutDirection: Int + ): Boolean { + parent.onLayoutChild(child, layoutDirection) + child.offsetTopAndBottom(parent.findViewById(R.id.list).top) + return true + } +} diff --git a/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadFragment.kt b/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadFragment.kt index b4c516f..5c5a6ab 100644 --- a/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadFragment.kt +++ b/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadFragment.kt @@ -22,6 +22,7 @@ import android.widget.MediaController import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.MenuProvider +import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged @@ -32,7 +33,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager @@ -47,12 +47,12 @@ import io.github.a13e300.ro_tieba.Logger import io.github.a13e300.ro_tieba.MobileNavigationDirections import io.github.a13e300.ro_tieba.R import io.github.a13e300.ro_tieba.databinding.DialogJumpPageBinding +import io.github.a13e300.ro_tieba.databinding.FragmentThreadBarBinding import io.github.a13e300.ro_tieba.databinding.FragmentThreadBinding import io.github.a13e300.ro_tieba.databinding.FragmentThreadCommentPreviewBinding import io.github.a13e300.ro_tieba.databinding.FragmentThreadHeaderBinding import io.github.a13e300.ro_tieba.databinding.FragmentThreadPostItemBinding import io.github.a13e300.ro_tieba.databinding.ImageContentBinding -import io.github.a13e300.ro_tieba.databinding.ThreadListFooterBinding import io.github.a13e300.ro_tieba.databinding.VideoViewBinding import io.github.a13e300.ro_tieba.db.EntryType import io.github.a13e300.ro_tieba.db.HistoryEntry @@ -75,6 +75,8 @@ import io.github.a13e300.ro_tieba.utils.configureDefaults import io.github.a13e300.ro_tieba.utils.configureImageForContent import io.github.a13e300.ro_tieba.utils.copyText import io.github.a13e300.ro_tieba.utils.displayImageInList +import io.github.a13e300.ro_tieba.utils.firstOrNullFrom +import io.github.a13e300.ro_tieba.utils.indexOfFrom import io.github.a13e300.ro_tieba.utils.setSelectedData import io.github.a13e300.ro_tieba.utils.toSimpleString import io.github.a13e300.ro_tieba.view.ContentTextView @@ -172,6 +174,8 @@ class ThreadFragment : BaseFragment() { } if (idx != -1) { scrollToPositionWithOffset(idx, request.offset) + if (request.offsetToBar) + prepareScrollOffsetToBar() if (request.highlight) mHighlightIdx = idx } else { @@ -191,15 +195,18 @@ class ThreadFragment : BaseFragment() { ) ) addOnScrollListener(PauseLoadOnQuickScrollListener()) + addOnScrollListener(mScrollListener) } viewModel.threadInfo.observe(viewLifecycleOwner) { binding.toolbar.title = it.forum?.name + notifyBarUpdate() if (!viewModel.historyAdded) { updateHistory() viewModel.historyAdded = true } } setupToolbar(binding.toolbar) + bindBar(binding.includeThreadBar) binding.toolbar.setOnClickListener { binding.list.scrollToPosition(0) } @@ -215,18 +222,13 @@ class ThreadFragment : BaseFragment() { R.id.sort -> { val v = !menuItem.isChecked - menuItem.setChecked(v) - viewModel.threadConfig = viewModel.threadConfig.copy(reverse = v) - postAdapter.refresh() + setReverse(v) true } R.id.see_lz -> { val v = !menuItem.isChecked - menuItem.setChecked(v) - viewModel.threadConfig = - viewModel.threadConfig.copy(seeLz = v, pid = 0L, page = 0) - postAdapter.refresh() + setSeeLz(v) true } @@ -276,6 +278,93 @@ class ThreadFragment : BaseFragment() { return binding.root } + private fun setSeeLz(v: Boolean) { + viewModel.threadConfig = + viewModel.threadConfig.copy(seeLz = v, pid = 0L, page = 0) + notifyBarUpdate() + postAdapter.refresh() + binding.toolbar.menu.findItem(R.id.see_lz).setChecked(viewModel.threadConfig.seeLz) + } + + private fun setReverse(v: Boolean) { + viewModel.threadConfig = viewModel.threadConfig.copy(reverse = v) + notifyBarUpdate() + postAdapter.refresh() + binding.toolbar.menu.findItem(R.id.sort).setChecked(viewModel.threadConfig.reverse) + } + + private fun notifyBarUpdate() { + val barIdx = + postAdapter.snapshot().items.indexOfFirst { it is ThreadViewModel.PostModel.Bar } + if (barIdx != -1) + postAdapter.notifyItemChanged(barIdx) + updateBar(binding.includeThreadBar) + } + + private fun bindBar(bar: FragmentThreadBarBinding) { + bar.seeLzBtn.setOnClickListener { + setSeeLz(!bar.seeLzBtn.isChecked) + } + bar.sortBtn.setOnClickListener { + setReverse(!bar.sortBtn.isChecked) + } + bar.jumpBtn.setOnClickListener { + handleJumpPage() + } + } + + private fun updateBar(bar: FragmentThreadBarBinding) { + val data = viewModel.threadInfo.value ?: return + val current = findCurrentPost() + bar.count.text = "${data.replyNum} 条回复" + bar.seeLzBtn.isChecked = viewModel.threadConfig.seeLz + bar.sortBtn.isChecked = viewModel.threadConfig.reverse + bar.jumpBtn.text = "第 ${current?.page} / ${viewModel.totalPage} 页" + } + + private var mScrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val pos = postLayoutManager.findFirstVisibleItemPosition() + if (pos == RecyclerView.NO_POSITION) return + val shouldHide = when (val item = postAdapter.snapshot()[pos]) { + is ThreadViewModel.PostModel.Header -> true + is ThreadViewModel.PostModel.Bar -> false + is ThreadViewModel.PostModel.Post -> { + item.post.floor == 1 + } + + null -> return + } + updateBar(binding.includeThreadBar) + ViewCompat.postOnAnimation(binding.mainStickyContainerLayout) { + binding.mainStickyContainerLayout.isGone = shouldHide + } + } + } + + private val mScrollOffsetToBarListener = + object : View.OnLayoutChangeListener { + override fun onLayoutChange( + view: View, + p1: Int, + p2: Int, + p3: Int, + p4: Int, + p5: Int, + p6: Int, + p7: Int, + p8: Int + ) { + val h = view.height + binding.list.scrollBy(0, -h) + view.removeOnLayoutChangeListener(this) + } + } + + private fun prepareScrollOffsetToBar() { + binding.mainStickyContainerLayout.addOnLayoutChangeListener(mScrollOffsetToBarListener) + } + private fun handleJumpPage() { val totalPage = viewModel.totalPage val page = findCurrentPost()?.page ?: 0 @@ -334,25 +423,16 @@ class ThreadFragment : BaseFragment() { } private fun saveLastSeenInfo() { - val pos = postLayoutManager.findFirstVisibleItemPosition() - if (pos == RecyclerView.NO_POSITION) return - val off = postLayoutManager.findViewByPosition(pos)?.top ?: return - val item = postAdapter.snapshot().items.getOrNull(pos) ?: return - val page: Int - val floor: Int - when (item) { - is ThreadViewModel.PostModel.Header -> { - page = - (postAdapter.snapshot().items.getOrNull(pos + 1) as? ThreadViewModel.PostModel.Post)?.post?.page - ?: 0 - floor = -1 - } - - is ThreadViewModel.PostModel.Post -> { - page = item.post.page - floor = item.post.floor - } + val pos = postLayoutManager.findFirstVisibleItemPosition().let { + if (it == RecyclerView.NO_POSITION) return + postAdapter.snapshot().items.indexOfFrom(it) { item -> item is ThreadViewModel.PostModel.Post } } + if (pos == -1) return + val off = postLayoutManager.findViewByPosition(pos)?.top ?: return + val item = postAdapter.snapshot().items[pos] as ThreadViewModel.PostModel.Post + val page = item.post.page + val floor = item.post.floor + // TODO: remove floor = -1 sLastSeenThreadInfo = LastSeenThreadInfo( args.tid, args.pid, page, floor, off, viewModel.threadConfig.seeLz, viewModel.threadConfig.reverse @@ -362,13 +442,8 @@ class ThreadFragment : BaseFragment() { private fun findCurrentPost() = postLayoutManager.findFirstVisibleItemPosition().let { if (it == RecyclerView.NO_POSITION) null else { - postAdapter.snapshot().items.let { items -> - (items[it].let { a -> - if (a is ThreadViewModel.PostModel.Header) { - items.getOrNull(it + 1)?.let { b -> (b as? ThreadViewModel.PostModel.Post) } - } else (a as? ThreadViewModel.PostModel.Post) - })?.post - } + (postAdapter.snapshot().items.firstOrNullFrom(it) { item -> item is ThreadViewModel.PostModel.Post } + as? ThreadViewModel.PostModel.Post)?.post } } @@ -401,7 +476,7 @@ class ThreadFragment : BaseFragment() { saveLastSeenInfo() } - class MyItemDecoration(private val mMargin: Int) : ItemDecoration() { + inner class MyItemDecoration(private val mMargin: Int) : ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, @@ -409,35 +484,24 @@ class ThreadFragment : BaseFragment() { state: RecyclerView.State ) { val pos = parent.getChildAdapterPosition(view) - if (pos >= 2) { + if (pos == RecyclerView.NO_POSITION) return + val item = postAdapter.snapshot().items[pos] + val minMarginFloor = if (viewModel.threadConfig.reverse) 0 else 2 + if (item is ThreadViewModel.PostModel.Post && item.post.floor > minMarginFloor) { outRect.set(0, mMargin, 0, 0) } } } - class FooterHolder(val binding: ThreadListFooterBinding) : ViewHolder(binding.root) - - inner class FooterAdapter : LoadStateAdapter() { - override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): FooterHolder { - return FooterHolder(ThreadListFooterBinding.inflate(layoutInflater, parent, false)) - } - - override fun onBindViewHolder(holder: FooterHolder, loadState: LoadState) { - val isErr = loadState is LoadState.Error - holder.binding.retryButton.isVisible = isErr - holder.binding.errorMessage.isVisible = isErr - if (loadState is LoadState.Error) { - holder.binding.errorMessage.text = loadState.error.message - } - } - } - class PostViewHolder(val binding: FragmentThreadPostItemBinding) : ViewHolder(binding.root) class HeaderViewHolder(val binding: FragmentThreadHeaderBinding) : ViewHolder(binding.root) + class BarViewHolder(val binding: FragmentThreadBarBinding) : + ViewHolder(binding.root) + inner class PostAdapter(diffCallback: DiffUtil.ItemCallback) : PagingDataAdapter( @@ -448,6 +512,7 @@ class ThreadFragment : BaseFragment() { return when (peek(position)) { is ThreadViewModel.PostModel.Post -> R.layout.fragment_thread_post_item is ThreadViewModel.PostModel.Header -> R.layout.fragment_thread_header + is ThreadViewModel.PostModel.Bar -> R.layout.fragment_thread_bar else -> throw IllegalStateException("unknown item") } } @@ -462,6 +527,9 @@ class ThreadFragment : BaseFragment() { } } else if (holder is HeaderViewHolder) { bindForHeader(holder) + } else if (holder is BarViewHolder) { + bindBar(holder.binding) + updateBar(holder.binding) } } @@ -790,6 +858,12 @@ class ThreadFragment : BaseFragment() { ) ) + R.layout.fragment_thread_bar -> BarViewHolder( + FragmentThreadBarBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + else -> throw IllegalStateException("unknown type $viewType") } } diff --git a/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadViewModel.kt b/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadViewModel.kt index 9d22c16..9121f38 100644 --- a/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadViewModel.kt +++ b/app/src/main/java/io/github/a13e300/ro_tieba/ui/thread/ThreadViewModel.kt @@ -8,6 +8,7 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.cachedIn import androidx.paging.insertHeaderItem +import androidx.paging.insertSeparators import androidx.paging.map import io.github.a13e300.ro_tieba.Logger import io.github.a13e300.ro_tieba.cache.CachedThread @@ -31,20 +32,24 @@ class ThreadViewModel : ViewModel() { sealed class ScrollRequest { abstract val offset: Int abstract val highlight: Boolean + abstract val offsetToBar: Boolean data class ByPid( val pid: Long, override val offset: Int = 0, - override val highlight: Boolean = true + override val highlight: Boolean = true, + override val offsetToBar: Boolean = true ) : ScrollRequest() data class ByFloor( val floor: Int, override val offset: Int = 0, - override val highlight: Boolean = true + override val highlight: Boolean = true, + override val offsetToBar: Boolean = false ) : ScrollRequest() data class ByPage( val page: Int, override val offset: Int = 0, - override val highlight: Boolean = false + override val highlight: Boolean = false, + override val offsetToBar: Boolean = true ) : ScrollRequest() } @@ -89,6 +94,7 @@ class ThreadViewModel : ViewModel() { sealed class PostModel { data class Post(val post: io.github.a13e300.ro_tieba.models.Post) : PostModel() data object Header : PostModel() + data object Bar : PostModel() } inner class PostPagingSource( @@ -156,6 +162,12 @@ class ThreadViewModel : ViewModel() { PostPagingSource(threadConfig) }.flow.map { it.map { item -> PostModel.Post(item) } + .insertSeparators { before: PostModel?, after: PostModel? -> + if ((!threadConfig.reverse && (before as? PostModel.Post)?.post?.floor == 1) + || (threadConfig.reverse && before == null) + ) PostModel.Bar + else null + } .insertHeaderItem(item = PostModel.Header) }.cachedIn(viewModelScope) } \ No newline at end of file diff --git a/app/src/main/java/io/github/a13e300/ro_tieba/utils/Utils.kt b/app/src/main/java/io/github/a13e300/ro_tieba/utils/Utils.kt index 2db9dde..6703517 100644 --- a/app/src/main/java/io/github/a13e300/ro_tieba/utils/Utils.kt +++ b/app/src/main/java/io/github/a13e300/ro_tieba/utils/Utils.kt @@ -402,3 +402,19 @@ fun DisplayRequest.Builder.configureDefaults(context: Context) { placeholder(ColorStateImage(color)) error(IconStateImage(R.drawable.ic_error, IntColor(color))) } + +inline fun List.firstOrNullFrom(from: Int = 0, predicate: (T) -> Boolean): T? { + for (i in from until size) { + val item = this[i] + if (predicate(item)) return item + } + return null +} + +inline fun List.indexOfFrom(from: Int = 0, predicate: (T) -> Boolean): Int { + for (i in from until size) { + val item = this[i] + if (predicate(item)) return i + } + return -1 +} diff --git a/app/src/main/res/drawable/ic_jump.xml b/app/src/main/res/drawable/ic_jump.xml new file mode 100644 index 0000000..dc42efc --- /dev/null +++ b/app/src/main/res/drawable/ic_jump.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_thread.xml b/app/src/main/res/layout/fragment_thread.xml index cc54592..0717901 100644 --- a/app/src/main/res/layout/fragment_thread.xml +++ b/app/src/main/res/layout/fragment_thread.xml @@ -31,4 +31,16 @@ app:layout_behavior="@string/appbar_scrolling_view_behavior" app:fitsSystemWindowsInsets="bottom" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_thread_bar.xml b/app/src/main/res/layout/fragment_thread_bar.xml new file mode 100644 index 0000000..b753c22 --- /dev/null +++ b/app/src/main/res/layout/fragment_thread_bar.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + \ No newline at end of file