diff --git a/readium/navigator/build.gradle.kts b/readium/navigator/build.gradle.kts index f9f7f75f6f..4c2feb0958 100644 --- a/readium/navigator/build.gradle.kts +++ b/readium/navigator/build.gradle.kts @@ -85,7 +85,6 @@ dependencies { implementation(libs.androidx.webkit) // Needed to avoid a crash with API 31, see https://stackoverflow.com/a/69152986/1474476 implementation("androidx.work:work-runtime-ktx:2.7.1") - implementation("com.duolingo.open:rtl-viewpager:1.0.3") // ChrisBane/PhotoView ( for the Zoom handling ) implementation(libs.photoview) @@ -94,7 +93,6 @@ dependencies { api(libs.bundles.exoplayer) implementation(libs.google.material) implementation(libs.timber) - implementation("com.shopgun.android:utils:1.0.9") implementation(libs.joda.time) implementation(libs.bundles.coroutines) implementation(libs.kotlinx.serialization.json) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/fxl/R2FXLLayout.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/fxl/R2FXLLayout.kt index 291f894ff9..d91fd2ae5d 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/fxl/R2FXLLayout.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/fxl/R2FXLLayout.kt @@ -21,8 +21,8 @@ import android.view.animation.DecelerateInterpolator import android.view.animation.Interpolator import android.widget.FrameLayout import androidx.core.view.ViewCompat -import com.shopgun.android.utils.NumberUtils import java.util.* +import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -94,12 +94,15 @@ class R2FXLLayout : FrameLayout { private var mOnDoubleTapListeners: MutableList? = null private var onLongTapListeners: MutableList? = null + private fun Float.equalsDelta(other: Float, delta: Float = 0.001f) = + this == other || abs(this - other) < delta + var scale: Float get() = getMatrixValue(scaleMatrix, Matrix.MSCALE_X) set(scale) = setScale(scale, true) val isScaled: Boolean - get() = !NumberUtils.isEqual(scale, 1.0f, 0.05f) + get() = !scale.equalsDelta(1.0f, 0.05f) private val translateDeltaBounds: RectF get() { @@ -317,8 +320,8 @@ class R2FXLLayout : FrameLayout { override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { val scale = scale - val newScale = NumberUtils.clamp(minScale, scale, maxScale) - if (NumberUtils.isEqual(newScale, scale)) { + val newScale = scale.coerceIn(minScale, maxScale) + if (newScale.equalsDelta(scale)) { // only fling if no scale is needed - scale will happen on ACTION_UP flingRunnable = FlingRunnable(context) flingRunnable!!.fling(velocityX.toInt(), velocityY.toInt()) @@ -424,7 +427,7 @@ class R2FXLLayout : FrameLayout { } fixFocusPoint(focusX, focusY) if (!isAllowOverScale) { - newScale = NumberUtils.clamp(minScale, newScale, maxScale) + newScale = newScale.coerceIn(minScale, maxScale) } if (animate) { animatedZoomRunnable = AnimatedZoomRunnable() @@ -443,12 +446,12 @@ class R2FXLLayout : FrameLayout { var tdy = dy if (clamp) { val bounds = translateDeltaBounds - tdx = NumberUtils.clamp(bounds.left, dx, bounds.right) - tdy = NumberUtils.clamp(bounds.top, dy, bounds.bottom) + tdx = dx.coerceIn(bounds.left, bounds.right) + tdy = dy.coerceIn(bounds.top, bounds.bottom) } val destPosX = tdx + posX val destPosY = tdy + posY - if (!NumberUtils.isEqual(destPosX, posX) || !NumberUtils.isEqual(destPosY, posY)) { + if (!destPosX.equalsDelta(posX) || !destPosY.equalsDelta(posY)) { translateMatrix.setTranslate(-destPosX, -destPosY) matrixUpdated() invalidate() @@ -519,16 +522,16 @@ class R2FXLLayout : FrameLayout { private var mTargetY: Float = 0.toFloat() internal fun doScale(): Boolean { - return !NumberUtils.isEqual(mZoomStart, mZoomEnd) + return !mZoomStart.equalsDelta(mZoomEnd) } internal fun doTranslate(): Boolean { - return !NumberUtils.isEqual(mStartX, mTargetX) || !NumberUtils.isEqual(mStartY, mTargetY) + return !mStartX.equalsDelta(mTargetX) || !mStartY.equalsDelta(mTargetY) } internal fun runValidation(): Boolean { val scale = scale - val newScale = NumberUtils.clamp(minScale, scale, maxScale) + val newScale = scale.coerceIn(minScale, maxScale) scale(scale, newScale, focusX, focusY, true) if (animatedZoomRunnable!!.doScale() || animatedZoomRunnable!!.doTranslate()) { ViewCompat.postOnAnimation(this@R2FXLLayout, animatedZoomRunnable!!) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/DelegatingPagerAdapter.java b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/DelegatingPagerAdapter.java new file mode 100644 index 0000000000..ef34b16c21 --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/DelegatingPagerAdapter.java @@ -0,0 +1,173 @@ +package org.readium.r2.navigator.pager; + +/* + * Copyright 2016–2020 Duolingo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.database.DataSetObserver; +import android.os.Parcelable; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.viewpager.widget.PagerAdapter; + +public class DelegatingPagerAdapter extends PagerAdapter { + + private final PagerAdapter mDelegate; + + DelegatingPagerAdapter(@NonNull final PagerAdapter delegate) { + this.mDelegate = delegate; + delegate.registerDataSetObserver(new MyDataSetObserver(this)); + } + + PagerAdapter getDelegate() { + return mDelegate; + } + + @Override + public int getCount() { + return mDelegate.getCount(); + } + + @Override + public void startUpdate(@NonNull ViewGroup container) { + mDelegate.startUpdate(container); + } + + @Override + public @NonNull + Object instantiateItem(@NonNull ViewGroup container, int position) { + return mDelegate.instantiateItem(container, position); + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + mDelegate.destroyItem(container, position, object); + } + + @Override + public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + mDelegate.setPrimaryItem(container, position, object); + } + + @Override + public void finishUpdate(@NonNull ViewGroup container) { + mDelegate.finishUpdate(container); + } + + @Deprecated + @Override + public void startUpdate(@NonNull View container) { + mDelegate.startUpdate(container); + } + + @Deprecated + @Override + public @NonNull + Object instantiateItem(@NonNull View container, int position) { + return mDelegate.instantiateItem(container, position); + } + + @Deprecated + @Override + public void destroyItem(@NonNull View container, int position, @NonNull Object object) { + mDelegate.destroyItem(container, position, object); + } + + @Deprecated + @Override + public void setPrimaryItem(@NonNull View container, int position, @NonNull Object object) { + mDelegate.setPrimaryItem(container, position, object); + } + + @Deprecated + @Override + public void finishUpdate(@NonNull View container) { + mDelegate.finishUpdate(container); + } + + @Override + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { + return mDelegate.isViewFromObject(view, object); + } + + @Override + public Parcelable saveState() { + return mDelegate.saveState(); + } + + @Override + public void restoreState(Parcelable state, ClassLoader loader) { + mDelegate.restoreState(state, loader); + } + + @Override + public int getItemPosition(@NonNull Object object) { + return mDelegate.getItemPosition(object); + } + + @Override + public void notifyDataSetChanged() { + mDelegate.notifyDataSetChanged(); + } + + @Override + public void registerDataSetObserver(@NonNull DataSetObserver observer) { + mDelegate.registerDataSetObserver(observer); + } + + @Override + public void unregisterDataSetObserver(@NonNull DataSetObserver observer) { + mDelegate.unregisterDataSetObserver(observer); + } + + @Override + public CharSequence getPageTitle(int position) { + return mDelegate.getPageTitle(position); + } + + @Override + public float getPageWidth(int position) { + return mDelegate.getPageWidth(position); + } + + private void superNotifyDataSetChanged() { + super.notifyDataSetChanged(); + } + + private static class MyDataSetObserver extends DataSetObserver { + + final DelegatingPagerAdapter mParent; + + private MyDataSetObserver(DelegatingPagerAdapter mParent) { + this.mParent = mParent; + } + + @Override + public void onChanged() { + if (mParent != null) { + mParent.superNotifyDataSetChanged(); + } + } + + @Override + public void onInvalidated() { + onChanged(); + } + + } + +} diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager.java b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager.java index 16a78dc88c..77deed1d49 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager.java +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/R2RTLViewPager.java @@ -30,8 +30,6 @@ import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; -import com.duolingo.open.rtlviewpager.DelegatingPagerAdapter; - import org.readium.r2.shared.publication.ReadingProgression; import java.util.HashMap; diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pager/RtlViewPager.java b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/RtlViewPager.java new file mode 100644 index 0000000000..d18dc86228 --- /dev/null +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pager/RtlViewPager.java @@ -0,0 +1,370 @@ +package org.readium.r2.navigator.pager; + +/* + * Copyright 2016–2020 Duolingo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import java.util.HashMap; + +/** + * RtlViewPager is an API-compatible implementation of ViewPager which + * orders paged views according to the layout direction of the view. In left to right mode, the + * first view is at the left side of the carousel, and in right to left mode it is at the right + * side. + *

+ * It accomplishes this by wrapping the provided PagerAdapter and any provided + * OnPageChangeListeners so that clients can be agnostic to layout direction and + * modifications are kept internal to RtlViewPager. + */ +public class RtlViewPager extends ViewPager { + + private final HashMap mPageChangeListeners = new HashMap<>(); + private int mLayoutDirection = ViewCompat.LAYOUT_DIRECTION_LTR; + + public RtlViewPager(Context context) { + super(context); + } + + public RtlViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onRtlPropertiesChanged(int layoutDirection) { + super.onRtlPropertiesChanged(layoutDirection); + int viewCompatLayoutDirection = layoutDirection == View.LAYOUT_DIRECTION_RTL ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR; + if (viewCompatLayoutDirection != mLayoutDirection) { + PagerAdapter adapter = super.getAdapter(); + int position = 0; + if (adapter != null) { + position = getCurrentItem(); + } + mLayoutDirection = viewCompatLayoutDirection; + if (adapter != null) { + adapter.notifyDataSetChanged(); + setCurrentItem(position); + } + } + } + + @Override + public void setAdapter(PagerAdapter adapter) { + if (adapter != null) { + adapter = new ReversingAdapter(adapter); + } + super.setAdapter(adapter); + setCurrentItem(0); + } + + @Override + public PagerAdapter getAdapter() { + ReversingAdapter adapter = (ReversingAdapter) super.getAdapter(); + return adapter == null ? null : adapter.getDelegate(); + } + + private boolean isRtl() { + return mLayoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL; + } + + @Override + public int getCurrentItem() { + int item = super.getCurrentItem(); + PagerAdapter adapter = super.getAdapter(); + if (adapter != null && isRtl()) { + item = adapter.getCount() - item - 1; + } + return item; + } + + @Override + public void setCurrentItem(int position, boolean smoothScroll) { + PagerAdapter adapter = super.getAdapter(); + if (adapter != null && isRtl()) { + position = adapter.getCount() - position - 1; + } + super.setCurrentItem(position, smoothScroll); + } + + @Override + public void setCurrentItem(int position) { + PagerAdapter adapter = super.getAdapter(); + if (adapter != null && isRtl()) { + position = adapter.getCount() - position - 1; + } + super.setCurrentItem(position); + } + + @Deprecated + @Override + public void setOnPageChangeListener(@NonNull ViewPager.OnPageChangeListener listener) { + super.setOnPageChangeListener(new ReversingOnPageChangeListener(listener)); + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + return new SavedState(superState, mLayoutDirection); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + SavedState ss = (SavedState) state; + mLayoutDirection = ss.mLayoutDirection; + super.onRestoreInstanceState(ss.mViewPagerSavedState); + } + + public static class SavedState implements Parcelable { + + private final Parcelable mViewPagerSavedState; + private final int mLayoutDirection; + + private SavedState(Parcelable viewPagerSavedState, int layoutDirection) { + mViewPagerSavedState = viewPagerSavedState; + mLayoutDirection = layoutDirection; + } + + private SavedState(Parcel in, ClassLoader loader) { + if (loader == null) { + loader = getClass().getClassLoader(); + } + mViewPagerSavedState = in.readParcelable(loader); + mLayoutDirection = in.readInt(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeParcelable(mViewPagerSavedState, flags); + out.writeInt(mLayoutDirection); + } + + // The `CREATOR` field is used to create the parcelable from a parcel, even though it is never referenced directly. + public static final Parcelable.ClassLoaderCreator CREATOR + = new Parcelable.ClassLoaderCreator() { + + @Override + public SavedState createFromParcel(Parcel source) { + return createFromParcel(source, null); + } + + @Override + public SavedState createFromParcel(Parcel source, ClassLoader loader) { + return new SavedState(source, loader); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + + } + + @Override + public void addOnPageChangeListener(@NonNull OnPageChangeListener listener) { + ReversingOnPageChangeListener reversingListener = new ReversingOnPageChangeListener(listener); + mPageChangeListeners.put(listener, reversingListener); + super.addOnPageChangeListener(reversingListener); + } + + @Override + public void removeOnPageChangeListener(@NonNull OnPageChangeListener listener) { + ReversingOnPageChangeListener reverseListener = mPageChangeListeners.remove(listener); + if (reverseListener != null) { + super.removeOnPageChangeListener(reverseListener); + } + } + + @Override + public void clearOnPageChangeListeners() { + super.clearOnPageChangeListeners(); + mPageChangeListeners.clear(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED) { + int height = 0; + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + child.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + int h = child.getMeasuredHeight(); + if (h > height) { + height = h; + } + } + heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + private class ReversingOnPageChangeListener implements OnPageChangeListener { + + private final OnPageChangeListener mListener; + + ReversingOnPageChangeListener(OnPageChangeListener listener) { + mListener = listener; + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + // The documentation says that `getPageWidth(...)` returns the fraction of the _measured_ width that that page takes up. However, the code seems to + // use the width so we will here too. + final int width = getWidth(); + PagerAdapter adapter = RtlViewPager.super.getAdapter(); + if (isRtl() && adapter != null) { + int count = adapter.getCount(); + int remainingWidth = (int) (width * (1 - adapter.getPageWidth(position))) + positionOffsetPixels; + while (position < count && remainingWidth > 0) { + position += 1; + remainingWidth -= (int) (width * adapter.getPageWidth(position)); + } + position = count - position - 1; + positionOffsetPixels = -remainingWidth; + positionOffset = positionOffsetPixels / (width * adapter.getPageWidth(position)); + } + mListener.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + PagerAdapter adapter = RtlViewPager.super.getAdapter(); + if (isRtl() && adapter != null) { + position = adapter.getCount() - position - 1; + } + mListener.onPageSelected(position); + } + + @Override + public void onPageScrollStateChanged(int state) { + mListener.onPageScrollStateChanged(state); + } + } + + private class ReversingAdapter extends DelegatingPagerAdapter { + + ReversingAdapter(@NonNull PagerAdapter adapter) { + super(adapter); + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + if (isRtl()) { + position = getCount() - position - 1; + } + super.destroyItem(container, position, object); + } + + @Deprecated + @Override + public void destroyItem(@NonNull View container, int position, @NonNull Object object) { + if (isRtl()) { + position = getCount() - position - 1; + } + super.destroyItem(container, position, object); + } + + @Override + public int getItemPosition(@NonNull Object object) { + int position = super.getItemPosition(object); + if (isRtl()) { + if (position == POSITION_UNCHANGED || position == POSITION_NONE) { + // We can't accept POSITION_UNCHANGED when in RTL mode because adding items to the end of the collection adds them to the beginning of the + // ViewPager. Items whose positions do not change from the perspective of the wrapped adapter actually do change from the perspective of + // the ViewPager. + position = POSITION_NONE; + } else { + position = getCount() - position - 1; + } + } + return position; + } + + @Override + public CharSequence getPageTitle(int position) { + if (isRtl()) { + position = getCount() - position - 1; + } + return super.getPageTitle(position); + } + + @Override + public float getPageWidth(int position) { + if (isRtl()) { + position = getCount() - position - 1; + } + return super.getPageWidth(position); + } + + @Override + public @NonNull + Object instantiateItem(@NonNull ViewGroup container, int position) { + if (isRtl()) { + position = getCount() - position - 1; + } + return super.instantiateItem(container, position); + } + + @Deprecated + @Override + public @NonNull + Object instantiateItem(@NonNull View container, int position) { + if (isRtl()) { + position = getCount() - position - 1; + } + return super.instantiateItem(container, position); + } + + @Override + public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + if (isRtl()) { + position = getCount() - position - 1; + } + super.setPrimaryItem(container, position, object); + } + + @Deprecated + @Override + public void setPrimaryItem(@NonNull View container, int position, @NonNull Object object) { + if (isRtl()) { + position = getCount() - position - 1; + } + super.setPrimaryItem(container, position, object); + } + + } +} +