From c3776ea3c60dcd74f70cb9b72687d582b6cffd21 Mon Sep 17 00:00:00 2001 From: MuhamadSyabitHidayattulloh Date: Wed, 10 Sep 2025 14:41:28 +0700 Subject: [PATCH 1/2] feat: Add Pull Gesture Navigate Chapter --- .idea/appInsightsSettings.xml | 26 +++++++ .../kotatsu/core/prefs/AppSettings.kt | 5 ++ .../kotatsu/reader/ui/ReaderViewModel.kt | 19 +++-- .../reader/ui/config/ReaderConfigSheet.kt | 8 ++ .../ui/pager/webtoon/WebtoonReaderFragment.kt | 63 ++++++++++++++++ .../ui/pager/webtoon/WebtoonRecyclerView.kt | 75 +++++++++++++++++++ app/src/main/res/drawable/ic_gesture.xml | 18 +++++ .../res/layout/fragment_reader_webtoon.xml | 26 +++++++ .../main/res/layout/sheet_reader_config.xml | 14 ++++ app/src/main/res/values/strings.xml | 6 ++ 10 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 .idea/appInsightsSettings.xml create mode 100644 app/src/main/res/drawable/ic_gesture.xml diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 000000000..371f2e299 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index b90bd8c6b..7dc07c3cc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 8f0146c3e..e951521d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -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)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index dc042aea8..577e9a9c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index 000b539f5..15f4325e3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -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() 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() 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() 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() 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() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt index 04eb2fd90..baf0bddc6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt @@ -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()) 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() + } } diff --git a/app/src/main/res/drawable/ic_gesture.xml b/app/src/main/res/drawable/ic_gesture.xml new file mode 100644 index 000000000..6c4c9e374 --- /dev/null +++ b/app/src/main/res/drawable/ic_gesture.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_reader_webtoon.xml b/app/src/main/res/layout/fragment_reader_webtoon.xml index d1d068765..766bb9063 100644 --- a/app/src/main/res/layout/fragment_reader_webtoon.xml +++ b/app/src/main/res/layout/fragment_reader_webtoon.xml @@ -16,4 +16,30 @@ android:orientation="vertical" app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" /> + + + + diff --git a/app/src/main/res/layout/sheet_reader_config.xml b/app/src/main/res/layout/sheet_reader_config.xml index c5f981b62..042a195b3 100644 --- a/app/src/main/res/layout/sheet_reader_config.xml +++ b/app/src/main/res/layout/sheet_reader_config.xml @@ -129,6 +129,20 @@ android:textColor="?colorOnSurfaceVariant" app:drawableStartCompat="@drawable/ic_split_horizontal" /> + + Data and privacy Restore previously created backup Allow zoom in gesture in webtoon mode + Release to open previous chapter + Release to open next chapter + No previous chapter + No next chapter Show the current time and reading progress at the top of the screen Show page numbers in bottom corner Clear cookies for specified domain only. In most cases will invalidate authorization @@ -644,6 +648,8 @@ Show updated Gaps in webtoon mode Show vertical gaps between pages in webtoon mode + Enable pull gesture + Use pull gesture to switch chapters in webtoon Less frequently More frequently Frequency of check From 5ef907d04629fc5135147c3b5b108fd817c3c406 Mon Sep 17 00:00:00 2001 From: MuhamadSyabitHidayattulloh Date: Thu, 11 Sep 2025 09:36:24 +0700 Subject: [PATCH 2/2] fix: Ui not visible if Control Panel show --- .../kotatsu/reader/ui/ReaderActivity.kt | 7 ++++++ .../kotatsu/reader/ui/ReaderViewModel.kt | 8 +++++++ .../ui/pager/webtoon/WebtoonReaderFragment.kt | 24 +++++++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index c32e46d94..a536bab7e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -374,6 +374,9 @@ class ReaderActivity : viewBinding.infoBar.isTimeVisible = isFullscreen updateScrollTimerButton() systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen) + val topOffset = if (isUiVisible) viewBinding.appbarTop.height else 0 + val bottomOffset = if (isUiVisible) (viewBinding.toolbarDocked?.height ?: 0) else 0 + viewModel.setReaderUiOffsets(topOffset, bottomOffset) } } @@ -395,6 +398,10 @@ class ReaderActivity : viewBinding.infoBar.updatePadding( top = systemBars.top, ) + viewModel.setReaderUiOffsets( + (if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else 0) + systemBars.top, + (if (viewBinding.toolbarDocked?.isVisible == true) (viewBinding.toolbarDocked?.height ?: 0) else 0) + systemBars.bottom, + ) return WindowInsetsCompat.Builder(insets) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .build() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index e951521d2..d1804e2f0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -120,6 +120,9 @@ class ReaderViewModel @Inject constructor( val onAskNsfwIncognito = MutableEventFlow() val uiState = MutableStateFlow(null) + val readerUiTopOffset = MutableStateFlow(0) + val readerUiBottomOffset = MutableStateFlow(0) + val isIncognitoMode = MutableStateFlow(savedStateHandle.get(ReaderIntent.EXTRA_INCOGNITO)) val content = MutableStateFlow(ReaderContent(emptyList(), null)) @@ -227,6 +230,11 @@ class ReaderViewModel @Inject constructor( discordRpc.setIdle() } + fun setReaderUiOffsets(top: Int, bottom: Int) { + readerUiTopOffset.value = top + readerUiBottomOffset.value = bottom + } + fun switchMode(newMode: ReaderMode) { launchJob { val manga = checkNotNull(getMangaOrNull()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index 15f4325e3..9d22f3292 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -49,6 +49,12 @@ class WebtoonReaderFragment : BaseReaderFragment() super.onViewBindingCreated(binding, savedInstanceState) var canGoPrev = true var canGoNext = true + viewModel.readerUiTopOffset.observe(viewLifecycleOwner) { top -> + binding.feedbackTop.translationY = top.toFloat() + } + viewModel.readerUiBottomOffset.observe(viewLifecycleOwner) { bottom -> + binding.feedbackBottom.translationY = -bottom.toFloat() + } with(binding.recyclerView) { setHasFixedSize(true) adapter = readerAdapter @@ -59,17 +65,17 @@ class WebtoonReaderFragment : BaseReaderFragment() setOnPullGestureListener(object : WebtoonRecyclerView.OnPullGestureListener { override fun onPullProgressTop(progress: Float) { if (canGoPrev) { - binding.feedbackTop.setText(R.string.pull_to_prev_chapter) + setFeedbackText(binding.feedbackTop, getString(R.string.pull_to_prev_chapter)) } else { - binding.feedbackTop.setText(R.string.pull_top_no_prev) + setFeedbackText(binding.feedbackTop, getString(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) + setFeedbackText(binding.feedbackBottom, getString(R.string.pull_to_next_chapter)) } else { - binding.feedbackBottom.setText(R.string.pull_bottom_no_next) + setFeedbackText(binding.feedbackBottom, getString(R.string.pull_bottom_no_next)) } updateFeedback(binding.feedbackBottom, progress) } @@ -241,3 +247,13 @@ private fun updateFeedback(tv: TextView, progress: Float) { private fun fadeOut(tv: TextView) { tv.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 + } +}