Reader loading progress indicator changes (#5587)

* Use CircularProgressIndicator on PageHolder

Manually rotate the CircularProgressIndicator inside a wrapper view instead of
drawing our own custom indicator.

* Use CircularProgressIndicator on TransitionHolder
This commit is contained in:
Ivan Iskandar 2021-07-21 04:38:19 +07:00 committed by GitHub
parent 8bd965267c
commit 6ba779fb7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 115 additions and 261 deletions

View File

@ -1,215 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import androidx.core.animation.doOnCancel
import androidx.core.animation.doOnEnd
import androidx.core.view.isGone
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
import kotlin.math.min
/**
* A custom progress bar that always rotates while being determinate. By always rotating we give
* the feedback to the user that the application isn't 'stuck', and by making it determinate the
* user also approximately knows how much the operation will take.
*/
class ReaderProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
/**
* The current sweep angle. It always starts at 10% because otherwise the bar and the rotation
* wouldn't be visible.
*/
private var sweepAngle = 10f
/**
* Whether the parent views are also visible.
*/
private var aggregatedIsVisible = false
/**
* The paint to use to draw the progress bar.
*/
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getResourceColor(R.attr.colorAccent)
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
}
/**
* The rectangle of the canvas where the progress bar should be drawn. This is calculated on
* layout.
*/
private val ovalRect = RectF()
/**
* The rotation animation to use while the progress bar is visible.
*/
private val rotationAnimation by lazy {
RotateAnimation(
0f,
360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
).apply {
interpolator = LinearInterpolator()
repeatCount = Animation.INFINITE
duration = 4000
}
}
/**
* Called when the view is layout. The position and thickness of the progress bar is calculated.
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
val diameter = min(width, height)
val thickness = diameter / 10f
val pad = thickness / 2f
ovalRect.set(pad, pad, diameter - pad, diameter - pad)
paint.strokeWidth = thickness
}
/**
* Called when the view is being drawn. An arc is drawn with the calculated rectangle. The
* animation will take care of rotation.
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawArc(ovalRect, -90f, sweepAngle, false, paint)
}
/**
* Calculates the sweep angle to use from the progress.
*/
private fun calcSweepAngleFromProgress(progress: Int): Float {
return 360f / 100 * progress
}
/**
* Called when this view is attached to window. It starts the rotation animation.
*/
override fun onAttachedToWindow() {
super.onAttachedToWindow()
startAnimation()
}
/**
* Called when this view is detached to window. It stops the rotation animation.
*/
override fun onDetachedFromWindow() {
stopAnimation()
super.onDetachedFromWindow()
}
/**
* Called when the visibility of this view changes.
*/
override fun setVisibility(visibility: Int) {
super.setVisibility(visibility)
val isVisible = visibility == VISIBLE
if (isVisible) {
startAnimation()
} else {
stopAnimation()
}
}
/**
* Starts the rotation animation if needed.
*/
private fun startAnimation() {
if (visibility != VISIBLE || windowVisibility != VISIBLE || animation != null) {
return
}
animation = rotationAnimation
animation.start()
}
/**
* Stops the rotation animation if needed.
*/
private fun stopAnimation() {
clearAnimation()
}
/**
* Hides this progress bar with an optional fade out if [animate] is true.
*/
fun hide(animate: Boolean = false) {
if (isGone) return
if (!animate) {
isVisible = false
} else {
ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
interpolator = DecelerateInterpolator()
duration = 1000
doOnEnd {
isVisible = false
alpha = 1f
}
doOnCancel {
alpha = 1f
}
start()
}
}
}
/**
* Completes this progress bar and fades out the view.
*/
fun completeAndFadeOut() {
setRealProgress(100)
hide(true)
}
/**
* Set progress of the circular progress bar ensuring a min max range in order to notice the
* rotation animation.
*/
fun setProgress(progress: Int) {
// Scale progress in [10, 95] range
val scaledProgress = 85 * progress / 100 + 10
setRealProgress(scaledProgress)
}
/**
* Sets the real progress of the circular progress bar. Note that if this progres is 0 or
* 100, the rotation animation won't be noticed by the user because nothing changes in the
* canvas.
*/
private fun setRealProgress(progress: Int) {
ValueAnimator.ofFloat(sweepAngle, calcSweepAngleFromProgress(progress)).apply {
interpolator = DecelerateInterpolator()
duration = 250
addUpdateListener { valueAnimator ->
sweepAngle = valueAnimator.animatedValue as Float
invalidate()
}
start()
}
}
}

View File

@ -0,0 +1,77 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import android.widget.FrameLayout
import androidx.annotation.IntRange
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.CircularProgressIndicator
/**
* A wrapper for [CircularProgressIndicator] that always rotates while being determinate.
*
* By always rotating we give the feedback to the user that the application isn't 'stuck',
* and by making it determinate the user also approximately knows how much the operation will take.
*/
class ReaderProgressIndicator @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val indicator: CircularProgressIndicator
private val rotateAnimation by lazy {
RotateAnimation(
0F,
360F,
Animation.RELATIVE_TO_SELF,
0.5F,
Animation.RELATIVE_TO_SELF,
0.5F
).apply {
interpolator = LinearInterpolator()
repeatCount = Animation.INFINITE
duration = 4000
}
}
init {
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
indicator = CircularProgressIndicator(context)
indicator.max = 100
addView(indicator)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (indicator.isVisible && animation == null) {
startAnimation(rotateAnimation)
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
clearAnimation()
}
fun show() {
indicator.show()
if (animation == null) {
startAnimation(rotateAnimation)
}
}
fun hide() {
indicator.hide()
clearAnimation()
}
fun setProgress(@IntRange(from = 0, to = 100) progress: Int, animated: Boolean = true) {
indicator.setProgressCompat(progress, animated)
}
}

View File

@ -14,6 +14,7 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import coil.imageLoader import coil.imageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
@ -24,7 +25,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.InsertPage import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
@ -56,7 +57,11 @@ class PagerPageHolder(
/** /**
* Loading progress bar to indicate the current progress. * Loading progress bar to indicate the current progress.
*/ */
private val progressBar = createProgressBar() private val progressIndicator = ReaderProgressIndicator(context).apply {
updateLayoutParams<LayoutParams> {
gravity = Gravity.CENTER
}
}
/** /**
* Image view that supports subsampling on zoom. * Image view that supports subsampling on zoom.
@ -95,7 +100,7 @@ class PagerPageHolder(
private var readImageHeaderSubscription: Subscription? = null private var readImageHeaderSubscription: Subscription? = null
init { init {
addView(progressBar) addView(progressIndicator)
observeStatus() observeStatus()
} }
@ -136,7 +141,7 @@ class PagerPageHolder(
.distinctUntilChanged() .distinctUntilChanged()
.onBackpressureLatest() .onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) } .subscribe { value -> progressIndicator.setProgress(value) }
} }
/** /**
@ -191,7 +196,7 @@ class PagerPageHolder(
* Called when the page is queued. * Called when the page is queued.
*/ */
private fun setQueued() { private fun setQueued() {
progressBar.isVisible = true progressIndicator.show()
retryButton?.isVisible = false retryButton?.isVisible = false
decodeErrorLayout?.isVisible = false decodeErrorLayout?.isVisible = false
} }
@ -200,7 +205,7 @@ class PagerPageHolder(
* Called when the page is loading. * Called when the page is loading.
*/ */
private fun setLoading() { private fun setLoading() {
progressBar.isVisible = true progressIndicator.show()
retryButton?.isVisible = false retryButton?.isVisible = false
decodeErrorLayout?.isVisible = false decodeErrorLayout?.isVisible = false
} }
@ -209,7 +214,7 @@ class PagerPageHolder(
* Called when the page is downloading. * Called when the page is downloading.
*/ */
private fun setDownloading() { private fun setDownloading() {
progressBar.isVisible = true progressIndicator.show()
retryButton?.isVisible = false retryButton?.isVisible = false
decodeErrorLayout?.isVisible = false decodeErrorLayout?.isVisible = false
} }
@ -218,8 +223,8 @@ class PagerPageHolder(
* Called when the page is ready. * Called when the page is ready.
*/ */
private fun setImage() { private fun setImage() {
progressBar.isVisible = true progressIndicator.setProgress(100)
progressBar.completeAndFadeOut() progressIndicator.hide()
retryButton?.isVisible = false retryButton?.isVisible = false
decodeErrorLayout?.isVisible = false decodeErrorLayout?.isVisible = false
@ -301,7 +306,7 @@ class PagerPageHolder(
* Called when the page has an error. * Called when the page has an error.
*/ */
private fun setError() { private fun setError() {
progressBar.isVisible = false progressIndicator.hide()
initRetryButton().isVisible = true initRetryButton().isVisible = true
} }
@ -309,30 +314,17 @@ class PagerPageHolder(
* Called when the image is decoded and going to be displayed. * Called when the image is decoded and going to be displayed.
*/ */
private fun onImageDecoded() { private fun onImageDecoded() {
progressBar.isVisible = false progressIndicator.hide()
} }
/** /**
* Called when an image fails to decode. * Called when an image fails to decode.
*/ */
private fun onImageDecodeError() { private fun onImageDecodeError() {
progressBar.isVisible = false progressIndicator.hide()
initDecodeErrorLayout().isVisible = true initDecodeErrorLayout().isVisible = true
} }
/**
* Creates a new progress bar.
*/
@SuppressLint("PrivateResource")
private fun createProgressBar(): ReaderProgressBar {
return ReaderProgressBar(context, null).apply {
val size = 48.dpToPx
layoutParams = LayoutParams(size, size).apply {
gravity = Gravity.CENTER
}
}
}
/** /**
* Initializes a subsampling scale view. * Initializes a subsampling scale view.
*/ */

View File

@ -7,8 +7,8 @@ import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import com.google.android.material.progressindicator.CircularProgressIndicator
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
@ -96,7 +96,8 @@ class PagerTransitionHolder(
* Sets the loading state on the pages container. * Sets the loading state on the pages container.
*/ */
private fun setLoading() { private fun setLoading() {
val progress = ProgressBar(context, null, android.R.attr.progressBarStyle) val progress = CircularProgressIndicator(context)
progress.isIndeterminate = true
val textView = AppCompatTextView(context).apply { val textView = AppCompatTextView(context).apply {
wrapContent() wrapContent()

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.annotation.SuppressLint
import android.content.res.Resources import android.content.res.Resources
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.view.Gravity import android.view.Gravity
@ -14,6 +13,8 @@ import android.widget.TextView
import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.widget.AppCompatButton
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import coil.clear import coil.clear
import coil.imageLoader import coil.imageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
@ -23,7 +24,7 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.dpToPx
@ -50,7 +51,7 @@ class WebtoonPageHolder(
/** /**
* Loading progress bar to indicate the current progress. * Loading progress bar to indicate the current progress.
*/ */
private val progressBar = createProgressBar() private val progressIndicator = createProgressIndicator()
/** /**
* Progress bar container. Needed to keep a minimum height size of the holder, otherwise the * Progress bar container. Needed to keep a minimum height size of the holder, otherwise the
@ -144,7 +145,7 @@ class WebtoonPageHolder(
subsamplingImageView?.isVisible = false subsamplingImageView?.isVisible = false
imageView?.clear() imageView?.clear()
imageView?.isVisible = false imageView?.isVisible = false
progressBar.setProgress(0) progressIndicator.setProgress(0, animated = false)
} }
/** /**
@ -177,7 +178,7 @@ class WebtoonPageHolder(
.distinctUntilChanged() .distinctUntilChanged()
.onBackpressureLatest() .onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) } .subscribe { value -> progressIndicator.setProgress(value) }
addSubscription(progressSubscription) addSubscription(progressSubscription)
} }
@ -235,7 +236,7 @@ class WebtoonPageHolder(
*/ */
private fun setQueued() { private fun setQueued() {
progressContainer.isVisible = true progressContainer.isVisible = true
progressBar.isVisible = true progressIndicator.show()
retryContainer?.isVisible = false retryContainer?.isVisible = false
removeDecodeErrorLayout() removeDecodeErrorLayout()
} }
@ -245,7 +246,7 @@ class WebtoonPageHolder(
*/ */
private fun setLoading() { private fun setLoading() {
progressContainer.isVisible = true progressContainer.isVisible = true
progressBar.isVisible = true progressIndicator.show()
retryContainer?.isVisible = false retryContainer?.isVisible = false
removeDecodeErrorLayout() removeDecodeErrorLayout()
} }
@ -255,7 +256,7 @@ class WebtoonPageHolder(
*/ */
private fun setDownloading() { private fun setDownloading() {
progressContainer.isVisible = true progressContainer.isVisible = true
progressBar.isVisible = true progressIndicator.show()
retryContainer?.isVisible = false retryContainer?.isVisible = false
removeDecodeErrorLayout() removeDecodeErrorLayout()
} }
@ -265,8 +266,8 @@ class WebtoonPageHolder(
*/ */
private fun setImage() { private fun setImage() {
progressContainer.isVisible = true progressContainer.isVisible = true
progressBar.isVisible = true progressIndicator.setProgress(100)
progressBar.completeAndFadeOut() progressIndicator.hide()
retryContainer?.isVisible = false retryContainer?.isVisible = false
removeDecodeErrorLayout() removeDecodeErrorLayout()
@ -342,16 +343,14 @@ class WebtoonPageHolder(
/** /**
* Creates a new progress bar. * Creates a new progress bar.
*/ */
@SuppressLint("PrivateResource") private fun createProgressIndicator(): ReaderProgressIndicator {
private fun createProgressBar(): ReaderProgressBar {
progressContainer = FrameLayout(context) progressContainer = FrameLayout(context)
frame.addView(progressContainer, MATCH_PARENT, parentHeight) frame.addView(progressContainer, MATCH_PARENT, parentHeight)
val progress = ReaderProgressBar(context).apply { val progress = ReaderProgressIndicator(context).apply {
val size = 48.dpToPx updateLayoutParams<FrameLayout.LayoutParams> {
layoutParams = FrameLayout.LayoutParams(size, size).apply {
gravity = Gravity.CENTER_HORIZONTAL gravity = Gravity.CENTER_HORIZONTAL
setMargins(0, parentHeight / 4, 0, 0) updateMargins(top = parentHeight / 4)
} }
} }
progressContainer.addView(progress) progressContainer.addView(progress)

View File

@ -4,11 +4,11 @@ import android.view.Gravity
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar
import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.widget.AppCompatButton
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.view.isNotEmpty import androidx.core.view.isNotEmpty
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.progressindicator.CircularProgressIndicator
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
@ -111,7 +111,8 @@ class WebtoonTransitionHolder(
* Sets the loading state on the pages container. * Sets the loading state on the pages container.
*/ */
private fun setLoading() { private fun setLoading() {
val progress = ProgressBar(context, null, android.R.attr.progressBarStyle) val progress = CircularProgressIndicator(context)
progress.isIndeterminate = true
val textView = AppCompatTextView(context).apply { val textView = AppCompatTextView(context).apply {
wrapContent() wrapContent()

View File

@ -22,7 +22,6 @@
android:layout_gravity="center" android:layout_gravity="center"
android:indeterminate="true" android:indeterminate="true"
android:visibility="gone" android:visibility="gone"
app:indicatorSize="56dp"
tools:visibility="visible" /> tools:visibility="visible" />
<eu.kanade.tachiyomi.ui.reader.PageIndicatorTextView <eu.kanade.tachiyomi.ui.reader.PageIndicatorTextView