feat: Add Pull Gesture Navigate Chapter

This commit is contained in:
MuhamadSyabitHidayattulloh
2025-09-10 14:41:28 +07:00
parent a624bffea3
commit c3776ea3c6
10 changed files with 255 additions and 5 deletions

26
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -488,6 +488,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
var isWebtoonPullGestureEnabled: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_PULL_GESTURE, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_PULL_GESTURE, value) }
@get:FloatRange(from = 0.0, to = 0.5)
val defaultWebtoonZoomOut: Float
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
@@ -748,6 +752,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_WEBTOON_PULL_GESTURE = "webtoon_pull_gesture"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_SOURCES_GRID = "sources_grid"

View File

@@ -157,6 +157,12 @@ class ReaderViewModel @Inject constructor(
valueProducer = { isWebtoonGapsEnabled },
)
val isWebtoonPullGestureEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_PULL_GESTURE,
valueProducer = { isWebtoonPullGestureEnabled },
)
val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest {
if (it) {
observeWebtoonZoomOut()
@@ -345,11 +351,14 @@ class ReaderViewModel @Inject constructor(
return@launchJob
}
ensureActive()
if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true)
}
if (lowerPos <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
val autoLoadAllowed = readerMode.value != ReaderMode.WEBTOON || !isWebtoonPullGestureEnabled.value
if (autoLoadAllowed) {
if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true)
}
if (lowerPos <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
}
}
if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT))

View File

@@ -86,6 +86,8 @@ class ReaderConfigSheet :
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.switchPullGesture.isChecked = settings.isWebtoonPullGestureEnabled
binding.switchPullGesture.isEnabled = mode == ReaderMode.WEBTOON
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
@@ -96,6 +98,7 @@ class ReaderConfigSheet :
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchPullGesture.setOnCheckedChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
@@ -172,6 +175,10 @@ class ReaderConfigSheet :
settings.isReaderDoubleOnLandscape = isChecked
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
R.id.switch_pull_gesture -> {
settings.isWebtoonPullGestureEnabled = isChecked
}
}
}
@@ -191,6 +198,7 @@ class ReaderConfigSheet :
else -> return
}
viewBinding?.switchDoubleReader?.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
viewBinding?.switchPullGesture?.isEnabled = newMode == ReaderMode.WEBTOON
if (newMode == mode) {
return
}

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
@@ -46,6 +47,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
var canGoPrev = true
var canGoNext = true
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = readerAdapter
@@ -53,6 +56,40 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
addOnScrollListener(it)
}
setOnPullGestureListener(object : WebtoonRecyclerView.OnPullGestureListener {
override fun onPullProgressTop(progress: Float) {
if (canGoPrev) {
binding.feedbackTop.setText(R.string.pull_to_prev_chapter)
} else {
binding.feedbackTop.setText(R.string.pull_top_no_prev)
}
updateFeedback(binding.feedbackTop, progress)
}
override fun onPullProgressBottom(progress: Float) {
if (canGoNext) {
binding.feedbackBottom.setText(R.string.pull_to_next_chapter)
} else {
binding.feedbackBottom.setText(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) {
binding.frame.isZoomEnable = it
@@ -70,6 +107,21 @@ 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
}
viewModel.uiState.observe(viewLifecycleOwner) { state ->
if (state != null) {
canGoPrev = state.chapterIndex > 0
canGoNext = state.chapterIndex < state.chaptersTotal - 1
} else {
canGoPrev = true
canGoNext = true
}
}
}
override fun onDestroyView() {
@@ -178,3 +230,14 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
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 fadeOut(tv: TextView) {
tv.animate().alpha(0f).setDuration(150L).start()
}

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.View
import android.widget.EdgeEffect
import androidx.core.view.ViewCompat.TYPE_TOUCH
import androidx.core.view.forEach
import androidx.core.view.isEmpty
@@ -23,6 +25,71 @@ class WebtoonRecyclerView @JvmOverloads constructor(
private val detachedViews = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
private var isFixingScroll = false
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
}
}
}
})
}
fun setOnPullGestureListener(listener: OnPullGestureListener?) {
pullListener = listener
}
override fun onChildDetachedFromWindow(child: View) {
super.onChildDetachedFromWindow(child)
detachedViews.add(child)
@@ -188,4 +255,12 @@ class WebtoonRecyclerView @JvmOverloads constructor(
lastVisiblePosition: Int,
)
}
interface OnPullGestureListener {
fun onPullProgressTop(progress: Float)
fun onPullProgressBottom(progress: Float)
fun onPullTriggeredTop()
fun onPullTriggeredBottom()
fun onPullCancelled()
}
}

View File

@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2Z"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:fillColor="@android:color/transparent"/>
<path
android:fillColor="@android:color/white"
android:pathData="M12,7l-3,3h2v4h2v-4h2z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M12,17l3,-3h-2v-4h-2v4h-2z"/>
</vector>

View File

@@ -16,4 +16,30 @@
android:orientation="vertical"
app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" />
<org.koitharu.kotatsu.reader.ui.ReaderToastView
android:id="@+id/feedbackTop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:gravity="center"
android:padding="16dp"
android:alpha="0"
android:text="@string/pull_to_prev_chapter"
android:textAppearance="?textAppearanceBodyLarge"
android:background="@drawable/bg_reader_indicator"
android:theme="@style/ThemeOverlay.Material3.Dark" />
<org.koitharu.kotatsu.reader.ui.ReaderToastView
android:id="@+id/feedbackBottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:gravity="center"
android:padding="16dp"
android:alpha="0"
android:text="@string/pull_to_next_chapter"
android:textAppearance="?textAppearanceBodyLarge"
android:background="@drawable/bg_reader_indicator"
android:theme="@style/ThemeOverlay.Material3.Dark" />
</org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame>

View File

@@ -129,6 +129,20 @@
android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_split_horizontal" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_pull_gesture"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/enable_pull_gesture_title"
android:textAppearance="?textAppearanceListItem"
android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_gesture" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_screen_rotate"
android:layout_width="match_parent"

View File

@@ -427,6 +427,10 @@
<string name="data_and_privacy">Data and privacy</string>
<string name="restore_summary">Restore previously created backup</string>
<string name="webtoon_zoom_summary">Allow zoom in gesture in webtoon mode</string>
<string name="pull_to_prev_chapter">Release to open previous chapter</string>
<string name="pull_to_next_chapter">Release to open next chapter</string>
<string name="pull_top_no_prev">No previous chapter</string>
<string name="pull_bottom_no_next">No next chapter</string>
<string name="reader_info_bar_summary">Show the current time and reading progress at the top of the screen</string>
<string name="show_pages_numbers_summary">Show page numbers in bottom corner</string>
<string name="clear_source_cookies_summary">Clear cookies for specified domain only. In most cases will invalidate authorization</string>
@@ -644,6 +648,8 @@
<string name="show_updated">Show updated</string>
<string name="webtoon_gaps">Gaps in webtoon mode</string>
<string name="webtoon_gaps_summary">Show vertical gaps between pages in webtoon mode</string>
<string name="enable_pull_gesture_title">Enable pull gesture</string>
<string name="enable_pull_gesture_summary">Use pull gesture to switch chapters in webtoon</string>
<string name="less_frequently">Less frequently</string>
<string name="more_frequently">More frequently</string>
<string name="frequency_of_check">Frequency of check</string>