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