Webtoon pull gesture refactoring

devel
Koitharu 7 months ago
parent 435c3824f7
commit d9612f3427
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

2
.idea/.gitignore vendored

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

@ -28,7 +28,8 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>(), class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>(),
WebtoonRecyclerView.OnWebtoonScrollListener { WebtoonRecyclerView.OnWebtoonScrollListener,
WebtoonRecyclerView.OnPullGestureListener {
@Inject @Inject
lateinit var networkState: NetworkState lateinit var networkState: NetworkState
@ -39,6 +40,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
private val scrollInterpolator = DecelerateInterpolator() private val scrollInterpolator = DecelerateInterpolator()
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
private var canGoPrev = true
private var canGoNext = true
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -47,8 +50,6 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
var canGoPrev = true
var canGoNext = true
viewModel.readerUiTopOffset.observe(viewLifecycleOwner) { top -> viewModel.readerUiTopOffset.observe(viewLifecycleOwner) { top ->
binding.feedbackTop.translationY = top.toFloat() binding.feedbackTop.translationY = top.toFloat()
} }
@ -62,40 +63,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also { recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
addOnScrollListener(it) addOnScrollListener(it)
} }
setOnPullGestureListener(object : WebtoonRecyclerView.OnPullGestureListener { setOnPullGestureListener(this@WebtoonReaderFragment)
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)
}
})
} }
viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) { viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it binding.frame.isZoomEnable = it
@ -113,11 +81,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) { viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
it.applyBackground(binding.root) 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 -> 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 -> viewModel.uiState.observe(viewLifecycleOwner) { state ->
if (state != null) { if (state != null) {
@ -226,6 +191,47 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
return true 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 { private fun RecyclerView.findCurrentPagePosition(): Int {
val centerX = width / 2f val centerX = width / 2f
val centerY = height - resources.getDimension(R.dimen.webtoon_pages_gap) 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 val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION
return getChildAdapterPosition(view) return getChildAdapterPosition(view)
} }
}
private fun updateFeedback(tv: TextView, progress: Float) { private fun TextView.updateFeedback(progress: Float) {
val clamped = progress.coerceIn(0f, 1.2f) val clamped = progress.coerceIn(0f, 1.2f)
tv.alpha = clamped.coerceAtMost(1f) this.alpha = clamped.coerceAtMost(1f)
tv.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f) this.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f)
tv.scaleY = tv.scaleX this.scaleY = this.scaleX
} }
private fun fadeOut(tv: TextView) { private fun TextView.fadeOut() {
tv.animate().alpha(0f).setDuration(150L).start() animate().alpha(0f).setDuration(150L).start()
} }
private fun setFeedbackText(tv: TextView, text: CharSequence) { private fun TextView.setFeedbackText(text: CharSequence) {
if (tv.alpha <= 0f && text.isNotEmpty()) { if (this.alpha <= 0f && text.isNotEmpty()) {
tv.alpha = 0f this.alpha = 0f
tv.text = text this.text = text
tv.animate().alpha(1f).setDuration(120L).start() animate().alpha(1f).setDuration(120L).start()
} else { } else {
tv.text = text this.text = text
}
} }
} }

@ -12,6 +12,8 @@ import androidx.core.view.isNotEmpty
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.Collections
import java.util.LinkedList import java.util.LinkedList
import java.util.WeakHashMap import java.util.WeakHashMap
@ -27,63 +29,10 @@ class WebtoonRecyclerView @JvmOverloads constructor(
var isPullGestureEnabled: Boolean = false var isPullGestureEnabled: Boolean = false
var pullThreshold: Float = 0.3f var pullThreshold: Float = 0.3f
private var pullProgressTop: Float = 0f
private var pullProgressBottom: Float = 0f
private var pullListener: OnPullGestureListener? = null private var pullListener: OnPullGestureListener? = null
init { init {
setEdgeEffectFactory(object : EdgeEffectFactory() { setEdgeEffectFactory(PullEffect.Factory())
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
}
}
}
})
} }
fun setOnPullGestureListener(listener: OnPullGestureListener?) { 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 { interface OnWebtoonScrollListener {
fun onScrollChanged( fun onScrollChanged(

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

Loading…
Cancel
Save