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 findViewById(R.id.tp_ext_keyboard).setOnClickListener(v -> {
126 if (listener != null)
127 listener.onTouchPointerToggleExtKeyboard();
128 });
129 }
130
131 public void setTouchPointerListener(TouchPointerListener listener)
132 {
133 this.listener = listener;
134 }
135
136 public int getPointerWidth()
137 {
138 return cluster.getWidth() > 0
139 ? cluster.getWidth()
140 : getResources().getDimensionPixelSize(R.dimen.tp_cluster_size);
141 }
142
143 public int getPointerHeight()
144 {
145 return cluster.getHeight() > 0
146 ? cluster.getHeight()
147 : getResources().getDimensionPixelSize(R.dimen.tp_cluster_size);
148 }
149
150 public float[] getPointerPosition()
151 {
152 return new float[] { cluster.getX(), cluster.getY() };
153 }
154
155 // click hotspot == cursor tip == cluster top-left corner, in overlay coords
156 private int[] hotspot()
157 {
158 return new int[] { (int)cluster.getX(), (int)cluster.getY() };
159 }
160
161 private void sendLeft(boolean down)
162 {
163 int[] h = hotspot();
164 if (listener != null)
165 listener.onTouchPointerLeftClick(h[0], h[1], down);
166 }
167
168 private void sendMove()
169 {
170 int[] h = hotspot();
171 if (listener != null)
172 listener.onTouchPointerMove(h[0], h[1]);
173 }
174
175 private void setClusterTranslation(float tx, float ty)
176 {
177 float maxX = getWidth() - cluster.getWidth();
178 float maxY = getHeight() - cluster.getHeight();
179 if (tx < 0)
180 tx = 0;
181 if (ty < 0)
182 ty = 0;
183 if (maxX > 0 && tx > maxX)
184 tx = maxX;
185 if (maxY > 0 && ty > maxY)
186 ty = maxY;
187 cluster.setTranslationX(tx);
188 cluster.setTranslationY(ty);
189 }
190
191 @Override protected void onLayout(boolean changed, int l, int t, int r, int b)
192 {
193 super.onLayout(changed, l, t, r, b);
194 if (!placed && getWidth() > 0 && cluster.getWidth() > 0)
195 {
196 placed = true;
197 setClusterTranslation((getWidth() - cluster.getWidth()) / 2.0f,
198 (getHeight() - cluster.getHeight()) / 2.0f);
199 }
200 else
201 {
202 setClusterTranslation(cluster.getTranslationX(), cluster.getTranslationY());
203 }
204 }
205
206 private boolean onPuckTouch(MotionEvent e)
207 {
208 switch (e.getActionMasked())
209 {
210 case MotionEvent.ACTION_DOWN:
211 downRawX = e.getRawX();
212 downRawY = e.getRawY();
213 startTransX = cluster.getTranslationX();
214 startTransY = cluster.getTranslationY();
215 dragging = false;
216 holdDragging = false;
217 uiHandler.postDelayed(longPress, LONG_PRESS_MS);
218 return true;
219 case MotionEvent.ACTION_MOVE:
220 {
221 float dx = e.getRawX() - downRawX;
222 float dy = e.getRawY() - downRawY;
223 if (!dragging && Math.hypot(dx, dy) > touchSlop)
224 {
225 dragging = true;
226 if (!holdDragging)
227 uiHandler.removeCallbacks(longPress);
228 }
229 if (dragging || holdDragging)
230 {
231 setClusterTranslation(startTransX + dx, startTransY + dy);
232 sendMove();
233 }
234 return true;
235 }
236 case MotionEvent.ACTION_UP:
237 uiHandler.removeCallbacks(longPress);
238 if (holdDragging)
239 {
240 sendLeft(false);
241 holdDragging = false;
242 }
243 else if (!dragging)
244 {
245 // tap -> left click (two quick taps register as a double-click)
246 sendLeft(true);
247 sendLeft(false);
248 }
249 if (listener != null)
250 listener.onTouchPointerMoveEnd();
251 return true;
252 case MotionEvent.ACTION_CANCEL:
253 uiHandler.removeCallbacks(longPress);
254 if (holdDragging)
255 {
256 sendLeft(false);
257 holdDragging = false;
258 }
259 if (listener != null)
260 listener.onTouchPointerMoveEnd();
261 return true;
262 }
263 return false;
264 }
265
266 private boolean onScrollTouch(MotionEvent e)
267 {
268 switch (e.getActionMasked())
269 {
270 case MotionEvent.ACTION_DOWN:
271 scrollLastRawY = e.getRawY();
272 scrollButton.setActivated(true);
273 scrollButton.bringToFront();
274 morphScroll(true);
275 return true;
276 case MotionEvent.ACTION_MOVE:
277 {
278 float dy = e.getRawY() - scrollLastRawY;
279 if (dy > SCROLL_DELTA)
280 {
281 if (listener != null)
282 listener.onTouchPointerScroll(true);
283 scrollLastRawY = e.getRawY();
284 }
285 else if (dy < -SCROLL_DELTA)
286 {
287 if (listener != null)
288 listener.onTouchPointerScroll(false);
289 scrollLastRawY = e.getRawY();
290 }
291 return true;
292 }
293 case MotionEvent.ACTION_UP:
294 case MotionEvent.ACTION_CANCEL:
295 scrollButton.setActivated(false);
296 morphScroll(false);
297 return true;
298 }
299 return false;
300 }
301
302 // grow the scroll button into a tall pill (covering its neighbours) and back
303 private void morphScroll(boolean expand)
304 {
305 if (scrollBaseHeight == 0)
306 scrollBaseHeight = scrollButton.getHeight();
307 float target = expand ? getResources().getDimensionPixelSize(R.dimen.tp_cluster_size)
308 : scrollBaseHeight;
309 if (pillAnimator != null)
310 pillAnimator.cancel();
311 pillAnimator = ValueAnimator.ofFloat(scrollButton.getHeight(), target);
312 pillAnimator.setDuration(140);
313 pillAnimator.addUpdateListener(a -> {
314 float h = (float)a.getAnimatedValue();
315 ViewGroup.LayoutParams lp = scrollButton.getLayoutParams();
316 lp.height = Math.round(h);
317 scrollButton.setLayoutParams(lp);
318 scrollButton.setTranslationY(-(h - scrollBaseHeight) / 2.0f);
319 });
320 pillAnimator.start();
321 }
322
323 // Set the real remote cursor bitmap (null clears to the fallback); never recycled.
324 public void setRemoteCursor(int[] pixels, int width, int height, int hotX, int hotY)
325 {
326 ViewGroup.LayoutParams lp = cursor.getLayoutParams();
327 if (pixels == null || width <= 0 || height <= 0)
328 {
329 cursor.setImageResource(R.drawable.ic_cursor);
330 ImageViewCompat.setImageTintList(cursor, ColorStateList.valueOf(cursorTint));
331 int s = getResources().getDimensionPixelSize(R.dimen.tp_cursor_size);
332 lp.width = s;
333 lp.height = s;
334 cursor.setLayoutParams(lp);
335 cursor.setTranslationX(0);
336 cursor.setTranslationY(0);
337 return;
338 }
339 Bitmap bmp = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888);
340 float scale = 40 * density / height;
341 if (scale < 1.2f)
342 scale = 1.2f;
343 if (scale > 3.0f)
344 scale = 3.0f;
345 ImageViewCompat.setImageTintList(cursor, null);
346 // filterBitmap=false -> nearest-neighbour scaling keeps the small cursor crisp
347 BitmapDrawable bd = new BitmapDrawable(getResources(), bmp);
348 bd.setFilterBitmap(false);
349 cursor.setImageDrawable(bd);
350 lp.width = Math.round(width * scale);
351 lp.height = Math.round(height * scale);
352 cursor.setLayoutParams(lp);
353 // place the bitmap hotspot pixel on the cluster's top-left corner (0,0)
354 cursor.setTranslationX(-hotX * scale);
355 cursor.setTranslationY(-hotY * scale);
356 }
357
358 // touch pointer listener - triggered when an action field is hit
359 public interface TouchPointerListener
360 {
361 void onTouchPointerClose();
362
363 void onTouchPointerLeftClick(int x, int y, boolean down);
364
365 void onTouchPointerRightClick(int x, int y, boolean down);
366
367 void onTouchPointerMove(int x, int y);
368
369 void onTouchPointerMoveEnd();
370
371 void onTouchPointerScroll(boolean down);
372
373 void onTouchPointerToggleKeyboard();
374
375 void onTouchPointerToggleExtKeyboard();
376
377 void onTouchPointerResetScrollZoom();
378 }
379}