diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt new file mode 100644 index 000000000..591fd6b99 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt @@ -0,0 +1,82 @@ +package org.koitharu.kotatsu.base.ui.list.fastscroll + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.view.View +import android.view.ViewAnimationUtils +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import org.koitharu.kotatsu.utils.ext.animatorDurationScale +import org.koitharu.kotatsu.utils.ext.measureWidth +import kotlin.math.hypot + +class BubbleAnimator( + private val bubble: View, +) { + + private val animationDuration = (bubble.resources.getInteger(android.R.integer.config_shortAnimTime) * + bubble.context.animatorDurationScale).toLong() + private var animator: Animator? = null + private var isHiding = false + + fun show() { + if (bubble.isVisible && !isHiding) { + return + } + isHiding = false + animator?.cancel() + animator = ViewAnimationUtils.createCircularReveal( + bubble, + bubble.measureWidth(), + bubble.measuredHeight, + 0f, + hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(), + ).apply { + bubble.isVisible = true + duration = animationDuration + interpolator = DecelerateInterpolator() + start() + } + } + + fun hide() { + if (!bubble.isVisible || isHiding) { + return + } + animator?.cancel() + isHiding = true + animator = ViewAnimationUtils.createCircularReveal( + bubble, + bubble.width, + bubble.height, + hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(), + 0f, + ).apply { + duration = animationDuration + interpolator = AccelerateInterpolator() + addListener(HideListener()) + start() + } + } + + private inner class HideListener : AnimatorListenerAdapter() { + + private var isCancelled = false + + override fun onAnimationCancel(animation: Animator?) { + super.onAnimationCancel(animation) + isCancelled = true + } + + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + if (!isCancelled && animation === this@BubbleAnimator.animator) { + bubble.isInvisible = true + isHiding = false + this@BubbleAnimator.animator = null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt new file mode 100644 index 000000000..079391703 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt @@ -0,0 +1,79 @@ +package org.koitharu.kotatsu.base.ui.list.fastscroll + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.utils.ext.parents + +class FastScrollRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle, +) : RecyclerView(context, attrs, defStyleAttr) { + + val fastScroller = FastScroller(context, attrs) + + init { + fastScroller.id = R.id.fast_scroller + fastScroller.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun setAdapter(adapter: Adapter<*>?) { + super.setAdapter(adapter) + fastScroller.setSectionIndexer(adapter as? FastScroller.SectionIndexer) + } + + override fun setVisibility(visibility: Int) { + super.setVisibility(visibility) + 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() + fastScroller.attachRecyclerView(this) + for (p in parents) { + if (p is SwipeRefreshLayout) { + fastScroller.setSwipeRefreshLayout(p) + return + } + } + } + + override fun onDetachedFromWindow() { + fastScroller.detachRecyclerView() + fastScroller.setSwipeRefreshLayout(null) + super.onDetachedFromWindow() + } +} \ 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/list/fastscroll/FastScroller.kt similarity index 64% rename from app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScroller.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt index 5ae00b18f..8f7b69448 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScroller.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt @@ -1,23 +1,5 @@ -/* - * 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 +package org.koitharu.kotatsu.base.ui.list.fastscroll + import android.annotation.SuppressLint import android.content.Context import android.content.res.TypedArray @@ -25,54 +7,43 @@ 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.view.* 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.annotation.* import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.GravityCompat +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.FastScrollerBinding import org.koitharu.kotatsu.utils.ext.* import kotlin.math.roundToInt +import com.google.android.material.R as materialR -private const val BUBBLE_ANIM_DURATION = 100L private const val SCROLLBAR_HIDE_DELAY = 1000L private const val TRACK_SNAP_RANGE = 5 @Suppress("MemberVisibilityCanBePrivate", "unused") -class FastScroller : LinearLayout { +class FastScroller @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = R.attr.fastScrollerStyle, +) : LinearLayout(context, attrs, defStyleAttr) { - enum class Size(@DrawableRes val drawableId: Int, @DimenRes val textSizeId: Int) { + enum class BubbleSize(@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 animationDuration = (context.resources.getInteger(R.integer.config_defaultAnimTime) * - context.animatorDurationScale).toLong() + private val binding = FastScrollerBinding.inflate(LayoutInflater.from(context), this) - 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() - } + private val scrollbarPaddingEnd = context.resources.getDimension(R.dimen.fastscroll_scrollbar_padding_end) @ColorInt private var bubbleColor = 0 @@ -86,14 +57,14 @@ class FastScroller : LinearLayout { private var hideScrollbar = true private var showBubble = true private var showBubbleAlways = false - private var bubbleSize = Size.NORMAL + private var bubbleSize = BubbleSize.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 val scrollbarAnimator = ScrollbarAnimator(binding.scrollbar, scrollbarPaddingEnd) + private val bubbleAnimator = BubbleAnimator(binding.bubble) private var fastScrollListener: FastScrollListener? = null private var sectionIndexer: SectionIndexer? = null @@ -103,19 +74,15 @@ class FastScroller : LinearLayout { 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) { + if (!binding.thumb.isSelected && isEnabled) { val y = recyclerView.scrollProportion setViewPositions(y) if (showBubbleAlways) { val targetPos = getRecyclerViewTargetPosition(y) - sectionIndexer?.let { bubbleView.text = it.getSectionText(targetPos) } + sectionIndexer?.let { binding.bubble.text = it.getSectionText(recyclerView.context, targetPos) } } } @@ -133,12 +100,10 @@ class FastScroller : LinearLayout { when (newState) { RecyclerView.SCROLL_STATE_DRAGGING -> { handler.removeCallbacks(scrollbarHider) - scrollbarAnimator?.cancel() - - if (!scrollbar.isVisible) showScrollbar() + showScrollbar() if (showBubbleAlways && sectionIndexer != null) showBubble() } - RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !handleView.isSelected) { + RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) { handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY) } } @@ -153,22 +118,42 @@ class FastScroller : LinearLayout { 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) - } + init { + clipChildren = false + orientation = HORIZONTAL - @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 - ) + @ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY) + @ColorInt var handleColor = bubbleColor + @ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY) + @ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE) + + var showTrack = false + + context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) { + bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) + handleColor = getColor(R.styleable.FastScroller_thumbColor, 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 = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) + val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) + binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) + } + + setTrackColor(trackColor) + setHandleColor(handleColor) + setBubbleColor(bubbleColor) + setBubbleTextColor(textColor) + setHideScrollbar(hideScrollbar) + setBubbleVisible(showBubble, showBubbleAlways) + setTrackVisible(showTrack) } - override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) = super.onSizeChanged(w, h, oldW, oldH).also { + override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { + super.onSizeChanged(w, h, oldW, oldH) viewHeight = h } @@ -180,18 +165,15 @@ class FastScroller : LinearLayout { setRecyclerViewPosition(y) } - when (event.action) { + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - if (event.x < handleView.x - scrollbar.compatPaddingStart) return false + if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false requestDisallowInterceptTouchEvent(true) setHandleSelected(true) handler.removeCallbacks(scrollbarHider) - scrollbarAnimator?.cancel() - bubbleAnimator?.cancel() - - if (!scrollbar.isVisible) showScrollbar() + showScrollbar() if (showBubble && sectionIndexer != null) showBubble() fastScrollListener?.onFastScrollStart(this) @@ -224,7 +206,8 @@ class FastScroller : LinearLayout { * * @param enabled True if this view is enabled, false otherwise */ - override fun setEnabled(enabled: Boolean) = super.setEnabled(enabled).also { + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) isVisible = enabled } @@ -283,9 +266,9 @@ class FastScroller : LinearLayout { } is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply { height = 0 - addRule(ALIGN_TOP, recyclerViewId) - addRule(ALIGN_BOTTOM, recyclerViewId) - addRule(ALIGN_END, recyclerViewId) + addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) + addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) + addRule(RelativeLayout.ALIGN_END, recyclerViewId) setMargins(0, marginTop, 0, marginBottom) } else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") @@ -302,6 +285,9 @@ class FastScroller : LinearLayout { * @see detachRecyclerView */ fun attachRecyclerView(recyclerView: RecyclerView) { + if (this.recyclerView != null) { + detachRecyclerView() + } this.recyclerView = recyclerView if (parent is ViewGroup) { @@ -314,7 +300,7 @@ class FastScroller : LinearLayout { recyclerView.addOnScrollListener(scrollListener) - // set initial positions for bubble and handle + // set initial positions for bubble and thumb post { setViewPositions(this.recyclerView?.scrollProportion ?: 0f) } } @@ -365,7 +351,8 @@ class FastScroller : LinearLayout { */ fun setHideScrollbar(hideScrollbar: Boolean) { if (this.hideScrollbar != hideScrollbar) { - scrollbar.isVisible = !hideScrollbar.also { this.hideScrollbar = it } + this.hideScrollbar = hideScrollbar + binding.scrollbar.isGone = hideScrollbar } } @@ -375,7 +362,7 @@ class FastScroller : LinearLayout { * @param visible True to show scroll track, false to hide */ fun setTrackVisible(visible: Boolean) { - trackView.isVisible = visible + binding.track.isVisible = visible } /** @@ -390,14 +377,14 @@ class FastScroller : LinearLayout { trackImage?.let { it.setTint(color) - trackView.setImageDrawable(it) + binding.track.setImageDrawable(it) } } /** - * Set the color of the scroll handle. + * Set the color of the scroll thumb. * - * @param color The color for the scroll handle + * @param color The color for the scroll thumb */ fun setHandleColor(@ColorInt color: Int) { handleColor = color @@ -408,7 +395,7 @@ class FastScroller : LinearLayout { handleImage?.let { it.setTint(handleColor) - handleView.setImageDrawable(it) + binding.thumb.setImageDrawable(it) } } @@ -416,7 +403,7 @@ class FastScroller : LinearLayout { * 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 + * @param always True to always show the bubble, false to only show on thumb touch */ @JvmOverloads fun setBubbleVisible(visible: Boolean, always: Boolean = false) { @@ -438,7 +425,7 @@ class FastScroller : LinearLayout { bubbleImage?.let { it.setTint(bubbleColor) - bubbleView.background = it + binding.bubble.background = it } } @@ -447,7 +434,7 @@ class FastScroller : LinearLayout { * * @param color The text color for the section bubble */ - fun setBubbleTextColor(@ColorInt color: Int) = bubbleView.setTextColor(color) + fun setBubbleTextColor(@ColorInt color: Int) = binding.bubble.setTextColor(color) /** * Set the scaled pixel text size of the section bubble. @@ -455,15 +442,15 @@ class FastScroller : LinearLayout { * @param size The scaled pixel text size for the section bubble */ fun setBubbleTextSize(size: Int) { - bubbleView.textSize = size.toFloat() + binding.bubble.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 + binding.thumb.y == 0f -> 0f + binding.thumb.y + handleHeight >= viewHeight - TRACK_SNAP_RANGE -> 1f else -> y / viewHeight.toFloat() } @@ -477,139 +464,65 @@ class FastScroller : LinearLayout { } ?: 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) } - } + val layoutManager = recyclerView?.layoutManager ?: return + val targetPos = getRecyclerViewTargetPosition(y) + layoutManager.scrollToPosition(targetPos) + if (showBubble) sectionIndexer?.let { binding.bubble.text = it.getSectionText(context, targetPos) } } private fun setViewPositions(y: Float) { - bubbleHeight = bubbleView.measuredHeight - handleHeight = handleView.measuredHeight + bubbleHeight = binding.bubble.measuredHeight + handleHeight = binding.thumb.measuredHeight val bubbleHandleHeight = bubbleHeight + handleHeight / 2f if (showBubble && viewHeight >= bubbleHandleHeight) { - bubbleView.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight) + binding.bubble.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight) } if (viewHeight >= handleHeight) { - handleView.y = (y - handleHeight / 2).coerceIn(0f, viewHeight - handleHeight.toFloat()) + binding.thumb.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 + binding.bubble.measure(measureSpec, measureSpec) + bubbleHeight = binding.bubble.measuredHeight + binding.thumb.measure(measureSpec, measureSpec) + handleHeight = binding.thumb.measuredHeight } private fun showBubble() { - if (!bubbleView.isVisible) { - bubbleView.isVisible = true - bubbleAnimator = bubbleView.animate().alpha(1f) - .setDuration(BUBBLE_ANIM_DURATION) - .setListener(alphaAnimatorListener) - } + bubbleAnimator.show() } 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 - } - }) - } + bubbleAnimator.hide() } private fun showScrollbar() { if ((recyclerView?.computeVerticalScrollRange() ?: (0 - viewHeight)) > 0) { - scrollbar.translationX = scrollbarPaddingEnd - scrollbar.isVisible = true - scrollbarAnimator = scrollbar.animate().translationX(0f).alpha(1f) - .setDuration(animationDuration) - .setListener(alphaAnimatorListener) + scrollbarAnimator.show() } } private fun hideScrollbar() { - scrollbarAnimator = scrollbar.animate().translationX(scrollbarPaddingEnd).alpha(0f) - .setDuration(animationDuration) - .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 - } - }) + scrollbarAnimator.hide() } private fun setHandleSelected(selected: Boolean) { - handleView.isSelected = selected + binding.thumb.isSelected = selected handleImage?.setTint(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 TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize { + val ordinal = getInt(index, -1) + return BubbleSize.values().getOrNull(ordinal) ?: defaultValue } - 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) - } + private val BubbleSize.textSize + @Px get() = resources.getDimension(textSizeId) interface FastScrollListener { @@ -620,6 +533,6 @@ class FastScroller : LinearLayout { interface SectionIndexer { - fun getSectionText(position: Int): CharSequence + fun getSectionText(context: Context, position: Int): CharSequence } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt new file mode 100644 index 000000000..a00fc90b9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt @@ -0,0 +1,69 @@ +package org.koitharu.kotatsu.base.ui.list.fastscroll + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.view.View +import android.view.ViewPropertyAnimator +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.utils.ext.animatorDurationScale + +class ScrollbarAnimator( + private val scrollbar: View, + private val scrollbarPaddingEnd: Float, +) { + + private val animationDuration = (scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) * + scrollbar.context.animatorDurationScale).toLong() + private var animator: ViewPropertyAnimator? = null + private var isHiding = false + + fun show() { + if (scrollbar.isVisible && !isHiding) { + return + } + isHiding = false + animator?.cancel() + scrollbar.translationX = scrollbarPaddingEnd + scrollbar.isVisible = true + animator = scrollbar + .animate() + .translationX(0f) + .alpha(1f) + .setDuration(animationDuration) + } + + fun hide() { + if (!scrollbar.isVisible || isHiding) { + return + } + animator?.cancel() + isHiding = true + animator = scrollbar + .animate() + .translationX(scrollbarPaddingEnd) + .alpha(0f) + .setDuration(animationDuration) + .setListener(HideListener()) + } + + private inner class HideListener : AnimatorListenerAdapter() { + + private var isCancelled = false + + override fun onAnimationCancel(animation: Animator?) { + super.onAnimationCancel(animation) + isCancelled = true + } + + override fun onAnimationEnd(animation: Animator?) { + super.onAnimationEnd(animation) + if (!isCancelled && animation === this@ScrollbarAnimator.animator) { + scrollbar.isInvisible = true + isHiding = false + this@ScrollbarAnimator.animator = null + } + } + } +} \ No newline at end of file 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 deleted file mode 100644 index bfaa34f06..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FastScrollRecyclerView.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 04e79d18d..0fb3dce40 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -10,6 +10,7 @@ import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets import androidx.core.view.MenuProvider import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -29,6 +30,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.end import kotlin.math.roundToInt class ChaptersFragment : @@ -192,6 +194,9 @@ class ChaptersFragment : binding.recyclerViewChapters.updatePadding( bottom = insets.bottom + (binding.spinnerBranches?.height ?: 0), ) + binding.recyclerViewChapters.fastScroller.updateLayoutParams { + bottomMargin = insets.bottom + } } private fun initSpinner(spinner: Spinner) { 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 0d0da65de..766ffb0e3 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 @@ -1,15 +1,16 @@ package org.koitharu.kotatsu.details.ui.adapter +import android.content.Context 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.base.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.details.ui.model.ChapterListItem import kotlin.jvm.internal.Intrinsics class ChaptersAdapter( onItemClickListener: OnListItemClickListener, -) : AsyncListDifferDelegationAdapter(DiffCallback()), FastScroller.SectionIndexer { +) : AsyncListDifferDelegationAdapter(DiffCallback()), FastScroller.SectionIndexer { init { setHasStableIds(true) @@ -41,7 +42,7 @@ class ChaptersAdapter( } } - override fun getSectionText(position: Int): CharSequence { + override fun getSectionText(context: Context, 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/history/ui/HistoryListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt new file mode 100644 index 000000000..b44f5db90 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.history.ui + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter +import org.koitharu.kotatsu.list.ui.adapter.MangaListListener + +class HistoryListAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + listener: MangaListListener +) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer { + + override fun getSectionText(context: Context, position: Int): CharSequence { + val list = items + for (i in (0..position).reversed()) { + val item = list[i] + if (item is DateTimeAgo) { + return item.format(context.resources) + } + } + return "" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index b68f247aa..bdd424098 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -6,6 +6,7 @@ import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode import com.google.android.material.snackbar.Snackbar +import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.ReversibleHandle @@ -53,6 +54,8 @@ class HistoryListFragment : MangaListFragment() { } } + override fun onCreateAdapter() = HistoryListAdapter(get(), viewLifecycleOwner, this) + private fun onItemsRemoved(reversibleHandle: ReversibleHandle) { Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG) .setAction(R.string.undo) { reversibleHandle.reverseAsync() } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 282ecaa7e..bbf088593 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -2,11 +2,13 @@ package org.koitharu.kotatsu.list.ui import android.os.Bundle import android.view.* +import android.view.ViewGroup.MarginLayoutParams import androidx.annotation.CallSuper import androidx.appcompat.view.ActionMode import androidx.collection.ArraySet import androidx.core.graphics.Insets import androidx.core.view.isNotEmpty +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -73,11 +75,7 @@ abstract class MangaListFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - listAdapter = MangaListAdapter( - coil = get(), - lifecycleOwner = viewLifecycleOwner, - listener = this, - ) + listAdapter = onCreateAdapter() selectionController = ListSelectionController( activity = requireActivity(), decoration = MangaSelectionDecoration(view.context), @@ -167,6 +165,14 @@ abstract class MangaListFragment : } } + protected open fun onCreateAdapter(): MangaListAdapter { + return MangaListAdapter( + coil = get(), + lifecycleOwner = viewLifecycleOwner, + listener = this, + ) + } + override fun onWindowInsetsChanged(insets: Insets) { binding.root.updatePadding( left = insets.left, @@ -175,6 +181,10 @@ abstract class MangaListFragment : binding.recyclerView.updatePadding( bottom = insets.bottom, ) + binding.recyclerView.fastScroller.updateLayoutParams { + bottomMargin = insets.bottom + marginEnd = insets.end(binding.recyclerView) + } if (activity is MainActivity) { val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.swipeRefreshLayout.setProgressViewOffset( diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index db4594911..ca4be8e13 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.list.ui.model.* import kotlin.jvm.internal.Intrinsics -class MangaListAdapter( +open class MangaListAdapter( coil: ImageLoader, lifecycleOwner: LifecycleOwner, listener: MangaListListener, diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/InsetsExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/InsetsExt.kt new file mode 100644 index 000000000..0f0ac0180 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/InsetsExt.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.utils.ext + +import android.view.View +import androidx.core.graphics.Insets + +fun Insets.end(view: View): Int { + return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) left else right +} + +fun Insets.start(view: View): Int { + return if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) right else left +} \ 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 0366b944c..a62abab53 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 @@ -4,6 +4,7 @@ import android.app.Activity import android.graphics.Rect import android.view.View import android.view.ViewGroup +import android.view.ViewParent import android.view.inputmethod.InputMethodManager import androidx.core.view.ViewCompat import androidx.core.view.children @@ -147,4 +148,13 @@ fun RecyclerView.invalidateNestedItemDecorations() { } } -internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this) \ No newline at end of file +internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this) + +val View.parents: Sequence + get() = sequence { + var p: ViewParent? = parent + while (p != null) { + yield(p) + p = p.parent + } + } \ 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 index 6bec150f4..88fac1384 100644 --- a/app/src/main/res/drawable/fastscroll_bubble.xml +++ b/app/src/main/res/drawable/fastscroll_bubble.xml @@ -1,11 +1,8 @@ - - - diff --git a/app/src/main/res/layout/fast_scroller.xml b/app/src/main/res/layout/fast_scroller.xml index 89f230cc4..5d44335ee 100644 --- a/app/src/main/res/layout/fast_scroller.xml +++ b/app/src/main/res/layout/fast_scroller.xml @@ -1,5 +1,4 @@ - -