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.ImageView;
29
30import androidx.core.content.ContextCompat;
31import androidx.core.widget.ImageViewCompat;
32
33import com.freerdp.freerdpcore.R;
34import com.freerdp.freerdpcore.utils.Mouse;
35
36// Full-screen overlay hosting a draggable touch-pointer button cluster.
37public class TouchPointerView extends FrameLayout
38{
39 private static final int SCROLL_TICK_MS = 16;
40 private static final float SCROLL_DEADZONE_DP = 8f; // no scroll within this of the press point
41 private static final float SCROLL_NPS_PER_DP = 0.6f; // notches/sec added per dp past deadzone
42 private static final float SCROLL_MAX_NPS = 25f; // max scroll speed (notches/sec)
43 private static final int LONG_PRESS_MS = 500;
44
45 private View cluster;
46 private ImageView cursor;
47
48 private TouchPointerListener listener = null;
49
50 private float density;
51 private int touchSlop;
52 private boolean placed = false;
53
54 // puck drag state
55 private float downRawX, downRawY, startTransX, startTransY;
56 private boolean dragging = false;
57 private boolean holdDragging = false;
58
59 private int cursorTint;
60
61 private final Handler uiHandler = new Handler(Looper.getMainLooper());
62 private final Runnable longPress = () ->
63 {
64 if (!dragging && !holdDragging)
65 {
66 holdDragging = true;
67 sendLeft(true);
68 }
69 };
70
71 private final RateScroller vScroller = new RateScroller(false);
72 private final RateScroller hScroller = new RateScroller(true);
73
74 // A scroll button held down: the displacement from the press point sets the scroll rate.
75 private final class RateScroller implements Runnable
76 {
77 private final boolean horizontal;
78 private View button;
79 private float anchor, current, accum, baseDim;
80 private boolean active;
81 private ValueAnimator animator;
82
83 RateScroller(boolean horizontal)
84 {
85 this.horizontal = horizontal;
86 }
87
88 void attach(View v)
89 {
90 button = v;
91 v.setOnTouchListener((view, e) -> onTouch(e));
92 }
93
94 // negated on Y so that a positive displacement always means a positive scroll
95 private float pos(MotionEvent e)
96 {
97 return horizontal ? e.getRawX() : -e.getRawY();
98 }
99
100 private void send(int notch)
101 {
102 if (listener == null)
103 return;
104 if (horizontal)
105 listener.onTouchPointerHScroll(notch);
106 else
107 listener.onTouchPointerScroll(notch);
108 }
109
110 private boolean onTouch(MotionEvent e)
111 {
112 switch (e.getActionMasked())
113 {
114 case MotionEvent.ACTION_DOWN:
115 anchor = current = pos(e);
116 accum = 1.0f;
117 active = true;
118 uiHandler.post(this);
119 button.setActivated(true);
120 button.bringToFront();
121 morph(true);
122 return true;
123 case MotionEvent.ACTION_MOVE:
124 current = pos(e);
125 return true;
126 case MotionEvent.ACTION_UP:
127 case MotionEvent.ACTION_CANCEL:
128 stop();
129 return true;
130 }
131 return false;
132 }
133
134 void stop()
135 {
136 uiHandler.removeCallbacks(this);
137 // collapsing a button that never grew would animate it to its unmeasured size of zero
138 if (!active)
139 return;
140 active = false;
141 button.setActivated(false);
142 morph(false);
143 }
144
145 @Override public void run()
146 {
147 float dispDp = (current - anchor) / density;
148 float adisp = Math.abs(dispDp);
149 if (adisp <= SCROLL_DEADZONE_DP)
150 {
151 accum = 1.0f; // primed: fire a notch immediately on leaving the deadzone
152 }
153 else
154 {
155 float nps =
156 Math.min((adisp - SCROLL_DEADZONE_DP) * SCROLL_NPS_PER_DP, SCROLL_MAX_NPS);
157 accum += nps * SCROLL_TICK_MS / 1000f;
158 int notch = dispDp > 0 ? Mouse.WHEEL_DELTA : -Mouse.WHEEL_DELTA;
159 while (accum >= 1.0f)
160 {
161 accum -= 1.0f;
162 send(notch);
163 }
164 }
165 uiHandler.postDelayed(this, SCROLL_TICK_MS);
166 }
167
168 // grow the button into a pill along the scroll axis (covering its neighbours) and back
169 private void morph(boolean expand)
170 {
171 float from = horizontal ? button.getWidth() : button.getHeight();
172 if (baseDim == 0)
173 baseDim = from;
174 float target =
175 expand ? getResources().getDimensionPixelSize(R.dimen.tp_cluster_size) : baseDim;
176 if (animator != null)
177 animator.cancel();
178 animator = ValueAnimator.ofFloat(from, target);
179 animator.setDuration(140);
180 animator.addUpdateListener(a -> {
181 float val = (float)a.getAnimatedValue();
182 ViewGroup.LayoutParams lp = button.getLayoutParams();
183 if (horizontal)
184 {
185 lp.width = Math.round(val);
186 button.setTranslationX(-(val - baseDim) / 2.0f);
187 }
188 else
189 {
190 lp.height = Math.round(val);
191 button.setTranslationY(-(val - baseDim) / 2.0f);
192 }
193 button.setLayoutParams(lp);
194 });
195 animator.start();
196 }
197 }
198
199 public TouchPointerView(Context context)
200 {
201 this(context, null);
202 }
203
204 public TouchPointerView(Context context, AttributeSet attrs)
205 {
206 this(context, attrs, 0);
207 }
208
209 public TouchPointerView(Context context, AttributeSet attrs, int defStyle)
210 {
211 super(context, attrs, defStyle);
212 initTouchPointer(context);
213 }
214
215 private void initTouchPointer(Context context)
216 {
217 density = getResources().getDisplayMetrics().density;
218 touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
219 cursorTint = ContextCompat.getColor(context, R.color.tp_icon);
220 setClipChildren(false);
221
222 LayoutInflater.from(context).inflate(R.layout.touch_pointer, this, true);
223 cluster = findViewById(R.id.tp_cluster);
224 cursor = findViewById(R.id.tp_cursor);
225 vScroller.attach(findViewById(R.id.tp_scroll));
226 hScroller.attach(findViewById(R.id.tp_hscroll));
227
228 findViewById(R.id.tp_puck).setOnTouchListener((v, e) -> onPuckTouch(e));
229
230 findViewById(R.id.tp_close).setOnClickListener(v -> {
231 if (listener != null)
232 listener.onTouchPointerClose();
233 });
234 findViewById(R.id.tp_rclick).setOnClickListener(v -> {
235 sendRight(true);
236 sendRight(false);
237 });
238 findViewById(R.id.tp_mclick).setOnClickListener(v -> {
239 sendMiddle(true);
240 sendMiddle(false);
241 });
242 findViewById(R.id.tp_reset).setOnClickListener(v -> {
243 if (listener != null)
244 listener.onTouchPointerResetScrollZoom();
245 });
246 findViewById(R.id.tp_keyboard).setOnClickListener(v -> {
247 if (listener != null)
248 listener.onTouchPointerToggleKeyboard();
249 });
250 }
251
252 public void setTouchPointerListener(TouchPointerListener listener)
253 {
254 this.listener = listener;
255 }
256
257 public int getPointerWidth()
258 {
259 return cluster.getWidth() > 0
260 ? cluster.getWidth()
261 : getResources().getDimensionPixelSize(R.dimen.tp_cluster_size);
262 }
263
264 public int getPointerHeight()
265 {
266 return cluster.getHeight() > 0
267 ? cluster.getHeight()
268 : getResources().getDimensionPixelSize(R.dimen.tp_cluster_size);
269 }
270
271 public float[] getPointerPosition()
272 {
273 return new float[] { cluster.getX(), cluster.getY() };
274 }
275
276 // click hotspot == cursor tip == cluster top-left corner, in overlay coords
277 private int[] hotspot()
278 {
279 return new int[] { (int)cluster.getX(), (int)cluster.getY() };
280 }
281
282 private void sendLeft(boolean down)
283 {
284 int[] h = hotspot();
285 if (listener != null)
286 listener.onTouchPointerLeftClick(h[0], h[1], down);
287 }
288
289 private void sendRight(boolean down)
290 {
291 int[] h = hotspot();
292 if (listener != null)
293 listener.onTouchPointerRightClick(h[0], h[1], down);
294 }
295
296 private void sendMiddle(boolean down)
297 {
298 int[] h = hotspot();
299 if (listener != null)
300 listener.onTouchPointerMiddleClick(h[0], h[1], down);
301 }
302
303 private void sendMove()
304 {
305 int[] h = hotspot();
306 if (listener != null)
307 listener.onTouchPointerMove(h[0], h[1]);
308 }
309
310 private void setClusterTranslation(float tx, float ty)
311 {
312 float maxX = getWidth() - cluster.getWidth();
313 float maxY = getHeight() - cluster.getHeight();
314 if (tx < 0)
315 tx = 0;
316 if (ty < 0)
317 ty = 0;
318 if (maxX > 0 && tx > maxX)
319 tx = maxX;
320 if (maxY > 0 && ty > maxY)
321 ty = maxY;
322 cluster.setTranslationX(tx);
323 cluster.setTranslationY(ty);
324 }
325
326 @Override protected void onLayout(boolean changed, int l, int t, int r, int b)
327 {
328 super.onLayout(changed, l, t, r, b);
329 if (!placed && getWidth() > 0 && cluster.getWidth() > 0)
330 {
331 placed = true;
332 setClusterTranslation((getWidth() - cluster.getWidth()) / 2.0f,
333 (getHeight() - cluster.getHeight()) / 2.0f);
334 }
335 else
336 {
337 setClusterTranslation(cluster.getTranslationX(), cluster.getTranslationY());
338 }
339 }
340
341 private boolean onPuckTouch(MotionEvent e)
342 {
343 switch (e.getActionMasked())
344 {
345 case MotionEvent.ACTION_DOWN:
346 downRawX = e.getRawX();
347 downRawY = e.getRawY();
348 startTransX = cluster.getTranslationX();
349 startTransY = cluster.getTranslationY();
350 dragging = false;
351 holdDragging = false;
352 uiHandler.postDelayed(longPress, LONG_PRESS_MS);
353 return true;
354 case MotionEvent.ACTION_MOVE:
355 {
356 float dx = e.getRawX() - downRawX;
357 float dy = e.getRawY() - downRawY;
358 if (!dragging && Math.hypot(dx, dy) > touchSlop)
359 {
360 dragging = true;
361 if (!holdDragging)
362 uiHandler.removeCallbacks(longPress);
363 }
364 if (dragging || holdDragging)
365 {
366 setClusterTranslation(startTransX + dx, startTransY + dy);
367 sendMove();
368 }
369 return true;
370 }
371 case MotionEvent.ACTION_UP:
372 uiHandler.removeCallbacks(longPress);
373 if (holdDragging)
374 {
375 sendLeft(false);
376 holdDragging = false;
377 }
378 else if (!dragging)
379 {
380 // tap -> left click (two quick taps register as a double-click)
381 sendLeft(true);
382 sendLeft(false);
383 }
384 if (listener != null)
385 listener.onTouchPointerMoveEnd();
386 return true;
387 case MotionEvent.ACTION_CANCEL:
388 uiHandler.removeCallbacks(longPress);
389 if (holdDragging)
390 {
391 sendLeft(false);
392 holdDragging = false;
393 }
394 if (listener != null)
395 listener.onTouchPointerMoveEnd();
396 return true;
397 }
398 return false;
399 }
400
401 // Nothing delivers an UP once the overlay is gone, so the held-button timers have to be
402 // killed explicitly or they keep re-posting forever.
403 private void cancelHeldGestures()
404 {
405 // reachable from onVisibilityChanged before our field initializers have run
406 if (uiHandler == null)
407 return;
408 uiHandler.removeCallbacks(longPress);
409 vScroller.stop();
410 hScroller.stop();
411 }
412
413 @Override protected void onDetachedFromWindow()
414 {
415 cancelHeldGestures();
416 super.onDetachedFromWindow();
417 }
418
419 @Override protected void onVisibilityChanged(View changedView, int visibility)
420 {
421 super.onVisibilityChanged(changedView, visibility);
422 if (visibility != VISIBLE)
423 cancelHeldGestures();
424 }
425
426 // Set the real remote cursor bitmap (null clears to the fallback); never recycled.
427 public void setRemoteCursor(int[] pixels, int width, int height, int hotX, int hotY)
428 {
429 ViewGroup.LayoutParams lp = cursor.getLayoutParams();
430 if (pixels == null || width <= 0 || height <= 0)
431 {
432 cursor.setImageResource(R.drawable.ic_cursor);
433 ImageViewCompat.setImageTintList(cursor, ColorStateList.valueOf(cursorTint));
434 int s = getResources().getDimensionPixelSize(R.dimen.tp_cursor_size);
435 lp.width = s;
436 lp.height = s;
437 cursor.setLayoutParams(lp);
438 cursor.setTranslationX(0);
439 cursor.setTranslationY(0);
440 return;
441 }
442 Bitmap bmp = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888);
443 float scale = 40 * density / height;
444 if (scale < 1.2f)
445 scale = 1.2f;
446 if (scale > 3.0f)
447 scale = 3.0f;
448 ImageViewCompat.setImageTintList(cursor, null);
449 // filterBitmap=false -> nearest-neighbour scaling keeps the small cursor crisp
450 BitmapDrawable bd = new BitmapDrawable(getResources(), bmp);
451 bd.setFilterBitmap(false);
452 cursor.setImageDrawable(bd);
453 lp.width = Math.round(width * scale);
454 lp.height = Math.round(height * scale);
455 cursor.setLayoutParams(lp);
456 // place the bitmap hotspot pixel on the cluster's top-left corner (0,0)
457 cursor.setTranslationX(-hotX * scale);
458 cursor.setTranslationY(-hotY * scale);
459 }
460
461 // touch pointer listener - triggered when an action field is hit
462 public interface TouchPointerListener
463 {
464 void onTouchPointerClose();
465
466 void onTouchPointerLeftClick(int x, int y, boolean down);
467
468 void onTouchPointerRightClick(int x, int y, boolean down);
469
470 void onTouchPointerMiddleClick(int x, int y, boolean down);
471
472 void onTouchPointerMove(int x, int y);
473
474 void onTouchPointerMoveEnd();
475
476 void onTouchPointerScroll(int amount);
477
478 void onTouchPointerHScroll(int amount);
479
480 void onTouchPointerToggleKeyboard();
481
482 void onTouchPointerResetScrollZoom();
483 }
484}