package totalcross.ui; import totalcross.sys.*; import totalcross.ui.event.*; import totalcross.ui.font.*; import totalcross.util.*; /** * Flick * * Add flick animations to Containers with ScrollBars. * * The animations conditionally start on the PenUp after drag events. The animations simulate friction using the * constant acceleration formula: * * x = x0 + v0 * (t - t0) + a * (t - t0)^2 / 2 * * The position at t -= t0 is: position = x0 + v0 * t + a * t^2 / 2; * * This class is for internal use. You should use the ScrollContainer class instead. */ public class Flick implements PenListener, TimerListener { public static final int BOTH_DIRECTIONS = 0; public static final int HORIZONTAL_DIRECTION_ONLY = 1; public static final int VERTICAL_DIRECTION_ONLY = 2; public int forcedFlickDirection = BOTH_DIRECTIONS; /** * Indicates that a flick animation is running. Only one can run at a time. */ public static Flick currentFlick; /** * Desired animation frame rate in frames/second. */ public static int defaultFrameRate = 40; // each frame with 25ms public int frameRate = defaultFrameRate; /** * Shortest flick animation allowed to start in milliseconds. Used to compute the minimum initial velocity. If a * flick animation will come to rest in a shorter time than this it isn't done. */ public static int defaultShortestFlick = 300; public int shortestFlick = defaultShortestFlick; /** * Longest flick animation allowed in milliseconds. The maximum initial velocity is limited so that no flick * animation takes longer than this to come to rest. * * Changing this property will affect the flick object of all Controls. If you have access to a flick * control, you can use the longestFlick property instead. Otherwise, set this property to a new value * before constructing the control and then set it back to the original value (2500) after the control * is constructed. */ public static int defaultLongestFlick = 2500; public int longestFlick = defaultLongestFlick; /** * Flick acceleration in inches/second^2. This value simulates friction to slow the flick motion. * Defaults to 2.95 for screen height > 320, or 1.6 otherwise. */ public static double defaultFlickAcceleration = Math.max(Settings.screenWidth,Settings.screenHeight)/Font.NORMAL_SIZE/(Settings.platform.equals(Settings.ANDROID) ? 10.0 : 5.0); public double flickAcceleration = defaultFlickAcceleration; // Device pixel densities in dpi. private int resX,resY; // Controls flick initialization and the physical drag that started it. private int dragId; // Acceleration converted to pixels/millisecond^2. private double pixelAccelerationX; private double pixelAccelerationY; // Signed acceleration used during a flick animation. Negative when motion is positive. private double a; // Beginning of a drag private int dragT0; private int dragX0; private int dragY0; // Drag progress. private int dragX; private int dragY; // Beginning of a flick private double v0; private int t0; // Flick progress private int flickPos; // Ending time of a flick. private int t1; // Direction of a flick. private int flickDirection; // Timer that runs the flick animation. TimerEvent timer; // Container owning this Flick object. private Scrollable target; // only flickStarted and flickEnded are called private Vector listeners; // used on paged scrolls int scrollDistance; private int distanceToAbortScroll; private int scrollDistanceRemaining; private int consecutiveDragCount; private int lastFlickDirection; public PagePosition pagepos; private int lastDragDirection; private double lastA; private boolean calledFlickStarted; private int initialRealPosX,initialRealPosY; /** True if the user is dragging a control that owns a Flick but the flick didn't started yet * (in that case, currentFlick would not be null). */ public static boolean isDragging; /** The maximum accelleration that can be applied when the user keep dragging the container. Defaults to 5. */ public int maximumAccelerationMultiplier = 5; /** * Create a Flick animation object for a FlickableContainer. */ public Flick(Scrollable s) { target = s; addEvents((Control)s); timer = new TimerEvent(); } /** Call this method to set the PagePosition control that will be updated with the current page * as the flick occurs. It must have all properties already set, since the Flick will only change * the current position. */ public void setPagePosition(PagePosition pp) { this.pagepos = pp; } /** Used in page scrolls, defines the distance that should be scrolled. Recomputes the time and the * initial velocity to ensure that this is the amount that will be scrolled. * Also updates distanceToAbortScroll. * @see #setDistanceToAbortScroll(int) */ public void setScrollDistance(int v) { scrollDistance = v; distanceToAbortScroll = v / (Control.isTablet ? 10 : 5); } /** The distance used to abort the scroll. Set to 0 to make it always scroll a page, even if it * dragged just a bit. Defaults to scrollDistance/5. * * Be sure to call the method setScrollDistance before calling this one. * @see #setScrollDistance(int) */ public void setDistanceToAbortScroll(int v) { distanceToAbortScroll = v; } /** Adds another listener of Scrollable events. */ public void addScrollableListener(Scrollable s) { if (listeners == null) listeners = new Vector(3); listeners.addElement(s); } /** Adds an event source to whom this flick will grab pen events. */ public void addEventSource(Control c) { addEvents(c); } private void addEvents(Control c) { c.addPenListener(this); c.addTimerListener(this); // So a container event listener can listen to events targeting the container's children. c.callListenersOnAllTargets = true; } /** Remove a previously added event source. */ public void removeEventSource(Control c) { c.removePenListener(this); c.removeTimerListener(this); // So a container event listener can listen to events targeting the container's children. c.callListenersOnAllTargets = false; } private void initialize(int dragId, int x, int y, int t) { lastFlickDirection = flickDirection; stop(false); this.dragId = dragId; // Adjust resolutions, which can change during rotation. some devices don't report properly. resX = Settings.screenWidthInDPI <= 0 ? 96 : Settings.screenWidthInDPI; resY = Settings.screenHeightInDPI<= 0 ? 96 : Settings.screenHeightInDPI; if (Control.isTablet) { // Prefer high density on high res screens resX = (resX < 150) ? 240 : resX; resY = (resY < 150) ? 240 : resY; } // Convert inches/second^2 to pixels/millisecond^2 pixelAccelerationX = flickAcceleration * resX / 1000000.0; pixelAccelerationY = flickAcceleration * resY / 1000000.0; a = v0 = 0; flickDirection = flickPos = t0 = t1 = 0; dragT0 = t; dragX0 = dragX = x; dragY0 = dragY = y; } /** * Indicates the start of a simple tap or a drag. */ public void penDown(PenEvent e) { if (scrollDistance != 0) { initialRealPosX = target.getScrollPosition(DragEvent.LEFT); initialRealPosY = target.getScrollPosition(DragEvent.DOWN); } if (currentFlick == this && scrollDistance == 0) stop(true); } public boolean isValidDirection(int direction) { boolean isHoriz = direction == DragEvent.LEFT || direction == DragEvent.RIGHT; return forcedFlickDirection == BOTH_DIRECTIONS || (isHoriz && forcedFlickDirection == Flick.HORIZONTAL_DIRECTION_ONLY) || (!isHoriz && forcedFlickDirection == Flick.VERTICAL_DIRECTION_ONLY); } /** * Indicates the start of a drag. */ public void penDragStart(DragEvent e) { if (e.direction != lastDragDirection) { consecutiveDragCount = 0; lastDragDirection = e.direction; } else if (++consecutiveDragCount > maximumAccelerationMultiplier) // used in acceleration consecutiveDragCount = maximumAccelerationMultiplier; isDragging = true; if (e.target instanceof ScrollBar) stop(false); else initialize(e.dragId, e.absoluteX, e.absoluteY, Vm.getTimeStamp()); } /** * Resets the drag start parameters if the direction changes. */ public void penDrag(DragEvent e) { if (dragId != e.dragId) return; int t = Vm.getTimeStamp(); // If this penDrag event was sent too fast, assume it was sent 1 millisecond // after the start of the drag so we can do our computations here. if (t <= dragT0) t = dragT0 + 1; int x = e.absoluteX; int y = e.absoluteY; int deltaX = x - dragX; int deltaY = y - dragY; int absDeltaX = deltaX < 0 ? -deltaX : deltaX; int absDeltaY = deltaY < 0 ? -deltaY : deltaY; int direction = 0; double v; // if user specified a single direction, ignore other directions if (absDeltaY >= absDeltaX && forcedFlickDirection == HORIZONTAL_DIRECTION_ONLY) return;//deltaY = absDeltaY = 0; else if (absDeltaX >= absDeltaY && forcedFlickDirection == VERTICAL_DIRECTION_ONLY) return;//deltaX = absDeltaX = 0; dragX = x; dragY = y; a = 0; if (absDeltaX > absDeltaY) { v = (double) deltaX / (t - dragT0); if (deltaX > 0) { direction = DragEvent.RIGHT; a = -pixelAccelerationX; } else if (deltaX < 0) { direction = DragEvent.LEFT; a = pixelAccelerationX; } } else { v = (double) deltaY / (t - dragT0); if (deltaY > 0) { direction = DragEvent.DOWN; a = -pixelAccelerationY; } else if (deltaY < 0) { direction = DragEvent.UP; a = pixelAccelerationY; } } if (a == 0) return; if (direction != 0) { if ((flickDirection != 0 && direction != flickDirection) || (-v / a) < shortestFlick) // if flick direction changed or movement was too slow, reset flick start { dragT0 = t; dragX0 = x; dragY0 = y; } flickDirection = direction; } } /** * Checks whether or not to start a flick animation. */ public void penDragEnd(DragEvent e) { boolean cancelFlick = false; isDragging = false; if (currentFlick != null || dragId != e.dragId) // the penDragEvent can be called for more than one control, so we have to handle it only on one control. That's what dragId for return; dragId = -1; // the drag event sequence has ended t0 = Vm.getTimeStamp(); // If this penUp event was sent too fast, assume it was sent 1 millisecond // after the start of the drag so we can do our computations here. if (t0 <= dragT0) t0 = dragT0 + 1; int deltaX = e.absoluteX - dragX0; int deltaY = e.absoluteY - dragY0; int absDeltaX = deltaX < 0 ? -deltaX : deltaX; int absDeltaY = deltaY < 0 ? -deltaY : deltaY; //if (absDeltaX <= Settings.touchTolerance && absDeltaY <= Settings.touchTolerance) // return; // If we could not compute the flick direction before, try to compute // the direction at the penUp event if (flickDirection == 0) { a = 0; cancelFlick = (absDeltaY >= absDeltaX && forcedFlickDirection == HORIZONTAL_DIRECTION_ONLY) || (absDeltaX >= absDeltaY && forcedFlickDirection == VERTICAL_DIRECTION_ONLY); if (absDeltaX > absDeltaY) { if (deltaX > 0) { flickDirection = cancelFlick ? DragEvent.DOWN : DragEvent.RIGHT; a = -pixelAccelerationX; } else if (deltaX < 0) { flickDirection = cancelFlick ? DragEvent.UP : DragEvent.LEFT; a = pixelAccelerationX; } } else { if (deltaY > 0) { flickDirection = cancelFlick ? DragEvent.RIGHT : DragEvent.DOWN; a = -pixelAccelerationY; } else if (deltaY < 0) { flickDirection = cancelFlick ? DragEvent.LEFT : DragEvent.UP; a = pixelAccelerationY; } } } if (scrollDistance != 0) { boolean isHorizontal = flickDirection == DragEvent.RIGHT || flickDirection == DragEvent.LEFT; boolean forward = flickDirection == DragEvent.RIGHT || flickDirection == DragEvent.DOWN; // case where a flick was started in a single direction but the user made a new drag in a non-valid direction // e.g.: was flicking to left but user moved to up if (a != 0) { lastA = a; if ((forward && a < 0) || (!forward && a > 0)) lastA = -a; } if (a == 0) a = lastA; forward = a < 0; t1 = longestFlick; if (lastFlickDirection != 0 && lastFlickDirection != flickDirection) consecutiveDragCount = 0; // compute what the user ran against our scrolling window // note that this is not the same of xTotal int realPos = target.getScrollPosition(flickDirection); int rpos = realPos; if (realPos < 0) realPos = -realPos; int runnedDistance = realPos % scrollDistance; int remainingDistance = scrollDistance - runnedDistance; // how much the user dragged. used to go back int dragged = e.direction == DragEvent.LEFT || e.direction == DragEvent.RIGHT ? e.xTotal : e.yTotal; if (dragged < 0) dragged = -dragged; int initialRealPos = isHorizontal ? initialRealPosX : initialRealPosY; // not enough to move? if (cancelFlick || (consecutiveDragCount <= 1 && distanceToAbortScroll > 0 && dragged < distanceToAbortScroll)) { int s0 = initialRealPos < 0 ? -initialRealPos : initialRealPos; int sf = rpos < 0 ? -rpos : rpos; if ((s0 % scrollDistance) != 0) scrollDistanceRemaining = forward ? runnedDistance : remainingDistance; else { if (s0 == sf) ; else if (s0 < sf) { if (a > 0) a = -a; forward = false; lastFlickDirection = flickDirection = isHorizontal ? DragEvent.LEFT : DragEvent.UP; scrollDistanceRemaining = sf-s0; } else { if (a < 0) a = -a; forward = true; lastFlickDirection = flickDirection = isHorizontal ? DragEvent.RIGHT : DragEvent.DOWN; scrollDistanceRemaining = s0-sf; } consecutiveDragCount = 0; } } else scrollDistanceRemaining = forward ? runnedDistance : remainingDistance; if (consecutiveDragCount > 1) scrollDistanceRemaining += (consecutiveDragCount-1) * scrollDistance; // acceleration v0 = (scrollDistanceRemaining - (a > 0 ? -a : a) * t1 * t1 / 2) / t1; if (a > 0) v0 = -v0; } else if (cancelFlick) return; else { if (a == 0) return; // Compute v0. switch (flickDirection) { case DragEvent.UP: case DragEvent.DOWN: v0 = (double) deltaY / (t0 - dragT0); break; case DragEvent.LEFT: case DragEvent.RIGHT: v0 = (double) deltaX / (t0 - dragT0); break; default: return; } // When the flick ends. No rounding is done, the maximum rounding error is 1 millisecond. t1 = (int) (-v0 / a); // Reject animations that are too slow and apply the speed limit. if (t1 < shortestFlick) return; if (t1 > longestFlick) { t1 = longestFlick; v0 = -t1 * a; } } // Start the animation int scrollDirection = DragEvent.getInverseDirection(flickDirection); calledFlickStarted = false; if (target.canScrollContent(scrollDirection, e.target)) { calledFlickStarted = true; if (target.flickStarted()) { callListeners(true,false); currentFlick = this; flickPos = 0; ((Control)target).addTimer(timer, 1000 / frameRate); } } } /** Calls the listeners of this flick. */ public void callListeners(boolean started, boolean atPenDown) { if (listeners != null) for (int i = listeners.size(); --i >= 0;) if (started) ((Scrollable)listeners.items[i]).flickStarted(); else ((Scrollable)listeners.items[i]).flickEnded(atPenDown); } /** * Stops a flick animation if one is running. */ void stop(boolean atPenDown) { if (currentFlick == null) // stop called during computation, so force end of drag sequence dragId = -1; else if (currentFlick == this) // stop calling during flick currentFlick = null; if (calledFlickStarted) { calledFlickStarted = false; ((Control)target).removeTimer(timer); callListeners(false, atPenDown); target.flickEnded(atPenDown); } } public void penUp(PenEvent e) { if (currentFlick == null) stop(false); } /** * Processes timer ticks to run the animation. */ public void timerTriggered(TimerEvent e) { if (e == timer && !totalcross.unit.UIRobot.abort) { double t = Vm.getTimeStamp() - t0; // No rounding is done, the maximum rounding error is 1 pixel. int newFlickPos = (int) (v0 * t + a * t * t / 2.0); int absNewFlickPos = newFlickPos < 0 ? -newFlickPos : newFlickPos; // check if the amount will overflow the scrollDistance if (scrollDistance != 0 && absNewFlickPos > scrollDistanceRemaining) newFlickPos = newFlickPos < 0 ? -scrollDistanceRemaining : scrollDistanceRemaining; int flickMotion = newFlickPos - flickPos; flickPos = newFlickPos; boolean endReached = flickMotion == 0; if (!endReached) switch (flickDirection) { case DragEvent.UP: case DragEvent.DOWN: if (listeners != null) for (int i = listeners.size(); --i >= 0;) ((Scrollable)listeners.items[i]).scrollContent(0, -flickMotion, true); if (!target.scrollContent(0, -flickMotion, true)) endReached = true; break; case DragEvent.LEFT: case DragEvent.RIGHT: if (listeners != null) for (int i = listeners.size(); --i >= 0;) ((Scrollable)listeners.items[i]).scrollContent(-flickMotion, 0, true); if (!target.scrollContent(-flickMotion, 0, true)) endReached = true; break; } if (endReached || currentFlick == null || t > t1) // Reached the end. { lastDragDirection = lastFlickDirection = consecutiveDragCount = 0; stop(false); } if (pagepos != null) { int p = target.getScrollPosition(flickDirection); if (p < 0) p = -p; pagepos.setPosition((p/scrollDistance)+1); } e.consumed = true; } } }