From c7e377bb2e7b80011fe027b0d984e14bbff5c9f7 Mon Sep 17 00:00:00 2001 From: erkas Date: Thu, 31 Jul 2025 10:41:53 +0900 Subject: [PATCH 1/2] refactor: migrate to viewpager2, improve fragment and state management --- readium/navigator/build.gradle.kts | 1 + .../org/readium/r2/navigator/R2WebView.kt | 2 +- .../navigator/epub/EpubNavigatorFragment.kt | 118 ++++++- .../navigator/image/ImageNavigatorFragment.kt | 32 +- .../r2/navigator/pager/R2CbzPageFragment.kt | 8 +- .../r2/navigator/pager/R2EpubPageFragment.kt | 9 +- .../navigator/pager/R2FragmentPagerAdapter.kt | 184 ++-------- .../r2/navigator/pager/R2PagerAdapter.kt | 100 +++--- .../r2/navigator/pager/R2RTLViewPager2.kt | 319 ++++++++++++++++++ .../readium/r2/navigator/pager/R2ViewPager.kt | 69 ++-- 10 files changed, 557 insertions(+), 285 deletions(-) create mode 100644 readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager2.kt diff --git a/readium/navigator/build.gradle.kts b/readium/navigator/build.gradle.kts index e3d0795204..de92f97828 100644 --- a/readium/navigator/build.gradle.kts +++ b/readium/navigator/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(libs.androidx.legacy.ui) implementation(libs.androidx.lifecycle.common) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) implementation(libs.bundles.media3) implementation(libs.androidx.webkit) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/R2WebView.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/R2WebView.kt index f278c173c1..caf232a879 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/R2WebView.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/R2WebView.kt @@ -723,7 +723,7 @@ internal class R2WebView(context: Context, attrs: AttributeSet) : R2BasicWebView val x = ev.safeGetX(pointerIndex) val xDiff = abs(x - mLastMotionX) - if (xDiff > mTouchSlop) { + if (!scrollMode && xDiff > mTouchSlop) { if (DEBUG) Timber.v("Starting drag!") mIsBeingDragged = true mLastMotionX = if (x - mInitialMotionX > 0) { diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt index 98184974c9..1ee1975038 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt @@ -31,7 +31,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.withStarted -import androidx.viewpager.widget.ViewPager +import androidx.viewpager2.widget.ViewPager2 import kotlin.math.ceil import kotlin.reflect.KClass import kotlinx.coroutines.Job @@ -425,7 +425,7 @@ public class EpubNavigatorFragment internal constructor( "The parent view of the EPUB `resourcePager` must be a ConstraintLayout" } // We need to null out the adapter explicitly, otherwise the page fragments will leak. - resourcePager.adapter = null + resourcePager.setAdapter(null) parent.removeView(resourcePager) resourcePager = R2ViewPager(requireContext()) @@ -434,17 +434,96 @@ public class EpubNavigatorFragment internal constructor( EpubLayout.REFLOWABLE, null -> R2ViewPager.PublicationType.EPUB EpubLayout.FIXED -> R2ViewPager.PublicationType.FXL } + + // Configure ViewPager orientation based on scroll settings + if (viewModel.layout == EpubLayout.REFLOWABLE && viewModel.settings.value.scroll) { + resourcePager.configureForVerticalScrolling() + } else { + resourcePager.configureForHorizontalPaging() + } + resourcePager.setBackgroundColor(viewModel.settings.value.effectiveBackgroundColor) // Let the page views handle the keyboard events. resourcePager.isFocusable = false - resourcePager.addOnPageChangeListener(PageChangeListener()) + resourcePager.registerOnPageChangeCallback(PageChangeListener()) + + // Set proper ConstraintLayout parameters + val layoutParams = ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT + ).apply { + topToTop = ConstraintLayout.LayoutParams.PARENT_ID + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + startToStart = ConstraintLayout.LayoutParams.PARENT_ID + endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + } + resourcePager.layoutParams = layoutParams parent.addView(resourcePager) resetResourcePagerAdapter() } - private inner class PageChangeListener : ViewPager.SimpleOnPageChangeListener() { + private inner class PageChangeListener : ViewPager2.OnPageChangeCallback() { + private var hasPresetScrollPosition = false + + override fun onPageScrollStateChanged(state: Int) { + when (state) { + ViewPager2.SCROLL_STATE_IDLE -> { + // Reset flag when swipe ends + hasPresetScrollPosition = false + } + else -> Unit + } + } + + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int, + ) { + // Only process when swipe has started and scroll position hasn't been set yet + if (positionOffset > 0f && !hasPresetScrollPosition) { + val currentPosition = resourcePager.currentItem + + // When swiping to previous page (position is less than current) + if (position < currentPosition) { + // Find target page fragment and set to last page + val targetFragment = fragmentAt(position) as? R2EpubPageFragment + targetFragment?.webView?.let { webView -> + if (viewModel.isScrollEnabled.value) { + webView.scrollToEnd() + } else if (webView.numPages > 1) { + if (resourcePager.isRtl()) { + webView.setCurrentItem(0, false) + } else { + webView.setCurrentItem(webView.numPages - 1, false) + } + } + } + hasPresetScrollPosition = true + } + // When swiping to next page (position + 1 is greater than current) + else if (position + 1 > currentPosition) { + // Find target page fragment and set to first page + val targetFragment = fragmentAt(position + 1) as? R2EpubPageFragment + targetFragment?.webView?.let { webView -> + if (viewModel.isScrollEnabled.value) { + webView.scrollToStart() + } else if (webView.numPages > 1) { + if (resourcePager.isRtl()) { + webView.setCurrentItem(webView.numPages - 1, false) + } else { + webView.setCurrentItem(0, false) + } + } + } + + hasPresetScrollPosition = true + } + } + } + override fun onPageSelected(position: Int) { currentReflowablePageFragment?.webView?.let { webView -> if (viewModel.isScrollEnabled.value) { @@ -456,16 +535,17 @@ public class EpubNavigatorFragment internal constructor( webView.scrollToEnd() } } else { - if (currentPagerPosition < position) { - // handle swipe LEFT - webView.setCurrentItem(0, false) - } else if (currentPagerPosition > position) { - // handle swipe RIGHT - webView.setCurrentItem(webView.numPages - 1, false) + if (!hasPresetScrollPosition) { + if (currentPagerPosition < position) { + webView.setCurrentItem(0, false) + } else if (currentPagerPosition > position) { + webView.setCurrentItem(webView.numPages - 1, false) + } } } } - currentPagerPosition = position // Update current position + + currentPagerPosition = position notifyCurrentLocation() } @@ -474,23 +554,23 @@ public class EpubNavigatorFragment internal constructor( private fun resetResourcePagerAdapter() { adapter = when (publication.metadata.presentation.layout) { EpubLayout.REFLOWABLE, null -> { - R2PagerAdapter(childFragmentManager, resourcesSingle) + R2PagerAdapter(this, resourcesSingle) } EpubLayout.FIXED -> { when (viewModel.dualPageMode) { // FIXME: Properly implement DualPage.AUTO depending on the device orientation. DualPage.OFF, DualPage.AUTO -> { - R2PagerAdapter(childFragmentManager, resourcesSingle) + R2PagerAdapter(this, resourcesSingle) } DualPage.ON -> { - R2PagerAdapter(childFragmentManager, resourcesDouble) + R2PagerAdapter(this, resourcesDouble) } } } } adapter.listener = PagerAdapterListener() - resourcePager.adapter = adapter - resourcePager.direction = overflow.value.readingProgression + resourcePager.setAdapter(adapter) + resourcePager.readingProgression = overflow.value.readingProgression resourcePager.layoutDirection = when (settings.value.readingProgression) { ReadingProgression.RTL -> LayoutDirection.RTL ReadingProgression.LTR -> LayoutDirection.LTR @@ -625,11 +705,13 @@ public class EpubNavigatorFragment internal constructor( else -> false } } ?: return + val (index, _) = page if (resourcePager.currentItem != index) { - resourcePager.currentItem = index + resourcePager.setCurrentItem(index, false) } + r2PagerAdapter?.loadLocatorAt(index, locator) } @@ -903,7 +985,7 @@ public class EpubNavigatorFragment internal constructor( private fun goToNextResource(jump: Boolean, animated: Boolean): Boolean { val adapter = resourcePager.adapter ?: return false - if (resourcePager.currentItem >= adapter.count - 1) { + if (resourcePager.currentItem >= adapter.itemCount - 1) { return false } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt index 5da9b86b9b..594ea530d1 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/image/ImageNavigatorFragment.kt @@ -15,7 +15,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentFactory -import androidx.viewpager.widget.ViewPager +import androidx.viewpager2.widget.ViewPager2 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -106,7 +106,7 @@ public class ImageNavigatorFragment private constructor( positions = runBlocking { publication.positions() } - resourcePager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() { + resourcePager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { notifyCurrentLocation() } @@ -114,20 +114,20 @@ public class ImageNavigatorFragment private constructor( val resources = publication.readingOrder .map { R2PagerAdapter.PageResource.Cbz(it) } - adapter = R2PagerAdapter(childFragmentManager, resources) + adapter = R2PagerAdapter(this, resources) - resourcePager.adapter = adapter + resourcePager.setAdapter(adapter) if (currentPagerPosition == 0) { if (requireActivity().layoutDirectionIsRTL()) { // The view has RTL layout - resourcePager.currentItem = resources.size - 1 + resourcePager.setCurrentItem(resources.size - 1, false) } else { // The view has LTR layout - resourcePager.currentItem = currentPagerPosition + resourcePager.setCurrentItem(currentPagerPosition, false) } } else { - resourcePager.currentItem = currentPagerPosition + resourcePager.setCurrentItem(currentPagerPosition, false) } if (initialLocator != null) { @@ -175,7 +175,7 @@ public class ImageNavigatorFragment private constructor( listener?.onJumpToLocator(locator) currentPagerPosition = resourceIndex - resourcePager.currentItem = currentPagerPosition + resourcePager.setCurrentItem(currentPagerPosition, false) return true } @@ -187,13 +187,7 @@ public class ImageNavigatorFragment private constructor( override fun goForward(animated: Boolean): Boolean { val current = resourcePager.currentItem - if (requireActivity().layoutDirectionIsRTL()) { - // The view has RTL layout - resourcePager.currentItem = current - 1 - } else { - // The view has LTR layout - resourcePager.currentItem = current + 1 - } + resourcePager.setCurrentItem(current + 1, false) notifyCurrentLocation() return current != resourcePager.currentItem @@ -201,13 +195,7 @@ public class ImageNavigatorFragment private constructor( override fun goBackward(animated: Boolean): Boolean { val current = resourcePager.currentItem - if (requireActivity().layoutDirectionIsRTL()) { - // The view has RTL layout - resourcePager.currentItem = current + 1 - } else { - // The view has LTR layout - resourcePager.currentItem = current - 1 - } + resourcePager.setCurrentItem(current - 1, false) notifyCurrentLocation() return current != resourcePager.currentItem diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2CbzPageFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2CbzPageFragment.kt index eecc5786c6..f944df9c92 100755 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2CbzPageFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2CbzPageFragment.kt @@ -88,7 +88,13 @@ internal class R2CbzPageFragment( private fun updatePadding() { viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + // Due to the migration from ViewPager to ViewPager2, + // adjacent pages now transition to the RESUMED state at onPageSelected, + // unlike the previous behavior. + // Therefore, changing the lifecycle state from RESUMED to STARTED + // allows padding to be pre-applied to the left and right pages, + // ensuring consistent UI behavior during page transitions. + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { val window = activity?.window ?: return@repeatOnLifecycle var top = 0 var bottom = 0 diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt index 97348b11e8..0e72f17cfe 100755 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt @@ -348,7 +348,13 @@ internal class R2EpubPageFragment : Fragment() { if (view == null) return viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + // Due to the migration from ViewPager to ViewPager2, + // adjacent pages now transition to the RESUMED state at onPageSelected, + // unlike the previous behavior. + // Therefore, changing the lifecycle state from RESUMED to STARTED + // allows padding to be pre-applied to the left and right pages, + // ensuring consistent UI behavior during page transitions. + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { val window = activity?.window ?: return@repeatOnLifecycle var top = 0 var bottom = 0 @@ -509,6 +515,7 @@ internal class R2EpubPageFragment : Fragment() { /** * Same as setOnClickListener, but will also report the tap point in the view. */ +@SuppressLint("ClickableViewAccessibility") private fun View.setOnClickListenerWithPoint(action: (View, PointF) -> Unit) { var point = PointF() diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2FragmentPagerAdapter.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2FragmentPagerAdapter.kt index 6f5871e996..31cfa66c5a 100755 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2FragmentPagerAdapter.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2FragmentPagerAdapter.kt @@ -9,175 +9,59 @@ package org.readium.r2.navigator.pager -import android.annotation.SuppressLint -import android.os.Bundle -import android.os.Parcelable -import android.view.View -import android.view.ViewGroup import androidx.collection.LongSparseArray -import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentTransaction -import androidx.viewpager.widget.PagerAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter -// This class will be going away when the navigator is rewritten -@Suppress("DEPRECATION") -internal abstract class R2FragmentPagerAdapter(private val mFragmentManager: FragmentManager) : androidx.fragment.app.FragmentStatePagerAdapter( - mFragmentManager, - BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT -) { +@Suppress("PROPERTY_HIDES_JAVA_FIELD") +internal abstract class R2FragmentPagerAdapter( + private val fragment: Fragment, +) : FragmentStateAdapter(fragment) { - val mFragments = LongSparseArray() - private val mSavedStates = LongSparseArray() - private var mCurTransaction: FragmentTransaction? = null - private var mCurrentPrimaryItem: Fragment? = null - - abstract override fun getItem(position: Int): Fragment - - override fun startUpdate(container: ViewGroup) { - if (container.id == View.NO_ID) { - throw IllegalStateException("ViewPager with adapter $this requires a view id") - } - } - - @SuppressLint("CommitTransaction") - override fun instantiateItem(container: ViewGroup, position: Int): Any { - val tag = getItemId(position) - var fragment: Fragment? = mFragments.get(tag) + // Keep FragmentManager for backward compatibility + protected val mFragmentManager: FragmentManager = fragment.childFragmentManager - if (fragment != null) { - return fragment - } - - if (mCurTransaction == null) { - mCurTransaction = mFragmentManager.beginTransaction() - } + val mFragments = LongSparseArray() - fragment = getItem(position) + abstract fun getItem(position: Int): Fragment - val savedState = mSavedStates.get(tag) - if (savedState != null) { - fragment.setInitialSavedState(savedState) - } - fragment.setMenuVisibility(false) - fragment.userVisibleHint = false - mFragments.put(tag, fragment) - mCurTransaction!!.add(container.id, fragment, "f$tag") + override fun createFragment(position: Int): Fragment { + val fragment = getItem(position) + val itemId = getItemId(position) + mFragments.put(itemId, fragment) return fragment } - @SuppressLint("CommitTransaction") - override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { - val fragment = `object` as Fragment - val currentPosition = getItemPosition(fragment) - - val index = mFragments.indexOfValue(fragment) - var fragmentKey: Long = -1 - if (index != -1) { - fragmentKey = mFragments.keyAt(index) - mFragments.removeAt(index) - } - - if (fragment.isAdded && currentPosition != PagerAdapter.POSITION_NONE) { - mFragmentManager.saveFragmentInstanceState(fragment)?.let { state -> - mSavedStates.put(fragmentKey, state) - } - } else { - mSavedStates.remove(fragmentKey) - } - - if (mCurTransaction == null) { - mCurTransaction = mFragmentManager.beginTransaction() - } - - mCurTransaction!!.remove(fragment) - } - - override fun setPrimaryItem(container: ViewGroup, position: Int, `object`: Any) { - val fragment = `object` as Fragment? - if (fragment !== mCurrentPrimaryItem) { - if (mCurrentPrimaryItem != null) { - mCurrentPrimaryItem!!.setMenuVisibility(false) - mCurrentPrimaryItem!!.userVisibleHint = false - } - if (fragment != null) { - fragment.setMenuVisibility(true) - fragment.userVisibleHint = true - } - mCurrentPrimaryItem = fragment - } - } - - override fun finishUpdate(container: ViewGroup) { - if (mCurTransaction != null) { - mCurTransaction!!.commitNowAllowingStateLoss() - mCurTransaction = null - } + override fun getItemId(position: Int): Long { + return position.toLong() } - override fun isViewFromObject(view: View, `object`: Any): Boolean { - return (`object` as Fragment).view === view + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + // Allow subclasses to handle state restoration after RecyclerView is attached + onRecyclerViewAttached() } - override fun saveState(): Parcelable? { - var state: Bundle? = null - if (mSavedStates.size() > 0) { - state = Bundle() - val stateIds = LongArray(mSavedStates.size()) - for (i in 0 until mSavedStates.size()) { - val entry = mSavedStates.valueAt(i) - stateIds[i] = mSavedStates.keyAt(i) - state.putParcelable(stateIds[i].toString(), entry) - } - state.putLongArray("states", stateIds) - } - for (i in 0 until mFragments.size()) { - val f = mFragments.valueAt(i) - if (f.isAdded) { - if (state == null) { - state = Bundle() - } - val key = "f" + mFragments.keyAt(i) - mFragmentManager.putFragment(state, key, f) - } - } - return state + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) } - override fun restoreState(state: Parcelable?, loader: ClassLoader?) { - if (state != null) { - val bundle = state as Bundle? - bundle!!.classLoader = loader - val fss = bundle.getLongArray("states") - mSavedStates.clear() - mFragments.clear() - if (fss != null) { - for (fs in fss) { - BundleCompat.getParcelable( - bundle, - fs.toString(), - Fragment.SavedState::class.java - )?.let { st -> - mSavedStates.put(fs, st) - } - } - } - val keys = bundle.keySet() - for (key in keys) { - if (key.startsWith("f")) { - val f = mFragmentManager.getFragment(bundle, key) - if (f != null) { - f.setMenuVisibility(false) - mFragments.put(java.lang.Long.parseLong(key.substring(1)), f) - } - } - } - } + /** + * Called when the adapter is attached to RecyclerView. + * Subclasses can override this to handle state restoration. + */ + protected open fun onRecyclerViewAttached() { + // Default implementation does nothing } - fun getItemId(position: Int): Long { - return position.toLong() + /** + * Utility method to get fragment by position if it exists + */ + protected fun getFragmentAtPosition(position: Int): Fragment? { + val id = getItemId(position) + return mFragments[id] } } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt index 3a88e4a22f..ce83d5f93e 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt @@ -10,12 +10,8 @@ package org.readium.r2.navigator.pager import android.os.Bundle -import android.os.Parcelable -import android.view.ViewGroup import androidx.collection.LongSparseArray -import androidx.collection.forEach import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import org.readium.r2.navigator.extensions.let import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Locator @@ -23,9 +19,9 @@ import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.Url internal class R2PagerAdapter internal constructor( - val fm: FragmentManager, + fragment: Fragment, private val resources: List, -) : R2FragmentPagerAdapter(fm) { +) : R2FragmentPagerAdapter(fragment) { internal interface Listener { fun onCreatePageFragment(fragment: Fragment) {} @@ -48,33 +44,18 @@ internal class R2PagerAdapter internal constructor( private var previousFragment: Fragment? = null private var nextFragment: Fragment? = null - fun getCurrentFragment(): Fragment? { - return currentFragment - } - - fun getPreviousFragment(): Fragment? { - return previousFragment - } + fun getCurrentFragment(): Fragment? = currentFragment + fun getPreviousFragment(): Fragment? = previousFragment + fun getNextFragment(): Fragment? = nextFragment - fun getNextFragment(): Fragment? { - return nextFragment - } - - override fun setPrimaryItem(container: ViewGroup, position: Int, `object`: Any) { - if (getCurrentFragment() !== `object`) { - currentFragment = `object` as Fragment - nextFragment = mFragments.get(getItemId(position + 1)) - previousFragment = mFragments.get(getItemId(position - 1)) - } - super.setPrimaryItem(container, position, `object`) - } - - internal fun getResource(position: Int): PageResource? = - resources.getOrNull(position) + internal fun getResource(position: Int): PageResource? = resources.getOrNull(position) override fun getItem(position: Int): Fragment { val locator = popPendingLocatorAt(getItemId(position)) - val fragment = when (val resource = resources[position]) { + + val resource = resources[position] + + val fragment = when (resource) { is PageResource.EpubReflowable -> { R2EpubPageFragment.newInstance( resource.url, @@ -90,41 +71,45 @@ internal class R2PagerAdapter internal constructor( ) } is PageResource.Cbz -> { - fm.fragmentFactory + mFragmentManager.fragmentFactory .instantiate( ClassLoader.getSystemClassLoader(), R2CbzPageFragment::class.java.name - ) - .also { - it.arguments = Bundle().apply { + ).apply { + arguments = Bundle().apply { putParcelable("link", resource.link) } } } } + listener?.onCreatePageFragment(fragment) return fragment } - override fun getCount(): Int { - return resources.size - } + override fun getItemCount(): Int = resources.size + + private val pendingLocators = LongSparseArray() - override fun restoreState(state: Parcelable?, loader: ClassLoader?) { - super.restoreState(state, loader) + override fun onRecyclerViewAttached() { + super.onRecyclerViewAttached() + // Restore pending locators to fragments that are now available + restorePendingLocators() + } - pendingLocators.forEach { i, locator -> - (mFragments.get(i) as? R2EpubPageFragment)?.loadLocator(locator) + private fun restorePendingLocators() { + // Process all pending locators and apply them to existing fragments + for (i in 0 until pendingLocators.size()) { + val id = pendingLocators.keyAt(i) + val locator = pendingLocators.valueAt(i) + val fragment = mFragments.get(id) + if (fragment != null) { + (fragment as? R2EpubPageFragment)?.loadLocator(locator) + } } - pendingLocators.clear() + // Don't clear here as fragments might not be ready yet } - private val pendingLocators = LongSparseArray() - - /** - * Loads the given [Locator] in the page fragment at the given position. If not loaded, it - * will be used when the fragment will be created. - */ internal fun loadLocatorAt(position: Int, locator: Locator) { val id = getItemId(position) val fragment = mFragments.get(id) @@ -132,10 +117,29 @@ internal class R2PagerAdapter internal constructor( pendingLocators.put(id, locator) } else { (fragment as? R2EpubPageFragment)?.loadLocator(locator) + // Remove from pending since it's been applied + pendingLocators.remove(id) + } + } + + /** + * Force restoration of pending locators - useful after configuration changes + */ + internal fun restoreState() { + restorePendingLocators() + // Clear pending locators that have been successfully applied + val toRemove = mutableListOf() + for (i in 0 until pendingLocators.size()) { + val id = pendingLocators.keyAt(i) + val fragment = mFragments[id] + if (fragment != null) { + toRemove.add(id) + } } + toRemove.forEach { pendingLocators.remove(it) } } private fun popPendingLocatorAt(id: Long): Locator? = - pendingLocators.get(id) + pendingLocators[id] .also { pendingLocators.remove(id) } } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager2.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager2.kt new file mode 100644 index 0000000000..80d89a20fc --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager2.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2018 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.navigator.pager + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import kotlin.math.abs +import org.readium.r2.navigator.R2BasicWebView +import org.readium.r2.navigator.preferences.ReadingProgression + +/** + * R2RTLViewPager2 is a wrapper around ViewPager2 that supports RTL (Right-to-Left) reading progression + * and can be configured for both horizontal and vertical orientation. + * + * This class handles RTL layout direction based on ReadingProgression setting and provides + * proper page ordering for different reading directions. It also intercepts touch events + * when WebView cannot scroll horizontally to enable ViewPager2 swipe functionality. + */ +public open class R2RTLViewPager2 @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + // Internal ViewPager2 instance + protected val viewPager2: ViewPager2 = ViewPager2(context) + + // Reading progression direction (LTR or RTL) + public var readingProgression: ReadingProgression = ReadingProgression.LTR + + // Current layout direction + private var mLayoutDirection = LAYOUT_DIRECTION_LTR + + // Touch event handling - store initial state for consistent behavior + private var initialX = 0f + private var initialY = 0f + private var initialCanScrollLeft = false + private var initialCanScrollRight = false + private var initialCanScrollUp = false + private var initialCanScrollDown = false + + init { + viewPager2.offscreenPageLimit = 1 + // Add ViewPager2 to this container + addView(viewPager2, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + // Delegate properties and methods to internal ViewPager2 + public var adapter: RecyclerView.Adapter<*>? + get() = viewPager2.adapter + set(value) { + viewPager2.adapter = value + } + + @ViewPager2.Orientation + private var orientation: Int + get() = viewPager2.orientation + set(value) { + if (value == ViewPager2.ORIENTATION_VERTICAL || value == ViewPager2.ORIENTATION_HORIZONTAL) { + viewPager2.orientation = value + optimizeForOrientation(value) + } + } + + public val currentItem: Int + get() = viewPager2.currentItem + + @SuppressLint("NotifyDataSetChanged") + override fun onRtlPropertiesChanged(layoutDirection: Int) { + super.onRtlPropertiesChanged(layoutDirection) + + // Override with reading progression setting + val finalLayoutDirection = if (readingProgression == ReadingProgression.RTL) { + LAYOUT_DIRECTION_RTL + } else { + LAYOUT_DIRECTION_LTR + } + + if (finalLayoutDirection != mLayoutDirection) { + val currentPosition = currentItem + mLayoutDirection = finalLayoutDirection + + // Update ViewPager2's layout direction + viewPager2.layoutDirection = finalLayoutDirection + + // Notify adapter of data change and restore position + adapter?.notifyDataSetChanged() + setCurrentItem(currentPosition, false) + } + } + + public fun isRtl(): Boolean { + return mLayoutDirection == LAYOUT_DIRECTION_RTL + } + + public fun setCurrentItem(item: Int, smoothScroll: Boolean) { + val position = item + viewPager2.setCurrentItem(position, smoothScroll) + } + + public fun setCurrentItem(item: Int) { + setCurrentItem(item, true) + } + + public fun registerOnPageChangeCallback(callback: ViewPager2.OnPageChangeCallback) { + viewPager2.registerOnPageChangeCallback(callback) + } + + public fun unregisterOnPageChangeCallback(callback: ViewPager2.OnPageChangeCallback) { + viewPager2.unregisterOnPageChangeCallback(callback) + } + + /** + * State saving and restoration + */ + public class SavedState : BaseSavedState { + private val layoutDirection: Int + + public constructor(superState: Parcelable?, layoutDirection: Int) : super(superState) { + this.layoutDirection = layoutDirection + } + + private constructor(parcel: Parcel) : super(parcel) { + layoutDirection = parcel.readInt() + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + super.writeToParcel(parcel, flags) + parcel.writeInt(layoutDirection) + } + + public companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + public fun getLayoutDirection(): Int = layoutDirection + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(superState, mLayoutDirection) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + + super.onRestoreInstanceState(state.superState) + mLayoutDirection = state.getLayoutDirection() + } + + /** + * Configure ViewPager2 for vertical scrolling + */ + public fun configureForVerticalScrolling() { + orientation = ViewPager2.ORIENTATION_VERTICAL + } + + /** + * Configure ViewPager2 for horizontal paging + */ + public fun configureForHorizontalPaging() { + orientation = ViewPager2.ORIENTATION_HORIZONTAL + } + + /** + * Optimize ViewPager2 settings based on orientation + */ + private fun optimizeForOrientation(@ViewPager2.Orientation orientation: Int) { + (viewPager2.getChildAt(0) as? RecyclerView)?.let { recyclerView -> + when (orientation) { + ViewPager2.ORIENTATION_VERTICAL -> { + // Optimize for vertical scrolling + recyclerView.layoutManager?.isItemPrefetchEnabled = false + } + + ViewPager2.ORIENTATION_HORIZONTAL -> { + // Standard settings for horizontal scrolling + recyclerView.layoutManager?.isItemPrefetchEnabled = true + } + } + } + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + initialX = event.x + initialY = event.y + + // Get real-time scroll capability from current WebView + val currentWebView = findCurrentWebView() + initialCanScrollLeft = currentWebView?.canScrollHorizontally(-1) ?: false + initialCanScrollRight = currentWebView?.canScrollHorizontally(1) ?: false + initialCanScrollUp = currentWebView?.canScrollVertically(-1) ?: false + initialCanScrollDown = currentWebView?.canScrollVertically(1) ?: false + } + + MotionEvent.ACTION_MOVE -> { + val currentX = event.x + val currentY = event.y + val diffX = abs(currentX - initialX) + val diffY = abs(currentY - initialY) + + when (orientation) { + ViewPager2.ORIENTATION_HORIZONTAL -> { + // Handle horizontal swipes for horizontal orientation + if (diffX > diffY && diffX > 10) { + val isSwipeLeft = currentX < initialX + + val shouldIntercept = if (isSwipeLeft) { + // Swiping left - intercept only if WebView cannot scroll right + !initialCanScrollRight + } else { + // Swiping right - intercept only if WebView cannot scroll left + !initialCanScrollLeft + } + + viewPager2.isUserInputEnabled = shouldIntercept + } + } + + ViewPager2.ORIENTATION_VERTICAL -> { + // For vertical orientation, allow ViewPager2 to handle vertical swipes + // but let WebView handle horizontal scrolls if needed + if (diffY > diffX && diffY > 10) { + val isScrollUp = currentY < initialY + + val shouldIntercept = if (isScrollUp) { + // Scrolling up - intercept only if WebView cannot scroll down + !initialCanScrollDown + } else { + // Scrolling down - intercept only if WebView cannot scroll up + !initialCanScrollUp + } + + viewPager2.isUserInputEnabled = shouldIntercept + } + } + } + } + } + return super.onInterceptTouchEvent(event) + } + + /** + * Attempts to find the currently active WebView inside the current visible ViewPager2 page. + */ + private fun findCurrentWebView(): WebView? { + val recyclerView = viewPager2.getChildAt(0) as? RecyclerView ?: return null + val layoutManager = recyclerView.layoutManager ?: return null + + // Get current ViewPager2 position + val currentPosition = viewPager2.currentItem + + // Try to find view by position using LayoutManager + val currentView = layoutManager.findViewByPosition(currentPosition) + if (currentView != null) { + val webView = findWebViewInView(currentView) + if (webView != null) { + return webView + } + } + + // Fallback: iterate through child views and match adapter position + for (i in 0 until recyclerView.childCount) { + val childView = recyclerView.getChildAt(i) + val viewHolder = recyclerView.getChildViewHolder(childView) + val adapterPosition = viewHolder.bindingAdapterPosition + + if (adapterPosition == currentPosition) { + val webView = findWebViewInView(childView) + if (webView != null) { + return webView + } + } + } + + return null + } + + /** + * Recursively search for a WebView (or subclass) in a view hierarchy. + */ + private fun findWebViewInView(view: View): WebView? { + if (view is R2BasicWebView) return view + if (view is WebView) return view + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + val found = findWebViewInView(child) + if (found != null) return found + } + } + return null + } +} diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2ViewPager.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2ViewPager.kt index b16cb3853c..32ec981e45 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2ViewPager.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2ViewPager.kt @@ -9,14 +9,10 @@ package org.readium.r2.navigator.pager -import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet -import android.view.MotionEvent -import org.readium.r2.navigator.BuildConfig.DEBUG -import timber.log.Timber -internal class R2ViewPager : R2RTLViewPager { +internal class R2ViewPager : R2RTLViewPager2 { internal enum class PublicationType { EPUB, @@ -32,52 +28,37 @@ internal class R2ViewPager : R2RTLViewPager { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - override fun setCurrentItem(item: Int) { - super.setCurrentItem(item, false) - } + /** + * Set adapter with automatic state restoration + */ + fun setAdapter(newAdapter: R2PagerAdapter?) { + // Use parent's adapter property instead of direct assignment + super.adapter = newAdapter - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(ev: MotionEvent): Boolean { - if (DEBUG) Timber.d("ev.action ${ev.action}") - if (publicationType == PublicationType.EPUB) { - when (ev.action and MotionEvent.ACTION_MASK) { - MotionEvent.ACTION_DOWN -> { - // prevent swipe from view pager directly - if (DEBUG) Timber.d("ACTION_DOWN") - return false - } + if (newAdapter != null) { + // Post to next frame to ensure ViewPager2 is fully initialized + post { + restoreAdapterState() } } + } - return try { - // The super implementation sometimes triggers: - // java.lang.IllegalArgumentException: pointerIndex out of range - // i.e. https://stackoverflow.com/q/48496257/1474476 - return super.onTouchEvent(ev) - } catch (ex: IllegalArgumentException) { - Timber.e(ex) - false + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Restore state when view is attached to window (e.g., after configuration change) + post { + restoreAdapterState() } } - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - if (publicationType == PublicationType.EPUB) { - when (ev.action and MotionEvent.ACTION_MASK) { - MotionEvent.ACTION_DOWN -> { - // prevent swipe from view pager directly - return false - } - } - } + // Cast adapter to R2PagerAdapter for convenience + val r2Adapter: R2PagerAdapter? + get() = adapter as? R2PagerAdapter - return try { - // The super implementation sometimes triggers: - // java.lang.IllegalArgumentException: pointerIndex out of range - // i.e. https://stackoverflow.com/q/48496257/1474476 - super.onInterceptTouchEvent(ev) - } catch (ex: IllegalArgumentException) { - Timber.e(ex) - false - } + /** + * Restore pending state after configuration changes or app restoration + */ + fun restoreAdapterState() { + r2Adapter?.restoreState() } } From 0def8e2eafef0a2b8e8ebc241cb299f85eea695b Mon Sep 17 00:00:00 2001 From: erkas Date: Tue, 12 Aug 2025 11:23:32 +0900 Subject: [PATCH 2/2] refactor: remove redundant fragment tracking and padding property for cleaner viewpager2 integration --- .../org/readium/r2/navigator/pager/R2EpubPageFragment.kt | 7 ------- .../java/org/readium/r2/navigator/pager/R2PagerAdapter.kt | 8 -------- 2 files changed, 15 deletions(-) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt index 0e72f17cfe..1c7e1f5724 100755 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2EpubPageFragment.kt @@ -387,13 +387,6 @@ internal class R2EpubPageFragment : Fragment() { } internal val paddingTop: Int get() = containerView.paddingTop - internal val paddingBottom: Int get() = containerView.paddingBottom - - private val isCurrentResource: Boolean get() { - val epubNavigator = navigator ?: return false - val currentFragment = (epubNavigator.resourcePager.adapter as? R2PagerAdapter)?.getCurrentFragment() as? R2EpubPageFragment ?: return false - return tag == currentFragment.tag - } private fun onLoadPage() { if (!isLoading) return diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt index ce83d5f93e..9d9883241b 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2PagerAdapter.kt @@ -40,14 +40,6 @@ internal class R2PagerAdapter internal constructor( data class Cbz(val link: Link) : PageResource() } - private var currentFragment: Fragment? = null - private var previousFragment: Fragment? = null - private var nextFragment: Fragment? = null - - fun getCurrentFragment(): Fragment? = currentFragment - fun getPreviousFragment(): Fragment? = previousFragment - fun getNextFragment(): Fragment? = nextFragment - internal fun getResource(position: Int): PageResource? = resources.getOrNull(position) override fun getItem(position: Int): Fragment {