FreeRDP
Loading...
Searching...
No Matches
TouchPointerView.java
1/*
2 Android Touch Pointer view
3
4 Copyright 2013 Thincast Technologies GmbH, Author: Martin Fleisz
5 Copyright 2026 Ibrahim Sevinc <ibrahim.sevinc.mail@gmail.com>
6
7 This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
8 If a copy of the MPL was not distributed with this file, You can obtain one at
9 http://mozilla.org/MPL/2.0/.
10*/
11
12package com.freerdp.freerdpcore.presentation;
13
14import android.animation.ValueAnimator;
15import android.content.Context;
16import android.content.res.ColorStateList;
17import android.graphics.Bitmap;
18import android.graphics.drawable.BitmapDrawable;
19import android.os.Handler;
20import android.os.Looper;
21import android.util.AttributeSet;
22import android.view.LayoutInflater;
23import android.view.MotionEvent;
24import android.view.View;
25import android.view.ViewConfiguration;
26import android.view.ViewGroup;
27import android.widget.FrameLayout;
28import android.widget.ImageButton;
29import android.widget.ImageView;
30
31import androidx.core.content.ContextCompat;
32import androidx.core.widget.ImageViewCompat;
33
34import com.freerdp.freerdpcore.R;
35
36// Full-screen overlay hosting a draggable touch-pointer button cluster.
37public class TouchPointerView extends FrameLayout
38{
39 private static final float SCROLL_DELTA = 10.0f;
40 private static final int LONG_PRESS_MS = 500;
41
42 private View cluster;
43 private ImageView cursor;
44 private ImageButton scrollButton;
45
46 private TouchPointerListener listener = null;
47
48 private float density;
49 private int touchSlop;
50 private boolean placed = false;
51
52 // puck drag state
53 private float downRawX, downRawY, startTransX, startTransY;
54 private boolean dragging = false;
55 private boolean holdDragging = false;
56
57 // scroll state
58 private float scrollLastRawY;
59 private float scrollBaseHeight;
60 private ValueAnimator pillAnimator;
61
62 private int cursorTint;
63
64 private final Handler uiHandler = new Handler(Looper.getMainLooper());
65 private final Runnable longPress = () ->
66 {
67 if (!dragging && !holdDragging)
68 {
69 holdDragging = true;
70 sendLeft(true);
71 }
72 };
73
74 public TouchPointerView(Context context)
75 {
76 this(context, null);
77 }
78
79 public TouchPointerView(Context context, AttributeSet attrs)
80 {
81 this(context, attrs, 0);
82 }
83
84 public TouchPointerView(Context context, AttributeSet attrs, int defStyle)
85 {
86 super(context, attrs, defStyle);
87 initTouchPointer(context);
88 }
89
90 private void initTouchPointer(Context context)
91 {
92 density = getResources().getDisplayMetrics().density;
93 touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
94 cursorTint = ContextCompat.getColor(context, R.color.tp_icon);
95 setClipChildren(false);
96
97 LayoutInflater.from(context).inflate(R.layout.touch_pointer, this, true);
98 cluster = findViewById(R.id.tp_cluster);
99 cursor = findViewById(R.id.tp_cursor);
100 scrollButton = findViewById(R.id.tp_scroll);
101
102 findViewById(R.id.tp_puck).setOnTouchListener((v, e) -> onPuckTouch(e));
103 scrollButton.setOnTouchListener((v, e) -> onScrollTouch(e));
104
105 findViewById(R.id.tp_close).setOnClickListener(v -> {
106 if (listener != null)
107 listener.onTouchPointerClose();
108 });
109 findViewById(R.id.tp_rclick).setOnClickListener(v -> {
110 int[] h = hotspot();
111 if (listener != null)
112 {
113 listener.onTouchPointerRightClick(h[0], h[1], true);
114 listener.onTouchPointerRightClick(h[0], h[1], false);
115 }
116 });
117 findViewById(R.id.tp_reset).setOnClickListener(v -> {
118 if (listener != null)
119 listener.onTouchPointerResetScrollZoom();
120 });
121 findViewById(R.id.tp_keyboard).setOnClickListener(v -> {
122 if (listener != null)
123 listener.onTouchPointerToggleKeyboard();
124 });
125 }
126
127 public void setTouchPointerListener(TouchPointerListener listener)
128 {
129 this.listener = listener;
130 }
131
132 public int getPointerWidth()
133 {
134 return cluster.getWidth() > 0
135 ? cluster.getWidth()
136 : getResources().getDimensionPixelSize(R.dimen.tp_cluster_size);
137 }
138
139 public int getPointerHeight()
140 {
141 return cluster.getHeight() > 0
142 ? cluster.getHeight()
143 : getResources().getDimensionPixelSize(R.dimen.tp_cluster_size);
144 }
145
146 public float[] getPointerPosition()
147 {
148 return new float[] { cluster.getX(), cluster.getY() };
149 }
150
151 // click hotspot == cursor tip == cluster top-left corner, in overlay coords
152 private int[] hotspot()
153 {
154 return new int[] { (int)cluster.getX(), (int)cluster.getY() };
155 }
156
157 private void sendLeft(boolean down)
158 {
159 int[] h = hotspot();
160 if (listener != null)
161 listener.onTouchPointerLeftClick(h[0], h[1], down);
162 }
163
164 private void sendMove()
165 {
166 int[] h = hotspot();
167 if (listener != null)
168 listener.onTouchPointerMove(h[0], h[1]);
169 }
170
171 private void setClusterTranslation(float tx, float ty)
172 {
173 float maxX = getWidth() - cluster.getWidth();
174 float maxY = getHeight() - cluster.getHeight();
175 if (tx < 0)
176 tx = 0;
177 if (ty < 0)
178 ty = 0;
179 if (maxX > 0 && tx > maxX)
180 tx = maxX;
181 if (maxY > 0 && ty > maxY)
182 ty = maxY;
183 cluster.setTranslationX(tx);
184 cluster.setTranslationY(ty);
185 }
186
187 @Override protected void onLayout(boolean changed, int l, int t, int r, int b)
188 {
189 super.onLayout(changed, l, t, r, b);
190 if (!placed && getWidth() > 0 && cluster.getWidth() > 0)
191 {
192 placed = true;
193 setClusterTranslation((getWidth() - cluster.getWidth()) / 2.0f,
194 (getHeight() - cluster.getHeight()) / 2.0f);
195 }
196 else
197 {
198 setClusterTranslation(cluster.getTranslationX(), cluster.getTranslationY());
199 }
200 }
201
202 private boolean onPuckTouch(MotionEvent e)
203 {
204 switch (e.getActionMasked())
205 {
206 case MotionEvent.ACTION_DOWN:
207 downRawX = e.getRawX();
208 downRawY = e.getRawY();
209 startTransX = cluster.getTranslationX();
210 startTransY = cluster.getTranslationY();
211 dragging = false;
212 holdDragging = false;
213 uiHandler.postDelayed(longPress, LONG_PRESS_MS);
214 return true;
215 case MotionEvent.ACTION_MOVE:
216 {
217 float dx = e.getRawX() - downRawX;
218 float dy = e.getRawY() - downRawY;
219 if (!dragging && Math.hypot(dx, dy) > touchSlop)
220 {
221 dragging = true;
222 if (!holdDragging)
223 uiHandler.removeCallbacks(longPress);
224 }
225 if (dragging || holdDragging)
226 {
227 setClusterTranslation(startTransX + dx, startTransY + dy);
228 sendMove();
229 }
230 return true;
231 }
232 case MotionEvent.ACTION_UP:
233 uiHandler.removeCallbacks(longPress);
234 if (holdDragging)
235 {
236 sendLeft(false);
237 holdDragging = false;
238 }
239 else if (!dragging)
240 {
241 // tap -> left click (two quick taps register as a double-click)
242 sendLeft(true);
243 sendLeft(false);
244 }
245 if (listener != null)
246 listener.onTouchPointerMoveEnd();
247 return true;
248 case MotionEvent.ACTION_CANCEL:
249 uiHandler.removeCallbacks(longPress);
250 if (holdDragging)
251 {
252 sendLeft(false);
253 holdDragging = false;
254 }
255 if (listener != null)
256 listener.onTouchPointerMoveEnd();
257 return true;
258 }
259 return false;
260 }
261
262 private boolean onScrollTouch(MotionEvent e)
263 {
264 switch (e.getActionMasked())
265 {
266 case MotionEvent.ACTION_DOWN:
267 scrollLastRawY = e.getRawY();
268 scrollButton.setActivated(true);
269 scrollButton.bringToFront();
270 morphScroll(true);
271 return true;
272 case MotionEvent.ACTION_MOVE:
273 {
274 float dy = e.getRawY() - scrollLastRawY;
275 if (dy > SCROLL_DELTA)
276 {
277 if (listener != null)
278 listener.onTouchPointerScroll(true);
279 scrollLastRawY = e.getRawY();
280 }
281 else if (dy < -SCROLL_DELTA)
282 {
283 if (listener != null)
284 listener.onTouchPointerScroll(false);
285 scrollLastRawY = e.getRawY();
286 }
287 return true;
288 }
289 case MotionEvent.ACTION_UP:
290 case MotionEvent.ACTION_CANCEL:
291 scrollButton.setActivated(false);
292 morphScroll(false);
293 return true;
294 }
295 return false;
296 }
297
298 // grow the scroll button into a tall pill (covering its neighbours) and back
299 private void morphScroll(boolean expand)
300 {
301 if (scrollBaseHeight == 0)
302 scrollBaseHeight = scrollButton.getHeight();
303 float target = expand ? getResources().getDimensionPixelSize(R.dimen.tp_cluster_size)
304 : scrollBaseHeight;
305 if (pillAnimator != null)
306 pillAnimator.cancel();
307 pillAnimator = ValueAnimator.ofFloat(scrollButton.getHeight(), target);
308 pillAnimator.setDuration(140);
309 pillAnimator.addUpdateListener(a -> {
310 float h = (float)a.getAnimatedValue();
311 ViewGroup.LayoutParams lp = scrollButton.getLayoutParams();
312 lp.height = Math.round(h);
313 scrollButton.setLayoutParams(lp);
314 scrollButton.setTranslationY(-(h - scrollBaseHeight) / 2.0f);
315 });
316 pillAnimator.start();
317 }
318
319 // Set the real remote cursor bitmap (null clears to the fallback); never recycled.
320 public void setRemoteCursor(int[] pixels, int width, int height, int hotX, int hotY)
321 {
322 ViewGroup.LayoutParams lp = cursor.getLayoutParams();
323 if (pixels == null || width <= 0 || height <= 0)
324 {
325 cursor.setImageResource(R.drawable.ic_cursor);
326 ImageViewCompat.setImageTintList(cursor, ColorStateList.valueOf(cursorTint));
327 int s = getResources().getDimensionPixelSize(R.dimen.tp_cursor_size);
328 lp.width = s;
329 lp.height = s;
330 cursor.setLayoutParams(lp);
331 cursor.setTranslationX(0);
332 cursor.setTranslationY(0);
333 return;
334 }
335 Bitmap bmp = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888);
336 float scale = 40 * density / height;
337 if (scale < 1.2f)
338 scale = 1.2f;
339 if (scale > 3.0f)
340 scale = 3.0f;
341 ImageViewCompat.setImageTintList(cursor, null);
342 // filterBitmap=false -> nearest-neighbour scaling keeps the small cursor crisp
343 BitmapDrawable bd = new BitmapDrawable(getResources(), bmp);
344 bd.setFilterBitmap(false);
345 cursor.setImageDrawable(bd);
346 lp.width = Math.round(width * scale);
347 lp.height = Math.round(height * scale);
348 cursor.setLayoutParams(lp);
349 // place the bitmap hotspot pixel on the cluster's top-left corner (0,0)
350 cursor.setTranslationX(-hotX * scale);
351 cursor.setTranslationY(-hotY * scale);
352 }
353
354 // touch pointer listener - triggered when an action field is hit
355 public interface TouchPointerListener
356 {
357 void onTouchPointerClose();
358
359 void onTouchPointerLeftClick(int x, int y, boolean down);
360
361 void onTouchPointerRightClick(int x, int y, boolean down);
362
363 void onTouchPointerMove(int x, int y);
364
365 void onTouchPointerMoveEnd();
366
367 void onTouchPointerScroll(boolean down);
368
369 void onTouchPointerToggleKeyboard();
370
371 void onTouchPointerResetScrollZoom();
372 }
373}