Webtoon pull gesture refactoring

This commit is contained in:
Koitharu
2025-09-21 11:57:27 +03:00
parent 435c3824f7
commit d9612f3427
4 changed files with 152 additions and 126 deletions

2
.idea/.gitignore generated vendored
View File

@@ -3,3 +3,5 @@
/workspace.xml
/migrations.xml
/runConfigurations.xml
/appInsightsSettings.xml
/kotlinCodeInsightSettings.xml

View File

@@ -28,7 +28,8 @@ import javax.inject.Inject
@AndroidEntryPoint
class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>(),
WebtoonRecyclerView.OnWebtoonScrollListener {
WebtoonRecyclerView.OnWebtoonScrollListener,
WebtoonRecyclerView.OnPullGestureListener {
@Inject
lateinit var networkState: NetworkState
@@ -39,6 +40,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
private val scrollInterpolator = DecelerateInterpolator()
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
private var canGoPrev = true
private var canGoNext = true
override fun onCreateViewBinding(
inflater: LayoutInflater,
@@ -47,8 +50,6 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
var canGoPrev = true
var canGoNext = true
viewModel.readerUiTopOffset.observe(viewLifecycleOwner) { top ->
binding.feedbackTop.translationY = top.toFloat()
}
@@ -62,40 +63,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
addOnScrollListener(it)
}
setOnPullGestureListener(object : WebtoonRecyclerView.OnPullGestureListener {
override fun onPullProgressTop(progress: Float) {
if (canGoPrev) {
setFeedbackText(binding.feedbackTop, getString(R.string.pull_to_prev_chapter))
} else {
setFeedbackText(binding.feedbackTop, getString(R.string.pull_top_no_prev))
}
updateFeedback(binding.feedbackTop, progress)
}
override fun onPullProgressBottom(progress: Float) {
if (canGoNext) {
setFeedbackText(binding.feedbackBottom, getString(R.string.pull_to_next_chapter))
} else {
setFeedbackText(binding.feedbackBottom, getString(R.string.pull_bottom_no_next))
}
updateFeedback(binding.feedbackBottom, progress)
}
override fun onPullTriggeredTop() {
fadeOut(binding.feedbackTop)
if (canGoPrev) {
viewModel.switchChapterBy(-1)
}
}
override fun onPullTriggeredBottom() {
fadeOut(binding.feedbackBottom)
if (canGoNext) {
viewModel.switchChapterBy(1)
}
}
override fun onPullCancelled() {
fadeOut(binding.feedbackTop)
fadeOut(binding.feedbackBottom)
}
})
setOnPullGestureListener(this@WebtoonReaderFragment)
}
viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it
@@ -113,11 +81,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
it.applyBackground(binding.root)
}
viewModel.readerMode.observe(viewLifecycleOwner) { mode ->
binding.recyclerView.isPullGestureEnabled = (mode == org.koitharu.kotatsu.core.prefs.ReaderMode.WEBTOON) && viewModel.isWebtoonPullGestureEnabled.value
}
viewModel.isWebtoonPullGestureEnabled.observe(viewLifecycleOwner) { enabled ->
binding.recyclerView.isPullGestureEnabled = (viewModel.readerMode.value == org.koitharu.kotatsu.core.prefs.ReaderMode.WEBTOON) && enabled
binding.recyclerView.isPullGestureEnabled = enabled
}
viewModel.uiState.observe(viewLifecycleOwner) { state ->
if (state != null) {
@@ -226,6 +191,47 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
return true
}
override fun onPullProgressTop(progress: Float) {
val binding = viewBinding ?: return
if (canGoPrev) {
binding.feedbackTop.setFeedbackText(getString(R.string.pull_to_prev_chapter))
} else {
binding.feedbackTop.setFeedbackText(getString(R.string.pull_top_no_prev))
}
binding.feedbackTop.updateFeedback(progress)
}
override fun onPullProgressBottom(progress: Float) {
val binding = viewBinding ?: return
if (canGoNext) {
binding.feedbackBottom.setFeedbackText(getString(R.string.pull_to_next_chapter))
} else {
binding.feedbackBottom.setFeedbackText(getString(R.string.pull_bottom_no_next))
}
binding.feedbackBottom.updateFeedback(progress)
}
override fun onPullTriggeredTop() {
(viewBinding ?: return).feedbackTop.fadeOut()
if (canGoPrev) {
viewModel.switchChapterBy(-1)
}
}
override fun onPullTriggeredBottom() {
(viewBinding ?: return).feedbackBottom.fadeOut()
if (canGoNext) {
viewModel.switchChapterBy(1)
}
}
override fun onPullCancelled() {
viewBinding?.apply {
feedbackTop.fadeOut()
feedbackBottom.fadeOut()
}
}
private fun RecyclerView.findCurrentPagePosition(): Int {
val centerX = width / 2f
val centerY = height - resources.getDimension(R.dimen.webtoon_pages_gap)
@@ -235,25 +241,25 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
return getChildAdapterPosition(view)
}
}
private fun updateFeedback(tv: TextView, progress: Float) {
val clamped = progress.coerceIn(0f, 1.2f)
tv.alpha = clamped.coerceAtMost(1f)
tv.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f)
tv.scaleY = tv.scaleX
}
private fun TextView.updateFeedback(progress: Float) {
val clamped = progress.coerceIn(0f, 1.2f)
this.alpha = clamped.coerceAtMost(1f)
this.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f)
this.scaleY = this.scaleX
}
private fun fadeOut(tv: TextView) {
tv.animate().alpha(0f).setDuration(150L).start()
}
private fun TextView.fadeOut() {
animate().alpha(0f).setDuration(150L).start()
}
private fun setFeedbackText(tv: TextView, text: CharSequence) {
if (tv.alpha <= 0f && text.isNotEmpty()) {
tv.alpha = 0f
tv.text = text
tv.animate().alpha(1f).setDuration(120L).start()
} else {
tv.text = text
private fun TextView.setFeedbackText(text: CharSequence) {
if (this.alpha <= 0f && text.isNotEmpty()) {
this.alpha = 0f
this.text = text
animate().alpha(1f).setDuration(120L).start()
} else {
this.text = text
}
}
}

View File

@@ -12,6 +12,8 @@ import androidx.core.view.isNotEmpty
import androidx.core.view.iterator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_BOTTOM
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_TOP
import java.util.Collections
import java.util.LinkedList
import java.util.WeakHashMap
@@ -27,63 +29,10 @@ class WebtoonRecyclerView @JvmOverloads constructor(
var isPullGestureEnabled: Boolean = false
var pullThreshold: Float = 0.3f
private var pullProgressTop: Float = 0f
private var pullProgressBottom: Float = 0f
private var pullListener: OnPullGestureListener? = null
init {
setEdgeEffectFactory(object : EdgeEffectFactory() {
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
return object : EdgeEffect(view.context) {
override fun onPull(deltaDistance: Float) {
val sign = if (direction == DIRECTION_TOP) 1f else if (direction == DIRECTION_BOTTOM) 1f else 0f
if (sign != 0f) onPull(deltaDistance, 0.5f)
}
override fun onPull(deltaDistance: Float, displacement: Float) {
if (!isPullGestureEnabled) return
if (direction == DIRECTION_TOP) {
pullProgressTop = (pullProgressTop + deltaDistance).coerceAtLeast(0f)
pullListener?.onPullProgressTop(pullProgressTop / pullThreshold)
} else if (direction == DIRECTION_BOTTOM) {
pullProgressBottom = (pullProgressBottom + deltaDistance).coerceAtLeast(0f)
pullListener?.onPullProgressBottom(pullProgressBottom / pullThreshold)
}
}
override fun onRelease() {
if (!isPullGestureEnabled) {
pullProgressTop = 0f
pullProgressBottom = 0f
return
}
var triggered = false
if (direction == DIRECTION_TOP) {
if (pullProgressTop >= pullThreshold) {
pullListener?.onPullTriggeredTop()
triggered = true
}
pullProgressTop = 0f
pullListener?.onPullProgressTop(0f)
} else if (direction == DIRECTION_BOTTOM) {
if (pullProgressBottom >= pullThreshold) {
pullListener?.onPullTriggeredBottom()
triggered = true
}
pullProgressBottom = 0f
pullListener?.onPullProgressBottom(0f)
}
if (!triggered) {
pullListener?.onPullCancelled()
}
}
override fun draw(canvas: Canvas?): Boolean {
return false
}
}
}
})
setEdgeEffectFactory(PullEffect.Factory())
}
fun setOnPullGestureListener(listener: OnPullGestureListener?) {
@@ -246,6 +195,68 @@ class WebtoonRecyclerView @JvmOverloads constructor(
}
}
private class PullEffect(
view: RecyclerView,
private val direction: Int,
private val pullThreshold: Float,
private val pullListener: OnPullGestureListener,
) : EdgeEffect(view.context) {
private var pullProgressTop: Float = 0f
private var pullProgressBottom: Float = 0f
override fun onPull(deltaDistance: Float) {
val sign = if (direction == DIRECTION_TOP) 1f else if (direction == DIRECTION_BOTTOM) 1f else 0f
if (sign != 0f) onPull(deltaDistance, 0.5f)
}
override fun onPull(deltaDistance: Float, displacement: Float) {
if (direction == DIRECTION_TOP) {
pullProgressTop = (pullProgressTop + deltaDistance).coerceAtLeast(0f)
pullListener.onPullProgressTop(pullProgressTop / pullThreshold)
} else if (direction == DIRECTION_BOTTOM) {
pullProgressBottom = (pullProgressBottom + deltaDistance).coerceAtLeast(0f)
pullListener.onPullProgressBottom(pullProgressBottom / pullThreshold)
}
}
override fun onRelease() {
var triggered = false
if (direction == DIRECTION_TOP) {
if (pullProgressTop >= pullThreshold) {
pullListener.onPullTriggeredTop()
triggered = true
}
pullProgressTop = 0f
pullListener.onPullProgressTop(0f)
} else if (direction == DIRECTION_BOTTOM) {
if (pullProgressBottom >= pullThreshold) {
pullListener.onPullTriggeredBottom()
triggered = true
}
pullProgressBottom = 0f
pullListener.onPullProgressBottom(0f)
}
if (!triggered) {
pullListener.onPullCancelled()
}
}
override fun draw(canvas: Canvas?): Boolean = false
class Factory : EdgeEffectFactory() {
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
val pullListener = (view as? WebtoonRecyclerView)?.pullListener
return if (pullListener != null && view.isPullGestureEnabled) {
PullEffect(view, direction, view.pullThreshold, pullListener)
} else {
super.createEdgeEffect(view, direction)
}
}
}
}
interface OnWebtoonScrollListener {
fun onScrollChanged(

View File

@@ -2,6 +2,7 @@
<org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -16,30 +17,36 @@
android:orientation="vertical"
app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" />
<org.koitharu.kotatsu.reader.ui.ReaderToastView
<TextView
android:id="@+id/feedbackTop"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:gravity="center"
android:padding="16dp"
android:layout_gravity="top|center_horizontal"
android:layout_margin="@dimen/screen_padding"
android:alpha="0"
android:background="@drawable/bg_reader_indicator"
android:gravity="center"
android:paddingHorizontal="@dimen/margin_normal"
android:paddingVertical="@dimen/margin_small"
android:text="@string/pull_to_prev_chapter"
android:textAppearance="?textAppearanceBodyLarge"
android:background="@drawable/bg_reader_indicator"
android:theme="@style/ThemeOverlay.Material3.Dark" />
android:theme="@style/ThemeOverlay.Material3.Dark"
tools:alpha="1" />
<org.koitharu.kotatsu.reader.ui.ReaderToastView
<TextView
android:id="@+id/feedbackBottom"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:gravity="center"
android:padding="16dp"
android:layout_gravity="bottom|center_horizontal"
android:layout_margin="@dimen/screen_padding"
android:alpha="0"
android:background="@drawable/bg_reader_indicator"
android:gravity="center"
android:paddingHorizontal="@dimen/margin_normal"
android:paddingVertical="@dimen/margin_small"
android:text="@string/pull_to_next_chapter"
android:textAppearance="?textAppearanceBodyLarge"
android:background="@drawable/bg_reader_indicator"
android:theme="@style/ThemeOverlay.Material3.Dark" />
android:theme="@style/ThemeOverlay.Material3.Dark"
tools:alpha="1" />
</org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame>