diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java
index 936f70851..b63df4589 100644
--- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java
+++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java
@@ -50,6 +50,7 @@
import app.grapheneos.pdfviewer.databinding.PdfviewerBinding;
import app.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment;
import app.grapheneos.pdfviewer.fragment.JumpToPageFragment;
+import app.grapheneos.pdfviewer.fragment.SetZoomFragment;
import app.grapheneos.pdfviewer.fragment.PasswordPromptFragment;
import app.grapheneos.pdfviewer.ktx.ViewKt;
import app.grapheneos.pdfviewer.loader.DocumentPropertiesAsyncTaskLoader;
@@ -452,12 +453,12 @@ public boolean onTapUp() {
@Override
public void onZoom(float scaleFactor, float focusX, float focusY) {
- zoom(scaleFactor, focusX, focusY, false);
+ onZoomPage(scaleFactor, focusX, focusY, false);
}
@Override
public void onZoomEnd() {
- zoomEnd();
+ onZoomPageEnd();
}
});
@@ -652,7 +653,7 @@ private void shareDocument() {
}
}
- private void zoom(float scaleFactor, float focusX, float focusY, boolean end) {
+ public void onZoomPage(float scaleFactor, float focusX, float focusY, boolean end) {
mZoomRatio = Math.min(Math.max(mZoomRatio * scaleFactor, MIN_ZOOM_RATIO), MAX_ZOOM_RATIO);
mZoomFocusX = focusX;
mZoomFocusY = focusY;
@@ -660,7 +661,7 @@ private void zoom(float scaleFactor, float focusX, float focusY, boolean end) {
invalidateOptionsMenu();
}
- private void zoomEnd() {
+ public void onZoomPageEnd() {
renderPage(1);
}
@@ -730,7 +731,7 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) {
R.id.action_next, R.id.action_previous, R.id.action_first, R.id.action_last,
R.id.action_rotate_clockwise, R.id.action_rotate_counterclockwise,
R.id.action_view_document_properties, R.id.action_share, R.id.action_save_as,
- R.id.action_outline));
+ R.id.action_outline, R.id.action_set_zoom));
if (BuildConfig.DEBUG) {
ids.add(R.id.debug_action_toggle_text_layer_visibility);
ids.add(R.id.debug_action_crash_webview);
@@ -759,6 +760,7 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) {
enableDisableMenuItem(menu.findItem(R.id.action_next), mPage < mNumPages);
enableDisableMenuItem(menu.findItem(R.id.action_previous), mPage > 1);
enableDisableMenuItem(menu.findItem(R.id.action_save_as), mUri != null);
+ enableDisableMenuItem(menu.findItem(R.id.action_set_zoom), mUri != null);
enableDisableMenuItem(menu.findItem(R.id.action_view_document_properties),
mDocumentProperties != null);
@@ -816,6 +818,13 @@ public boolean onOptionsItemSelected(MenuItem item) {
new JumpToPageFragment()
.show(getSupportFragmentManager(), JumpToPageFragment.TAG);
return true;
+ } else if (itemId == R.id.action_set_zoom) {
+ SetZoomFragment zoomFragment = new SetZoomFragment(mZoomRatio, MIN_ZOOM_RATIO, MAX_ZOOM_RATIO);
+ // TODO: horizontally center the zooming focus.
+ // Need to get the coordinates of viewport top-center.
+ // zoomFragment.setZoomFocusX((float) binding.webview.getWidth() / 2);
+ zoomFragment.show(getSupportFragmentManager(), SetZoomFragment.TAG);
+ return true;
} else if (itemId == R.id.action_share) {
shareDocument();
return true;
diff --git a/app/src/main/java/app/grapheneos/pdfviewer/fragment/SetZoomFragment.kt b/app/src/main/java/app/grapheneos/pdfviewer/fragment/SetZoomFragment.kt
new file mode 100644
index 000000000..8e8d828aa
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/pdfviewer/fragment/SetZoomFragment.kt
@@ -0,0 +1,125 @@
+package app.grapheneos.pdfviewer.fragment
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.Gravity
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.SeekBar
+import android.widget.TextView
+import androidx.core.view.marginTop
+import androidx.fragment.app.DialogFragment
+import app.grapheneos.pdfviewer.PdfViewer
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlin.math.ln
+import kotlin.math.pow
+
+class SetZoomFragment(
+ private var mCurrentViewerZoomRatio: Double,
+ private var mMinZoomRatio: Double,
+ private var mMaxZoomRatio: Double,
+) : DialogFragment() {
+
+ companion object {
+ const val TAG = "SetZoomFragment"
+ private const val STATE_SEEKBAR_CUR = "seekbar_cur"
+ private const val STATE_SEEKBAR_MIN = "seekbar_min"
+ private const val STATE_SEEKBAR_MAX = "seekbar_max"
+ private const val STATE_VIEWER_CUR = "viewer_cur"
+ private const val STATE_VIEWER_MIN = "viewer_min"
+ private const val STATE_VIEWER_MAX = "viewer_max"
+ private const val STATE_ZOOM_FOCUSX = "viewer_zoom_focusx"
+ private const val STATE_ZOOM_FOCUSY = "viewer_zoom_focusy"
+ private const val SEEKBAR_RESOLUTION = 1024
+ }
+
+ private val mSeekBar: SeekBar by lazy { SeekBar(requireActivity()) }
+ private val mZoomLevelText: TextView by lazy { TextView(requireActivity()) }
+
+ private var mZoomFocusX: Float = 0.0f
+ public fun setZoomFocusX(value: Float) {mZoomFocusX = value}
+ private var mZoomFocusY: Float = 0.0f
+ public fun setZoomFocusY(value: Float) {mZoomFocusY = value}
+
+ private fun progressToZoom(progress: Int): Double {
+ val progressClip = progress.coerceAtLeast(0).coerceAtMost(SEEKBAR_RESOLUTION);
+ return mMinZoomRatio * (mMaxZoomRatio / mMinZoomRatio).pow(progressClip.toDouble() / SEEKBAR_RESOLUTION)
+ }
+
+ private fun zoomToProgress(zoom: Double): Int {
+ val zoomClip = zoom.coerceAtLeast(mMinZoomRatio).coerceAtMost(mMaxZoomRatio);
+ return (SEEKBAR_RESOLUTION * ln(zoomClip / mMinZoomRatio) / ln(mMaxZoomRatio / mMinZoomRatio)).toInt()
+ }
+
+ fun refreshZoomText(progress: Int) {
+ mZoomLevelText.text = "${(progressToZoom(progress) * 100).toInt()}%"
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+
+ val viewerActivity: PdfViewer = (requireActivity() as PdfViewer)
+
+ if (savedInstanceState != null) {
+ val progress = savedInstanceState.getInt(STATE_SEEKBAR_CUR)
+ mSeekBar.setMin(savedInstanceState.getInt(STATE_SEEKBAR_MIN))
+ mSeekBar.setMax(savedInstanceState.getInt(STATE_SEEKBAR_MAX))
+ mSeekBar.progress = progress
+ refreshZoomText(progress)
+ mCurrentViewerZoomRatio = savedInstanceState.getDouble(STATE_VIEWER_CUR)
+ mMinZoomRatio = savedInstanceState.getDouble(STATE_VIEWER_MIN)
+ mMaxZoomRatio = savedInstanceState.getDouble(STATE_VIEWER_MAX)
+ mZoomFocusX = savedInstanceState.getFloat(STATE_ZOOM_FOCUSX)
+ mZoomFocusY = savedInstanceState.getFloat(STATE_ZOOM_FOCUSY)
+ } else {
+ mSeekBar.setMin(0)
+ mSeekBar.setMax(SEEKBAR_RESOLUTION)
+ val progress = zoomToProgress(mCurrentViewerZoomRatio)
+ mSeekBar.setProgress(progress)
+ refreshZoomText(progress)
+ }
+ mSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+ refreshZoomText(progress)
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
+ })
+ val layout = LinearLayout(requireActivity())
+ layout.orientation = LinearLayout.VERTICAL
+ layout.gravity = Gravity.CENTER
+ val textParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ )
+ textParams.setMargins(0, 24, 0, 0) // Margin above the text
+ layout.addView(mZoomLevelText, textParams)
+ layout.addView(
+ mSeekBar, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ )
+ )
+ return MaterialAlertDialogBuilder(requireActivity())
+ .setView(layout)
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
+ mSeekBar.clearFocus()
+ val zoom = progressToZoom(mSeekBar.progress)
+ viewerActivity.onZoomPage((zoom / mCurrentViewerZoomRatio).toFloat(), mZoomFocusX, mZoomFocusY, true)
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .create()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putInt(STATE_SEEKBAR_CUR, mSeekBar.progress)
+ outState.putInt(STATE_SEEKBAR_MIN, mSeekBar.min)
+ outState.putInt(STATE_SEEKBAR_MAX, mSeekBar.max)
+ outState.putDouble(STATE_VIEWER_CUR, mCurrentViewerZoomRatio)
+ outState.putDouble(STATE_VIEWER_MIN, mMinZoomRatio)
+ outState.putDouble(STATE_VIEWER_MAX, mMaxZoomRatio)
+ outState.putFloat(STATE_ZOOM_FOCUSX, mZoomFocusX)
+ outState.putFloat(STATE_ZOOM_FOCUSY, mZoomFocusY)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_zoom_in_24dp.xml b/app/src/main/res/drawable/ic_zoom_in_24dp.xml
new file mode 100644
index 000000000..b916610ff
--- /dev/null
+++ b/app/src/main/res/drawable/ic_zoom_in_24dp.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/menu/pdf_viewer.xml b/app/src/main/res/menu/pdf_viewer.xml
index 16cd9b289..6b62f0236 100644
--- a/app/src/main/res/menu/pdf_viewer.xml
+++ b/app/src/main/res/menu/pdf_viewer.xml
@@ -43,6 +43,12 @@
android:title="@string/action_jump_to_page"
app:showAsAction="ifRoom" />
+
+
- First page
Last page
Jump to page
+ Zoom
Rotate clockwise
Rotate counterclockwise
Share