| /* |
| * Copyright 2024 The Android Open Source Project |
| * |
| * 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. |
| */ |
| |
| package androidx.pdf.view |
| |
| import android.R as androidR |
| import android.animation.ValueAnimator |
| import android.content.ClipData |
| import android.content.ClipboardManager |
| import android.content.Context |
| import android.content.Intent |
| import android.content.res.Configuration |
| import android.content.res.Configuration.ORIENTATION_UNDEFINED |
| import android.graphics.Canvas |
| import android.graphics.Point |
| import android.graphics.PointF |
| import android.graphics.Rect |
| import android.graphics.RectF |
| import android.graphics.drawable.Drawable |
| import android.os.Looper |
| import android.os.Parcelable |
| import android.util.AttributeSet |
| import android.util.Range |
| import android.util.SparseArray |
| import android.view.ActionMode |
| import android.view.KeyEvent |
| import android.view.Menu |
| import android.view.Menu.NONE |
| import android.view.MenuItem |
| import android.view.MotionEvent |
| import android.view.ScaleGestureDetector |
| import android.view.View |
| import android.view.accessibility.AccessibilityManager |
| import androidx.annotation.MainThread |
| import androidx.annotation.RestrictTo |
| import androidx.annotation.VisibleForTesting |
| import androidx.core.animation.addListener |
| import androidx.core.os.HandlerCompat |
| import androidx.core.util.Pools |
| import androidx.core.util.keyIterator |
| import androidx.core.util.valueIterator |
| import androidx.core.view.ViewCompat |
| import androidx.pdf.PdfDocument |
| import androidx.pdf.PdfPoint |
| import androidx.pdf.R |
| import androidx.pdf.content.ExternalLink |
| import androidx.pdf.event.PdfTrackingEvent |
| import androidx.pdf.event.RequestFailureEvent |
| import androidx.pdf.exceptions.RequestFailedException |
| import androidx.pdf.models.FormWidgetInfo |
| import androidx.pdf.selection.ContextMenuComponent |
| import androidx.pdf.selection.PdfSelectionMenuKeys |
| import androidx.pdf.selection.SelectionMenuComponent |
| import androidx.pdf.selection.SelectionMenuSession |
| import androidx.pdf.util.Accessibility |
| import androidx.pdf.util.MathUtils |
| import androidx.pdf.util.ZoomUtils |
| import androidx.pdf.view.fastscroll.FastScrollCalculator |
| import androidx.pdf.view.fastscroll.FastScrollDrawer |
| import androidx.pdf.view.fastscroll.FastScrollGestureDetector |
| import androidx.pdf.view.fastscroll.FastScroller |
| import androidx.pdf.view.fastscroll.getDimensions |
| import com.google.android.material.snackbar.Snackbar |
| import java.util.LinkedList |
| import java.util.Queue |
| import java.util.concurrent.Executors |
| import kotlin.math.abs |
| import kotlin.math.max |
| import kotlin.math.roundToInt |
| import kotlinx.coroutines.CoroutineScope |
| import kotlinx.coroutines.CoroutineStart |
| import kotlinx.coroutines.Job |
| import kotlinx.coroutines.SupervisorJob |
| import kotlinx.coroutines.android.asCoroutineDispatcher |
| import kotlinx.coroutines.asCoroutineDispatcher |
| import kotlinx.coroutines.cancelChildren |
| import kotlinx.coroutines.flow.MutableSharedFlow |
| import kotlinx.coroutines.launch |
| |
| /** |
| * A [View] for presenting PDF content, represented by [PdfDocument]. |
| * |
| * This View supports zooming, scrolling, and flinging. Zooming is supported via pinch gesture, |
| * quick scale gesture, and double tap to zoom in or snap back to fitting the page width inside its |
| * bounds. Zoom can be changed using the [zoom] property, which is notably distinct from |
| * [View.getScaleX] / [View.getScaleY]. Scroll position is based on the [View.getScrollX] / |
| * [View.getScrollY] properties. |
| */ |
| public open class PdfView |
| @JvmOverloads |
| constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : |
| View(context, attrs, defStyle) { |
| |
| public var fastScrollVerticalThumbDrawable: Drawable = |
| requireNotNull(context.getDrawable(R.drawable.fast_scroll_thumb_drawable)) |
| set(value) { |
| field = value |
| fastScroller?.fastScrollDrawer?.thumbDrawable = value |
| invalidate() |
| } |
| |
| public var fastScrollPageIndicatorBackgroundDrawable: Drawable = |
| requireNotNull(context.getDrawable(R.drawable.page_indicator_background)) |
| set(value) { |
| field = value |
| fastScroller?.fastScrollDrawer?.pageIndicatorBackground = value |
| invalidate() |
| } |
| |
| public var fastScrollVerticalThumbMarginEnd: Int = |
| context.getDimensions(R.dimen.scroll_thumb_margin_end).toInt() |
| set(value) { |
| field = value |
| fastScroller?.fastScrollDrawer?.thumbMarginEnd = value |
| invalidate() |
| } |
| |
| public var fastScrollPageIndicatorMarginEnd: Int = |
| context.getDimensions(R.dimen.page_indicator_right_margin).toInt() |
| set(value) { |
| field = value |
| fastScroller?.fastScrollDrawer?.pageIndicatorMarginEnd = value |
| invalidate() |
| } |
| |
| @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public var isFormFillingEnabled: Boolean = false |
| |
| /** The maximum scaling factor that can be applied to this View using the [zoom] property */ |
| public var maxZoom: Float = DEFAULT_MAX_ZOOM |
| |
| /** The minimum scaling factor that can be applied to this View using the [zoom] property */ |
| public var minZoom: Float = DEFAULT_MIN_ZOOM |
| |
| // After the pagination model has loaded and the first set of pages are made visible (or if |
| // the view is not attached to a window, we fetch all the dimensions to optimize subsequent |
| // rendering. |
| private var startedFetchingAllDimensions = false |
| |
| init { |
| val typedArray = context.obtainStyledAttributes(attrs, R.styleable.PdfView) |
| if (typedArray.hasValue(R.styleable.PdfView_fastScrollVerticalThumbDrawable)) { |
| val drawable = |
| typedArray.getDrawable(R.styleable.PdfView_fastScrollVerticalThumbDrawable) |
| if (drawable != null) fastScrollVerticalThumbDrawable = drawable |
| } |
| if (typedArray.hasValue(R.styleable.PdfView_fastScrollPageIndicatorBackgroundDrawable)) { |
| val drawable = |
| typedArray.getDrawable( |
| R.styleable.PdfView_fastScrollPageIndicatorBackgroundDrawable |
| ) |
| if (drawable != null) fastScrollPageIndicatorBackgroundDrawable = drawable |
| } |
| if (typedArray.hasValue(R.styleable.PdfView_fastScrollPageIndicatorMarginEnd)) { |
| fastScrollPageIndicatorMarginEnd = |
| typedArray.getDimensionPixelSize( |
| R.styleable.PdfView_fastScrollPageIndicatorMarginEnd, |
| fastScrollPageIndicatorMarginEnd, |
| ) |
| } |
| if (typedArray.hasValue(R.styleable.PdfView_fastScrollVerticalThumbMarginEnd)) { |
| fastScrollVerticalThumbMarginEnd = |
| typedArray.getDimensionPixelSize( |
| R.styleable.PdfView_fastScrollVerticalThumbMarginEnd, |
| fastScrollVerticalThumbMarginEnd, |
| ) |
| } |
| if (typedArray.hasValue(R.styleable.PdfView_isFormFillingEnabled)) { |
| isFormFillingEnabled = |
| typedArray.getBoolean(R.styleable.PdfView_isFormFillingEnabled, false) |
| } |
| if (typedArray.hasValue(R.styleable.PdfView_minZoom)) { |
| minZoom = typedArray.getFloat(R.styleable.PdfView_minZoom, minZoom) |
| } |
| if (typedArray.hasValue(R.styleable.PdfView_maxZoom)) { |
| maxZoom = typedArray.getFloat(R.styleable.PdfView_maxZoom, maxZoom) |
| } |
| typedArray.recycle() |
| } |
| |
| /** Supply a [PdfDocument] to process the PDF content for rendering */ |
| public var pdfDocument: PdfDocument? = null |
| set(value) { |
| checkMainThread() |
| value?.let { |
| if (field == value) return |
| field = it |
| reset() |
| onDocumentSet() |
| } |
| } |
| |
| /** |
| * The zoom level of this view, as a factor of the content's natural size with when 1 pixel is |
| * equal to 1 PDF point. Will always be clamped within ([minZoom], [maxZoom]) |
| */ |
| public var zoom: Float = DEFAULT_INIT_ZOOM |
| set(value) { |
| checkMainThread() |
| field = value |
| onViewportChanged() |
| } |
| |
| private var appliedHighlights: List<Highlight> = listOf() |
| set(value) { |
| checkMainThread() |
| val localPageManager = |
| pageManager |
| ?: throw IllegalStateException("Can't highlightAreas without PdfDocument") |
| localPageManager.setHighlights(value) |
| } |
| |
| private val visiblePages: Range<Int> |
| get() = pageMetadataLoader?.visiblePages ?: Range(0, 0) |
| |
| private val fullyVisiblePages: Range<Int> |
| get() = pageMetadataLoader?.fullyVisiblePages ?: Range(0, 0) |
| |
| /** The first page in the viewport, including partially-visible pages. 0-indexed. */ |
| public val firstVisiblePage: Int |
| get() = visiblePages.lower |
| |
| /** The number of pages visible in the viewport, including partially visible pages */ |
| public val visiblePagesCount: Int |
| get() = if (pdfDocument != null) visiblePages.upper - visiblePages.lower + 1 else 0 |
| |
| /** |
| * The current state of the PDF view with respect to external inputs, e.g. user touch. Returns |
| * one of [GESTURE_STATE_IDLE], [GESTURE_STATE_INTERACTING], or [GESTURE_STATE_SETTLING] |
| */ |
| public var gestureState: Int = GESTURE_STATE_IDLE |
| @MainThread private set |
| |
| /** |
| * A [Pools.Pool] of [Rect] for dispatching to [OnViewportChangedListener] to avoid excessive |
| * per-frame allocations |
| */ |
| private val pageLocationsPool = Pools.SimplePool<RectF>(maxPoolSize = 100) |
| |
| /** |
| * Listener interface to receive callbacks when the PdfView starts and stops being affected by |
| * an external input like user touch. |
| */ |
| public interface OnGestureStateChangedListener { |
| /** |
| * Callback when the PdfView starts and stops being affected by an external input like user |
| * touch. [newState] will be one of [GESTURE_STATE_IDLE], [GESTURE_STATE_INTERACTING], or |
| * [GESTURE_STATE_SETTLING] |
| */ |
| public fun onGestureStateChanged(newState: Int) |
| } |
| |
| private val onGestureStateChangedListeners = mutableListOf<OnGestureStateChangedListener>() |
| |
| /** |
| * Listener interface to receive changes to the viewport, i.e. the window of visible PDF content |
| */ |
| public interface OnViewportChangedListener { |
| /** |
| * Called when there has been a change in visible PDF content |
| * |
| * @param firstVisiblePage the first page that's visible in the View, even if partially so |
| * @param visiblePagesCount the number of pages that are visible in the View, including |
| * partially visible pages |
| * @param pageLocations the location of each page in View coordinates. At extremely low zoom |
| * levels, only the first 100 page locations will be provided, and the rest can be |
| * obtained from [pdfToViewPoint]. [Rect] instances are recycled; make a copy of any you |
| * wish to make use of beyond the scope of this method. |
| * @param zoomLevel the current zoom level |
| */ |
| public fun onViewportChanged( |
| firstVisiblePage: Int, |
| visiblePagesCount: Int, |
| pageLocations: SparseArray<RectF>, |
| zoomLevel: Float, |
| ) |
| } |
| |
| private val onViewportChangedListeners = mutableListOf<OnViewportChangedListener>() |
| |
| /** Listener interface for handling clicks on links in a PDF document. */ |
| public interface LinkClickListener { |
| /** |
| * Called when a link in the PDF is clicked. |
| * |
| * @param externalLink The ExternalLink associated with the link. |
| * @return True if the link click was handled, false to use the default behavior. |
| */ |
| public fun onLinkClicked(externalLink: ExternalLink): Boolean |
| } |
| |
| /** The listener that is notified when a link in the PDF is clicked. */ |
| private var linkClickListener: LinkClickListener? = null |
| |
| /** The [ActionMode.Callback2] for selection */ |
| private val selectionActionModeCallback: DefaultSelectionActionModeCallback = |
| DefaultSelectionActionModeCallback(this) |
| |
| /** Interface to customize the set of actions in the selection menu */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public interface SelectionMenuItemPreparer { |
| /** |
| * Customize the text selection menu, by adding items to or removing items from |
| * [components]. |
| */ |
| public fun onPrepareSelectionMenuItems(components: MutableList<ContextMenuComponent>) |
| } |
| |
| private var selectionMenuItemPreparer: SelectionMenuItemPreparer? = null |
| |
| /** |
| * The [SelectionMenuItemPreparer] for this View. If null, a default set of selection menu |
| * actions will be provided in all cases |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public fun setSelectionMenuItemPreparer(selectionMenuItemPreparer: SelectionMenuItemPreparer?) { |
| this.selectionMenuItemPreparer = selectionMenuItemPreparer |
| } |
| |
| /** The currently selected PDF content, as [Selection] */ |
| public val currentSelection: Selection? |
| get() { |
| return selectionStateManager?.selectionModel?.value?.documentSelection?.selection |
| } |
| |
| @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public var requestFailedListener: EventListener? = null |
| |
| @VisibleForTesting |
| @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public val currentPageIndicatorLabel: String |
| get() = fastScroller?.fastScrollDrawer?.currentPageIndicatorLabel ?: "" |
| |
| /** Listener interface to receive updates when the [currentSelection] changes */ |
| public interface OnSelectionChangedListener { |
| /** Called when the [Selection] has changed */ |
| public fun onSelectionChanged(newSelection: Selection?) |
| } |
| |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public interface EventListener { |
| public fun onEvent(event: PdfTrackingEvent) |
| } |
| |
| private var onSelectionChangedListeners = mutableListOf<OnSelectionChangedListener>() |
| |
| /** |
| * The [CoroutineScope] used to make suspending calls to [PdfDocument]. The size of the fixed |
| * thread pool is arbitrary and subject to tuning. |
| */ |
| internal var backgroundScope: CoroutineScope = |
| CoroutineScope(Executors.newFixedThreadPool(5).asCoroutineDispatcher() + SupervisorJob()) |
| |
| private var pageMetadataLoader: PageMetadataLoader? = null |
| private var pageManager: PageManager? = null |
| private var formWidgetInteractionHandler: FormWidgetInteractionHandler? = null |
| private var layoutInfoCollector: Job? = null |
| private var pageSignalCollector: Job? = null |
| private var selectionStateCollector: Job? = null |
| private var errorStateCollector: Job? = null |
| private var formEditInfoCollector: Job? = null |
| |
| private var deferredScrollPage: Int? = null |
| private var deferredScrollPosition: PdfPoint? = null |
| private var lastOrientation: Int = resources.configuration.orientation |
| |
| /** Used to restore saved state */ |
| private var stateToRestore: PdfViewSavedState? = null |
| private var awaitingFirstLayout: Boolean = true |
| private var scrollPositionToRestore: PointF? = null |
| private var zoomToRestore: Float? = null |
| private val errorFlow = MutableSharedFlow<Throwable>() |
| /** Used to track is the first page is rendered. */ |
| private var isFirstPageRendered: Boolean = false |
| |
| /** |
| * We use this flag to avoid recomputing the visible content during composite zoom+scroll |
| * operations until we've applied both zoom *and* scroll |
| */ |
| private var deferViewportUpdate: Boolean = false |
| |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public enum class FastScrollVisibility { |
| AUTO_HIDE, |
| ALWAYS_SHOW, |
| ALWAYS_HIDE, |
| } |
| |
| /** |
| * Indicates whether the fast scroller's visibility is managed externally. |
| * |
| * If `true`, the [androidx.pdf.view.PdfView] will not automatically change the visibility of |
| * the fast scroller in response to actions like scrolling or zooming. |
| * |
| * This allows an external source to manage the visibility. |
| */ |
| @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public var fastScrollVisibility: FastScrollVisibility = FastScrollVisibility.AUTO_HIDE |
| set(value) { |
| field = value |
| if (value == FastScrollVisibility.ALWAYS_SHOW) fastScroller?.show { postInvalidate() } |
| else if (value == FastScrollVisibility.ALWAYS_HIDE) fastScroller?.hide() |
| } |
| |
| // Stores width set from onSizeChanged or while restoring state |
| private var oldWidth: Int? = null |
| |
| @VisibleForTesting |
| @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public var fastScroller: FastScroller? = null |
| private var fastScrollGestureDetector: FastScrollGestureDetector? = null |
| |
| private val gestureHandler = ZoomScrollGestureHandler() |
| private val gestureTracker = GestureTracker(context).apply { delegate = gestureHandler } |
| |
| private val scroller = RelativeScroller(context) |
| /** Whether we are in a fling movement. This is used to detect the end of that movement */ |
| private var isFling = false |
| |
| private var doubleTapAnimator: ValueAnimator? = null |
| internal var lastFastScrollerVisibility: Boolean = false |
| |
| @VisibleForTesting |
| @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public var isAutoScrollingEnabled: Boolean = true |
| |
| private var isAutoScrolling = false |
| private var prevDragEvent: MotionEvent? = null |
| |
| /** |
| * Returns true if neither zoom nor scroll are actively changing. Does not account for |
| * externally-driven changes in position (e.g. a animating scrollY or zoom) |
| */ |
| internal val positionIsStable: Boolean |
| get() { |
| val zoomIsChanging = gestureTracker.matches(GestureTracker.Gesture.ZOOM) |
| val scrollIsChanging = |
| gestureTracker.matches( |
| GestureTracker.Gesture.DRAG, |
| GestureTracker.Gesture.DRAG_X, |
| GestureTracker.Gesture.DRAG_Y, |
| ) || |
| isFling || |
| doubleTapAnimator?.isRunning == true || |
| fastScrollGestureDetector?.trackingFastScrollGesture == true |
| return !zoomIsChanging && !scrollIsChanging |
| } |
| |
| // To avoid allocations during drawing |
| private val visibleAreaRect = RectF() |
| |
| private val fastScrollGestureHandler = |
| object : FastScrollGestureDetector.FastScrollGestureHandler { |
| override fun onFastScrollStart() { |
| dispatchGestureStateChanged(newState = GESTURE_STATE_INTERACTING) |
| } |
| |
| override fun onFastScrollEnd() { |
| dispatchGestureStateChanged(newState = GESTURE_STATE_IDLE) |
| } |
| |
| override fun onFastScrollDetected(eventY: Float) { |
| fastScroller?.let { |
| val updatedY = |
| it.viewScrollPositionFromFastScroller( |
| scrollY = eventY, |
| viewHeight = height, |
| estimatedFullHeight = toViewCoord(contentHeight, zoom, scroll = 0), |
| ) |
| scrollTo(scrollX, updatedY) |
| invalidate() |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| public fun arePagesFullyRendered(): Boolean { |
| // If no document is set, there are no pages to render. In that case, we assume all pages |
| // are rendered. For testing purposes, the idling resource can be registered before |
| // the document is loaded during test setup. Consequently, this method may be called |
| // before the document is ready, and it must return true to avoid a timeout; |
| // otherwise, the idling resource might never become idle. |
| |
| return pageManager?.areAllVisiblePagesFullyRendered( |
| visiblePages, |
| zoom, |
| pageMetadataLoader?.visiblePageAreas, |
| ) ?: true |
| } |
| |
| @VisibleForTesting internal var pdfViewAccessibilityManager: PdfViewAccessibilityManager? = null |
| @VisibleForTesting |
| internal var isAccessibilityEnabled: Boolean = |
| Accessibility.get().isAccessibilityEnabled(context) |
| set(value) { |
| field = value |
| pageManager?.isAccessibilityEnabled = value |
| } |
| |
| private var accessibilityManager: AccessibilityManager = |
| Accessibility.getAccessibilityManager(context) |
| |
| internal val accessibilityStateChangeHandler = |
| AccessibilityManager.AccessibilityStateChangeListener { isEnabled -> |
| isAccessibilityEnabled = isEnabled |
| } |
| |
| private var selectionStateManager: SelectionStateManager? = null |
| private val selectionRenderer = SelectionRenderer(context) |
| private var selectionActionMode: ActionMode? = null |
| |
| // True if the zoom was calculated before the layouting completed and needs to be recalculated |
| private var pendingZoomRecalculation = false |
| |
| /** |
| * Scrolls to the 0-indexed [pageNum], optionally animating the scroll |
| * |
| * This View cannot scroll to a page until it knows its dimensions. If [pageNum] is distant from |
| * the currently-visible page in a large PDF, there may be some delay while dimensions are being |
| * loaded from the PDF. |
| */ |
| @Suppress("UNUSED_PARAMETER") |
| public fun scrollToPage(pageNum: Int) { |
| checkMainThread() |
| val localPageLayoutManager = |
| pageMetadataLoader |
| ?: throw IllegalStateException("Can't scrollToPage without PdfDocument") |
| require(pageNum < (pdfDocument?.pageCount ?: Int.MIN_VALUE)) { |
| "Page $pageNum not in document" |
| } |
| |
| if (localPageLayoutManager.reach >= pageNum) { |
| gotoPage(pageNum) |
| } else { |
| localPageLayoutManager.increaseReach(pageNum) |
| deferredScrollPage = pageNum |
| deferredScrollPosition = null |
| } |
| } |
| |
| /** |
| * Scrolls to [position], optionally animating the scroll |
| * |
| * This View cannot scroll to a page until it knows its dimensions. If [position] is distant |
| * from the currently-visible page in a large PDF, there may be some delay while dimensions are |
| * being loaded from the PDF. |
| */ |
| @Suppress("UNUSED_PARAMETER") |
| public fun scrollToPosition(position: PdfPoint) { |
| checkMainThread() |
| val localPageLayoutManager = |
| pageMetadataLoader |
| ?: throw IllegalStateException("Can't scrollToPage without PdfDocument") |
| |
| if (position.pageNum >= (pdfDocument?.pageCount ?: Int.MIN_VALUE)) { |
| return |
| } |
| |
| if (localPageLayoutManager.reach >= position.pageNum) { |
| gotoPoint(position) |
| } else { |
| localPageLayoutManager.increaseReach(position.pageNum) |
| deferredScrollPosition = position |
| deferredScrollPage = null |
| } |
| } |
| |
| /** |
| * Registers [listener] as the callback to be invoked when an external link in the PDF is |
| * clicked. Supply `null` to clear any current listener. |
| */ |
| public fun setLinkClickListener(listener: LinkClickListener?) { |
| linkClickListener = listener |
| } |
| |
| /** |
| * Adds the specified listener to the list of listeners that will be notified of selection |
| * change events. |
| * |
| * @param listener listener to notify when selection change events occur |
| * @see removeOnSelectionChangedListener |
| */ |
| public fun addOnSelectionChangedListener(listener: OnSelectionChangedListener) { |
| onSelectionChangedListeners.add(listener) |
| } |
| |
| /** |
| * Removes the specified listener from the list of listeners that will be notified of selection |
| * change events. |
| * |
| * @param listener listener to remove |
| */ |
| public fun removeOnSelectionChangedListener(listener: OnSelectionChangedListener) { |
| onSelectionChangedListeners.remove(listener) |
| } |
| |
| /** |
| * Adds the specified listener to the list of listeners that will be notified of changes in |
| * state with respect to this PdfView being affected by an external input, e.g. user touch. |
| * |
| * @param listener listener to notify when interaction state change events occur |
| * @see removeOnGestureStateChangedListener |
| */ |
| public fun addOnGestureStateChangedListener(listener: OnGestureStateChangedListener) { |
| onGestureStateChangedListeners.add(listener) |
| } |
| |
| /** |
| * Removes the specified listener from the list of listeners that will be notified of changes in |
| * state with respect to this PdfView being affected by an external input, e.g. user touch. |
| * |
| * @param listener listener to remove |
| */ |
| public fun removeOnGestureStateChangedListener(listener: OnGestureStateChangedListener) { |
| onGestureStateChangedListeners.remove(listener) |
| } |
| |
| /** |
| * Fast scroll gestures and corresponding state changes are handled by separate logic than most |
| * other gesture events. This method dispatches state changes except during active fast scroll, |
| * i.e. to avoid noise from spurious non-fast-scroll gestures detected during a fast scroll |
| * sequence. |
| */ |
| private fun dispatchGestureStateChangedUnlessFastScroll(newState: Int) { |
| if (fastScrollGestureDetector?.trackingFastScrollGesture == false) { |
| dispatchGestureStateChanged(newState) |
| } |
| } |
| |
| private fun dispatchGestureStateChanged(newState: Int) { |
| require(newState in VALID_GESTURE_STATES) { |
| "Invalid state change from $gestureState to $newState" |
| } |
| if (newState == gestureState) { |
| return |
| } |
| gestureState = newState |
| for (listener in onGestureStateChangedListeners) { |
| listener.onGestureStateChanged(newState) |
| } |
| } |
| |
| /** |
| * Applies a set of [Highlight] to be drawn over this PDF. Each [Highlight] may be a different |
| * color. This overrides any previous highlights, there is no merging of new and previous |
| * values. [highlights] are defensively copied and the list or its contents may be modified |
| * after providing it here. |
| */ |
| public fun setHighlights(highlights: List<Highlight>) { |
| this.appliedHighlights = ArrayList(highlights.map { highlight -> highlight.copy() }) |
| } |
| |
| private fun dispatchSelectionChanged(new: Selection?) { |
| for (listener in onSelectionChangedListeners) { |
| listener.onSelectionChanged(new) |
| } |
| } |
| |
| /** |
| * Adds the specified listener to the list of listeners that will be notified of viewport change |
| * events. |
| * |
| * @param listener listener to add |
| */ |
| public fun addOnViewportChangedListener(listener: OnViewportChangedListener) { |
| onViewportChangedListeners.add(listener) |
| } |
| |
| /** |
| * Removes the specified listener from the list of listeners that will be notified of viewport |
| * change events. |
| * |
| * @param listener listener to remove |
| */ |
| public fun removeOnViewportChangedListener(listener: OnViewportChangedListener) { |
| onViewportChangedListeners.remove(listener) |
| } |
| |
| /** |
| * Returns the [PdfPoint] corresponding to [viewPoint] in View coordinates, or null if no PDF |
| * content has been laid out at [viewPoint] |
| */ |
| public fun viewToPdfPoint(viewPoint: PointF): PdfPoint? { |
| return viewToPdfPoint(viewPoint.x, viewPoint.y) |
| } |
| |
| /** |
| * Returns the [PdfPoint] corresponding to ([x], [y])in View coordinates, or null if no PDF |
| * content has been laid out at that point. |
| */ |
| public fun viewToPdfPoint(x: Float, y: Float): PdfPoint? { |
| return pageMetadataLoader?.getPdfPointAt( |
| toContentX(x), |
| toContentY(y), |
| getVisibleAreaInContentCoords(), |
| scanAllPages = true, |
| ) |
| } |
| |
| /** |
| * Returns the View coordinate location of [pdfPoint], or null if that PDF content has not been |
| * laid out yet. |
| */ |
| public fun pdfToViewPoint(pdfPoint: PdfPoint): PointF? { |
| val pageLocation = |
| pageMetadataLoader?.getPageLocation(pdfPoint.pageNum, getVisibleAreaInContentCoords()) |
| ?: return null |
| val ret = |
| PointF( |
| toViewCoord(pageLocation.left + pdfPoint.x, zoom, scroll = scrollX), |
| toViewCoord(pageLocation.top + pdfPoint.y, zoom, scroll = scrollY), |
| ) |
| return ret |
| } |
| |
| private fun gotoPage(pageNum: Int) { |
| checkMainThread() |
| val localPageLayoutManager = |
| pageMetadataLoader |
| ?: throw IllegalStateException("Can't scrollToPage without PdfDocument") |
| check(pageNum <= localPageLayoutManager.reach) { "Can't gotoPage that's not laid out" } |
| |
| val pageRect = |
| localPageLayoutManager.getPageLocation(pageNum, getVisibleAreaInContentCoords()) |
| // Zoom should match the width of the page |
| val zoom = |
| ZoomUtils.calculateZoomToFit( |
| viewportWidth.toFloat(), |
| viewportHeight.toFloat(), |
| pageRect.width(), |
| 1f, |
| ) |
| val x = ((pageRect.left + pageRect.width() / 2f) * zoom - (viewportWidth / 2f)).roundToInt() |
| val y = |
| ((pageRect.top + pageRect.height() / 2f) * zoom - (viewportHeight / 2f)).roundToInt() |
| |
| // Set zoom to fit the width of the page, then scroll to the center of the page |
| this.zoom = zoom |
| scrollTo(x, y) |
| } |
| |
| /** Clears the current selection, if one exists. No-op if there is no current [Selection] */ |
| public fun clearSelection() { |
| selectionStateManager?.clearSelection() |
| } |
| |
| private fun gotoPoint(position: PdfPoint) { |
| checkMainThread() |
| val localPageLayoutManager = |
| pageMetadataLoader |
| ?: throw IllegalStateException("Can't scrollToPage without PdfDocument") |
| check(position.pageNum <= localPageLayoutManager.reach) { |
| "Can't gotoPoint on page that's not laid out" |
| } |
| |
| val pageRect = |
| localPageLayoutManager.getPageLocation( |
| position.pageNum, |
| getVisibleAreaInContentCoords(), |
| ) |
| |
| val x = ((pageRect.left + position.x) * zoom - (viewportWidth / 2f)).roundToInt() |
| val y = ((pageRect.top + position.y) * zoom - (viewportHeight / 2f)).roundToInt() |
| |
| scrollTo(x, y) |
| } |
| |
| override fun dispatchHoverEvent(event: MotionEvent?): Boolean { |
| return event?.let { pdfViewAccessibilityManager?.dispatchHoverEvent(it) } == true || |
| super.dispatchHoverEvent(event) |
| } |
| |
| override fun dispatchKeyEvent(event: KeyEvent?): Boolean { |
| return event?.let { pdfViewAccessibilityManager?.dispatchKeyEvent(it) } == true || |
| super.dispatchKeyEvent(event) |
| } |
| |
| override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { |
| super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) |
| pdfViewAccessibilityManager?.onFocusChanged(gainFocus, direction, previouslyFocusedRect) |
| } |
| |
| override fun onDraw(canvas: Canvas) { |
| super.onDraw(canvas) |
| val localPaginationManager = pageMetadataLoader ?: return |
| canvas.save() |
| // View itself translates the Canvas by scroll position, so we don't have to |
| canvas.scale(zoom, zoom) |
| val selectionModel = selectionStateManager?.selectionModel |
| for (i in visiblePages.lower..visiblePages.upper) { |
| // Scroll and zoom are applied to the Canvas, so we draw to the Canvas using content |
| // coordinates |
| val pageLoc = localPaginationManager.getPageLocation(i, getVisibleAreaInContentCoords()) |
| pageManager?.drawPage(i, canvas, pageLoc) |
| selectionModel?.value?.let { |
| selectionRenderer.drawSelectionOnPage( |
| model = it, |
| pageNum = i, |
| canvas, |
| pageLoc, |
| zoom, |
| ) |
| } |
| } |
| canvas.restore() |
| |
| // Fast scroller is non-content and shouldn't be affected by zoom. It's drawn after |
| // restoring the Canvas to its unscaled state |
| val documentPageCount = pdfDocument?.pageCount ?: 0 |
| if (documentPageCount > 1) { |
| fastScroller?.drawScroller( |
| canvas = canvas, |
| scrollX = scrollX, |
| scrollY = scrollY, |
| viewWidth = width, |
| viewHeight = height, |
| visiblePages = fullyVisiblePages, |
| estimatedFullHeight = |
| toViewCoord(contentCoord = contentHeight, zoom = zoom, scroll = 0), |
| ) |
| } |
| } |
| |
| override fun onTouchEvent(event: MotionEvent?): Boolean { |
| // Needs to be set so that any ancestor of PdfView does not consume the touch event before |
| // the library does. This particularly creates an issue with zoom and scroll when integrated |
| // with the ViewPager library. |
| parent?.requestDisallowInterceptTouchEvent(true) |
| |
| var handled = |
| event?.let { fastScrollGestureDetector?.handleEvent(it, parent, width) } ?: false |
| handled = handled || maybeDragSelectionHandle(event) |
| handled = |
| handled || |
| event?.let { gestureTracker.feed(it, parent, isContentAtHorizontalEdges()) } |
| ?: false |
| |
| if (!handled) { |
| parent?.requestDisallowInterceptTouchEvent(false) |
| } |
| |
| return handled || super.onTouchEvent(event) |
| } |
| |
| private fun isContentAtHorizontalEdges(): Boolean { |
| val leftContentEdgePx = -scrollX |
| val rightContentEdgePx = |
| toViewCoord(contentWidth.toFloat(), zoom, scrollX).toInt() - paddingRight - paddingLeft |
| |
| return leftContentEdgePx == 0 || rightContentEdgePx == viewportWidth |
| } |
| |
| private fun maybeShowFastScroller() { |
| // Forced visibility takes precedence |
| if (fastScrollVisibility != FastScrollVisibility.AUTO_HIDE) return |
| |
| fastScroller?.show { postInvalidate() } |
| } |
| |
| private fun maybeHideFastScroller() { |
| // Forced visibility takes precedence |
| if (fastScrollVisibility != FastScrollVisibility.AUTO_HIDE) return |
| |
| fastScroller?.hide() |
| } |
| |
| private fun maybeDragSelectionHandle(event: MotionEvent?): Boolean { |
| if (event == null) return false |
| val touchPoint = |
| pageMetadataLoader?.getPdfPointAt( |
| toContentX(event.x), |
| toContentY(event.y), |
| getVisibleAreaInContentCoords(), |
| ) |
| |
| if (event.action == MotionEvent.ACTION_UP) { |
| isAutoScrolling = false |
| parent?.requestDisallowInterceptTouchEvent(false) |
| } |
| |
| prevDragEvent = event |
| if ( |
| selectionStateManager?.maybeDragSelectionHandle(event.action, touchPoint, zoom) == true |
| ) { |
| if (event.action == MotionEvent.ACTION_DOWN && isAutoScrollingEnabled) { |
| startAutoScrolling() |
| } |
| return true |
| } |
| return false |
| } |
| |
| private fun startAutoScrolling() { |
| isAutoScrolling = true |
| handler?.post( |
| object : Runnable { |
| override fun run() { |
| if (isAutoScrolling) { |
| // Perform the scroll. |
| scrollAsYouSelect() |
| handler?.postDelayed( |
| this, |
| AUTO_SCROLL_DELAY_IN_MILLIS, |
| ) // Adjust delay for smoother/faster scroll |
| } |
| } |
| } |
| ) |
| } |
| |
| private fun scrollAsYouSelect() { |
| prevDragEvent?.let { event -> |
| if (event.y > height * SCROLL_SELECTION_TOLERANCE_RATIO) { |
| scrollBy(0, AUTO_SCROLL_BY_VALUE) |
| } else if (event.y < height * (1 - SCROLL_SELECTION_TOLERANCE_RATIO)) { |
| scrollBy(0, -AUTO_SCROLL_BY_VALUE) |
| } |
| } |
| } |
| |
| override fun onConfigurationChanged(newConfig: Configuration?) { |
| super.onConfigurationChanged(newConfig) |
| /** |
| * For activities which doesn't recreate upon orientation changes, restore path for |
| * [androidx.pdf.view.PdfView] will not kick-in. We need to manually store the current |
| * scroll position which then will be restored in [onLayout]. |
| */ |
| if (newConfig?.orientation != lastOrientation) { |
| val contentCenterX = toContentX(viewportWidth.toFloat() / 2f) |
| // Keep scroll at top if previously at top. |
| val contentCenterY = if (scrollY <= 0) 0F else toContentY(viewportHeight.toFloat() / 2f) |
| scrollPositionToRestore = PointF(contentCenterX, contentCenterY) |
| |
| lastOrientation = newConfig?.orientation ?: ORIENTATION_UNDEFINED |
| } |
| } |
| |
| override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { |
| super.onSizeChanged(w, h, oldw, oldh) |
| // Ignore oldw if we're just added to view hierarchy. |
| if (oldw != 0) oldWidth = oldw |
| onViewportChanged() |
| } |
| |
| override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { |
| super.onScrollChanged(l, t, oldl, oldt) |
| // TODO(b/390003204): Prevent showing of the scrubber when the document only been |
| // translated on the x-axis |
| if (t != oldt) { |
| maybeShowFastScroller() |
| } |
| onViewportChanged() |
| } |
| |
| override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { |
| super.onLayout(changed, left, top, right, bottom) |
| if (pendingZoomRecalculation) { |
| this.zoom = getDefaultZoom() |
| pendingZoomRecalculation = false |
| } |
| |
| if (changed || awaitingFirstLayout) maybeAdjustZoomAndScroll() |
| |
| awaitingFirstLayout = false |
| } |
| |
| private fun maybeAdjustZoomAndScroll() { |
| val localScrollPosition = scrollPositionToRestore |
| val localOldWidth = oldWidth |
| /** |
| * We only want to adjust zoom if we're restoring from a saved state or PdfView's size has |
| * changed, i.e. we'll have a valid [oldWidth] to use. |
| * |
| * For view init scenario, zoom set from [getDefaultZoom] should be enough to fit to width. |
| */ |
| if (localOldWidth != null) { |
| // Either we're restoring or view size has changed; adjust zoom by factor of w / oldW. |
| val factor = width.toFloat() / localOldWidth |
| val resolvedZoom = zoomToRestore ?: zoom |
| |
| // Calculate new zoom, clamped between min and max zoom possible. |
| val newZoom = (resolvedZoom * factor).coerceIn(minZoom, maxZoom) |
| this.zoom = newZoom |
| zoomToRestore = null |
| |
| /** |
| * If view isn't recreated, we won't have a scroll position from bundle. In this case, |
| * adjust view's current scrollX and scrollY according to change in zoom. |
| */ |
| if (localScrollPosition == null) { |
| val newScrollX = (scrollX * (newZoom / resolvedZoom)).roundToInt() |
| val newScrollY = (scrollY * (newZoom / resolvedZoom)).roundToInt() |
| scrollTo(newScrollX, newScrollY) |
| } |
| } |
| |
| // The view is recreated, and we have a position to restore from bundle |
| if (localScrollPosition != null) { |
| scrollToRestoredPosition(localScrollPosition, this.zoom) |
| scrollPositionToRestore = null |
| } |
| |
| oldWidth = null |
| } |
| |
| override fun onAttachedToWindow() { |
| super.onAttachedToWindow() |
| stopCollectingData() |
| awaitingFirstLayout = true |
| |
| accessibilityManager.addAccessibilityStateChangeListener(accessibilityStateChangeHandler) |
| } |
| |
| override fun onWindowVisibilityChanged(visibility: Int) { |
| super.onWindowVisibilityChanged(visibility) |
| if (visibility == VISIBLE) { |
| startCollectingData() |
| // Show selection action mode if selection is visible |
| updateSelectionActionModeVisibility() |
| } else { |
| stopCollectingData() |
| onSelectionUiSignal(SelectionUiSignal.ToggleActionMode(show = false)) |
| } |
| } |
| |
| override fun onDetachedFromWindow() { |
| super.onDetachedFromWindow() |
| stopCollectingData() |
| onSelectionUiSignal(SelectionUiSignal.ToggleActionMode(show = false)) |
| awaitingFirstLayout = true |
| pageManager?.cleanup() |
| |
| accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeHandler) |
| } |
| |
| override fun onSaveInstanceState(): Parcelable? { |
| val superState = super.onSaveInstanceState() |
| val state = PdfViewSavedState(superState) |
| state.zoom = zoom |
| state.viewWidth = width |
| state.contentCenterX = toContentX(viewportWidth.toFloat() / 2f) |
| state.contentCenterY = toContentY(viewportHeight.toFloat() / 2f) |
| // Keep scroll at top if previously at top. |
| if (scrollY <= 0) { |
| state.contentCenterY = 0F |
| } |
| state.documentUri = pdfDocument?.uri |
| state.paginationModel = pageMetadataLoader?.paginationModel |
| state.selectionModel = selectionStateManager?.selectionModel?.value |
| return state |
| } |
| |
| override fun onRestoreInstanceState(state: Parcelable?) { |
| if (state !is PdfViewSavedState) { |
| super.onRestoreInstanceState(state) |
| return |
| } |
| super.onRestoreInstanceState(state.superState) |
| stateToRestore = state |
| if (pdfDocument != null) { |
| maybeRestoreState() |
| } |
| } |
| |
| override fun computeScroll() { |
| // Cause OverScroller to compute the new position |
| if (scroller.computeScrollOffset()) { |
| scroller.apply(this) |
| postInvalidateOnAnimation() |
| } else if (isFling) { |
| isFling = false |
| dispatchGestureStateChangedUnlessFastScroll(newState = GESTURE_STATE_IDLE) |
| // We hide the action mode during a fling, so reveal it when the fling is over |
| updateSelectionActionModeVisibility() |
| // Once the fling has ended, prompt the page manager to start fetching data for pages |
| // that we don't fetch during a fling |
| maybeUpdatePageVisibility() |
| } |
| } |
| |
| override fun scrollBy(x: Int, y: Int) { |
| // This is precisely the implementation of View.scrollBy; this is defensive in case the |
| // View implementation changes given we assume all scrolling flows through scrollTo |
| scrollTo(scrollX + x, scrollY + y) |
| } |
| |
| override fun scrollTo(x: Int, y: Int) { |
| val cappedX = x.coerceIn(0..computeHorizontalScrollRange()) |
| val cappedY = y.coerceIn(minVerticalScrollPosition..computeVerticalScrollRange()) |
| super.scrollTo(cappedX, cappedY) |
| } |
| |
| override fun computeHorizontalScrollRange(): Int { |
| // Note we provide scroll = 0 here, as we shouldn't consider the current scroll position |
| // to compute the maximum scroll position. Scroll position is absolute, not relative |
| val contentWidthPx = toViewCoord(contentWidth.toFloat(), zoom, scroll = 0) |
| return if (contentWidthPx < width) 0 else (contentWidthPx - width).roundToInt() |
| } |
| |
| private val minVerticalScrollPosition: Int |
| get() { |
| // Note we provide scroll = 0 here, as we shouldn't consider the current scroll position |
| // to compute the maximum scroll position. Scroll position is absolute, not relative |
| val contentHeightPx = toViewCoord(contentHeight.toFloat(), zoom, scroll = 0) |
| return if (contentHeightPx < height) { |
| // Center vertically |
| -(height - contentHeightPx).roundToInt() / 2 |
| } else { |
| 0 |
| } |
| } |
| |
| override fun computeVerticalScrollRange(): Int { |
| // Note we provide scroll = 0 here, as we shouldn't consider the current scroll position |
| // to compute the maximum scroll position. Scroll position is absolute, not relative |
| val contentHeightPx = toViewCoord(contentHeight.toFloat(), zoom, scroll = 0) |
| return if (contentHeightPx < height) { |
| // Center vertically |
| -(height - contentHeightPx).roundToInt() / 2 |
| } else { |
| (contentHeightPx - height).roundToInt() |
| } |
| } |
| |
| @VisibleForTesting |
| internal fun getDefaultZoom(): Float { |
| if (contentWidth == 0f || viewportWidth <= 0) { |
| if (awaitingFirstLayout) pendingZoomRecalculation = true |
| return DEFAULT_INIT_ZOOM |
| } |
| val widthZoom = viewportWidth.toFloat() / contentWidth |
| return MathUtils.clamp(widthZoom, minZoom, maxZoom) |
| } |
| |
| /** |
| * Returns true if we are able to restore a previous state from savedInstanceState |
| * |
| * We are not be able to restore our previous state if it pertains to a different document, or |
| * if it is missing critical data like page layout information. |
| */ |
| private fun maybeRestoreState(): Boolean { |
| val localStateToRestore = stateToRestore ?: return false |
| val localPdfDocument = pdfDocument ?: return false |
| if ( |
| localPdfDocument.uri != localStateToRestore.documentUri || |
| !localStateToRestore.hasEnoughStateToRestore |
| ) { |
| stateToRestore = null |
| return false |
| } |
| pageMetadataLoader = |
| PageMetadataLoader( |
| localPdfDocument, |
| backgroundScope, |
| topPageMarginPx = context.getDimensions(R.dimen.top_page_margin), |
| pageSpacingPx = context.getDimensions(R.dimen.page_spacing), |
| paginationModel = requireNotNull(localStateToRestore.paginationModel), |
| errorFlow = errorFlow, |
| isFormFillingEnabled = isFormFillingEnabled, |
| ) |
| .apply { onViewportChanged() } |
| selectionStateManager = |
| SelectionStateManager( |
| pdfDocument = localPdfDocument, |
| backgroundScope = backgroundScope, |
| handleTouchTargetSizePx = |
| resources.getDimensionPixelSize(R.dimen.text_select_handle_touch_size), |
| errorFlow = errorFlow, |
| pageMetadataLoader = pageMetadataLoader, |
| initialSelection = localStateToRestore.selectionModel, |
| ) |
| |
| val positionToRestore = |
| PointF(localStateToRestore.contentCenterX, localStateToRestore.contentCenterY) |
| |
| if (awaitingFirstLayout) { |
| scrollPositionToRestore = positionToRestore |
| zoomToRestore = localStateToRestore.zoom |
| oldWidth = localStateToRestore.viewWidth |
| } else { |
| scrollToRestoredPosition(positionToRestore, localStateToRestore.zoom) |
| } |
| setAccessibility() |
| |
| stateToRestore = null |
| return true |
| } |
| |
| private fun scrollToRestoredPosition(position: PointF, zoom: Float) { |
| this.zoom = zoom |
| val scrollX = (position.x * zoom - viewportWidth / 2f).roundToInt() |
| val scrollY = (position.y * zoom - viewportHeight / 2f).roundToInt() |
| scrollTo(scrollX, scrollY) |
| scrollPositionToRestore = null |
| zoomToRestore = null |
| } |
| |
| /** |
| * Launches a tree of coroutines to collect data from helper classes while we're attached to a |
| * visible window |
| */ |
| @MainThread |
| private fun startCollectingData() { |
| val mainScope = |
| CoroutineScope(HandlerCompat.createAsync(handler.looper).asCoroutineDispatcher()) |
| pageMetadataLoader?.let { manager -> |
| // Don't let two copies of this run concurrently |
| val layoutInfoToJoin = layoutInfoCollector?.apply { cancel() } |
| layoutInfoCollector = |
| mainScope.launch(start = CoroutineStart.UNDISPATCHED) { |
| // Prevent 2 copies from running concurrently |
| layoutInfoToJoin?.join() |
| launch { manager.pageInfos.collect { onPageMetaDataReceived(it) } } |
| } |
| } |
| pageManager?.let { manager -> |
| val pageSignalsToJoin = pageSignalCollector?.apply { cancel() } |
| pageSignalCollector = |
| mainScope.launch(start = CoroutineStart.UNDISPATCHED) { |
| // Prevent 2 copies from running concurrently |
| pageSignalsToJoin?.join() |
| launch { |
| manager.invalidationSignalFlow.collect { |
| isFirstPageRendered = true |
| invalidate() |
| } |
| } |
| launch { |
| manager.pageTextReadyFlow.collect { pageNum -> |
| pdfViewAccessibilityManager?.onPageTextReady(pageNum) |
| } |
| } |
| } |
| } |
| selectionStateManager?.let { manager -> |
| val selectionToJoin = selectionStateCollector?.apply { cancel() } |
| selectionStateCollector = |
| mainScope.launch(start = CoroutineStart.UNDISPATCHED) { |
| // Prevent 2 copies from running concurrently |
| selectionToJoin?.join() |
| launch { manager.selectionUiSignalBus.collect { onSelectionUiSignal(it) } } |
| launch { |
| manager.selectionModel.collect { newModel -> |
| dispatchSelectionChanged(newModel?.documentSelection?.selection) |
| } |
| } |
| } |
| } |
| |
| formWidgetInteractionHandler?.let { handler -> |
| val formEditActionToJoin = formEditInfoCollector?.apply { cancel() } |
| formEditInfoCollector = |
| mainScope.launch(start = CoroutineStart.UNDISPATCHED) { |
| formEditActionToJoin?.join() |
| launch { |
| handler.invalidatedAreas.collect { |
| val localPageLayoutManager = pageMetadataLoader ?: return@collect |
| pageManager?.maybeInvalidateAreas( |
| pageNum = it.first, |
| visibleArea = localPageLayoutManager.visiblePageAreas[it.first], |
| currentZoom = zoom, |
| areasToUpdate = it.second, |
| ) |
| pageManager?.maybeUpdateFormWidgetMetadata(it.first) |
| } |
| } |
| } |
| } |
| |
| val errorsToJoin = errorStateCollector?.apply { cancel() } |
| errorStateCollector = |
| mainScope.launch(start = CoroutineStart.UNDISPATCHED) { |
| // Prevent 2 copies from running concurrently |
| errorsToJoin?.join() |
| // Add debounce to prevents multiple, rapid error indicators from being displayed |
| // to the user in quick succession. |
| errorFlow.collect { error -> |
| val localError = |
| if (error is RequestFailedException) |
| error.copy(isFirstPageRendered = isFirstPageRendered) |
| else error |
| |
| if (localError is RequestFailedException && localError.showError) |
| showErrorInSnackbar(localError) |
| |
| requestFailedListener?.onEvent(RequestFailureEvent(localError)) |
| } |
| } |
| } |
| |
| private fun stopCollectingData() { |
| layoutInfoCollector?.cancel() |
| pageSignalCollector?.cancel() |
| selectionStateCollector?.cancel() |
| formEditInfoCollector?.cancel() |
| errorStateCollector?.cancel() |
| } |
| |
| private fun onSelectionUiSignal(signal: SelectionUiSignal) { |
| when (signal) { |
| is SelectionUiSignal.PlayHapticFeedback -> { |
| performHapticFeedback(signal.level) |
| } |
| is SelectionUiSignal.Invalidate -> { |
| invalidate() |
| } |
| is SelectionUiSignal.ToggleActionMode -> { |
| if (signal.show && selectionActionMode == null && currentSelection != null) { |
| startActionMode(selectionActionModeCallback, ActionMode.TYPE_FLOATING) |
| } else if (!signal.show) { |
| selectionActionMode?.finish() |
| selectionActionMode = null |
| } |
| } |
| } |
| } |
| |
| private fun showErrorInSnackbar(error: Throwable) { |
| val errorMsg = |
| when (error) { |
| // TODO(b/404836992): Fix strings after confirmation from UXW |
| is RequestFailedException -> context.getString(R.string.error_cannot_open_pdf) |
| else -> context.getString(R.string.error_cannot_open_pdf) |
| } |
| Snackbar.make(this, errorMsg, Snackbar.LENGTH_SHORT).show() |
| } |
| |
| /** Start using the [PdfDocument] to present PDF content */ |
| // Display.width and height are deprecated in favor of WindowMetrics, but in this case we |
| // actually want to use the size of the display and not the size of the window. |
| @Suppress("deprecation") |
| private fun onDocumentSet() { |
| val localPdfDocument = pdfDocument ?: return |
| /* We use the maximum pixel dimension of the display as the maximum pixel dimension for any |
| single Bitmap we render, i.e. the threshold for tiled rendering. This is an arbitrary, |
| but reasonable threshold to use that does not depend on volatile state like the current |
| screen orientation or the current size of our application's Window. */ |
| val maxBitmapDimensionPx = max(context.display.width, context.display.height) |
| |
| pageManager = |
| PageManager( |
| localPdfDocument, |
| backgroundScope, |
| Point(maxBitmapDimensionPx, maxBitmapDimensionPx), |
| errorFlow, |
| isAccessibilityEnabled, |
| ) |
| |
| formWidgetInteractionHandler = |
| FormWidgetInteractionHandler(context, localPdfDocument, backgroundScope, errorFlow) |
| |
| val fastScrollCalculator = FastScrollCalculator(context) |
| val fastScrollDrawer = |
| FastScrollDrawer( |
| context, |
| localPdfDocument, |
| fastScrollVerticalThumbDrawable, |
| fastScrollPageIndicatorBackgroundDrawable, |
| fastScrollVerticalThumbMarginEnd, |
| fastScrollPageIndicatorMarginEnd, |
| ) |
| |
| val localFastScroller = FastScroller(fastScrollDrawer, fastScrollCalculator) |
| fastScroller = localFastScroller |
| /* Invalidate the virtual views within the accessibility hierarchy when the fast scroller auto-hides. */ |
| fastScroller?.visibilityChangeListener = { isVisible -> |
| if (lastFastScrollerVisibility != isVisible) { |
| lastFastScrollerVisibility = isVisible |
| if (!isVisible) { |
| pdfViewAccessibilityManager?.invalidateRoot() |
| } |
| } |
| } |
| fastScrollGestureDetector = |
| FastScrollGestureDetector(localFastScroller, fastScrollGestureHandler) |
| // set initial visibility of fast scroller |
| maybeHideFastScroller() |
| |
| // We'll either create our layout and selection managers from restored state, or |
| // instantiate new ones |
| if (!maybeRestoreState()) { |
| pageMetadataLoader = |
| PageMetadataLoader( |
| localPdfDocument, |
| backgroundScope, |
| topPageMarginPx = context.getDimensions(R.dimen.top_page_margin), |
| pageSpacingPx = context.getDimensions(R.dimen.page_spacing), |
| errorFlow = errorFlow, |
| isFormFillingEnabled = isFormFillingEnabled, |
| ) |
| .apply { onViewportChanged() } |
| selectionStateManager = |
| SelectionStateManager( |
| pdfDocument = localPdfDocument, |
| backgroundScope = backgroundScope, |
| handleTouchTargetSizePx = |
| resources.getDimensionPixelSize(R.dimen.text_select_handle_touch_size), |
| errorFlow = errorFlow, |
| pageMetadataLoader = pageMetadataLoader, |
| ) |
| setAccessibility() |
| } |
| |
| // If not, we'll start doing this when we _are_ attached to a visible window |
| if (isAttachedToVisibleWindow) { |
| startCollectingData() |
| } else { |
| // Fetch the page dimensions upfront. |
| startedFetchingAllDimensions = true |
| pageMetadataLoader?.fetchAllPageDimensionsInBgGradually() |
| } |
| } |
| |
| private val View.isAttachedToVisibleWindow |
| get() = isAttachedToWindow && windowVisibility == VISIBLE |
| |
| private fun onViewportChanged() { |
| if (deferViewportUpdate) return |
| val prevVisiblePages = visiblePages |
| // If the viewport didn't actually change, short-circuit all of the downstream work |
| if (pageMetadataLoader?.onViewportChanged(getVisibleAreaInContentCoords()) != true) return |
| dispatchViewportChanged() |
| // Avoid fetching Bitmaps during active gestures like zoom and scroll, except to render |
| // net new pages |
| if (positionIsStable || visiblePages != prevVisiblePages) { |
| maybeUpdatePageVisibility() |
| } |
| pdfViewAccessibilityManager?.invalidateRoot() |
| } |
| |
| private fun dispatchViewportChanged() { |
| // If we don't have a page layout manager, we have no viewport to report |
| val localPageLayoutManager = pageMetadataLoader ?: return |
| val pageLocations = localPageLayoutManager.pageLocations |
| |
| // Copy each page location into the SparseArray dispatched to listeners, i.e. to avoid |
| // developers mutating our source of truth. Use a Pool to populate the SparseArray |
| // dispatched to listeners, i.e. to avoid excessive Rect allocations at low zoom levels |
| val dispatchedLocations = SparseArray<RectF>(pageLocations.size()) |
| for (page in pageLocations.keyIterator()) { |
| val dispatchedLocation = pageLocationsPool.acquire() ?: RectF() |
| dispatchedLocation.set(pageLocations.get(page)) |
| dispatchedLocations.put(page, dispatchedLocation.asViewRectF()) |
| } |
| |
| for (listener in onViewportChangedListeners) { |
| listener.onViewportChanged( |
| firstVisiblePage, |
| visiblePagesCount, |
| dispatchedLocations, |
| zoom, |
| ) |
| } |
| |
| // Release copied Rects that we've dispatched to our listener. Our API documentation |
| // specifies the page location Rects will be recycled and shouldn't be used beyond the |
| // scope of the listener method. |
| for (location in dispatchedLocations.valueIterator()) { |
| pageLocationsPool.release(location) |
| } |
| } |
| |
| /** |
| * Shows or hides the selection action mode, as appropriate. If the current selection is visible |
| * and a gesture is not in progress, the action mode will be shown. Otherwise, it will be |
| * hidden. |
| */ |
| private fun updateSelectionActionModeVisibility() { |
| if (selectionIsVisible() && gestureState == GESTURE_STATE_IDLE) { |
| selectionStateManager?.maybeShowActionMode() |
| selectionActionMode?.invalidateContentRect() |
| } else { |
| selectionStateManager?.maybeHideActionMode() |
| } |
| } |
| |
| private fun selectionIsVisible(): Boolean { |
| // If we don't have a selection or any way to understand the layout of our pages, the |
| // selection is not visible |
| val localSelection = currentSelection ?: return false |
| val localPageLayoutManager = pageMetadataLoader ?: return false |
| |
| val viewport = getVisibleAreaInContentCoords() |
| val firstPage = localSelection.bounds.minOf { it.pageNum } |
| val lastPage = localSelection.bounds.maxOf { it.pageNum } |
| // Top and bottom edge must be on the first and last page, respectively |
| // If we can't locate any edge of the selection, we consider it invisible |
| val topEdge = |
| localSelection.bounds |
| .filter { it.pageNum == firstPage } |
| .minByOrNull { it.top } |
| ?.let { localPageLayoutManager.getViewRect(it, viewport) } |
| ?.top ?: return false |
| val bottomEdge = |
| localSelection.bounds |
| .filter { it.pageNum == lastPage } |
| .maxByOrNull { it.bottom } |
| ?.let { localPageLayoutManager.getViewRect(it, viewport) } |
| ?.bottom ?: return false |
| // The left or right edge may be on any page |
| val leftEdge = |
| localSelection.bounds |
| .minByOrNull { it.left } |
| ?.let { localPageLayoutManager.getViewRect(it, viewport) } |
| ?.left ?: return false |
| val rightEdge = |
| localSelection.bounds |
| .maxByOrNull { it.right } |
| ?.let { localPageLayoutManager.getViewRect(it, viewport) } |
| ?.right ?: return false |
| |
| return RectF(viewport).intersects(leftEdge, topEdge, rightEdge, bottomEdge) |
| } |
| |
| private fun reset() { |
| // Stop any in progress fling when we open a new document |
| scroller.forceFinished(true) |
| scrollTo(0, 0) |
| pageManager?.cleanup() |
| zoom = DEFAULT_INIT_ZOOM |
| pageManager = null |
| pageMetadataLoader = null |
| startedFetchingAllDimensions = false |
| backgroundScope.coroutineContext.cancelChildren() |
| stopCollectingData() |
| } |
| |
| private fun maybeUpdatePageVisibility() { |
| val localPageLayoutManager = pageMetadataLoader ?: return |
| val visiblePageAreas = localPageLayoutManager.visiblePageAreas |
| pageManager?.updatePageVisibilities( |
| visiblePageAreas, |
| zoom, |
| positionIsStable, |
| localPageLayoutManager.layingOutPages, |
| ) |
| |
| if (!startedFetchingAllDimensions) { |
| startedFetchingAllDimensions = true |
| pageMetadataLoader?.fetchAllPageDimensionsInBgGradually() |
| } |
| } |
| |
| /** React to a page's metadata being made available */ |
| private fun onPageMetaDataReceived(pageInfo: PdfDocument.PageInfo) { |
| val pageNum = pageInfo.pageNum |
| val size = Point(pageInfo.width, pageInfo.height) |
| val formWidgetInfos = pageInfo.formWidgetInfos |
| |
| val localPageLayoutManager = pageMetadataLoader ?: return |
| val visiblePageArea = localPageLayoutManager.visiblePageAreas.get(pageNum) |
| pageManager?.addPage( |
| pageNum, |
| size, |
| zoom, |
| positionIsStable, |
| visiblePageArea, |
| localPageLayoutManager.layingOutPages, |
| PdfFormFillingConfig( |
| { isFormFillingEnabled }, |
| context.getColor(R.color.form_fields_highlight_color), |
| ), |
| formWidgetInfos, |
| ) |
| // Learning the dimensions of a page can change our understanding of the content that's in |
| // the viewport |
| onViewportChanged() |
| |
| // We use scrollY to center content smaller than the viewport. This triggers the initial |
| // centering if it's needed. It doesn't override any restored state because we're scrolling |
| // to the current scroll position. |
| if (pageNum == 0) { |
| // Only set default zoom if zoom is still the initial value |
| if (zoom == DEFAULT_INIT_ZOOM) { |
| this.zoom = getDefaultZoom() |
| } |
| scrollTo(scrollX, scrollY) |
| } |
| |
| val localDeferredPosition = deferredScrollPosition |
| val localDeferredPage = deferredScrollPage |
| if (localDeferredPosition != null && localDeferredPosition.pageNum <= pageNum) { |
| gotoPoint(localDeferredPosition) |
| deferredScrollPosition = null |
| } else if (localDeferredPage != null && localDeferredPage <= pageNum) { |
| gotoPage(pageNum) |
| deferredScrollPage = null |
| } |
| } |
| |
| /** Set the zoom, using the given point as a pivot point to zoom in or out of */ |
| internal fun zoomTo(zoom: Float, pivotX: Float, pivotY: Float) { |
| // TODO(b/376299551) - Restore to developer-configured initial zoom value once that API is |
| // implemented |
| val newZoom = if (Float.NaN.equals(zoom)) DEFAULT_INIT_ZOOM else zoom |
| val deltaX = scrollDeltaNeededForZoomChange(this.zoom, newZoom, pivotX, scrollX) |
| val deltaY = scrollDeltaNeededForZoomChange(this.zoom, newZoom, pivotY, scrollY) |
| |
| deferViewportUpdate = true |
| this.zoom = newZoom |
| scrollBy(deltaX, deltaY) |
| deferViewportUpdate = false |
| onViewportChanged() |
| } |
| |
| private fun scrollDeltaNeededForZoomChange( |
| oldZoom: Float, |
| newZoom: Float, |
| pivot: Float, |
| scroll: Int, |
| ): Int { |
| // Find where the given pivot point would move to when we change the zoom, and return the |
| // delta. |
| val contentPivot = toContentCoord(pivot, oldZoom, scroll) |
| val movedZoomViewPivot: Float = toViewCoord(contentPivot, newZoom, scroll) |
| return (movedZoomViewPivot - pivot).toInt() |
| } |
| |
| /** |
| * Computes the part of the content visible within the outer part of this view (including this |
| * view's padding) in co-ordinates of the content. |
| */ |
| internal fun getVisibleAreaInContentCoords(): RectF { |
| visibleAreaRect.set( |
| toContentX(0F), |
| toContentY(0F), |
| toContentX(viewportWidth.toFloat() + paddingRight + paddingLeft), |
| toContentY(viewportHeight.toFloat() + paddingBottom + paddingTop), |
| ) |
| return visibleAreaRect |
| } |
| |
| /** |
| * Initializes and sets the accessibility delegate for the PdfView. |
| * |
| * This method creates an instance of [PdfViewAccessibilityManager] if both [.pageLayoutManager] |
| * and [.pageManager] are initialized, and sets it as the accessibility delegate for the view |
| * using [ViewCompat.setAccessibilityDelegate]. |
| */ |
| private fun setAccessibility() { |
| if (pageMetadataLoader != null && pageManager != null) { |
| pdfViewAccessibilityManager = |
| PdfViewAccessibilityManager( |
| this, |
| pageMetadataLoader!!, |
| pageManager!!, |
| formWidgetInteractionHandler!!, |
| ) { |
| fastScroller |
| } |
| ViewCompat.setAccessibilityDelegate(this, pdfViewAccessibilityManager) |
| } |
| } |
| |
| /** The height of the viewport, minus padding */ |
| private val viewportHeight: Int |
| get() = bottom - top - paddingBottom - paddingTop |
| |
| /** The width of the viewport, minus padding */ |
| private val viewportWidth: Int |
| get() = right - left - paddingRight - paddingLeft |
| |
| /** |
| * Converts an X coordinate in View space (scaled) to an X coordinate in content space |
| * (unscaled) |
| */ |
| internal fun toContentX(viewX: Float): Float { |
| return toContentCoord(viewX, zoom, scrollX) |
| } |
| |
| /** |
| * Converts a Y coordinate in View space (scaled) to a Y coordinate in content space (unscaled) |
| */ |
| internal fun toContentY(viewY: Float): Float { |
| return toContentCoord(viewY, zoom, scrollY) |
| } |
| |
| private val contentWidth: Float |
| get() = pageMetadataLoader?.paginationModel?.maxWidth ?: 0f |
| |
| internal val contentHeight: Float |
| get() = pageMetadataLoader?.paginationModel?.totalEstimatedHeight ?: 0f |
| |
| /** The default [ActionMode.Callback2] for selection */ |
| private inner class DefaultSelectionActionModeCallback(private val pdfView: PdfView) : |
| ActionMode.Callback2(), SelectionMenuSession { |
| |
| private val defaultMenuItems = |
| listOf<ContextMenuComponent>( |
| SelectionMenuComponent( |
| key = PdfSelectionMenuKeys.CopyKey, |
| label = context.getString(androidR.string.copy), |
| ) { |
| // We can't copy the current selection if no text is selected |
| val text = (currentSelection as? TextSelection)?.text |
| if (text != null) copyToClipboard(text.toString()) |
| // close the context menu upon copy action |
| close() |
| }, |
| SelectionMenuComponent( |
| key = PdfSelectionMenuKeys.SelectAllKey, |
| label = context.getString(androidR.string.selectAll), |
| ) { |
| val page = currentSelection?.bounds?.first()?.pageNum |
| // We can't select all if we don't know what page the selection is on, or if |
| // we don't know the size of that page |
| if (page != null) selectionStateManager?.selectAllTextOnPageAsync(page) |
| }, |
| ) |
| private lateinit var selectionMenuItems: MutableList<ContextMenuComponent> |
| |
| override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { |
| pdfView.selectionActionMode = mode |
| // Start afresh with the default menu items |
| selectionMenuItems = defaultMenuItems.toMutableList() |
| selectionMenuItemPreparer?.onPrepareSelectionMenuItems(selectionMenuItems) |
| |
| selectionMenuItems.forEachIndexed { i, component -> |
| if (component is SelectionMenuComponent) { |
| val menuItem = |
| menu?.add( |
| /* groupId = */ NONE, |
| /* itemId = */ i, |
| /* order = */ NONE, |
| /* title = */ component.label, |
| ) |
| component.contentDescription?.let { menuItem?.contentDescription = it } |
| menuItem?.setOnMenuItemClickListener { |
| component.onClick(this) |
| true |
| } |
| } |
| } |
| return true |
| } |
| |
| override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { |
| return false |
| } |
| |
| override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { |
| return false |
| } |
| |
| override fun close() { |
| pdfView.clearSelection() |
| } |
| |
| private fun copyToClipboard(text: String) { |
| val manager = context.getSystemService(ClipboardManager::class.java) |
| val clip = ClipData.newPlainText(context.getString(R.string.clipboard_label), text) |
| manager.setPrimaryClip(clip) |
| } |
| |
| override fun onDestroyActionMode(mode: ActionMode?) { |
| // No-op |
| } |
| |
| override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) { |
| // If we don't know about page layout, defer to the default implementation |
| val localPageLayoutManager = |
| pdfView.pageMetadataLoader ?: return super.onGetContentRect(mode, view, outRect) |
| val viewport = pdfView.getVisibleAreaInContentCoords() |
| val firstSelection = pdfView.currentSelection?.bounds?.firstOrNull() |
| val lastSelection = pdfView.currentSelection?.bounds?.lastOrNull() |
| |
| // Try to position the context menu near the first selection if it's visible |
| if (firstSelection != null) { |
| // Copy bounds to avoid mutating the real data |
| val boundsInView = localPageLayoutManager.getViewRect(firstSelection, viewport) |
| if ( |
| boundsInView?.let { |
| viewport.intersects(it.left, it.top, it.right, it.bottom) |
| } == true |
| ) { |
| outRect?.set(pdfView.toViewRect(boundsInView)) |
| return |
| } |
| } |
| |
| // Else, try to position the context menu near the last selection if it's visible |
| if (lastSelection != null) { |
| // Copy bounds to avoid mutating the real data |
| val boundsInView = localPageLayoutManager.getViewRect(lastSelection, viewport) |
| if ( |
| boundsInView?.let { |
| viewport.intersects(it.left, it.top, it.right, it.bottom) |
| } == true |
| ) { |
| outRect?.set(pdfView.toViewRect(boundsInView)) |
| return |
| } |
| } |
| |
| // Else, center the context menu in view |
| val centerX = (pdfView.x + pdfView.width / 2).roundToInt() |
| val centerY = (pdfView.y + pdfView.height / 2).roundToInt() |
| outRect?.set(centerX, centerY, centerX + 1, centerY + 1) |
| } |
| } |
| |
| /** Returns a new [Rect] representing [contentRect] in View coordinates */ |
| private fun toViewRect(contentRect: RectF): Rect = |
| toViewRect(contentRect.left, contentRect.top, contentRect.right, contentRect.bottom) |
| |
| /** Returns a new [Rect] representing [contentRect] in View coordinates */ |
| private fun toViewRect(contentRect: Rect): Rect = |
| toViewRect(contentRect.left, contentRect.top, contentRect.right, contentRect.bottom) |
| |
| private fun toViewRect(left: Number, top: Number, right: Number, bottom: Number): Rect { |
| return Rect( |
| toViewCoord(left.toFloat(), zoom, scrollX).roundToInt(), |
| toViewCoord(top.toFloat(), zoom, scrollY).roundToInt(), |
| toViewCoord(right.toFloat(), zoom, scrollX).roundToInt(), |
| toViewCoord(bottom.toFloat(), zoom, scrollY).roundToInt(), |
| ) |
| } |
| |
| /** Converts an existing [RectF] in content coordinates to View coordinates */ |
| private fun RectF.asViewRectF(): RectF { |
| this.set( |
| toViewCoord(left, zoom, scrollX), |
| toViewCoord(top, zoom, scrollY), |
| toViewCoord(right, zoom, scrollX), |
| toViewCoord(bottom, zoom, scrollY), |
| ) |
| return this |
| } |
| |
| /** Adjusts the position of [PdfView] in response to gestures detected by [GestureTracker] */ |
| private inner class ZoomScrollGestureHandler : GestureTracker.GestureHandler() { |
| |
| /** |
| * The multiplier to convert from a scale gesture's delta span, in pixels, to scale factor. |
| * |
| * [ScaleGestureDetector] returns scale factors proportional to the ratio of `currentSpan / |
| * prevSpan`. This is problematic because it results in scale factors that are very large |
| * for small pixel spans, which is particularly problematic for quickScale gestures, where |
| * the span pixel values can be small, but the ratio can yield very large scale factors. |
| * |
| * Instead, we use this to ensure that pinching or quick scale dragging a certain number of |
| * pixels always corresponds to a certain change in zoom. The equation that we've found to |
| * work well is a delta span of the larger screen dimension should result in a zoom change |
| * of 2x. |
| */ |
| private val linearScaleSpanMultiplier: Float = |
| 2f / maxOf(resources.displayMetrics.heightPixels, resources.displayMetrics.widthPixels) |
| |
| /** The maximum scroll distance used to determine if the direction is vertical. */ |
| private val maxScrollWindow = |
| (resources.displayMetrics.density * MAX_SCROLL_WINDOW_DP).toInt() |
| |
| /** The smallest scroll distance that can switch mode to "free scrolling". */ |
| private val minScrollToSwitch = |
| (resources.displayMetrics.density * MIN_SCROLL_TO_SWITCH_DP).toInt() |
| |
| /** Remember recent scroll events so we can examine the general direction. */ |
| private val scrollQueue: Queue<PointF> = LinkedList() |
| |
| /** Are we correcting vertical scroll for the current gesture? */ |
| private var straightenCurrentVerticalScroll = true |
| |
| private var totalX = 0f |
| private var totalY = 0f |
| |
| private val totalScrollLength |
| // No need for accuracy of correct hypotenuse calculation |
| get() = abs(totalX) + abs(totalY) |
| |
| override fun onGestureStart() { |
| // Stop any in-progress fling when a new gesture begins |
| scroller.forceFinished(true) |
| dispatchGestureStateChangedUnlessFastScroll(newState = GESTURE_STATE_INTERACTING) |
| // We should hide the action mode during a gesture |
| updateSelectionActionModeVisibility() |
| } |
| |
| override fun onGestureEnd(gesture: GestureTracker.Gesture?) { |
| // Update page visibility after scroll / zoom gestures end, because we avoid fetching |
| // certain data while those gestures are in progress |
| if (gesture in ZOOM_OR_SCROLL_GESTURES) maybeUpdatePageVisibility() |
| val newState = |
| if (gesture !in ANIMATED_GESTURES) { |
| GESTURE_STATE_IDLE |
| } else { |
| GESTURE_STATE_SETTLING |
| } |
| dispatchGestureStateChangedUnlessFastScroll(newState) |
| totalX = 0f |
| totalY = 0f |
| straightenCurrentVerticalScroll = true |
| scrollQueue.clear() |
| // We should reveal the action mode after a gesture |
| updateSelectionActionModeVisibility() |
| } |
| |
| override fun onScroll( |
| e1: MotionEvent?, |
| e2: MotionEvent, |
| distanceX: Float, |
| distanceY: Float, |
| ): Boolean { |
| var dx = distanceX.roundToInt() |
| val dy = distanceY.roundToInt() |
| |
| if (straightenCurrentVerticalScroll) { |
| // Remember a window of recent scroll events. |
| scrollQueue.offer(PointF(distanceX, distanceY)) |
| totalX += distanceX |
| totalY += distanceY |
| |
| // Only consider scroll direction for a certain window of scroll events. |
| while (totalScrollLength > maxScrollWindow && scrollQueue.size > 1) { |
| // Remove the oldest scroll event - it is too far away to determine scroll |
| // direction. |
| val oldest = scrollQueue.poll() |
| oldest?.let { |
| totalY -= oldest.y |
| totalX -= oldest.x |
| } |
| } |
| |
| if ( |
| totalScrollLength > minScrollToSwitch && |
| abs((totalY / totalX).toDouble()) < SCROLL_CORRECTION_RATIO |
| ) { |
| straightenCurrentVerticalScroll = false |
| } else { |
| // Ignore the horizontal component of the scroll. |
| dx = 0 |
| } |
| } |
| |
| scrollBy(dx, dy) |
| return true |
| } |
| |
| override fun onFling( |
| e1: MotionEvent?, |
| e2: MotionEvent, |
| velocityX: Float, |
| velocityY: Float, |
| ): Boolean { |
| // Assume a fling in a roughly vertical direction was meant to be exactly vertical. |
| val myVelocityX = |
| if (velocityY / velocityX > SCROLL_CORRECTION_RATIO) { |
| 0 |
| } else { |
| velocityX |
| } |
| |
| isFling = true |
| scroller.fling( |
| scrollX, |
| scrollY, |
| -myVelocityX.toInt(), |
| -velocityY.toInt(), |
| /* minX= */ minVerticalScrollPosition, |
| computeHorizontalScrollRange(), |
| minVerticalScrollPosition, |
| computeVerticalScrollRange(), |
| ) |
| |
| postInvalidateOnAnimation() // Triggers computeScroll() |
| return true |
| } |
| |
| override fun onLongPress(e: MotionEvent) { |
| super.onLongPress(e) |
| val pageLayoutManager = pageMetadataLoader ?: return super.onLongPress(e) |
| val touchPoint = |
| pageLayoutManager.getPdfPointAt( |
| toContentX(e.x), |
| toContentY(e.y), |
| getVisibleAreaInContentCoords(), |
| ) ?: return super.onLongPress(e) |
| |
| selectionStateManager?.maybeSelectWordAtPoint(touchPoint) |
| } |
| |
| override fun onDoubleTap(e: MotionEvent): Boolean { |
| val currentZoom = zoom |
| |
| val newZoom = |
| ZoomUtils.calculateZoomForDoubleTap( |
| viewportWidth, |
| viewportHeight, |
| contentWidth, |
| currentZoom, |
| minZoom, |
| maxZoom, |
| ) |
| if (newZoom == 0f) { |
| // viewport not initialized yet maybe? |
| return false |
| } |
| doubleTapAnimator?.cancel() |
| |
| doubleTapAnimator = |
| ValueAnimator.ofFloat(0f, 1f).apply { |
| // Slightly shorter duration for snappier feel |
| duration = DOUBLE_TAP_ANIMATION_DURATION_MS |
| addUpdateListener { animator -> |
| val animatedValue = animator.animatedValue as Float |
| val value = currentZoom + (newZoom - currentZoom) * animatedValue |
| zoomTo(value, e.x, e.y) |
| } |
| // We avoid pinging pages with new zoom states and fetching new bitmaps during |
| // animations. Update pages with the final zoom state when the animation ends |
| addListener( |
| onCancel = { |
| dispatchGestureStateChangedUnlessFastScroll( |
| newState = GESTURE_STATE_IDLE |
| ) |
| maybeUpdatePageVisibility() |
| }, |
| onEnd = { |
| dispatchGestureStateChangedUnlessFastScroll( |
| newState = GESTURE_STATE_IDLE |
| ) |
| maybeUpdatePageVisibility() |
| }, |
| ) |
| |
| start() |
| } |
| |
| return true |
| } |
| |
| override fun onScale(detector: ScaleGestureDetector): Boolean { |
| val rawScaleFactor = detector.scaleFactor |
| val deltaSpan = abs(detector.currentSpan - detector.previousSpan) |
| val scaleDelta = deltaSpan * linearScaleSpanMultiplier |
| val linearScaleFactor = if (rawScaleFactor >= 1f) 1f + scaleDelta else 1f - scaleDelta |
| val newZoom = (zoom * linearScaleFactor).coerceIn(minZoom, maxZoom) |
| |
| zoomTo(newZoom, detector.focusX, detector.focusY) |
| return true |
| } |
| |
| override fun onSingleTapConfirmed(e: MotionEvent): Boolean { |
| selectionStateManager?.clearSelection() |
| val pageLayoutManager = pageMetadataLoader ?: return super.onSingleTapConfirmed(e) |
| val touchPoint = |
| pageLayoutManager.getPdfPointAt( |
| toContentX(e.x), |
| toContentY(e.y), |
| getVisibleAreaInContentCoords(), |
| ) ?: return super.onSingleTapConfirmed(e) |
| |
| pageManager?.getLinkAtTapPoint(touchPoint)?.let { links -> |
| val touchPointOnPage = PointF(touchPoint.x, touchPoint.y) |
| if (handleGotoLinks(links, touchPointOnPage)) return true |
| if (handleExternalLinks(links, touchPointOnPage)) return true |
| } |
| |
| pageManager?.getWidgetAtTapPoint(touchPoint)?.let { widgets -> |
| if (handleTapOnFormWidget(widgets, touchPoint)) return true |
| } |
| |
| return super.onSingleTapConfirmed(e) |
| } |
| |
| private fun handleGotoLinks( |
| links: PdfDocument.PdfPageLinks, |
| pdfCoordinates: PointF, |
| ): Boolean { |
| links.gotoLinks.forEach { gotoLink -> |
| if (gotoLink.bounds.any { it.contains(pdfCoordinates.x, pdfCoordinates.y) }) { |
| val destination = |
| PdfPoint( |
| pageNum = gotoLink.destination.pageNumber, |
| pagePoint = |
| PointF( |
| gotoLink.destination.xCoordinate, |
| gotoLink.destination.yCoordinate, |
| ), |
| ) |
| |
| scrollToPosition(destination) |
| return true |
| } |
| } |
| return false |
| } |
| |
| private fun handleExternalLinks( |
| links: PdfDocument.PdfPageLinks, |
| pdfCoordinates: PointF, |
| ): Boolean { |
| links.externalLinks.forEach { externalLink -> |
| if (externalLink.bounds.any { it.contains(pdfCoordinates.x, pdfCoordinates.y) }) { |
| val link = ExternalLink(externalLink.uri) |
| if (linkClickListener?.onLinkClicked(link) == true) { |
| return true |
| } else { |
| try { |
| val intent = Intent(Intent.ACTION_VIEW, link.uri) |
| context.startActivity(intent) |
| } catch (_: Exception) { |
| return false |
| } |
| } |
| return true |
| } |
| } |
| return false |
| } |
| |
| private fun handleTapOnFormWidget( |
| formWidgetInfos: List<FormWidgetInfo>, |
| touchPoint: PdfPoint, |
| ): Boolean { |
| formWidgetInfos.forEach { formWidgetInfo -> |
| // TODO: b/410008790 Implement business logic to perform action on form widget |
| if ( |
| formWidgetInfo.widgetRect.contains( |
| touchPoint.x.roundToInt(), |
| touchPoint.y.roundToInt(), |
| ) |
| ) { |
| formWidgetInteractionHandler?.handleInteraction(touchPoint, formWidgetInfo) |
| return true |
| } |
| } |
| return false |
| } |
| } |
| |
| public companion object { |
| /** The PdfView is not currently being affected by an outside input, e.g. user touch */ |
| public const val GESTURE_STATE_IDLE: Int = 0 |
| |
| /** The PdfView is currently being affected by an outside input, e.g. user touch */ |
| public const val GESTURE_STATE_INTERACTING: Int = 1 |
| |
| /** |
| * The PdfView is currently animating to a final position while not under outside control, |
| * e.g. settling on a final position following a fling gesture. |
| */ |
| public const val GESTURE_STATE_SETTLING: Int = 2 |
| |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public const val DEFAULT_INIT_ZOOM: Float = 1.0f |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public const val DEFAULT_MAX_ZOOM: Float = 25.0f |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public const val DEFAULT_MIN_ZOOM: Float = 0.5f |
| |
| /** The ratio of vertical to horizontal scroll that is assumed to be vertical only */ |
| private const val SCROLL_CORRECTION_RATIO = 1.5f |
| |
| /** The maximum scroll distance used to determine if the direction is vertical */ |
| private const val MAX_SCROLL_WINDOW_DP = 70 |
| |
| /** The smallest scroll distance that can switch mode to "free scrolling" */ |
| private const val MIN_SCROLL_TO_SWITCH_DP = 30 |
| |
| /** The duration of the double tap to zoom animation, in milliseconds */ |
| private const val DOUBLE_TAP_ANIMATION_DURATION_MS = 200L |
| |
| /** The amount of delay between two scroll events */ |
| private const val AUTO_SCROLL_DELAY_IN_MILLIS = 5L |
| |
| /** |
| * The tolerance in percentage to control how close the touch point needs to be to the |
| * bottom or top of the viewport for scroll-during-selection to start. |
| */ |
| private const val SCROLL_SELECTION_TOLERANCE_RATIO = 0.85f |
| |
| /** The amount to control how much it scrolls while selection */ |
| private const val AUTO_SCROLL_BY_VALUE = 20 |
| |
| private val ZOOM_OR_SCROLL_GESTURES = |
| setOf( |
| GestureTracker.Gesture.ZOOM, |
| GestureTracker.Gesture.DRAG, |
| GestureTracker.Gesture.DRAG_X, |
| GestureTracker.Gesture.DRAG_Y, |
| GestureTracker.Gesture.FLING, |
| ) |
| |
| private val ANIMATED_GESTURES = |
| setOf(GestureTracker.Gesture.FLING, GestureTracker.Gesture.DOUBLE_TAP) |
| |
| private val VALID_GESTURE_STATES = |
| setOf(GESTURE_STATE_IDLE, GESTURE_STATE_INTERACTING, GESTURE_STATE_SETTLING) |
| |
| private fun checkMainThread() { |
| check(Looper.myLooper() == Looper.getMainLooper()) { |
| "Property must be set on the main thread" |
| } |
| } |
| |
| /** |
| * Converts a one-dimensional coordinate in View space (scaled, offset by scroll position) |
| * to a one-dimensional coordinate in content space (unscaled). |
| * |
| * In both coordinate spaces the origin is at the top left corner of the page with the |
| * positive X direction being left and the positive Y direction being down. |
| */ |
| internal fun toContentCoord(viewCoord: Float, zoom: Float, scroll: Int): Float { |
| return (viewCoord + scroll) / zoom |
| } |
| |
| /** |
| * Converts a one-dimensional coordinate in content space (unscaled) to a View coordinate |
| * (scaled, offset by scroll position) |
| * |
| * In both coordinate spaces the origin is at the top left corner of the page with the |
| * positive X direction being left and the positive Y direction being down. |
| */ |
| internal fun toViewCoord(contentCoord: Float, zoom: Float, scroll: Int): Float { |
| return (contentCoord * zoom) - scroll |
| } |
| } |
| } |