1 /* 2 * Copyright 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.pdf.widget; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.view.MotionEvent; 22 import android.view.View; 23 import android.view.View.OnTouchListener; 24 import android.view.ViewGroup; 25 import android.view.ViewGroup.LayoutParams; 26 import android.widget.ImageView; 27 28 import androidx.annotation.RestrictTo; 29 import androidx.pdf.R; 30 import androidx.pdf.util.ObservableValue; 31 import androidx.pdf.util.ObservableValue.ValueObserver; 32 import androidx.pdf.widget.ZoomView.ZoomScroll; 33 34 import org.jspecify.annotations.NonNull; 35 36 /** 37 * Base class for selection handles on content inside a ZoomView. 38 * 39 * @param <S> The type of the selection that this class observes, updating the 40 * handles whenever it changes. 41 */ 42 @RestrictTo(RestrictTo.Scope.LIBRARY) 43 @SuppressWarnings("deprecation") 44 public abstract class ZoomableSelectionHandles<S> { 45 private static final float SCALE_OFFSET = 0.5f; 46 private static final float HANDLE_ALPHA = 1.0f; 47 private static final float RIGHT_HANDLE_X_MARGIN = -0.25f; 48 private static final float LEFT_HANDLE_X_MARGIN = -0.75f; 49 protected final ZoomView mZoomView; 50 protected final ObservableValue<S> mSelectionObservable; 51 52 protected final ImageView mStartHandle; 53 protected final ImageView mStopHandle; 54 55 protected final OnTouchListener mOnTouchListener; 56 57 /** Handle objects for removing observers at end of life. */ 58 protected final Object mSelectionObserverKey; 59 protected final Object mZoomViewObserverKey; 60 61 protected S mSelection; 62 ZoomableSelectionHandles(@onNull ZoomView zoomView, @NonNull ViewGroup handleParent, @NonNull ObservableValue<S> selectionObservable)63 protected ZoomableSelectionHandles(@NonNull ZoomView zoomView, @NonNull ViewGroup handleParent, 64 @NonNull ObservableValue<S> selectionObservable) { 65 this.mZoomView = zoomView; 66 this.mSelectionObservable = selectionObservable; 67 68 this.mOnTouchListener = new HandleTouchListener(); 69 70 Resources resources = handleParent.getContext().getResources(); 71 String packageName = handleParent.getContext().getPackageName(); 72 int startHandleId = resources.getIdentifier("start_drag_handle", "id", packageName); 73 int stopHandleId = resources.getIdentifier("stop_drag_handle", "id", packageName); 74 this.mStartHandle = createHandle(handleParent, false, startHandleId); 75 this.mStopHandle = createHandle(handleParent, true, stopHandleId); 76 77 mSelectionObserverKey = createSelectionObserver(); 78 mZoomViewObserverKey = createZoomViewObserver(); 79 } 80 createSelectionObserver()81 protected @NonNull Object createSelectionObserver() { 82 return mSelectionObservable.addObserver(new ValueObserver<S>() { 83 @Override 84 public void onChange(S oldSelection, S newSelection) { 85 mSelection = newSelection; 86 updateHandles(); 87 } 88 89 @Override 90 public @NonNull String toString() { 91 return "ZoomableSelectionHandles#selectionObserver"; 92 } 93 }); 94 } 95 96 protected @NonNull Object createZoomViewObserver() { 97 return mZoomView.zoomScroll().addObserver(new ValueObserver<ZoomScroll>() { 98 @Override 99 public void onChange(ZoomScroll oldValue, ZoomScroll newValue) { 100 if (oldValue.zoom != newValue.zoom) { 101 updateHandles(); 102 } 103 } 104 105 @Override 106 public @NonNull String toString() { 107 return "ZoomableSelectionHandles#zoomViewObserver"; 108 } 109 }); 110 } 111 112 /** Destroy start and stop handles. */ 113 public void destroy() { 114 mSelectionObservable.removeObserver(mSelectionObserverKey); 115 mZoomView.zoomScroll().removeObserver(mZoomViewObserverKey); 116 destroyHandle(mStartHandle); 117 destroyHandle(mStopHandle); 118 } 119 120 /** 121 * Show or hide both handles, according to the current selection. Should delegate 122 * to {@link #hideHandles} or to {@link #showHandle} - showHandle will take the 123 * zoom into account when displaying the handles. 124 */ 125 protected abstract void updateHandles(); 126 127 protected void hideHandles() { 128 mStartHandle.setVisibility(View.GONE); 129 mStopHandle.setVisibility(View.GONE); 130 } 131 132 protected void showHandle(@NonNull ImageView handle, float rawX, float rawY, boolean isRight) { 133 int resId = isRight 134 ? R.drawable.selection_drag_handle_right 135 : R.drawable.selection_drag_handle_left; 136 handle.setImageResource(resId); 137 138 // The sharp point of the handle is found at a particular point in the image - 139 // (25%, 0%) for the right handle, and (75%, 0%) for a left handle. We apply these 140 // as negative margins so that the handle's point is at the point specified. 141 float xMargin = isRight ? RIGHT_HANDLE_X_MARGIN : LEFT_HANDLE_X_MARGIN; 142 float yMargin = 0; 143 float scale = 1.0f / mZoomView.getZoom(); 144 float x = calcTranslation(rawX, handle.getDrawable().getIntrinsicWidth(), scale, xMargin); 145 float y = calcTranslation(rawY, handle.getDrawable().getIntrinsicHeight(), scale, yMargin); 146 147 handle.setScaleX(scale); 148 handle.setScaleY(scale); 149 handle.setTranslationX(x); 150 handle.setTranslationY(y); 151 handle.setVisibility(View.VISIBLE); 152 } 153 154 protected float calcTranslation(float rawPos, float rawSize, float scale, float margin) { 155 float result = rawPos; 156 // Undo translation of top-left corner that is a side effect of scaling around the image 157 // center: 158 result += SCALE_OFFSET * rawSize * (scale - 1); 159 // Apply margin to top-left corner. 160 result += margin * rawSize * scale; 161 return result; 162 } 163 164 /** 165 * Creates a new text selection handle ImageView and adds it to the parent. Returns the handle. 166 */ 167 protected @NonNull ImageView createHandle(@NonNull ViewGroup parent, boolean isStop, int id) { 168 Context context = parent.getContext(); 169 ImageView handle = new ImageView(context); 170 handle.setId(id); 171 handle.setLayoutParams( 172 new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); 173 handle.setColorFilter( 174 context.getResources().getColor(R.color.pdf_viewer_selection_handles)); 175 handle.setAlpha(HANDLE_ALPHA); 176 177 int descId = isStop ? R.string.desc_selection_stop : R.string.desc_selection_start; 178 handle.setContentDescription(context.getString(descId)); 179 handle.setVisibility(View.GONE); 180 parent.addView(handle); 181 handle.setOnTouchListener(mOnTouchListener); 182 return handle; 183 } 184 185 protected void destroyHandle(@NonNull ImageView handle) { 186 handle.setOnTouchListener(null); 187 if (handle.getParent() != null) { 188 ((ViewGroup) handle.getParent()).removeView(handle); 189 } 190 } 191 192 /** 193 * Handle drag begins. Implementation should note which handle is being dragged and store the 194 * original positions of both handles. 195 */ 196 protected abstract void onDragHandleDown(boolean isStopHandle); 197 198 /** 199 * Handle drag continues. Implementation should update the selection so that it spans between 200 * the fixed handle, and the new position of the dragging handle, which is moved by 201 * (deltaX, deltaY) from its original position. 202 */ 203 protected abstract void onDragHandleMove(int deltaX, int deltaY); 204 205 /** Handle drag stops. The implementation might not need to do anything here. */ 206 protected abstract void onDragHandleUp(); 207 208 /** 209 * Touch listener for each handle that works out what the new boundary locations 210 * should be by measuring where they are dragged to, and notifies the 211 * onDragSelectionListener (if it is set). 212 */ 213 private class HandleTouchListener implements OnTouchListener { 214 private float mXDragDown; 215 private float mYDragDown; 216 217 @Override 218 public boolean onTouch(View view, MotionEvent e) { 219 boolean isStopHandle = (view == mStopHandle); 220 221 switch (e.getActionMasked()) { 222 case MotionEvent.ACTION_DOWN: 223 // Starting a new drag: just record where we are starting: 224 onDragHandleDown(isStopHandle); 225 mXDragDown = e.getRawX(); 226 mYDragDown = e.getRawY(); 227 mZoomView.requestDisallowInterceptTouchEvent(true); 228 break; 229 case MotionEvent.ACTION_MOVE: 230 // Handle has been dragged: find out where it is now and update listener. 231 int xDelta = (int) ((e.getRawX() - mXDragDown) / mZoomView.getZoom()); 232 int yDelta = (int) ((e.getRawY() - mYDragDown) / mZoomView.getZoom()); 233 onDragHandleMove(xDelta, yDelta); 234 break; 235 case MotionEvent.ACTION_CANCEL: 236 case MotionEvent.ACTION_UP: 237 onDragHandleUp(); 238 mZoomView.requestDisallowInterceptTouchEvent(false); 239 } 240 241 return true; 242 } 243 } 244 245 } 246