/*
* Copyright (C) 2012 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.google.tv.anymotelibrary.client;
import android.os.CountDownTimer;
import android.view.MotionEvent;
import android.view.View;
import com.example.google.tv.anymotelibrary.R;
import com.example.google.tv.anymotelibrary.touch.ScaleGestureDetector;
import com.example.google.tv.anymotelibrary.touch.ScaleGestureDetectorFactory;
import com.example.google.tv.anymotelibrary.util.Action;
/**
* Handler for touch events. Instantiate this handler for a View to send its
* touch events to the connected Google TV using Anymote protocol.
*
*/
public final class TouchHandler implements View.OnTouchListener {
/**
* Defines the kind of events this handler is supposed to generate.
*/
private final Mode mode;
/**
* Interface to send anymoteSender during a touch sequence.
*/
private final AnymoteSender anymoteSender;
/**
* The current touch sequence.
*/
private Sequence state;
/**
* {@code true} if the touch handler is active.
*/
private boolean isActive;
/**
* Scale gesture detector.
*/
private final ScaleGestureDetector scaleGestureDetector;
private final float zoomThreshold;
/**
* Max thresholds for a sequence to be considered a click.
*/
private static final int CLICK_DISTANCE_THRESHOLD_SQUARE = 30 * 30;
private static final int CLICK_TIME_THRESHOLD = 500;
private static final float SCROLLING_FACTOR = 0.2f;
/**
* Threshold to send a sendScroll event.
*/
private static final int SCROLL_THRESHOLD = 2;
/**
* Thresholds for multitouch gestures.
*/
private static final float MT_SCROLL_BEGIN_DIST_THRESHOLD_SQR = 20.0f * 20.0f;
private static final float MT_SCROLL_BEGIN_THRESHOLD = 1.2f;
private static final float MT_SCROLL_END_THRESHOLD = 1.4f;
private static final float MT_ZOOM_SCALE_THRESHOLD = 1.8f;
/**
* Describes the way touches should be interpreted.
*/
public enum Mode {
POINTER,
POINTER_MULTITOUCH,
SCROLL_VERTICAL,
SCROLL_HORIZONTAL,
ZOOM_VERTICAL
}
/**
* Constructor
* @param view The view on the remote app, whose touch events are sent to Google TV.
* @param mode The value of {@code Mode}
* @param anymoteSender Sends Anymote messages to Google TV.
*/
public TouchHandler(View view, Mode mode, AnymoteSender anymoteSender) {
if (Mode.POINTER_MULTITOUCH.equals(mode)) {
this.scaleGestureDetector = ScaleGestureDetectorFactory
.createScaleGestureDetector(view, new MultitouchHandler());
this.mode = Mode.POINTER;
} else {
this.scaleGestureDetector = null;
this.mode = mode;
}
this.anymoteSender = anymoteSender;
isActive = true;
zoomThreshold = view.getResources().getInteger(R.integer.zoom_threshold);
view.setOnTouchListener(this);
}
public boolean onTouch(View v, MotionEvent event) {
if (!isActive) {
return false;
}
if (scaleGestureDetector != null) {
scaleGestureDetector.onTouchEvent(event);
if (scaleGestureDetector.isInProgress()) {
if (state != null) {
state.cancelDownTimer();
state = null;
}
return true;
}
}
int x = (int) event.getX();
int y = (int) event.getY();
long timestamp = event.getEventTime();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
state = new Sequence(x, y, timestamp);
return true;
case MotionEvent.ACTION_CANCEL:
state = null;
return true;
case MotionEvent.ACTION_UP:
boolean handled = state != null && state.handleUp(x, y, timestamp);
state = null;
return handled;
case MotionEvent.ACTION_MOVE:
return state != null && state.handleMove(x, y, timestamp);
default:
return false;
}
}
/**
* {@code true} activates the touch handler, {@code false} deactivates it.
*/
public void setActive(boolean active) {
isActive = active;
}
/**
* Stores parameters of a touch sequence, i.e. down - move(s) - up and
* handles new touch events.
*/
private class Sequence {
/**
* Location of the sequence's start event.
*/
private final int refX, refY;
/**
* Location of the last touch event.
*/
private int lastX, lastY;
private long lastTimestamp;
/**
* Delta Y accumulated across several touches.
*/
private int accuY;
/**
* Timer that expires when a click down has to be sent.
*/
private CountDownTimer clickDownTimer;
/**
* {@code true} if a click down has been sent.
*/
private boolean clickDownSent;
public Sequence(int x, int y, long timestamp) {
refX = x;
refY = y;
clickDownSent = false;
setLastTouch(x, y, timestamp);
if (mode == Mode.POINTER) {
startClickDownTimer();
}
}
private void setLastTouch(int x, int y, long timestamp) {
lastX = x;
lastY = y;
lastTimestamp = timestamp;
}
/**
* Returns {@code true} if a sequence is a movement.
*/
private boolean isMove(int x, int y) {
int distance = ((refX - x) * (refX - x)) + ((refY - y) * (refY - y));
return distance > CLICK_DISTANCE_THRESHOLD_SQUARE;
}
/**
* Starts a timer that will expire after
* {@link TouchHandler#CLICK_TIME_THRESHOLD} and start to send a click
* down event if the touch event cannot be interpreted as a movement.
*/
private void startClickDownTimer() {
clickDownTimer = new CountDownTimer(CLICK_TIME_THRESHOLD,
CLICK_TIME_THRESHOLD) {
@Override
public void onTick(long arg0) {
// Nothing to do.
}
@Override
public void onFinish() {
clickDown();
}
};
clickDownTimer.start();
}
/**
* Cancels the timer, no-op if there is no timer available.
*
* @return {@code true} if there was a timer to cancel
*/
private boolean cancelDownTimer() {
if (clickDownTimer != null) {
clickDownTimer.cancel();
clickDownTimer = null;
return true;
}
return false;
}
/**
* Sends a click down message.
*/
private void clickDown() {
Action.CLICK_DOWN.execute(anymoteSender);
clickDownSent = true;
}
/**
* Handles a touch up. A click will be issued if the initial touch of
* the sequence is close enough both timewise and distance-wise.
*
* @param x an integer representing the touch's x coordinate
* @param y an integer representing the touch's y coordinate
* @param timestamp a long representing the touch's time
* @return {@code true} if a click was issued
*/
public boolean handleUp(int x, int y, long timestamp) {
if (mode != Mode.POINTER) {
return true;
}
// If a click down is waiting, send it.
if (cancelDownTimer()) {
clickDown();
}
if (clickDownSent) {
Action.CLICK_UP.execute(anymoteSender);
}
return true;
}
/**
* Handles a touch move. Depending on the initial touch of the sequence,
* this will result in a pointer move or in a sendScrolling action.
*
* @param x an integer representing the touch's x coordinate
* @param y an integer representing the touch's y coordinate
* @param timestamp a long representing the touch's time
* @return {@code true} if any action was taken
*/
public boolean handleMove(int x, int y, long timestamp) {
if (mode == Mode.POINTER) {
if (!isMove(x, y)) {
// Stand still while it's not a move to avoid a movement
// when a click
// is performed.
} else {
cancelDownTimer();
}
}
long timeDelta = timestamp - lastTimestamp;
int deltaX = x - lastX;
int deltaY = y - lastY;
switch (mode) {
case POINTER:
anymoteSender.sendMoveRelative(deltaX, deltaY);
break;
case SCROLL_VERTICAL:
if (shouldTriggerScrollEvent(deltaY)) {
anymoteSender.sendScroll(0, deltaY);
}
break;
case SCROLL_HORIZONTAL:
if (shouldTriggerScrollEvent(deltaX)) {
anymoteSender.sendScroll(deltaX, 0);
}
break;
case ZOOM_VERTICAL:
accuY += deltaY;
if (Math.abs(accuY) >= zoomThreshold) {
if (accuY < 0) {
Action.ZOOM_IN.execute(anymoteSender);
} else {
Action.ZOOM_OUT.execute(anymoteSender);
}
accuY = 0;
}
break;
}
setLastTouch(x, y, timestamp);
return true;
}
}
/**
* Handles multitouch events to capture zoom and sendScroll events.
*/
private class MultitouchHandler
implements ScaleGestureDetector.OnScaleGestureListener {
private float lastScrollX;
private float lastScrollY;
private boolean isScrolling;
public boolean onScale(ScaleGestureDetector detector) {
float scaleFactor = detector.getScaleFactor();
float deltaX = scaleGestureDetector.getFocusX() - lastScrollX;
float deltaY = scaleGestureDetector.getFocusY() - lastScrollY;
toggleScrolling(scaleFactor, deltaX, deltaY);
float absX = Math.abs(deltaX);
float signX = Math.signum(deltaX);
float absY = Math.abs(deltaY);
float signY = Math.signum(deltaY);
// If both translations are less than 1
// pick greater one and align to 1
if ((absX < 1) && (absY < 1)) {
if (absX > absY) {
deltaX = signX;
deltaY = 0;
} else {
deltaX = 0;
deltaY = signY;
}
} else {
if (absX < 1) {
deltaX = 0;
} else {
deltaX = ((absX - 1) * SCROLLING_FACTOR + 1) * signX;
}
if (absY < 1) {
deltaY = 0;
} else {
deltaY = ((absY - 1) * SCROLLING_FACTOR + 1) * signY;
}
}
if (isScrolling) {
if (shouldTriggerScrollEvent(deltaX)
|| shouldTriggerScrollEvent(deltaY)) {
executeScrollEvent(deltaX, deltaY);
}
return false;
}
if (!isWithinInvRange(scaleFactor, MT_ZOOM_SCALE_THRESHOLD)) {
executeZoomEvent(scaleFactor);
return true;
}
return false;
}
public boolean onScaleBegin(ScaleGestureDetector detector) {
resetScroll();
return true;
}
public void onScaleEnd(ScaleGestureDetector detector) {
// Do nothing
}
/**
* Resets sendScrolling mode.
*/
private void resetScroll() {
isScrolling = false;
updateScroll();
}
/**
* Updates last sendScroll positions.
*/
private void updateScroll() {
lastScrollX = scaleGestureDetector.getFocusX();
lastScrollY = scaleGestureDetector.getFocusY();
}
/**
* Sends zoom event.
*
* @param scaleFactor scale factor.
*/
private void executeZoomEvent(float scaleFactor) {
resetScroll();
if (scaleFactor > 1.0f) {
Action.ZOOM_IN.execute(anymoteSender);
} else {
Action.ZOOM_OUT.execute(anymoteSender);
}
}
/**
* Sends sendScroll event.
*/
private void executeScrollEvent(float deltaX, float deltaY) {
anymoteSender.sendScroll(Math.round(deltaX), Math.round(deltaY));
updateScroll();
}
/**
* Enables of disables sendScrolling, depending on the current state,
* scale factor, and distance from last registered focus position. mode
* should be enabled / disabled depending on the speed of dragging vs.
* scale factor.
*/
private void toggleScrolling(
float scaleFactor, float deltaX, float deltaY) {
if (!isScrolling
&& isWithinInvRange(scaleFactor, MT_SCROLL_BEGIN_THRESHOLD)) {
float dist = deltaX * deltaX + deltaY * deltaY;
if (dist > MT_SCROLL_BEGIN_DIST_THRESHOLD_SQR) {
isScrolling = true;
}
} else if (isScrolling
&& !isWithinInvRange(scaleFactor, MT_SCROLL_END_THRESHOLD)) {
// Stop sendScrolling if zooming occurs.
isScrolling = false;
}
}
/**
* Returns {@code true} if {@code (1/upperLimit) < scaleFactor <
* upperLimit}
*/
private boolean isWithinInvRange(float scaleFactor, float upperLimit) {
if (upperLimit < 1.0f) {
throw new IllegalArgumentException("Upper limit < 1.0f: " + upperLimit);
}
return 1.0f / upperLimit < scaleFactor && scaleFactor < upperLimit;
}
}
/**
* Returns {@code true} if the delta measured when sendScrolling is enough
* to trigger a sendScroll event.
*
* @param deltaScroll the amount of sendScroll wanted
*/
private static boolean shouldTriggerScrollEvent(float deltaScroll) {
return Math.abs(deltaScroll) >= SCROLL_THRESHOLD;
}
}