Add more improved fast scroller

This commit is contained in:
Zakhar Timoshenko
2022-07-09 00:26:05 +03:00
parent c5de765e52
commit b519b53419
27 changed files with 1012 additions and 55 deletions

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2022 Randy Webster. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.ColorInt
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
class FastScrollRecyclerView : RecyclerView {
private val fastScroller: FastScroller
constructor(context: Context) : super(context) {
fastScroller = context.layout()
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
@JvmOverloads
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) {
fastScroller = context.layout(attrs)
}
override fun setAdapter(adapter: Adapter<*>?) = super.setAdapter(adapter).also {
when (adapter) {
is FastScroller.SectionIndexer -> fastScroller.setSectionIndexer(adapter)
null -> fastScroller.setSectionIndexer(null)
}
}
override fun setVisibility(visibility: Int) = super.setVisibility(visibility).also {
fastScroller.visibility = visibility
}
fun setFastScrollListener(fastScrollListener: FastScroller.FastScrollListener?) =
fastScroller.setFastScrollListener(fastScrollListener)
fun setFastScrollEnabled(enabled: Boolean) {
fastScroller.isEnabled = enabled
}
fun setHideScrollbar(hideScrollbar: Boolean) = fastScroller.setHideScrollbar(hideScrollbar)
fun setTrackVisible(visible: Boolean) = fastScroller.setTrackVisible(visible)
fun setTrackColor(@ColorInt color: Int) = fastScroller.setTrackColor(color)
fun setHandleColor(@ColorInt color: Int) = fastScroller.setHandleColor(color)
@JvmOverloads
fun setBubbleVisible(visible: Boolean, always: Boolean = false) = fastScroller.setBubbleVisible(visible, always)
fun setBubbleColor(@ColorInt color: Int) = fastScroller.setBubbleColor(color)
fun setBubbleTextColor(@ColorInt color: Int) = fastScroller.setBubbleTextColor(color)
fun setBubbleTextSize(size: Int) = fastScroller.setBubbleTextSize(size)
override fun onAttachedToWindow() = super.onAttachedToWindow().also {
fastScroller.attachRecyclerView(this)
}
override fun onDetachedFromWindow() {
fastScroller.detachRecyclerView()
super.onDetachedFromWindow()
}
private fun Context.layout(attrs: AttributeSet? = null) =
FastScroller(this, attrs).apply { id = R.id.fast_scroller }
}

View File

@@ -0,0 +1,625 @@
/*
* Copyright 2022 Randy Webster. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.koitharu.kotatsu.base.ui.widgets
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.TypedArray
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.widget.*
import android.widget.RelativeLayout.*
import androidx.annotation.ColorInt
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.annotation.StyleableRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.getCompatDrawable
import org.koitharu.kotatsu.utils.ext.isLayoutReversed
import org.koitharu.kotatsu.utils.ext.setCompatTint
import org.koitharu.kotatsu.utils.ext.wrap
import kotlin.math.roundToInt
private const val BUBBLE_ANIM_DURATION = 100L
private const val SCROLLBAR_ANIM_DURATION = 300L
private const val SCROLLBAR_HIDE_DELAY = 1000L
private const val TRACK_SNAP_RANGE = 5
@Suppress("MemberVisibilityCanBePrivate", "unused")
class FastScroller : LinearLayout {
enum class Size(@DrawableRes val drawableId: Int, @DimenRes val textSizeId: Int) {
NORMAL(R.drawable.fastscroll_bubble, R.dimen.fastscroll_bubble_text_size),
SMALL(R.drawable.fastscroll_bubble_small, R.dimen.fastscroll_bubble_text_size_small)
}
private val Size.textSize get() = resources.getDimension(textSizeId)
private val bubbleView: TextView by lazy { findViewById(R.id.fastscroll_bubble) }
private val handleView: ImageView by lazy { findViewById(R.id.fastscroll_handle) }
private val trackView: ImageView by lazy { findViewById(R.id.fastscroll_track) }
private val scrollbar: View by lazy { findViewById(R.id.fastscroll_scrollbar) }
private val scrollbarPaddingEnd by lazy {
resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_padding_end).toFloat()
}
@ColorInt
private var bubbleColor = 0
@ColorInt
private var handleColor = 0
private var bubbleHeight = 0
private var handleHeight = 0
private var viewHeight = 0
private var hideScrollbar = true
private var showBubble = true
private var showBubbleAlways = false
private var bubbleSize = Size.NORMAL
private var bubbleImage: Drawable? = null
private var handleImage: Drawable? = null
private var trackImage: Drawable? = null
private var recyclerView: RecyclerView? = null
private var swipeRefreshLayout: SwipeRefreshLayout? = null
private var scrollbarAnimator: ViewPropertyAnimator? = null
private var bubbleAnimator: ViewPropertyAnimator? = null
private var fastScrollListener: FastScrollListener? = null
private var sectionIndexer: SectionIndexer? = null
private val scrollbarHider = Runnable {
hideBubble()
hideScrollbar()
}
private val alphaAnimatorListener = object : AnimatorListenerAdapter() {
/* adapter required for new alpha value to stick */
}
private val scrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (!handleView.isSelected && isEnabled) {
val y = recyclerView.scrollProportion
setViewPositions(y)
if (showBubbleAlways) {
val targetPos = getRecyclerViewTargetPosition(y)
sectionIndexer?.let { bubbleView.text = it.getSectionText(targetPos) }
}
}
swipeRefreshLayout?.let {
val firstVisibleItem = recyclerView.layoutManager.firstVisibleItemPosition
val topPosition = if (recyclerView.childCount == 0) 0 else recyclerView.getChildAt(0).top
it.isEnabled = firstVisibleItem == 0 && topPosition >= 0
}
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (isEnabled) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
handler.removeCallbacks(scrollbarHider)
scrollbarAnimator?.cancel()
if (!scrollbar.isVisible) showScrollbar()
if (showBubbleAlways && sectionIndexer != null) showBubble()
}
RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !handleView.isSelected) {
handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
}
}
}
}
}
private val RecyclerView.scrollProportion: Float
get() {
val rangeDiff = computeVerticalScrollRange() - computeVerticalScrollExtent()
val proportion = computeVerticalScrollOffset() / if (rangeDiff > 0) rangeDiff.toFloat() else 1f
return viewHeight * proportion
}
@JvmOverloads
constructor(context: Context, size: Size = Size.NORMAL) : super(context) {
context.layout(size = size)
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)
}
@JvmOverloads
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) {
context.layout(attrs)
layoutParams = attrs?.let { generateLayoutParams(it) } ?: LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.MATCH_PARENT
)
}
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) = super.onSizeChanged(w, h, oldW, oldH).also {
viewHeight = h
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val setYPositions: () -> Unit = {
val y = event.y
setViewPositions(y)
setRecyclerViewPosition(y)
}
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (event.x < handleView.x - scrollbar.compatPaddingStart) return false
requestDisallowInterceptTouchEvent(true)
setHandleSelected(true)
handler.removeCallbacks(scrollbarHider)
scrollbarAnimator?.cancel()
bubbleAnimator?.cancel()
if (!scrollbar.isVisible) showScrollbar()
if (showBubble && sectionIndexer != null) showBubble()
fastScrollListener?.onFastScrollStart(this)
setYPositions()
return true
}
MotionEvent.ACTION_MOVE -> {
setYPositions()
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
requestDisallowInterceptTouchEvent(false)
setHandleSelected(false)
if (hideScrollbar) handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
if (!showBubbleAlways) hideBubble()
fastScrollListener?.onFastScrollStop(this)
return true
}
}
return super.onTouchEvent(event)
}
/**
* Set the enabled state of this view.
*
* @param enabled True if this view is enabled, false otherwise
*/
override fun setEnabled(enabled: Boolean) = super.setEnabled(enabled).also {
isVisible = enabled
}
/**
* Set the [ViewGroup.LayoutParams] associated with this view. These supply
* parameters to the *parent* of this view specifying how it should be arranged.
*
* @param params The [ViewGroup.LayoutParams] for this view, cannot be null
*/
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
params.width = LayoutParams.WRAP_CONTENT
super.setLayoutParams(params)
}
/**
* Set the [ViewGroup.LayoutParams] associated with this view. These supply
* parameters to the *parent* of this view specifying how it should be arranged.
*
* @param viewGroup The parent [ViewGroup] for this view, cannot be null
*/
fun setLayoutParams(viewGroup: ViewGroup) {
val recyclerViewId = recyclerView?.id ?: NO_ID
val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
when (viewGroup) {
is ConstraintLayout -> {
val endId = if (recyclerView?.parent === parent) recyclerViewId else ConstraintSet.PARENT_ID
val startId = id
ConstraintSet().apply {
clone(viewGroup)
connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP)
connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM)
connect(startId, ConstraintSet.END, endId, ConstraintSet.END)
applyTo(viewGroup)
}
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
height = 0
setMargins(0, marginTop, 0, marginBottom)
}
}
is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply {
height = LayoutParams.MATCH_PARENT
anchorGravity = GravityCompat.END
anchorId = recyclerViewId
setMargins(0, marginTop, 0, marginBottom)
}
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
height = LayoutParams.MATCH_PARENT
gravity = GravityCompat.END
setMargins(0, marginTop, 0, marginBottom)
}
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
height = 0
addRule(ALIGN_TOP, recyclerViewId)
addRule(ALIGN_BOTTOM, recyclerViewId)
addRule(ALIGN_END, recyclerViewId)
setMargins(0, marginTop, 0, marginBottom)
}
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
}
updateViewHeights()
}
/**
* Set the [RecyclerView] associated with this [FastScroller]. This allows the
* FastScroller to set its layout parameters and listen for scroll changes.
*
* @param recyclerView The [RecyclerView] to attach, cannot be null
* @see detachRecyclerView
*/
fun attachRecyclerView(recyclerView: RecyclerView) {
this.recyclerView = recyclerView
if (parent is ViewGroup) {
setLayoutParams(parent as ViewGroup)
} else if (recyclerView.parent is ViewGroup) {
val viewGroup = recyclerView.parent as ViewGroup
viewGroup.addView(this)
setLayoutParams(viewGroup)
}
recyclerView.addOnScrollListener(scrollListener)
// set initial positions for bubble and handle
post { setViewPositions(this.recyclerView?.scrollProportion ?: 0f) }
}
/**
* Clears references to the attached [RecyclerView] and stops listening for scroll changes.
*
* @see attachRecyclerView
*/
fun detachRecyclerView() {
recyclerView?.removeOnScrollListener(scrollListener)
recyclerView = null
}
/**
* Set a new [FastScrollListener] that will listen to fast scroll events.
*
* @param fastScrollListener The new [FastScrollListener] to set, or null to set none
*/
fun setFastScrollListener(fastScrollListener: FastScrollListener?) {
this.fastScrollListener = fastScrollListener
}
/**
* Set a new [SectionIndexer] that provides section text for this [FastScroller].
*
* @param sectionIndexer The new [SectionIndexer] to set, or null to set none
*/
fun setSectionIndexer(sectionIndexer: SectionIndexer?) {
this.sectionIndexer = sectionIndexer
}
/**
* Set a [SwipeRefreshLayout] to disable when the [RecyclerView] is scrolled away from the top.
*
* Required when SDK target precedes [VERSION_CODES.LOLLIPOP], otherwise use
* [setNestedScrollingEnabled(true)][View.setNestedScrollingEnabled].
*
* @param swipeRefreshLayout The [SwipeRefreshLayout] to set, or null to set none
*/
fun setSwipeRefreshLayout(swipeRefreshLayout: SwipeRefreshLayout?) {
this.swipeRefreshLayout = swipeRefreshLayout
}
/**
* Hide the scrollbar when not scrolling.
*
* @param hideScrollbar True to hide the scrollbar, false to show
*/
fun setHideScrollbar(hideScrollbar: Boolean) {
if (this.hideScrollbar != hideScrollbar) {
scrollbar.isVisible = !hideScrollbar.also { this.hideScrollbar = it }
}
}
/**
* Show the scroll track while scrolling.
*
* @param visible True to show scroll track, false to hide
*/
fun setTrackVisible(visible: Boolean) {
trackView.isVisible = visible
}
/**
* Set the color of the scroll track.
*
* @param color The color for the scroll track
*/
fun setTrackColor(@ColorInt color: Int) {
if (trackImage == null) {
context.getCompatDrawable(R.drawable.fastscroll_track)?.let { trackImage = it.wrap().mutate() }
}
trackImage?.let {
it.setCompatTint(color)
trackView.setImageDrawable(it)
}
}
/**
* Set the color of the scroll handle.
*
* @param color The color for the scroll handle
*/
fun setHandleColor(@ColorInt color: Int) {
handleColor = color
if (handleImage == null) {
context.getCompatDrawable(R.drawable.fastscroll_handle)?.let { handleImage = it.wrap().mutate() }
}
handleImage?.let {
it.setCompatTint(handleColor)
handleView.setImageDrawable(it)
}
}
/**
* Show the section bubble while scrolling.
*
* @param visible True to show the bubble, false to hide
* @param always True to always show the bubble, false to only show on handle touch
*/
@JvmOverloads
fun setBubbleVisible(visible: Boolean, always: Boolean = false) {
showBubble = visible
showBubbleAlways = visible && always
}
/**
* Set the background color of the section bubble.
*
* @param color The background color for the section bubble
*/
fun setBubbleColor(@ColorInt color: Int) {
bubbleColor = color
if (bubbleImage == null) {
context.getCompatDrawable(bubbleSize.drawableId)?.let { bubbleImage = it.wrap().mutate() }
}
bubbleImage?.let {
it.setCompatTint(bubbleColor)
bubbleView.background = it
}
}
/**
* Set the text color of the section bubble.
*
* @param color The text color for the section bubble
*/
fun setBubbleTextColor(@ColorInt color: Int) = bubbleView.setTextColor(color)
/**
* Set the scaled pixel text size of the section bubble.
*
* @param size The scaled pixel text size for the section bubble
*/
fun setBubbleTextSize(size: Int) {
bubbleView.textSize = size.toFloat()
}
private fun getRecyclerViewTargetPosition(y: Float) = recyclerView?.let { recyclerView ->
val itemCount = recyclerView.adapter?.itemCount ?: 0
val proportion = when {
handleView.y == 0f -> 0f
handleView.y + handleHeight >= viewHeight - TRACK_SNAP_RANGE -> 1f
else -> y / viewHeight.toFloat()
}
var scrolledItemCount = (proportion * itemCount).roundToInt()
if (recyclerView.layoutManager.isLayoutReversed) {
scrolledItemCount = itemCount - scrolledItemCount
}
if (itemCount > 0) scrolledItemCount.coerceIn(0, itemCount - 1) else 0
} ?: 0
private fun setRecyclerViewPosition(y: Float) {
recyclerView?.layoutManager?.let { layoutManager ->
val targetPos = getRecyclerViewTargetPosition(y)
layoutManager.scrollToPosition(targetPos)
if (showBubble) sectionIndexer?.let { bubbleView.text = it.getSectionText(targetPos) }
}
}
private fun setViewPositions(y: Float) {
bubbleHeight = bubbleView.measuredHeight
handleHeight = handleView.measuredHeight
val bubbleHandleHeight = bubbleHeight + handleHeight / 2f
if (showBubble && viewHeight >= bubbleHandleHeight) {
bubbleView.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight)
}
if (viewHeight >= handleHeight) {
handleView.y = (y - handleHeight / 2).coerceIn(0f, viewHeight - handleHeight.toFloat())
}
}
private fun updateViewHeights() {
val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
bubbleView.measure(measureSpec, measureSpec)
bubbleHeight = bubbleView.measuredHeight
handleView.measure(measureSpec, measureSpec)
handleHeight = handleView.measuredHeight
}
private fun showBubble() {
if (!bubbleView.isVisible) {
bubbleView.isVisible = true
bubbleAnimator = bubbleView.animate().alpha(1f)
.setDuration(BUBBLE_ANIM_DURATION)
.setListener(alphaAnimatorListener)
}
}
private fun hideBubble() {
if (bubbleView.isVisible) {
bubbleAnimator = bubbleView.animate().alpha(0f)
.setDuration(BUBBLE_ANIM_DURATION)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
bubbleView.isVisible = false
bubbleAnimator = null
}
override fun onAnimationCancel(animation: Animator) {
super.onAnimationCancel(animation)
bubbleView.isVisible = false
bubbleAnimator = null
}
})
}
}
private fun showScrollbar() {
if ((recyclerView?.computeVerticalScrollRange() ?: (0 - viewHeight)) > 0) {
scrollbar.translationX = scrollbarPaddingEnd
scrollbar.isVisible = true
scrollbarAnimator = scrollbar.animate().translationX(0f).alpha(1f)
.setDuration(SCROLLBAR_ANIM_DURATION)
.setListener(alphaAnimatorListener)
}
}
private fun hideScrollbar() {
scrollbarAnimator = scrollbar.animate().translationX(scrollbarPaddingEnd).alpha(0f)
.setDuration(SCROLLBAR_ANIM_DURATION)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
scrollbar.isVisible = false
scrollbarAnimator = null
}
override fun onAnimationCancel(animation: Animator) {
super.onAnimationCancel(animation)
scrollbar.isVisible = false
scrollbarAnimator = null
}
})
}
private fun setHandleSelected(selected: Boolean) {
handleView.isSelected = selected
handleImage?.setCompatTint(if (selected) bubbleColor else handleColor)
}
private fun TypedArray.getSize(@StyleableRes index: Int, defValue: Int) = getInt(index, defValue).let { ordinal ->
Size.values().find { it.ordinal == ordinal } ?: Size.NORMAL
}
private fun Context.layout(attrs: AttributeSet? = null, size: Size = Size.NORMAL) {
inflate(this, R.layout.fast_scroller, this@FastScroller)
clipChildren = false
orientation = HORIZONTAL
@ColorInt var bubbleColor = Color.GRAY
@ColorInt var handleColor = Color.DKGRAY
@ColorInt var trackColor = Color.LTGRAY
@ColorInt var textColor = Color.WHITE
var showTrack = false
var textSize = size.textSize
withStyledAttributes(attrs, R.styleable.FastScroller) {
bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor)
handleColor = getColor(R.styleable.FastScroller_handleColor, handleColor)
trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor)
textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor)
hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar)
showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble)
showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways)
showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack)
bubbleSize = getSize(R.styleable.FastScroller_bubbleSize, size.ordinal)
textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
}
setTrackColor(trackColor)
setHandleColor(handleColor)
setBubbleColor(bubbleColor)
setBubbleTextColor(textColor)
setHideScrollbar(hideScrollbar)
setBubbleVisible(showBubble, showBubbleAlways)
setTrackVisible(showTrack)
bubbleView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
}
interface FastScrollListener {
fun onFastScrollStart(fastScroller: FastScroller)
fun onFastScrollStop(fastScroller: FastScroller)
}
interface SectionIndexer {
fun getSectionText(position: Int): CharSequence
}
}

View File

@@ -7,14 +7,9 @@ import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
import androidx.customview.view.AbsSavedState
import androidx.fragment.app.FragmentContainerView
import androidx.viewpager.widget.ViewPager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.utils.ext.findChild
import org.koitharu.kotatsu.utils.ext.findDescendant
class KotatsuCoordinatorLayout @JvmOverloads constructor(
context: Context,

View File

@@ -229,7 +229,7 @@ class DetailsFragment :
return
}
imageViewCover.newImageRequest(scrobbling.coverUrl)
.crossfade(true)
.crossfade((300 * context?.animatorDurationScale!!).toInt())
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
@@ -331,6 +331,7 @@ class DetailsFragment :
binding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
}
@@ -357,7 +358,7 @@ class DetailsFragment :
val request = ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.data(imageUrl)
.crossfade(true)
.crossfade((300 * context?.animatorDurationScale!!).toInt())
.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner)
lastResult?.drawable?.let {

View File

@@ -3,12 +3,13 @@ package org.koitharu.kotatsu.details.ui.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.widgets.FastScroller
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()) {
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()), FastScroller.SectionIndexer {
init {
setHasStableIds(true)
@@ -39,4 +40,8 @@ class ChaptersAdapter(
return null
}
}
override fun getSectionText(position: Int): CharSequence {
return items[position].chapter.number.toString()
}
}

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.animatorDurationScale
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -110,7 +111,7 @@ class ScrobblingInfoBottomSheet :
ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.data(scrobbling.coverUrl)
.crossfade(true)
.crossfade((300 * context?.animatorDurationScale!!).toInt())
.lifecycle(viewLifecycleOwner)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
class FeedFragment :
BaseFragment<FragmentFeedBinding>(),
@@ -56,7 +57,11 @@ class FeedFragment :
)
addItemDecoration(decoration)
}
binding.swipeRefreshLayout.isEnabled = false
with(binding.swipeRefreshLayout) {
setProgressBackgroundColorSchemeColor(context.getThemeColor(com.google.android.material.R.attr.colorPrimary))
setColorSchemeColors(context.getThemeColor(com.google.android.material.R.attr.colorOnPrimary))
isEnabled = false
}
addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel))
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)

View File

@@ -15,8 +15,10 @@ import android.view.ViewPropertyAnimator
import android.view.Window
import android.view.animation.Animation
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.DrawableRes
import androidx.constraintlayout.motion.widget.MotionScene
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.lifecycle.Lifecycle
@@ -122,6 +124,8 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float
val Context.animatorDurationScale: Float
get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
internal fun Context.getCompatDrawable(@DrawableRes drawableId: Int) = ContextCompat.getDrawable(this, drawableId)
fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply {
this.duration = (this.duration * context.animatorDurationScale).toLong()
}

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
fun ImageView.newImageRequest(url: String?) = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.crossfade((300 * context.animatorDurationScale).toInt())
.target(this)
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.utils.ext
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import androidx.core.graphics.drawable.DrawableCompat
internal fun Drawable.setCompatTint(@ColorInt color: Int) = DrawableCompat.setTint(this, color)
internal fun Drawable.wrap(): Drawable = DrawableCompat.wrap(this)

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.utils.ext
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
internal val RecyclerView.LayoutManager?.firstVisibleItemPosition
get() = when (this) {
is LinearLayoutManager -> findFirstVisibleItemPosition()
is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null)[0]
else -> 0
}
internal val RecyclerView.LayoutManager?.isLayoutReversed
get() = when (this) {
is LinearLayoutManager -> reverseLayout
is StaggeredGridLayoutManager -> reverseLayout
else -> false
}

View File

@@ -5,6 +5,7 @@ import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.core.view.ViewCompat
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -144,4 +145,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
findViewsByType(RecyclerView::class.java).forEach {
it.invalidateItemDecorations()
}
}
}
internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this)

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:shape="rectangle">
<tools:solid android:color="#777777" />
<corners
android:topLeftRadius="@dimen/fastscroll_bubble_radius"
android:topRightRadius="@dimen/fastscroll_bubble_radius"
android:bottomLeftRadius="@dimen/fastscroll_bubble_radius"
android:bottomRightRadius="8dp" />
<size
android:height="@dimen/fastscroll_bubble_size"
android:width="@dimen/fastscroll_bubble_size" />
<padding
android:left="@dimen/fastscroll_bubble_padding"
android:right="@dimen/fastscroll_bubble_padding" />
</shape>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:shape="rectangle">
<tools:solid android:color="#777777" />
<corners
android:topLeftRadius="@dimen/fastscroll_bubble_radius_small"
android:topRightRadius="@dimen/fastscroll_bubble_radius_small"
android:bottomLeftRadius="@dimen/fastscroll_bubble_radius_small"
android:bottomRightRadius="8dp" />
<size
android:height="@dimen/fastscroll_bubble_size_small"
android:width="@dimen/fastscroll_bubble_size_small" />
<padding
android:left="@dimen/fastscroll_bubble_padding_small"
android:right="@dimen/fastscroll_bubble_padding_small" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:shape="rectangle">
<tools:solid android:color="#555555" />
<corners android:radius="@dimen/fastscroll_handle_radius" />
<size
android:height="@dimen/fastscroll_handle_height"
android:width="@dimen/fastscroll_handle_width" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:shape="rectangle">
<tools:solid android:color="#CCCCCC" />
<size android:width="@dimen/fastscroll_track_width" />
</shape>

View File

@@ -6,13 +6,13 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
<org.koitharu.kotatsu.base.ui.widgets.FastScrollRecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:fastScrollEnabled="true"
app:handleColor="?attr/colorTertiary"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_chapter" />

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2022 Randy Webster. All rights reserved.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<TextView
android:id="@+id/fastscroll_bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:gravity="center"
android:maxLines="1"
android:visibility="gone"
tools:background="@drawable/fastscroll_bubble"
tools:text="A"
tools:textColor="#ffffff"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/fastscroll_scrollbar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingEnd="@dimen/fastscroll_scrollbar_padding_end"
android:paddingStart="@dimen/fastscroll_scrollbar_padding_start"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:id="@+id/fastscroll_track"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
tools:ignore="ContentDescription"
tools:src="@drawable/fastscroll_track" />
<ImageView
android:id="@+id/fastscroll_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
tools:ignore="ContentDescription"
tools:src="@drawable/fastscroll_handle" />
</FrameLayout>
</merge>

View File

@@ -17,7 +17,7 @@
tools:listitem="@layout/item_branch"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
<org.koitharu.kotatsu.base.ui.widgets.FastScrollRecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="0dp"
android:layout_height="0dp"
@@ -28,8 +28,11 @@
android:layout_alignParentBottom="true"
android:clipToPadding="false"
android:orientation="vertical"
app:fastScrollEnabled="true"
app:bubbleColor="?attr/colorTertiary"
app:bubbleTextColor="?attr/colorOnTertiary"
app:handleColor="?attr/colorTertiary"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:showBubble="true"
tools:listitem="@layout/item_chapter" />
<com.google.android.material.progressindicator.CircularProgressIndicator

View File

@@ -7,18 +7,24 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_feed" />
android:layout_height="match_parent">
<org.koitharu.kotatsu.base.ui.widgets.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
app:trackColor="?attr/colorOutline"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_feed" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -1,15 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/recyclerView"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager" />
android:paddingBottom="@dimen/grid_spacing_outer">
<org.koitharu.kotatsu.base.ui.widgets.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:handleColor="?attr/colorTertiary"
tools:listitem="@layout/item_feed" />
</FrameLayout>

View File

@@ -7,18 +7,24 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingRight="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
tools:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingRight="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingBottom="@dimen/grid_spacing_outer"
app:handleColor="?attr/colorTertiary"
tools:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -1,17 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
<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:id="@+id/recyclerView"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
app:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager"
tools:listitem="@layout/item_manga_list" />
android:paddingBottom="@dimen/grid_spacing_outer">
<org.koitharu.kotatsu.base.ui.widgets.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:handleColor="?attr/colorTertiary"
tools:listitem="@layout/item_feed" />
</FrameLayout>

View File

@@ -22,13 +22,13 @@
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
<org.koitharu.kotatsu.base.ui.widgets.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="vertical"
app:fastScrollEnabled="true"
app:handleColor="?attr/colorOutline"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_chapter" />

View File

@@ -52,4 +52,20 @@
<attr name="progressStyle" format="reference" />
</declare-styleable>
<declare-styleable name="FastScroller">
<attr format="boolean" name="hideScrollbar" />
<attr format="boolean" name="showBubble" />
<attr format="boolean" name="showBubbleAlways" />
<attr format="boolean" name="showTrack" />
<attr format="color" name="bubbleColor" />
<attr format="color" name="bubbleTextColor" />
<attr format="color" name="handleColor" />
<attr format="color" name="trackColor" />
<attr format="dimension" name="bubbleTextSize" />
<attr format="enum" name="bubbleSize">
<enum name="normal" value="0" />
<enum name="small" value="1" />
</attr>
</declare-styleable>
</resources>

View File

@@ -36,4 +36,27 @@
<dimen name="dialog_radius">8dp</dimen>
<dimen name="appwidget_corner_radius_inner">8dp</dimen>
<!-- FastScroller -->
<dimen name="fastscroll_bubble_radius">44dp</dimen>
<dimen name="fastscroll_bubble_size">88dp</dimen>
<dimen name="fastscroll_bubble_text_size">48sp</dimen>
<dimen name="fastscroll_bubble_padding">16dp</dimen>
<dimen name="fastscroll_bubble_radius_small">32dp</dimen>
<dimen name="fastscroll_bubble_size_small">64dp</dimen>
<dimen name="fastscroll_bubble_text_size_small">36sp</dimen>
<dimen name="fastscroll_bubble_padding_small">12dp</dimen>
<dimen name="fastscroll_handle_height">58dp</dimen>
<dimen name="fastscroll_handle_width">6dp</dimen>
<dimen name="fastscroll_handle_radius">4dp</dimen>
<dimen name="fastscroll_track_width">1dp</dimen>
<dimen name="fastscroll_scrollbar_margin_top">8dp</dimen>
<dimen name="fastscroll_scrollbar_margin_bottom">8dp</dimen>
<dimen name="fastscroll_scrollbar_padding_start">6dp</dimen>
<dimen name="fastscroll_scrollbar_padding_end">6dp</dimen>
</resources>

View File

@@ -3,4 +3,5 @@
<item name="toolbar" type="id" />
<item name="container" type="id" />
<item name="action_leaks" type="id" />
<item name="fast_scroller" type="id" />
</resources>