Improve keyboard control in reader
This commit is contained in:
@@ -107,7 +107,7 @@ class ReaderActivity :
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
touchHelper = GridTouchHelper(this, this)
|
||||
scrollTimer = scrollTimerFactory.create(this, this)
|
||||
controlDelegate = ReaderControlDelegate(settings, this, this)
|
||||
controlDelegate = ReaderControlDelegate(resources, settings, this, this)
|
||||
viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
|
||||
viewBinding.slider.setLabelFormatter(PageLabelFormatter())
|
||||
ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider)
|
||||
@@ -347,8 +347,8 @@ class ReaderActivity :
|
||||
readerManager.currentReader?.switchPageBy(delta)
|
||||
}
|
||||
|
||||
override fun scrollBy(delta: Int): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta) ?: false
|
||||
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta, smooth) ?: false
|
||||
}
|
||||
|
||||
override fun toggleUiVisibility() {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.view.KeyEvent
|
||||
import android.view.SoundEffectConstants
|
||||
import android.view.View
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.GridTouchHelper
|
||||
|
||||
class ReaderControlDelegate(
|
||||
resources: Resources,
|
||||
private val settings: AppSettings,
|
||||
private val listener: OnInteractionListener,
|
||||
owner: LifecycleOwner,
|
||||
@@ -19,6 +22,7 @@ class ReaderControlDelegate(
|
||||
private var isTapSwitchEnabled: Boolean = true
|
||||
private var isVolumeKeysSwitchEnabled: Boolean = false
|
||||
private var isReaderTapsAdaptive: Boolean = true
|
||||
private var minScrollDelta = resources.getDimensionPixelSize(R.dimen.reader_scroll_delta_min)
|
||||
|
||||
init {
|
||||
owner.lifecycle.addObserver(this)
|
||||
@@ -82,8 +86,6 @@ class ReaderControlDelegate(
|
||||
|
||||
KeyEvent.KEYCODE_SPACE,
|
||||
KeyEvent.KEYCODE_PAGE_DOWN,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
-> {
|
||||
listener.switchPageBy(1)
|
||||
true
|
||||
@@ -95,8 +97,6 @@ class ReaderControlDelegate(
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_PAGE_UP,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
-> {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
@@ -112,6 +112,22 @@ class ReaderControlDelegate(
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
if (!listener.scrollBy(-minScrollDelta, smooth = true)) {
|
||||
listener.switchPageBy(-1)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
if (!listener.scrollBy(minScrollDelta, smooth = true)) {
|
||||
listener.switchPageBy(1)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -139,7 +155,7 @@ class ReaderControlDelegate(
|
||||
|
||||
fun switchPageBy(delta: Int)
|
||||
|
||||
fun scrollBy(delta: Int): Boolean
|
||||
fun scrollBy(delta: Int, smooth: Boolean): Boolean
|
||||
|
||||
fun toggleUiVisibility()
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class ScrollTimer @AssistedInject constructor(
|
||||
if (!listener.isReaderResumed()) {
|
||||
continue
|
||||
}
|
||||
if (!listener.scrollBy(1)) {
|
||||
if (!listener.scrollBy(1, false)) {
|
||||
accumulator += delayMs
|
||||
}
|
||||
if (accumulator >= pageSwitchDelay) {
|
||||
|
||||
@@ -66,7 +66,7 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>() {
|
||||
|
||||
abstract fun switchPageTo(position: Int, smooth: Boolean)
|
||||
|
||||
open fun scrollBy(delta: Int): Boolean = false
|
||||
open fun scrollBy(delta: Int, smooth: Boolean): Boolean = false
|
||||
|
||||
abstract fun getCurrentState(): ReaderState?
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
@@ -27,6 +28,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.absoluteValue
|
||||
@@ -54,6 +56,10 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
|
||||
offscreenPageLimit = 2
|
||||
doOnPageChanged(::notifyPageChanged)
|
||||
setOnGenericMotionListener(this@ReversedReaderFragment)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
recyclerView?.defaultFocusHighlightEnabled = false
|
||||
}
|
||||
PagerEventSupplier(this).attach()
|
||||
}
|
||||
|
||||
viewModel.pageAnimation.observe(viewLifecycleOwner) {
|
||||
|
||||
@@ -35,7 +35,6 @@ open class PageHolder(
|
||||
binding.ssiv.bindToLifecycle(owner)
|
||||
binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
|
||||
binding.ssiv.addOnImageEventListener(delegate)
|
||||
binding.ssiv.setOnGenericMotionListener(SsivZoomListener())
|
||||
@Suppress("LeakingThis")
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
@Suppress("LeakingThis")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.children
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
|
||||
class PagerEventSupplier(private val pager: ViewPager2) : View.OnKeyListener {
|
||||
|
||||
fun attach() {
|
||||
pager.recyclerView?.setOnKeyListener(this)
|
||||
}
|
||||
|
||||
override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean {
|
||||
val rootView = pager.recyclerView?.findViewHolderForAdapterPosition(pager.currentItem)?.itemView as? ViewGroup
|
||||
?: return false
|
||||
return rootView.children.firstNotNullOfOrNull { x ->
|
||||
x as? SubsamplingScaleImageView
|
||||
}?.dispatchKeyEvent(event) == true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
@@ -55,6 +56,10 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
|
||||
offscreenPageLimit = 2
|
||||
doOnPageChanged(::notifyPageChanged)
|
||||
setOnGenericMotionListener(this@PagerReaderFragment)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
recyclerView?.defaultFocusHighlightEnabled = false
|
||||
}
|
||||
PagerEventSupplier(this).attach()
|
||||
}
|
||||
|
||||
viewModel.pageAnimation.observe(viewLifecycleOwner) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.OnGenericMotionListener
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
|
||||
class SsivZoomListener : OnGenericMotionListener {
|
||||
|
||||
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
|
||||
val ssiv = v as? SubsamplingScaleImageView ?: return false
|
||||
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
|
||||
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
|
||||
if (withCtrl || ssiv.scale > ssiv.minScale) {
|
||||
val center = PointF(event.x, event.y)
|
||||
val scale = ssiv.scale + axisValue * 1.6f
|
||||
(ssiv.animateScaleAndCenter(scale, center) ?: return false)
|
||||
.withInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class WebtoonFrameLayout @JvmOverloads constructor(
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
val target by lazy(LazyThreadSafetyMode.NONE) {
|
||||
findViewById<WebtoonImageView>(R.id.ssiv)
|
||||
val target: WebtoonImageView by lazy(LazyThreadSafetyMode.NONE) {
|
||||
findViewById(R.id.ssiv)
|
||||
}
|
||||
|
||||
fun dispatchVerticalScroll(dy: Int): Int {
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.async
|
||||
@@ -31,7 +31,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private val scrollInterpolator = AccelerateDecelerateInterpolator()
|
||||
private val scrollInterpolator = DecelerateInterpolator()
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
@@ -122,8 +122,12 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
|
||||
requireViewBinding().recyclerView.firstVisibleItemPosition = position
|
||||
}
|
||||
|
||||
override fun scrollBy(delta: Int): Boolean {
|
||||
requireViewBinding().recyclerView.nestedScrollBy(0, delta)
|
||||
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
|
||||
if (smooth && isAnimationEnabled()) {
|
||||
requireViewBinding().recyclerView.smoothScrollBy(0, delta, scrollInterpolator)
|
||||
} else {
|
||||
requireViewBinding().recyclerView.nestedScrollBy(0, delta)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,17 @@ import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.OverScroller
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
import androidx.core.view.ViewConfigurationCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
|
||||
private const val MAX_SCALE = 2.5f
|
||||
private const val MIN_SCALE = 0.5f
|
||||
private const val WHEEL_SCALE_FACTOR = 0.2f
|
||||
|
||||
class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -43,6 +46,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
private var halfHeight = 0f
|
||||
private val translateBounds = RectF()
|
||||
private val targetHitRect = Rect()
|
||||
private var animator: ValueAnimator? = null
|
||||
|
||||
var isZoomEnable = true
|
||||
set(value) {
|
||||
@@ -81,13 +85,15 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
|
||||
if (isZoomEnable && event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
|
||||
val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
|
||||
if (withCtrl) {
|
||||
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
val newScale =
|
||||
(scale + axisValue * WHEEL_SCALE_FACTOR).coerceIn(MIN_SCALE, MAX_SCALE)
|
||||
val axisValue =
|
||||
event.getAxisValue(MotionEvent.AXIS_VSCROLL) * ViewConfigurationCompat.getScaledVerticalScrollFactor(
|
||||
ViewConfiguration.get(context), context,
|
||||
)
|
||||
val newScale = (scale + axisValue).coerceIn(MIN_SCALE, MAX_SCALE)
|
||||
scaleChild(newScale, event.x, event.y)
|
||||
return true
|
||||
}
|
||||
@@ -96,6 +102,49 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (!isZoomEnable) {
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_ZOOM_IN,
|
||||
KeyEvent.KEYCODE_NUMPAD_ADD,
|
||||
KeyEvent.KEYCODE_PLUS -> {
|
||||
smoothScaleTo(scale * 1.1f)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_ZOOM_OUT,
|
||||
KeyEvent.KEYCODE_NUMPAD_SUBTRACT,
|
||||
KeyEvent.KEYCODE_MINUS -> {
|
||||
smoothScaleTo(scale * 0.9f)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_ESCAPE -> {
|
||||
smoothScaleTo(1f)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return if (isZoomEnable) {
|
||||
keyCode == KeyEvent.KEYCODE_NUMPAD_ADD
|
||||
|| keyCode == KeyEvent.KEYCODE_PLUS
|
||||
|| keyCode == KeyEvent.KEYCODE_NUMPAD_SUBTRACT
|
||||
|| keyCode == KeyEvent.KEYCODE_MINUS
|
||||
|| keyCode == KeyEvent.KEYCODE_ZOOM_IN
|
||||
|| keyCode == KeyEvent.KEYCODE_ZOOM_OUT
|
||||
|| keyCode == KeyEvent.KEYCODE_ESCAPE
|
||||
|| super.onKeyUp(keyCode, event)
|
||||
} else {
|
||||
super.onKeyUp(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
halfWidth = w / 2f
|
||||
@@ -173,10 +222,24 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true
|
||||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
|
||||
animator?.cancel()
|
||||
animator = null
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScaleEnd(p0: ScaleGestureDetector) = Unit
|
||||
|
||||
private fun smoothScaleTo(target: Float) {
|
||||
val newScale = target.coerceIn(MIN_SCALE, MAX_SCALE)
|
||||
animator?.cancel()
|
||||
animator = ValueAnimator.ofFloat(scale, newScale).apply {
|
||||
setDuration(context.getAnimationDuration(android.R.integer.config_shortAnimTime))
|
||||
interpolator = DecelerateInterpolator()
|
||||
addUpdateListener { scaleChild(it.animatedValue as Float, halfWidth, halfHeight) }
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
|
||||
|
||||
@@ -231,7 +294,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
if (overScroller.computeScrollOffset()) {
|
||||
transformMatrix.postTranslate(
|
||||
overScroller.currX - transX,
|
||||
overScroller.currY - transY
|
||||
overScroller.currY - transY,
|
||||
)
|
||||
invalidateTarget()
|
||||
postOnAnimation(this)
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
android:layout_height="match_parent"
|
||||
android:defaultFocusHighlightEnabled="false" />
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/frame"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="true"
|
||||
android:defaultFocusHighlightEnabled="false">
|
||||
|
||||
<org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonRecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:defaultFocusHighlightEnabled="false"
|
||||
android:orientation="vertical"
|
||||
app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" />
|
||||
</org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
android:id="@+id/ssiv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:defaultFocusHighlightEnabled="false"
|
||||
android:focusable="true"
|
||||
app:restoreStrategy="deferred" />
|
||||
|
||||
<TextView
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="wrap_content"
|
||||
android:defaultFocusHighlightEnabled="false">
|
||||
|
||||
<org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonImageView
|
||||
android:id="@+id/ssiv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:defaultFocusHighlightEnabled="false"
|
||||
android:minHeight="1dp"
|
||||
app:panEnabled="false"
|
||||
app:quickScaleEnabled="false"
|
||||
|
||||
@@ -80,4 +80,6 @@
|
||||
<dimen name="fastscroll_scrollbar_padding_end">6dp</dimen>
|
||||
|
||||
<dimen name="m3_side_sheet_width">400dp</dimen>
|
||||
|
||||
<dimen name="reader_scroll_delta_min">200dp</dimen>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user