From a1877607e75aef9b778a8c0a1b20d8ecf0f7e173 Mon Sep 17 00:00:00 2001 From: amusergrieve Date: Fri, 4 Apr 2025 00:52:03 -0700 Subject: [PATCH] Add support for menu-based zooming on non-touchscreens --- .../app/grapheneos/pdfviewer/PdfViewer.java | 19 ++- .../pdfviewer/fragment/SetZoomFragment.kt | 125 ++++++++++++++++++ app/src/main/res/drawable/ic_zoom_in_24dp.xml | 12 ++ app/src/main/res/menu/pdf_viewer.xml | 6 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/pdfviewer/fragment/SetZoomFragment.kt create mode 100644 app/src/main/res/drawable/ic_zoom_in_24dp.xml diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index 3c4236e03..5381d8604 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; @@ -443,12 +444,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(); } }); @@ -643,7 +644,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; @@ -651,7 +652,7 @@ private void zoom(float scaleFactor, float focusX, float focusY, boolean end) { invalidateOptionsMenu(); } - private void zoomEnd() { + public void onZoomPageEnd() { renderPage(1); } @@ -721,7 +722,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); @@ -750,6 +751,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); @@ -807,6 +809,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