diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScrollRecyclerView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScrollRecyclerView.kt new file mode 100644 index 000000000..bfaa34f06 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScrollRecyclerView.kt @@ -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 } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScroller.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScroller.kt new file mode 100644 index 000000000..c97d24447 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScroller.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt index d91cc85fd..8f7b0be10 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt @@ -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, diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index e6f74daa6..fab3b59e5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt index 033b9ed92..0d0da65de 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt @@ -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, -) : AsyncListDifferDelegationAdapter(DiffCallback()) { +) : AsyncListDifferDelegationAdapter(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() + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt index 8347b0a7e..f4100297d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index ca0678a0d..41fc975b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -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(), @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 23b6ec51d..766ddee52 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -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() } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index 16416bf51..b2fda4d6e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -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()) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DrawableExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DrawableExt.kt new file mode 100644 index 000000000..dcb5d4826 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DrawableExt.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LayoutManagerExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LayoutManagerExt.kt new file mode 100644 index 000000000..57e9c4a8f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LayoutManagerExt.kt @@ -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 + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index 41e2cd2b8..0366b944c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -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() } -} \ No newline at end of file +} + +internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this) \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_bubble.xml b/app/src/main/res/drawable/fastscroll_bubble.xml new file mode 100644 index 000000000..6bec150f4 --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_bubble.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_bubble_small.xml b/app/src/main/res/drawable/fastscroll_bubble_small.xml new file mode 100644 index 000000000..90b7d0bb0 --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_bubble_small.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_handle.xml b/app/src/main/res/drawable/fastscroll_handle.xml new file mode 100644 index 000000000..f49c6c5fc --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_handle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_track.xml b/app/src/main/res/drawable/fastscroll_track.xml new file mode 100644 index 000000000..aac6cb020 --- /dev/null +++ b/app/src/main/res/drawable/fastscroll_track.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w720dp/fragment_chapters.xml b/app/src/main/res/layout-w720dp/fragment_chapters.xml index 7c4195c0d..f8c999d4d 100644 --- a/app/src/main/res/layout-w720dp/fragment_chapters.xml +++ b/app/src/main/res/layout-w720dp/fragment_chapters.xml @@ -6,13 +6,13 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - diff --git a/app/src/main/res/layout/fast_scroller.xml b/app/src/main/res/layout/fast_scroller.xml new file mode 100644 index 000000000..89f230cc4 --- /dev/null +++ b/app/src/main/res/layout/fast_scroller.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chapters.xml b/app/src/main/res/layout/fragment_chapters.xml index d05821f2a..d228d0f47 100644 --- a/app/src/main/res/layout/fragment_chapters.xml +++ b/app/src/main/res/layout/fragment_chapters.xml @@ -17,7 +17,7 @@ tools:listitem="@layout/item_branch" tools:visibility="visible" /> - - + android:layout_height="match_parent"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_library.xml b/app/src/main/res/layout/fragment_library.xml index 157bad984..99b4ae005 100644 --- a/app/src/main/res/layout/fragment_library.xml +++ b/app/src/main/res/layout/fragment_library.xml @@ -1,15 +1,24 @@ - \ No newline at end of file + android:paddingBottom="@dimen/grid_spacing_outer"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_list.xml b/app/src/main/res/layout/fragment_list.xml index 6fd2271c0..9650f6c2d 100644 --- a/app/src/main/res/layout/fragment_list.xml +++ b/app/src/main/res/layout/fragment_list.xml @@ -7,18 +7,24 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + android:layout_height="match_parent"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_list_simple.xml b/app/src/main/res/layout/fragment_list_simple.xml index a8c22162a..99b4ae005 100644 --- a/app/src/main/res/layout/fragment_list_simple.xml +++ b/app/src/main/res/layout/fragment_list_simple.xml @@ -1,17 +1,24 @@ - \ No newline at end of file + android:paddingBottom="@dimen/grid_spacing_outer"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_chapters.xml b/app/src/main/res/layout/sheet_chapters.xml index e1932e31e..cd2a5b23f 100644 --- a/app/src/main/res/layout/sheet_chapters.xml +++ b/app/src/main/res/layout/sheet_chapters.xml @@ -22,13 +22,13 @@ - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 739309dfb..44e16f7af 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -52,4 +52,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 622bdf251..ed649d9c2 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -36,4 +36,27 @@ 8dp 8dp + + + 44dp + 88dp + 48sp + 16dp + + 32dp + 64dp + 36sp + 12dp + + 58dp + 6dp + 4dp + + 1dp + + 8dp + 8dp + 6dp + 6dp + \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index a1c761db4..c0143cbb7 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -3,4 +3,5 @@ + \ No newline at end of file